Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,77 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ArtifactCardOptions configure an ArtifactCard. Title is required.
|
||||
type ArtifactCardOptions struct {
|
||||
Kind agent.ArtifactKind
|
||||
Title string
|
||||
// Detail is a short fact, such as "Go, 120 lines".
|
||||
Detail string
|
||||
// Streaming shows the artifact is still being written.
|
||||
Streaming bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
var artifactKinds = [3]struct{ icon, name string }{
|
||||
{"file", "artifact_card.code"}, {"file-text", "artifact_card.document"}, {"layout-grid", "artifact_card.web"},
|
||||
}
|
||||
|
||||
// ArtifactCard stands for an artifact; activating asks to open it.
|
||||
func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) core.ChoiceResult {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: ArtifactCard needs a Title")
|
||||
}
|
||||
if opts.Kind < agent.ArtifactCode || opts.Kind > agent.ArtifactWeb {
|
||||
panic("mujica: ArtifactCard Kind out of range")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
kind := artifactKinds[opts.Kind]
|
||||
var b *ui.Element
|
||||
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() {
|
||||
b = ui.ButtonBase(c).MinWidth(260).MaxWidth(420).MinHeight(56).Padding(d.Unit()*2, d.Unit()*3).Gap(d.Unit()*3).
|
||||
Justify(ui.Start).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
|
||||
Label(core.Msg(c, kind.name) + ": " + opts.Title)
|
||||
})
|
||||
internal.Ring(b, k.Focus, theme.CardRadius)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Transition(core.ColorFade)
|
||||
b.Children(func() {
|
||||
ui.Box(c).Size(36, 36).Shrink(0).Center().Radius(theme.ControlRadius).Background(k.Selection).Children(func() {
|
||||
ui.Icon(c, icons.Must(kind.icon)).Size(18, 18).TextColor(k.AccentText)
|
||||
})
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
|
||||
ui.Text(c, opts.Title).SingleLine().Bold().TextColor(k.Text)
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||
detail := core.Msg(c, kind.name)
|
||||
if opts.Detail != "" {
|
||||
detail += " · " + opts.Detail
|
||||
}
|
||||
ui.Text(c, detail).SingleLine().FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
if opts.Streaming {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 12, Label: core.Msg(c, "artifact_card.writing")})
|
||||
}
|
||||
})
|
||||
})
|
||||
ui.Text(c, core.Msg(c, "artifact_card.open")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).Shrink(0)
|
||||
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.AccentText)
|
||||
})
|
||||
open := b.Clicked() && !b.IsDisabled()
|
||||
if open {
|
||||
slog.Debug("mujica: artifact_card open", "kind", opts.Kind)
|
||||
}
|
||||
return core.NewChoiceResult(b, open)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestArtifactCardOpens(t *testing.T) {
|
||||
opens := 0
|
||||
tt := chatTester(480, 160, func(c *ui.Context) {
|
||||
if ArtifactCard(c, ArtifactCardOptions{Kind: agent.ArtifactDocument, Title: "Decree", Detail: "2 pages"}).Changed() {
|
||||
opens++
|
||||
}
|
||||
})
|
||||
if !tt.HasText("Document · 2 pages") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Decree")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opens != 2 {
|
||||
t.Fatalf("opens %d", opens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCardStreamingDisabledAndChecks(t *testing.T) {
|
||||
opens := 0
|
||||
tt := chatTester(480, 160, func(c *ui.Context) {
|
||||
if ArtifactCard(c, ArtifactCardOptions{Kind: agent.ArtifactWeb, Title: "Menu", Streaming: true, Disabled: true}).Changed() {
|
||||
opens++
|
||||
}
|
||||
})
|
||||
_ = tt.Click("Menu")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opens != 0 || !tt.HasText("Web page") {
|
||||
t.Fatalf("opens %d texts %q", opens, tt.Texts())
|
||||
}
|
||||
for _, opts := range []ArtifactCardOptions{{}, {Title: "x", Kind: 7}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: no panic", opts)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ArtifactCard(c, opts)
|
||||
}, 100, 100, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "artifact_card", 460, 210, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
ArtifactCard(c, ArtifactCardOptions{Kind: agent.ArtifactCode, Title: "seating.go", Detail: "Go · 120 lines"})
|
||||
ArtifactCard(c, ArtifactCardOptions{Kind: agent.ArtifactDocument, Title: "Royal decree", Streaming: true})
|
||||
ArtifactCard(c, ArtifactCardOptions{Kind: agent.ArtifactWeb, Title: "Ball invitation", Disabled: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Attachment describes a caller-uploaded file.
|
||||
type Attachment struct {
|
||||
// Name is required.
|
||||
Name string
|
||||
// Type supplies MIME metadata; empty uses the filename.
|
||||
Type string
|
||||
Size int64
|
||||
// Uploading shows Progress, 0 to 1.
|
||||
Uploading bool
|
||||
Progress float64
|
||||
// Err shows a failed upload with retry.
|
||||
Err error
|
||||
}
|
||||
|
||||
// AttachmentChipResult carries the chip and requested action.
|
||||
type AttachmentChipResult struct {
|
||||
Element *ui.Element
|
||||
removed, retried bool
|
||||
}
|
||||
|
||||
// Removed reports a press of the remove button.
|
||||
func (r AttachmentChipResult) Removed() bool { return r.removed }
|
||||
|
||||
// Retried reports a press of retry on a failed upload.
|
||||
func (r AttachmentChipResult) Retried() bool { return r.retried }
|
||||
|
||||
// AttachmentChip displays upload status, retry and removal controls.
|
||||
func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult {
|
||||
if a.Name == "" || a.Size < 0 {
|
||||
panic("mujica: Attachment needs a Name and a non-negative Size")
|
||||
}
|
||||
if a.Uploading && !(a.Progress >= 0 && a.Progress <= 1) {
|
||||
panic("mujica: Attachment Progress must be from 0 to 1")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r AttachmentChipResult
|
||||
border := k.Border
|
||||
if a.Err != nil {
|
||||
border = k.Danger
|
||||
}
|
||||
r.Element = ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u, u, u*2).Width(220).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleGroup).Label(a.Name)
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
icon, color := fileMessageIcon(a.Name, a.Type), k.AccentText
|
||||
if a.Err != nil {
|
||||
icon, color = "circle-alert", k.Danger
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(20, 20).Shrink(0).TextColor(color)
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(u / 2).Children(func() {
|
||||
ui.Text(c, a.Name).SingleLine().FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600)
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
switch {
|
||||
case a.Err != nil:
|
||||
ui.Text(c, core.Msg(c, "attachment_chip.failed")).SingleLine().FontSize(small).TextColor(k.Danger)
|
||||
case a.Uploading:
|
||||
pct := int(math.Round(a.Progress * 100))
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "attachment_chip.uploading"), pct)).SingleLine().FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
frac := float32(a.Progress)
|
||||
ui.Box(c).Height(3).Radius(1.5).Background(k.Border).Role(ui.RoleProgress).Label(fmt.Sprintf(core.Msg(c, "attachment_chip.uploading"), pct)).
|
||||
Draw(func(p *ui.Painter, b ui.Rect) {
|
||||
p.Fill(ui.Rect{X: b.X, Y: b.Y, W: b.W * frac, H: b.H}, k.AccentText, 1.5)
|
||||
})
|
||||
default:
|
||||
ui.Text(c, fileMessageSize(c, a.Size)).SingleLine().FontSize(small).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
if a.Err != nil && core.IconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(core.Msg(c, "attachment_chip.retry"), a.Name)).Clicked() && !disabled {
|
||||
r.retried = true
|
||||
slog.Debug("mujica: attachment_chip retry")
|
||||
}
|
||||
if core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "attachment_chip.remove"), a.Name)).Clicked() && !disabled {
|
||||
r.removed = true
|
||||
slog.Debug("mujica: attachment_chip remove")
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestAttachmentChipStatesAndActions(t *testing.T) {
|
||||
a := Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4}
|
||||
removed, retried := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := AttachmentChip(c, a)
|
||||
if r.Removed() {
|
||||
removed++
|
||||
}
|
||||
if r.Retried() {
|
||||
retried++
|
||||
}
|
||||
}, 400, 120)
|
||||
if !tt.HasText("Uploading 40%") {
|
||||
t.Fatalf("progress: %q", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("Retry upload of guests.csv"); ok {
|
||||
t.Fatal("retry shown while uploading")
|
||||
}
|
||||
a.Uploading, a.Err = false, errors.New("413")
|
||||
tt.Frame()
|
||||
_ = tt.Click("Retry upload of guests.csv")
|
||||
_ = tt.Click("Remove guests.csv")
|
||||
if retried != 1 || removed != 1 || !tt.HasText("Upload failed") {
|
||||
t.Fatalf("retried %d removed %d texts %q", retried, removed, tt.Texts())
|
||||
}
|
||||
a.Err = nil
|
||||
tt.Frame()
|
||||
if !tt.HasText("4.0 KB") {
|
||||
t.Fatalf("size: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentChipBadProgress(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for progress 2")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
AttachmentChip(c, Attachment{Name: "a", Uploading: true, Progress: 2})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestAttachmentChipSnapshot(t *testing.T) {
|
||||
snapshot(t, "attachment_chip", 520, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Gap(8).Children(func() {
|
||||
AttachmentChip(c, Attachment{Name: "guests.csv", Size: 4096})
|
||||
AttachmentChip(c, Attachment{Name: "portrait.png", Size: 812000, Uploading: true, Progress: 0.62})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAttachmentChipKeyboardAndDisabled(t *testing.T) {
|
||||
off, removed, retried := false, 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Disabled(off).Children(func() {
|
||||
r := AttachmentChip(c, Attachment{Name: "guests.csv", Size: 0, Err: errors.New("upload failed")})
|
||||
if r.Removed() {
|
||||
removed++
|
||||
}
|
||||
if r.Retried() {
|
||||
retried++
|
||||
}
|
||||
})
|
||||
}, 400, 160)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if retried != 1 || removed != 1 {
|
||||
t.Fatalf("retry %d remove %d", retried, removed)
|
||||
}
|
||||
off = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("Retry upload of guests.csv")
|
||||
if retried != 1 || removed != 1 {
|
||||
t.Fatal("disabled attachment handled input")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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", core.Msg(c, "audio_message.play")
|
||||
if opts.Playing {
|
||||
icon, name = "pause", core.Msg(c, "audio_message.pause")
|
||||
}
|
||||
if core.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(core.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(core.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,94 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,54 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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) core.ChoiceResult {
|
||||
if count < 1 || *index < 0 || *index >= count {
|
||||
panic("mujica: BranchNavigator needs 0 <= index < count")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
label := fmt.Sprintf(core.Msg(c, "branch_navigator.label"), *index+1, count)
|
||||
var r core.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 = i, core.NewChoiceResult(r.Element, true)
|
||||
c.Announce(fmt.Sprintf(core.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 input.NumberInputButton(c, "chevron-left", core.Msg(c, "branch_navigator.previous"), *index > 0) {
|
||||
step(-1)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("%d / %d", *index+1, count)).FontFeatures("tnum").FontSize(core.FontSize(c, theme.CaptionSize)).
|
||||
TextColor(k.TextMuted).SingleLine()
|
||||
if input.NumberInputButton(c, "chevron-right", core.Msg(c, "branch_navigator.next"), *index < count-1) {
|
||||
step(1)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestBranchNavigatorButtonsAndKeys(t *testing.T) {
|
||||
i, changes, off := 1, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,95 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CapabilityCard explains one assistant capability with example prompts.
|
||||
type CapabilityCard struct {
|
||||
Title, Description string
|
||||
Icon *ui.SVG
|
||||
Examples []string
|
||||
}
|
||||
|
||||
// CapabilityCardsOptions configure CapabilityCards.
|
||||
type CapabilityCardsOptions struct {
|
||||
// Columns is the most cards per row; zero is 3.
|
||||
Columns int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// CapabilityCardsView is built CapabilityCards and the example clicked this frame.
|
||||
type CapabilityCardsView struct {
|
||||
Element *ui.Element
|
||||
// Example is the example prompt chosen, empty for none.
|
||||
Example string
|
||||
}
|
||||
|
||||
// CapabilityCards shows capabilities; a clicked example goes to the caller.
|
||||
func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCardsOptions) CapabilityCardsView {
|
||||
if opts.Columns < 0 {
|
||||
panic("mujica: CapabilityCards Columns is negative")
|
||||
}
|
||||
cols := opts.Columns
|
||||
if cols == 0 {
|
||||
cols = 3
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
g := ui.Grid(c).Columns(cols).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||
v := CapabilityCardsView{Element: g}
|
||||
g.Children(func() {
|
||||
for i, card := range cards {
|
||||
if card.Title == "" {
|
||||
panic("mujica: CapabilityCard needs a Title")
|
||||
}
|
||||
box := ui.Column(c).Key(i).Gap(d.Unit()*2).Padding(d.Unit()*4).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Role(ui.RoleGroup).Label(card.Title)
|
||||
box.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
if card.Icon != nil {
|
||||
ui.Icon(c, card.Icon).Size(20, 20).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Text(c, card.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
})
|
||||
if card.Description != "" {
|
||||
ui.Text(c, card.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Vertical).Children(func() {
|
||||
for _, ex := range card.Examples {
|
||||
if capabilityExample(c, k, ex) {
|
||||
v.Example = ex
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
if v.Example != "" {
|
||||
slog.Debug("mujica: capability_cards example", "len", len(v.Example))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// capabilityExample is a quoted example button reporting a click.
|
||||
func capabilityExample(c *ui.Context, k theme.Tokens, text string) bool {
|
||||
d := core.Density(c)
|
||||
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start).
|
||||
Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Label(text)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case b.Pressed():
|
||||
b.Background(k.Selection)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
|
||||
}
|
||||
b.Transition(core.ColorFade)
|
||||
b.Children(func() { ui.Text(c, "“"+text+"”").TextColor(k.Text) })
|
||||
return b.Clicked() && !b.IsDisabled()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestCapabilityCardsExample(t *testing.T) {
|
||||
got := ""
|
||||
tt := chatTester(900, 400, func(c *ui.Context) {
|
||||
if v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{}); v.Example != "" {
|
||||
got = v.Example
|
||||
}
|
||||
})
|
||||
if !tt.HasText("Banquets, seating and schedules.") {
|
||||
t.Fatal("description missing")
|
||||
}
|
||||
if err := tt.Click("“Plan a ball for 200 guests”"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "Plan a ball for 200 guests" {
|
||||
t.Fatalf("example %q", got)
|
||||
}
|
||||
// Tab moves from the clicked example to the next card's.
|
||||
got = ""
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if got != "Explain the minuet" {
|
||||
t.Fatalf("keys chose %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityCardsColumnsAndDisabled(t *testing.T) {
|
||||
got := ""
|
||||
var g *ui.Element
|
||||
tt := chatTester(900, 600, func(c *ui.Context) {
|
||||
v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{Columns: 1, Disabled: true})
|
||||
g = v.Element
|
||||
if v.Example != "" {
|
||||
got = v.Example
|
||||
}
|
||||
})
|
||||
_ = tt.Click("“Explain the minuet”")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if got != "" {
|
||||
t.Fatalf("disabled cards chose %q", got)
|
||||
}
|
||||
a, _ := tt.Find("Write")
|
||||
b, _ := tt.Find("Plan")
|
||||
if b.Y <= a.Y || g == nil {
|
||||
t.Fatalf("one column should stack cards: %v %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityCardsNeedTitles(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CapabilityCards(c, []CapabilityCard{{}}, CapabilityCardsOptions{})
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
func TestCapabilityCardsSnapshot(t *testing.T) {
|
||||
snapshot(t, "capability_cards", 860, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { CapabilityCards(c, chatCards(), CapabilityCardsOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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,67 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
core.Use(c, core.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() { input.TextInput(c, &a.draft, input.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,60 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
// ChatEmptyKind is why a chat is empty; zero is ChatEmptyNew.
|
||||
type ChatEmptyKind int
|
||||
|
||||
const (
|
||||
// ChatEmptyNew: a new conversation with no messages.
|
||||
ChatEmptyNew ChatEmptyKind = iota
|
||||
// ChatEmptyNoResults: a conversation search matched nothing.
|
||||
ChatEmptyNoResults
|
||||
// ChatEmptyOffline: the connection is down.
|
||||
ChatEmptyOffline
|
||||
)
|
||||
|
||||
// ChatEmptyStateOptions configure a ChatEmptyState; empty texts use built-in copy.
|
||||
type ChatEmptyStateOptions struct {
|
||||
Kind ChatEmptyKind
|
||||
// Query, for ChatEmptyNoResults, is named in the description.
|
||||
Query string
|
||||
Title string
|
||||
Description string
|
||||
// Action replaces the kind's built-in button text.
|
||||
Action string
|
||||
}
|
||||
|
||||
// ChatEmptyState explains an empty chat and offers the next step.
|
||||
func ChatEmptyState(c *ui.Context, opts ChatEmptyStateOptions) feedback.EmptyStateResult {
|
||||
switch opts.Kind {
|
||||
case ChatEmptyNew:
|
||||
return feedback.EmptyState(c, feedback.EmptyStateOptions{
|
||||
Title: cmp.Or(opts.Title, core.Msg(c, "chat_empty_state.new.title")),
|
||||
Description: cmp.Or(opts.Description, core.Msg(c, "chat_empty_state.new.description")),
|
||||
Icon: icons.Must("star"),
|
||||
Action: cmp.Or(opts.Action, core.Msg(c, "chat_empty_state.new.action")),
|
||||
})
|
||||
case ChatEmptyNoResults:
|
||||
return feedback.EmptyState(c, feedback.EmptyStateOptions{
|
||||
Kind: feedback.EmptyNoResults, Query: opts.Query, Title: opts.Title, Description: opts.Description,
|
||||
Action: cmp.Or(opts.Action, core.Msg(c, "chat_empty_state.no_results.action")),
|
||||
})
|
||||
case ChatEmptyOffline:
|
||||
return feedback.EmptyState(c, feedback.EmptyStateOptions{
|
||||
Title: cmp.Or(opts.Title, core.Msg(c, "chat_empty_state.offline.title")),
|
||||
Description: cmp.Or(opts.Description, core.Msg(c, "chat_empty_state.offline.description")),
|
||||
Icon: icons.Must("wifi-off"),
|
||||
Action: cmp.Or(opts.Action, core.Msg(c, "chat_empty_state.offline.action")),
|
||||
})
|
||||
}
|
||||
panic("mujica: ChatEmptyState Kind out of range")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestChatEmptyStateKinds(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
opts ChatEmptyStateOptions
|
||||
texts []string
|
||||
action string
|
||||
}{
|
||||
{ChatEmptyStateOptions{}, []string{"Start a conversation"}, "New conversation"},
|
||||
{ChatEmptyStateOptions{Kind: ChatEmptyNoResults, Query: "minuet"}, []string{"minuet"}, "Clear search"},
|
||||
{ChatEmptyStateOptions{Kind: ChatEmptyOffline}, []string{"You are offline"}, "Retry"},
|
||||
{ChatEmptyStateOptions{Kind: ChatEmptyOffline, Title: "Lost the court", Action: "Reconnect"}, []string{"Lost the court"}, "Reconnect"},
|
||||
} {
|
||||
acted := 0
|
||||
tt := chatTester(420, 320, func(c *ui.Context) {
|
||||
if ChatEmptyState(c, tc.opts).Action {
|
||||
acted++
|
||||
}
|
||||
})
|
||||
for _, s := range tc.texts {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("%+v: %q missing from %q", tc.opts, s, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if err := tt.Click(tc.action); err != nil || acted != 2 {
|
||||
t.Errorf("%+v: action %q acted %d (%v)", tc.opts, tc.action, acted, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmptyStateRejectsBadKind(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ChatEmptyState(c, ChatEmptyStateOptions{Kind: 9})
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
func TestChatEmptyStateSnapshot(t *testing.T) {
|
||||
snapshot(t, "chat_empty_state", 820, 240, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(8).Children(func() {
|
||||
for _, k := range []ChatEmptyKind{ChatEmptyNew, ChatEmptyNoResults, ChatEmptyOffline} {
|
||||
ui.Column(c).Width(260).Children(func() { ChatEmptyState(c, ChatEmptyStateOptions{Kind: k, Query: "minuet"}) })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
// chatNow is the fixed clock of the chat session tests.
|
||||
var chatNow = time.Date(2026, time.October, 5, 15, 30, 0, 0, time.UTC)
|
||||
|
||||
func chatConversations() []ChatConversation {
|
||||
h := time.Hour
|
||||
return []ChatConversation{
|
||||
{ID: "a", Title: "Court etiquette notes", Updated: chatNow.Add(-2 * h)},
|
||||
{ID: "b", Title: "Ballroom seating plan", Updated: chatNow.Add(-26 * h)},
|
||||
{ID: "c", Title: "Harpsichord repair", Updated: chatNow.Add(-4 * 24 * h)},
|
||||
{ID: "d", Title: "Coronation menu", Updated: chatNow.Add(-40 * 24 * h)},
|
||||
{ID: "e", Title: "Royal letters", Updated: chatNow.Add(-90 * 24 * h), Pinned: true},
|
||||
{ID: "f", Title: "Garden maze", Updated: chatNow.Add(-1 * h)},
|
||||
}
|
||||
}
|
||||
|
||||
func chatMessages() []SessionMessage {
|
||||
return []SessionMessage{
|
||||
{Role: SessionUser, Text: "How should guests be seated at the banquet?", Time: chatNow.Add(-10 * time.Minute)},
|
||||
{Role: SessionAssistant, Text: "Seat by rank: the host at the head, honored guests to the right.", Time: chatNow.Add(-9 * time.Minute)},
|
||||
{Role: SessionUser, Author: "Lady Ann", Text: "And the musicians?", Time: chatNow.Add(-5 * time.Minute)},
|
||||
}
|
||||
}
|
||||
|
||||
func chatCards() []CapabilityCard {
|
||||
return []CapabilityCard{
|
||||
{Title: "Write", Description: "Letters, speeches and verse.", Icon: icons.Must("file-text"), Examples: []string{"Draft a royal decree"}},
|
||||
{Title: "Plan", Description: "Banquets, seating and schedules.", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball for 200 guests"}},
|
||||
{Title: "Research", Description: "History, heraldry and music.", Icon: icons.Must("search"), Examples: []string{"Explain the minuet"}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Source is a document an answer cites.
|
||||
type Source struct {
|
||||
// Title and URL are required.
|
||||
Title, URL string
|
||||
Site string
|
||||
Snippet string
|
||||
Published time.Time
|
||||
}
|
||||
|
||||
// CitationState links citations and sources through caller-owned state.
|
||||
type CitationState struct {
|
||||
// Active selects a 1-based source; zero clears selection.
|
||||
Active int
|
||||
hover int
|
||||
owner uint64
|
||||
seen time.Time
|
||||
locate uint64
|
||||
}
|
||||
|
||||
// lit reports whether source n is chosen or hovered anywhere.
|
||||
func (s *CitationState) lit(n int) bool { return n > 0 && (s.Active == n || s.hover == n) }
|
||||
|
||||
// track refreshes pointer ownership for the current frame.
|
||||
func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
|
||||
switch over := e.Hovered() && !e.IsDisabled(); {
|
||||
case over && s.hover != n:
|
||||
s.hover, s.owner = n, e.ID()
|
||||
c.Invalidate()
|
||||
case !over && s.owner == e.ID() && s.hover != 0:
|
||||
s.hover, s.owner = 0, 0
|
||||
c.Invalidate()
|
||||
}
|
||||
if s.owner == e.ID() {
|
||||
s.seen = c.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CitationState) expire(c *ui.Context, frame time.Time) {
|
||||
if s.hover != 0 && s.seen != frame {
|
||||
s.hover, s.owner = 0, 0
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// CitationBadge previews and locates a numbered source.
|
||||
func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) core.ChoiceResult {
|
||||
if n < 1 || st == nil || src.Title == "" || src.URL == "" {
|
||||
panic("mujica: CitationBadge needs n >= 1, a state and a Source with Title and URL")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
label := fmt.Sprintf(core.Msg(c, "citation_badge.label"), n, src.Title)
|
||||
size := core.FontSize(c, theme.CaptionSize-1)
|
||||
b := ui.ButtonBase(c).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(core.ColorFade)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
st.track(c, b, n)
|
||||
if st.lit(n) {
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).TextColor(k.AccentText)
|
||||
} else {
|
||||
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted)
|
||||
}
|
||||
frame := c.Now()
|
||||
b.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
st.expire(c, frame)
|
||||
if st.lit(n) {
|
||||
p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 4, W: r.W - 8, H: 2}, k.AccentText, 0)
|
||||
}
|
||||
})
|
||||
b.Children(func() { ui.Text(c, fmt.Sprint(n)).FontSize(size).FontFeatures("tnum").FontWeight(600).SingleLine() })
|
||||
r := core.ChoiceResult{Element: b}
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
st.Active, r = n, core.NewChoiceResult(r.Element, true)
|
||||
st.locate++
|
||||
slog.Debug("mujica: citation_badge activate", "n", n)
|
||||
}
|
||||
citationPreview(c, b, n, src)
|
||||
return r
|
||||
}
|
||||
|
||||
// citationPreviewDelay sets the hover and focus wait.
|
||||
const citationPreviewDelay = 500 * time.Millisecond
|
||||
|
||||
// citationPreview displays a source after resting hover or focus.
|
||||
// Pass-through preserves badge clicks; Escape dismisses the preview.
|
||||
func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) {
|
||||
st := ui.Local(b, "mujica.citation", func() overlay.TooltipState { return overlay.TooltipState{} })
|
||||
size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} })
|
||||
if b.IsDisabled() || !b.Hovered() && !b.FocusVisible() {
|
||||
*st = overlay.TooltipState{}
|
||||
return
|
||||
}
|
||||
if st.Dismissed {
|
||||
return
|
||||
}
|
||||
if st.Since.IsZero() {
|
||||
st.Since = c.Now()
|
||||
}
|
||||
if wait := citationPreviewDelay - c.Now().Sub(st.Since); wait > 0 {
|
||||
c.After(wait)
|
||||
return
|
||||
}
|
||||
if core.OverlayEscape(c, st) {
|
||||
st.Dismissed = true
|
||||
return
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
ww, wh := c.Size()
|
||||
w, h := min(float32(300), max(0, ww-8)), max(0, wh-8)
|
||||
x, y := core.PlaceOverlay(c, b.Bounds(), w, min(size.H, h), 6)
|
||||
ui.Overlay(c, func() {
|
||||
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(u * 3).PassThrough().Role(ui.RoleTooltip).Label(core.Msg(c, "citation_badge.preview"))
|
||||
core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, *size))
|
||||
*size = panel.Bounds()
|
||||
panel.Children(func() { ui.Scroll(c).Shrink(1).Children(func() { sourceSummary(c, n, src) }) })
|
||||
})
|
||||
}
|
||||
|
||||
// sourceSummary shows a source's number, site, title, time and snippet.
|
||||
func sourceSummary(c *ui.Context, n int, src Source) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Column(c).Gap(u).MinWidth(0).Grow(1).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("[%d]", n)).FontSize(small).FontFeatures("tnum").TextColor(k.AccentText).SingleLine()
|
||||
site := src.Site
|
||||
if site == "" {
|
||||
site = src.URL
|
||||
}
|
||||
ui.Text(c, site).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(1).MinWidth(0)
|
||||
if !src.Published.IsZero() {
|
||||
ui.Text(c, src.Published.Format("2006-01-02")).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
|
||||
}
|
||||
})
|
||||
ui.Text(c, src.Title).FontWeight(600).MaxLines(2)
|
||||
if src.Snippet != "" {
|
||||
ui.Text(c, src.Snippet).FontSize(small).TextColor(k.TextMuted).MaxLines(3)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var sourcesSample = []Source{
|
||||
{Title: "The Palace of Versailles", URL: "https://example.com/versailles", Site: "example.com",
|
||||
Snippet: "Seat of the court from 1682.", Published: time.Date(2025, 5, 4, 0, 0, 0, 0, time.UTC)},
|
||||
{Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches of mirrors face the garden."},
|
||||
{Title: "Court etiquette", URL: "https://example.net/etiquette", Site: "example.net"},
|
||||
}
|
||||
|
||||
// citationApp shares state between inline citations and sources.
|
||||
type citationApp struct {
|
||||
st CitationState
|
||||
opened []int
|
||||
}
|
||||
|
||||
func (a *citationApp) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Fill().Padding(16).Gap(16).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Gap(4).Children(func() {
|
||||
ui.Text(c, "The court moved in 1682")
|
||||
CitationBadge(c, 1, sourcesSample[0], &a.st)
|
||||
CitationBadge(c, 3, sourcesSample[2], &a.st)
|
||||
})
|
||||
ui.Column(c).Width(300).Height(400).Children(func() {
|
||||
if n, ok := SourcesPanel(c, sourcesSample, &a.st).Opened(); ok {
|
||||
a.opened = append(a.opened, n)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCitationBadgeActivatesAndPreviews(t *testing.T) {
|
||||
a := &citationApp{}
|
||||
tt := ui.NewTester(a.view, 900, 500)
|
||||
r, ok := tt.Find("Source 3: Court etiquette")
|
||||
if !ok {
|
||||
t.Fatal("badge not named")
|
||||
}
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Frame()
|
||||
if a.st.hover != 3 {
|
||||
t.Fatalf("hover %d, want 3", a.st.hover)
|
||||
}
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if n := len(tt.Texts()); n == 0 || !tt.HasText("example.net") {
|
||||
t.Fatalf("no preview card: %q", tt.Texts())
|
||||
}
|
||||
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
|
||||
if a.st.Active != 3 {
|
||||
t.Fatalf("active %d", a.st.Active)
|
||||
}
|
||||
tt.Move(5, 480)
|
||||
tt.Frame()
|
||||
if a.st.hover != 0 {
|
||||
t.Fatalf("hover %d after leaving", a.st.hover)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCitationBadgeKeyboard(t *testing.T) {
|
||||
a := &citationApp{}
|
||||
tt := ui.NewTester(a.view, 900, 500)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.st.Active != 1 {
|
||||
t.Fatalf("Enter on the first badge: active %d", a.st.Active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCitationBadgeBadNumber(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for number 0")
|
||||
}
|
||||
}()
|
||||
var st CitationState
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CitationBadge(c, 0, sourcesSample[0], &st)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestCitationBadgeSnapshot(t *testing.T) {
|
||||
snapshot(t, "citation_badge", 360, 60, func(c *ui.Context) {
|
||||
st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} })
|
||||
ui.Row(c).Padding(16).Gap(4).Children(func() {
|
||||
ui.Text(c, "Seventeen arches")
|
||||
CitationBadge(c, 1, sourcesSample[0], st)
|
||||
CitationBadge(c, 2, sourcesSample[1], st)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCitationBadgeHitAreaAndDisabledPreview(t *testing.T) {
|
||||
var st CitationState
|
||||
off := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Padding(16).Disabled(off).Children(func() {
|
||||
CitationBadge(c, 1, sourcesSample[0], &st)
|
||||
})
|
||||
}, 240, 160)
|
||||
b, _ := tt.Find("Source 1: The Palace of Versailles")
|
||||
if b.W < 28 || b.H < 28 {
|
||||
t.Errorf("hit area %v, want at least 28x28", b)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
time.Sleep(550 * time.Millisecond)
|
||||
tt.Frame()
|
||||
off = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.HasText("Seat of the court from 1682.") || st.Active != 0 {
|
||||
t.Errorf("disabled preview or activation: active %d texts %q", st.Active, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCitationBadgePreviewFitsNarrowWindow(t *testing.T) {
|
||||
var st CitationState
|
||||
var preview ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
src := sourcesSample[0]
|
||||
ui.Row(c).Padding(8).Children(func() { CitationBadge(c, 1, src, &st) })
|
||||
}, 180, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
time.Sleep(550 * time.Millisecond)
|
||||
tt.Frame()
|
||||
preview, ok := tt.Find("Source preview")
|
||||
if !ok || preview.X < 0 || preview.X+preview.W > 180 || preview.Y+preview.H > 120 {
|
||||
t.Fatalf("preview content outside window: %v found %v", preview, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCitationBadgeRemovedOwnerClearsHover(t *testing.T) {
|
||||
show := true
|
||||
st := CitationState{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
ui.Row(c).Key("badge").Height(36).Children(func() {
|
||||
if show {
|
||||
CitationBadge(c, 1, sourcesSample[0], &st)
|
||||
}
|
||||
})
|
||||
ui.Row(c).Key("card").Width(300).Children(func() { SourceCard(c, 1, sourcesSample[0], &st) })
|
||||
})
|
||||
}, 400, 300)
|
||||
b, _ := tt.Find("Source 1: The Palace of Versailles")
|
||||
tt.Move(b.X+b.W/2, b.Y+b.H/2)
|
||||
tt.Frame()
|
||||
if st.hover != 1 {
|
||||
t.Fatal("hover did not activate")
|
||||
}
|
||||
show = false
|
||||
tt.Move(390, 290)
|
||||
tt.Frame()
|
||||
if st.hover != 0 {
|
||||
t.Fatalf("removed owner retained hover %d", st.hover)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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 = core.Msg(c, "code_block.plain")
|
||||
}
|
||||
ui.Text(c, lang).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Grow(1)
|
||||
if codeBlockToggle(c, "wrap-text", core.Msg(c, "code_block.wrap"), &st.wrap) {
|
||||
slog.Debug("mujica: code_block wrap", "on", st.wrap)
|
||||
}
|
||||
icon, name := "copy", core.Msg(c, "code_block.copy")
|
||||
if left := codeBlockCopiedFor - c.Now().Sub(st.copiedAt); left > 0 {
|
||||
icon, name = "check", core.Msg(c, "code_block.copied")
|
||||
c.After(left)
|
||||
}
|
||||
if core.IconAction(c, icons.Must(icon), name).Clicked() && !disabled {
|
||||
c.WriteClipboard(code)
|
||||
st.copiedAt, r.copied = c.Now(), true
|
||||
c.Announce(core.Msg(c, "code_block.copied"))
|
||||
slog.Debug("mujica: code_block copy", "lines", len(lines))
|
||||
}
|
||||
fold, foldName := "chevron-up", core.Msg(c, "code_block.collapse")
|
||||
if st.collapsed {
|
||||
fold, foldName = "chevron-down", fmt.Sprintf(core.Msg(c, "code_block.expand"), len(lines))
|
||||
}
|
||||
if core.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(core.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 := core.Tokens(c)
|
||||
u := core.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 := core.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,81 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,81 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ContextChipsOptions configure ContextChips.
|
||||
type ContextChipsOptions struct {
|
||||
// Max sets visible chips before folding; zero means four.
|
||||
Max int
|
||||
}
|
||||
|
||||
// RemoveResult carries the element and removed item ID.
|
||||
type RemoveResult struct {
|
||||
Element *ui.Element
|
||||
removed string
|
||||
}
|
||||
|
||||
// Removed returns the ID whose remove button was pressed.
|
||||
func (r RemoveResult) Removed() (string, bool) { return r.removed, r.removed != "" }
|
||||
|
||||
// ContextChips displays removable context with folded overflow.
|
||||
func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions) RemoveResult {
|
||||
if opts.Max < 0 {
|
||||
panic("mujica: ContextChips Max must not be negative")
|
||||
}
|
||||
limit := opts.Max
|
||||
if limit == 0 {
|
||||
limit = 4
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r RemoveResult
|
||||
r.Element = ui.Row(c).Gap(u * 2).Wrap().AlignItems(ui.Center).Role(ui.RoleList).Label(core.Msg(c, "context_chips.label"))
|
||||
expanded := ui.Local(r.Element, "expanded", func() bool { return false })
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
shown := len(items)
|
||||
if !*expanded {
|
||||
shown = min(shown, limit)
|
||||
}
|
||||
for _, it := range items[:shown] {
|
||||
ui.Row(c).Key(it.ID).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(core.Density(c).ControlHeight()).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleListItem).
|
||||
Children(func() {
|
||||
ui.Icon(c, icons.Must(it.Kind.icon())).Size(14, 14).Shrink(0).TextColor(k.AccentText)
|
||||
ui.Text(c, it.Label).SingleLine().MaxWidth(180).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
if core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "context_chips.remove"), it.Label)).Clicked() && !disabled {
|
||||
r.removed = it.ID
|
||||
slog.Debug("mujica: context_chips remove", "id", it.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(items) <= limit {
|
||||
return
|
||||
}
|
||||
label := fmt.Sprintf(core.Msg(c, "context_chips.more"), len(items)-limit)
|
||||
if *expanded {
|
||||
label = core.Msg(c, "context_chips.less")
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(core.Density(c).ControlHeight()).Padding(0, u*2).Radius(theme.ControlRadius).Label(label).TextColor(k.AccentText)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() { ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine() })
|
||||
if b.Clicked() && !disabled {
|
||||
*expanded = !*expanded
|
||||
slog.Debug("mujica: context_chips fold", "expanded", *expanded)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestContextChipsRemoveAndFold(t *testing.T) {
|
||||
items := append([]ContextItem{}, mentionSample...)
|
||||
items = append(items, ContextItem{ID: "w2", Label: "Hall of Mirrors", Kind: ContextWeb})
|
||||
off := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(off).Children(func() {
|
||||
if id, ok := ContextChips(c, items, ContextChipsOptions{Max: 3}).Removed(); ok {
|
||||
for i, it := range items {
|
||||
if it.ID == id {
|
||||
items = append(items[:i], items[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 600, 200)
|
||||
if tt.HasText("seating.xlsx") || !tt.HasText("+2 more") {
|
||||
t.Fatalf("fold: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("+2 more")
|
||||
if !tt.HasText("Hall of Mirrors") || !tt.HasText("Show less") {
|
||||
t.Fatalf("expand: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Remove guests.csv")
|
||||
if len(items) != 4 || tt.HasText("guests.csv") {
|
||||
t.Fatalf("remove left %d items", len(items))
|
||||
}
|
||||
off = true
|
||||
_ = tt.Click("Remove Protocol")
|
||||
if len(items) != 4 {
|
||||
t.Fatal("disabled chips removed an item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextChipsNegativeMax(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a negative Max")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ContextChips(c, nil, ContextChipsOptions{Max: -1})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestContextChipsSnapshot(t *testing.T) {
|
||||
snapshot(t, "context_chips", 560, 90, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ContextChips(c, mentionSample, ContextChipsOptions{Max: 3}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestContextChipsKeyboardAndEmpty(t *testing.T) {
|
||||
items, removed := mentionSample, ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if id, ok := ContextChips(c, items, ContextChipsOptions{Max: 1}).Removed(); ok {
|
||||
removed = id
|
||||
}
|
||||
}, 400, 160)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if removed != "w1" {
|
||||
t.Fatalf("removed %q, want w1", removed)
|
||||
}
|
||||
items = nil
|
||||
tt.Frame()
|
||||
if tt.HasText("+3 more") || tt.HasText("Versailles") {
|
||||
t.Fatal("empty chips retained content")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// ContextKind is what a piece of context is.
|
||||
type ContextKind int
|
||||
|
||||
const (
|
||||
ContextFile ContextKind = iota
|
||||
ContextDoc
|
||||
ContextWeb
|
||||
)
|
||||
|
||||
func (k ContextKind) icon() string {
|
||||
switch k {
|
||||
case ContextFile:
|
||||
return "file"
|
||||
case ContextDoc:
|
||||
return "file-text"
|
||||
case ContextWeb:
|
||||
return "globe"
|
||||
}
|
||||
panic("mujica: unknown ContextKind")
|
||||
}
|
||||
|
||||
func (k ContextKind) group() string {
|
||||
return [...]string{"context_mention_menu.file", "context_mention_menu.doc", "context_mention_menu.web"}[k]
|
||||
}
|
||||
|
||||
// ContextItem describes a file, document or webpage mention.
|
||||
type ContextItem struct {
|
||||
// ID is the item's stable identity; required.
|
||||
ID string
|
||||
Label string
|
||||
Detail string
|
||||
Kind ContextKind
|
||||
}
|
||||
|
||||
// ContextMentionOptions configure a ContextMentionMenu.
|
||||
type ContextMentionOptions struct {
|
||||
// Loading indicates caller-owned asynchronous fetching.
|
||||
Loading bool
|
||||
}
|
||||
|
||||
// ContextMentionResult carries the popup, query and chosen item.
|
||||
type ContextMentionResult struct {
|
||||
Element *ui.Element
|
||||
query string
|
||||
queryChanged bool
|
||||
chosen ContextItem
|
||||
ok bool
|
||||
}
|
||||
|
||||
// Query returns mention text; QueryChanged requests fresh results.
|
||||
func (r ContextMentionResult) Query() string { return r.query }
|
||||
func (r ContextMentionResult) QueryChanged() bool { return r.queryChanged }
|
||||
|
||||
// Chosen returns the selected item and consumes its trigger.
|
||||
func (r ContextMentionResult) Chosen() (ContextItem, bool) { return r.chosen, r.ok }
|
||||
|
||||
// ContextMentionMenu groups results for the active mention query.
|
||||
// The caller filters results synchronously or asynchronously.
|
||||
func ContextMentionMenu(c *ui.Context, composer input.InputResult, value *string, items []ContextItem, opts ContextMentionOptions) ContextMentionResult {
|
||||
if composer.Element == nil || composer.Input == nil || value == nil {
|
||||
panic("mujica: ContextMentionMenu needs composer and value")
|
||||
}
|
||||
data := ui.Local(composer.Element, "mention-data", func() mentionMenuData { return mentionMenuData{} })
|
||||
locale := core.CurrentLocale(c)
|
||||
if !data.built || locale != data.locale || !slices.Equal(data.items, items) {
|
||||
data.built, data.locale = true, locale
|
||||
data.items = append(data.items[:0], items...)
|
||||
data.rows, data.order = data.rows[:0], data.order[:0]
|
||||
|
||||
for kind := ContextFile; kind <= ContextWeb; kind++ {
|
||||
for i, it := range items {
|
||||
if it.ID == "" || it.Label == "" {
|
||||
panic("mujica: ContextItem needs ID and Label")
|
||||
}
|
||||
if it.Kind == kind {
|
||||
data.rows = append(data.rows, slashMenuRow{label: it.Label, detail: it.Detail, icon: kind.icon(), group: core.Msg(c, kind.group())})
|
||||
data.order = append(data.order, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(data.order) != len(items) {
|
||||
panic("mujica: unknown ContextKind")
|
||||
}
|
||||
}
|
||||
rows, order := data.rows, data.order
|
||||
st := ui.Local(composer.Element, slashMenuKey{"mention"}, func() slashMenuState { return slashMenuState{} })
|
||||
at := contextMentionAt(*value)
|
||||
var r ContextMentionResult
|
||||
if at >= 0 {
|
||||
r.query = (*value)[at+1:]
|
||||
}
|
||||
// Opening or changing queries requests caller-owned results.
|
||||
r.queryChanged = at >= 0 && !composer.Element.IsDisabled() && composer.Input.Focused() && (r.query != st.query || !st.open && st.dismissed == nil)
|
||||
empty := core.Msg(c, "context_mention_menu.empty")
|
||||
if opts.Loading {
|
||||
empty = core.Msg(c, "context_mention_menu.loading")
|
||||
}
|
||||
i, _, panel := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: core.Msg(c, "context_mention_menu.label"), loading: opts.Loading})
|
||||
r.Element = panel
|
||||
if i >= 0 {
|
||||
r.chosen, r.ok = items[order[i]], true
|
||||
*value = (*value)[:at]
|
||||
slog.Debug("mujica: context_mention_menu choose", "id", r.chosen.ID)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// contextMentionAt returns where the trailing "@query" starts, or -1.
|
||||
func contextMentionAt(s string) int {
|
||||
at := strings.LastIndexByte(s, '@')
|
||||
if at < 0 || strings.ContainsAny(s[at+1:], " \t\n") || at > 0 && !strings.ContainsRune(" \t\n", rune(s[at-1])) {
|
||||
return -1
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
type mentionMenuData struct {
|
||||
built bool
|
||||
locale core.Locale
|
||||
items []ContextItem
|
||||
rows []slashMenuRow
|
||||
order []int
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var mentionSample = []ContextItem{
|
||||
{ID: "w1", Label: "Versailles — Wikipedia", Detail: "en.wikipedia.org", Kind: ContextWeb},
|
||||
{ID: "f1", Label: "guests.csv", Detail: "~/ball", Kind: ContextFile},
|
||||
{ID: "d1", Label: "Protocol", Detail: "Notion", Kind: ContextDoc},
|
||||
{ID: "f2", Label: "seating.xlsx", Detail: "~/ball", Kind: ContextFile},
|
||||
}
|
||||
|
||||
// contextMentionTester supplies filtered results one frame later.
|
||||
type contextMentionTester struct {
|
||||
text string
|
||||
items []ContextItem
|
||||
loading bool
|
||||
chosen []string
|
||||
queries []string
|
||||
}
|
||||
|
||||
func (m *contextMentionTester) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() {
|
||||
p := PromptComposer(c, &m.text, PromptComposerOptions{Label: "Prompt"})
|
||||
r := ContextMentionMenu(c, p, &m.text, m.items, ContextMentionOptions{Loading: m.loading})
|
||||
if r.QueryChanged() {
|
||||
m.queries = append(m.queries, r.Query())
|
||||
m.items = nil
|
||||
for _, it := range mentionSample {
|
||||
if strings.Contains(strings.ToLower(it.Label), strings.ToLower(r.Query())) {
|
||||
m.items = append(m.items, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if it, ok := r.Chosen(); ok {
|
||||
m.chosen = append(m.chosen, it.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContextMentionMenuGroupsAndChooses(t *testing.T) {
|
||||
m := &contextMentionTester{}
|
||||
tt := ui.NewTester(m.view, 600, 500)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("Read @")
|
||||
tt.Frame()
|
||||
files, _ := tt.Find("Files")
|
||||
docs, _ := tt.Find("Documents")
|
||||
web, ok := tt.Find("Web pages")
|
||||
if !ok || !(files.Y < docs.Y && docs.Y < web.Y) {
|
||||
t.Fatalf("groups not in file, doc, web order: %q", tt.Texts())
|
||||
}
|
||||
tt.Type("s")
|
||||
tt.Frame()
|
||||
if len(m.queries) < 2 || m.queries[len(m.queries)-1] != "s" {
|
||||
t.Fatalf("queries %q", m.queries)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(m.chosen) != 1 || m.text != "Read " {
|
||||
t.Fatalf("chosen %v text %q", m.chosen, m.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMentionMenuOnlyAfterSpaceAndLoading(t *testing.T) {
|
||||
m := &contextMentionTester{loading: true}
|
||||
tt := ui.NewTester(m.view, 600, 500)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("mail@host")
|
||||
if tt.HasText("Files") || len(m.queries) != 0 {
|
||||
t.Fatal("an @ inside a word opened the menu")
|
||||
}
|
||||
tt.Type(" @x")
|
||||
m.items = nil
|
||||
tt.Frame()
|
||||
if !tt.HasText("Searching…") {
|
||||
t.Fatalf("loading state missing: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(m.chosen) != 0 {
|
||||
t.Fatal("Enter chose from an empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMentionMenuBadItem(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an item without ID")
|
||||
}
|
||||
}()
|
||||
m := &contextMentionTester{items: []ContextItem{{Label: "x"}}}
|
||||
ui.NewTester(m.view, 300, 300)
|
||||
}
|
||||
|
||||
func TestContextMentionMenuSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "context_mention_menu", 560, 420, func(c *ui.Context) {
|
||||
(*ui.Local(c.Root(), "m", func() *contextMentionTester { return &contextMentionTester{items: mentionSample} })).view(c)
|
||||
}, func(tt *ui.Tester) {
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("Compare @")
|
||||
tt.Frame()
|
||||
})
|
||||
}
|
||||
|
||||
func TestContextMentionMenuAsyncFirstChoiceAndDisabledQuery(t *testing.T) {
|
||||
text, off := "@", false
|
||||
var items []ContextItem
|
||||
picked, queries := "", 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt", Disabled: off})
|
||||
r := ContextMentionMenu(c, p, &text, items, ContextMentionOptions{Loading: items == nil})
|
||||
if r.QueryChanged() {
|
||||
queries++
|
||||
}
|
||||
if it, ok := r.Chosen(); ok {
|
||||
picked = it.ID
|
||||
}
|
||||
}, 400, 300)
|
||||
_ = tt.Click("Prompt")
|
||||
items = []ContextItem{{ID: "first", Label: "First", Kind: ContextDoc}}
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if picked != "first" || text != "" {
|
||||
t.Errorf("async first choice: %q text %q", picked, text)
|
||||
}
|
||||
off, text = true, "@other"
|
||||
before := queries
|
||||
tt.Frame()
|
||||
if queries != before {
|
||||
t.Errorf("disabled menu issued %d queries", queries-before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ContextSegment describes one part of the context window.
|
||||
type ContextSegment struct {
|
||||
Label string
|
||||
Tokens int
|
||||
}
|
||||
|
||||
// ContextWindowMeterOptions configure a ContextWindowMeter.
|
||||
type ContextWindowMeterOptions struct {
|
||||
// Warn sets the warning threshold; zero means 0.8.
|
||||
Warn float64
|
||||
}
|
||||
|
||||
// ContextWindowMeter displays segmented usage and threshold warnings.
|
||||
// Palette colors and shaped legend markers identify segments.
|
||||
func ContextWindowMeter(c *ui.Context, segments []ContextSegment, limit int, opts ContextWindowMeterOptions) *ui.Element {
|
||||
if limit <= 0 || opts.Warn < 0 || opts.Warn > 1 {
|
||||
panic("mujica: ContextWindowMeter needs limit > 0 and 0 <= Warn <= 1")
|
||||
}
|
||||
used := 0
|
||||
for _, s := range segments {
|
||||
if s.Label == "" || s.Tokens < 0 {
|
||||
panic("mujica: ContextSegment needs a Label and non-negative Tokens")
|
||||
}
|
||||
used += s.Tokens
|
||||
}
|
||||
warn := opts.Warn
|
||||
if warn == 0 {
|
||||
warn = 0.8
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
pal := internal.Palette(k)
|
||||
share := float64(used) / float64(limit)
|
||||
pct := int(math.Round(share * 100))
|
||||
summary := fmt.Sprintf(core.Msg(c, "context_window_meter.used"), tokenThousands(used), tokenThousands(limit), pct)
|
||||
col := ui.Column(c).Gap(u * 2).MinWidth(200).Role(ui.RoleMeter).Label(core.Msg(c, "context_window_meter.label")).Description(summary)
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, core.Msg(c, "context_window_meter.label")).FontWeight(600).Grow(1).SingleLine()
|
||||
ui.Text(c, summary).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
ui.Box(c).Height(10).Radius(theme.ControlRadius).Background(k.Border).Clip().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
x := r.X
|
||||
for i, s := range segments {
|
||||
w := r.W * float32(min(float64(s.Tokens)/float64(limit), 1))
|
||||
w = min(w, r.X+r.W-x)
|
||||
p.Fill(ui.Rect{X: x, Y: r.Y, W: w, H: r.H}, pal[i%len(pal)], 0)
|
||||
x += w
|
||||
// A surface gap parts neighbouring segments.
|
||||
if i < len(segments)-1 && w > 0 {
|
||||
p.Fill(ui.Rect{X: internal.Snap(p, x-1), Y: r.Y, W: 1, H: r.H}, k.Surface, 0)
|
||||
}
|
||||
}
|
||||
mark := r.X + r.W*float32(warn)
|
||||
p.Fill(ui.Rect{X: internal.Snap(p, mark), Y: r.Y, W: 1, H: r.H}, k.Text, 0)
|
||||
})
|
||||
ui.Row(c).Gap(u * 4).Wrap().Children(func() {
|
||||
for i, s := range segments {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
internal.Marker(p, i, r.X+r.W/2, r.Y+r.H/2, 9, pal[i%len(pal)])
|
||||
})
|
||||
ui.Text(c, s.Label+" "+tokenThousands(s.Tokens)).FontSize(small).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
}
|
||||
ui.Text(c, core.Msg(c, "context_window_meter.free")+" "+tokenThousands(max(limit-used, 0))).FontSize(small).
|
||||
FontFeatures("tnum").TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
if share >= warn {
|
||||
color := k.Warning
|
||||
if used > limit {
|
||||
color = k.Danger
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(color).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "context_window_meter.warn"), pct)).FontSize(small)
|
||||
})
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
var contextSegmentsSample = []ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}, {Label: "Attachments", Tokens: 24000}}
|
||||
|
||||
func TestContextWindowMeterLegendAndWarning(t *testing.T) {
|
||||
segs := contextSegmentsSample
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ContextWindowMeter(c, segs, 128000, ContextWindowMeterOptions{})
|
||||
}, 600, 200)
|
||||
for _, s := range []string{"System 1,200", "History 58,000", "Attachments 24,000", "Free 44,800", "83,200 of 128,000 tokens (65%)"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
if tt.HasText("Context is") {
|
||||
t.Fatal("warning below the threshold")
|
||||
}
|
||||
segs = append(segs, ContextSegment{Label: "Draft", Tokens: 30000})
|
||||
tt.Frame()
|
||||
if !tt.HasText("Context is 88% full") {
|
||||
t.Fatalf("warning missing: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWindowMeterPaletteContrast(t *testing.T) {
|
||||
for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
|
||||
for i, col := range internal.Palette(k) {
|
||||
if r := theme.Contrast(col, k.Surface); r < 3 {
|
||||
t.Errorf("dark %v series %d contrast %.2f", k.Dark, i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWindowMeterBadOptions(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a zero limit")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ContextWindowMeter(c, nil, 0, ContextWindowMeterOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestContextWindowMeterSnapshot(t *testing.T) {
|
||||
snapshot(t, "context_window_meter", 520, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ContextWindowMeter(c, append(contextSegmentsSample, ContextSegment{Label: "Draft", Tokens: 30000}), 128000, ContextWindowMeterOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ExportFormat is an export's text form; zero is ExportMarkdown.
|
||||
type ExportFormat int
|
||||
|
||||
const (
|
||||
ExportMarkdown ExportFormat = iota
|
||||
ExportJSON
|
||||
ExportText
|
||||
)
|
||||
|
||||
// ExportRange is which messages an export holds; zero is ExportAll.
|
||||
type ExportRange int
|
||||
|
||||
const (
|
||||
ExportAll ExportRange = iota
|
||||
// ExportLast holds the last State.Last messages.
|
||||
ExportLast
|
||||
// ExportSelected holds the messages at Options.Selected.
|
||||
ExportSelected
|
||||
)
|
||||
|
||||
var exportRoles = map[SessionRole]string{SessionUser: "user", SessionAssistant: "assistant", SessionSystem: "system"}
|
||||
|
||||
// ExportConversation writes messages as Markdown, JSON or plain text.
|
||||
func ExportConversation(title string, messages []SessionMessage, format ExportFormat) string {
|
||||
var b strings.Builder
|
||||
switch format {
|
||||
case ExportMarkdown:
|
||||
fmt.Fprintf(&b, "# %s\n", title)
|
||||
for _, m := range messages {
|
||||
fmt.Fprintf(&b, "\n**%s**%s\n\n%s\n", exportAuthor(m), exportTime(m, " · "), m.Text)
|
||||
}
|
||||
case ExportJSON:
|
||||
type msgJSON struct {
|
||||
Role string `json:"role"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Time string `json:"time,omitempty"`
|
||||
}
|
||||
doc := struct {
|
||||
Title string `json:"title"`
|
||||
Messages []msgJSON `json:"messages"`
|
||||
}{Title: title, Messages: []msgJSON{}}
|
||||
for _, m := range messages {
|
||||
doc.Messages = append(doc.Messages, msgJSON{exportRoles[m.Role], m.Author, m.Text, exportTime(m, "")})
|
||||
}
|
||||
out, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b.Write(out)
|
||||
b.WriteByte('\n')
|
||||
case ExportText:
|
||||
fmt.Fprintf(&b, "%s\n", title)
|
||||
for _, m := range messages {
|
||||
fmt.Fprintf(&b, "\n%s%s:\n%s\n", exportAuthor(m), exportTime(m, " "), m.Text)
|
||||
}
|
||||
default:
|
||||
panic("mujica: unknown ExportFormat")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func exportAuthor(m SessionMessage) string {
|
||||
if m.Author != "" {
|
||||
return m.Author
|
||||
}
|
||||
r, ok := exportRoles[m.Role]
|
||||
if !ok {
|
||||
panic("mujica: SessionMessage Role out of range")
|
||||
}
|
||||
return strings.ToUpper(r[:1]) + r[1:]
|
||||
}
|
||||
|
||||
func exportTime(m SessionMessage, sep string) string {
|
||||
if m.Time.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if sep == "" {
|
||||
return m.Time.Format(time.RFC3339)
|
||||
}
|
||||
return sep + "(" + m.Time.Format("2006-01-02 15:04") + ")"
|
||||
}
|
||||
|
||||
// ConversationExportState is the chosen format and range.
|
||||
type ConversationExportState struct {
|
||||
Format ExportFormat
|
||||
Range ExportRange
|
||||
// Last is the count for ExportLast; zero is 10.
|
||||
Last float64
|
||||
cache conversationExportCache
|
||||
}
|
||||
|
||||
// conversationExportCache holds the last export text until its inputs change.
|
||||
type conversationExportCache struct {
|
||||
key conversationExportInputs
|
||||
content string
|
||||
}
|
||||
|
||||
type conversationExportInputs struct {
|
||||
title string
|
||||
format ExportFormat
|
||||
rng ExportRange
|
||||
last float64
|
||||
first *SessionMessage
|
||||
n int
|
||||
sel string
|
||||
built bool
|
||||
}
|
||||
|
||||
// conversationExportContent rebuilds the export only when its inputs change.
|
||||
func conversationExportContent(s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) string {
|
||||
in := conversationExportInputs{title: opts.Title, format: s.Format, rng: s.Range, last: s.Last, n: len(messages), sel: fmt.Sprint(opts.Selected), built: true}
|
||||
if len(messages) > 0 {
|
||||
in.first = &messages[0]
|
||||
}
|
||||
if s.cache.key != in {
|
||||
s.cache = conversationExportCache{in, ExportConversation(opts.Title, conversationExportPick(s, messages, opts.Selected), s.Format)}
|
||||
}
|
||||
return s.cache.content
|
||||
}
|
||||
|
||||
// ConversationExportOptions configure a ConversationExport. Title is required.
|
||||
type ConversationExportOptions struct {
|
||||
Title string
|
||||
// Selected are message indices for ExportSelected.
|
||||
Selected []int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ConversationExportView is a built ConversationExport and the export asked for.
|
||||
type ConversationExportView struct {
|
||||
Element *ui.Element
|
||||
// Exported reports Content is ready for the caller to write.
|
||||
Exported bool
|
||||
Content string
|
||||
Format ExportFormat
|
||||
}
|
||||
|
||||
// ConversationExport picks format and range, previews, and hands over text.
|
||||
func ConversationExport(c *ui.Context, s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) ConversationExportView {
|
||||
if s == nil || opts.Title == "" {
|
||||
panic("mujica: ConversationExport needs a state and a Title")
|
||||
}
|
||||
for _, i := range opts.Selected {
|
||||
if i < 0 || i >= len(messages) {
|
||||
panic("mujica: ConversationExport Selected index out of range")
|
||||
}
|
||||
}
|
||||
if s.Last == 0 {
|
||||
s.Last = 10
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := ui.Column(c).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||
v := ConversationExportView{Element: col, Format: s.Format}
|
||||
content := conversationExportContent(s, messages, opts)
|
||||
col.Children(func() {
|
||||
input.FormField(c, core.Msg(c, "conversation_export.format"), input.FormFieldOptions{}, func() *ui.Element {
|
||||
return input.RadioGroup(c, &s.Format, []input.RadioItem[ExportFormat]{
|
||||
{Value: ExportMarkdown, Label: "Markdown"}, {Value: ExportJSON, Label: "JSON"},
|
||||
{Value: ExportText, Label: core.Msg(c, "conversation_export.text")},
|
||||
}, input.RadioGroupOptions{Horizontal: true, Label: core.Msg(c, "conversation_export.format"), Disabled: opts.Disabled}).Element
|
||||
})
|
||||
input.FormField(c, core.Msg(c, "conversation_export.range"), input.FormFieldOptions{}, func() *ui.Element {
|
||||
return ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||
input.RadioGroup(c, &s.Range, []input.RadioItem[ExportRange]{
|
||||
{Value: ExportAll, Label: fmt.Sprintf(core.Msg(c, "conversation_export.all"), len(messages))},
|
||||
{Value: ExportLast, Label: core.Msg(c, "conversation_export.last")},
|
||||
{Value: ExportSelected, Label: fmt.Sprintf(core.Msg(c, "conversation_export.selected"), len(opts.Selected)), Disabled: len(opts.Selected) == 0},
|
||||
}, input.RadioGroupOptions{Horizontal: true, Label: core.Msg(c, "conversation_export.range"), Disabled: opts.Disabled})
|
||||
if s.Range == ExportLast {
|
||||
input.NumberInput(c, &s.Last, input.NumberInputOptions{Min: 1, Max: float64(max(1, len(messages))), Step: 1,
|
||||
Label: core.Msg(c, "conversation_export.last"), Disabled: opts.Disabled}).Element.Width(120)
|
||||
}
|
||||
})
|
||||
})
|
||||
ui.Text(c, core.Msg(c, "conversation_export.preview")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Scroll(c).MaxHeight(220).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.ControlRadius).Children(func() {
|
||||
ui.Text(c, content).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).Selectable()
|
||||
})
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
v.Exported = input.Button(c, core.Msg(c, "conversation_export.export"), input.ButtonOptions{Icon: icons.Must("upload"), Disabled: opts.Disabled}).Clicked()
|
||||
})
|
||||
})
|
||||
if v.Exported && !col.IsDisabled() {
|
||||
v.Content = content
|
||||
slog.Debug("mujica: conversation_export export", "format", s.Format, "bytes", len(content))
|
||||
} else {
|
||||
v.Exported = false
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// conversationExportPick returns the messages the range holds, in order.
|
||||
func conversationExportPick(s *ConversationExportState, messages []SessionMessage, selected []int) []SessionMessage {
|
||||
switch s.Range {
|
||||
case ExportLast:
|
||||
return messages[max(0, len(messages)-int(s.Last)):]
|
||||
case ExportSelected:
|
||||
var out []SessionMessage
|
||||
for i := range messages {
|
||||
for _, j := range selected {
|
||||
if i == j {
|
||||
out = append(out, messages[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return messages
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestExportConversationFormats(t *testing.T) {
|
||||
msgs := chatMessages()
|
||||
md := ExportConversation("Banquet", msgs, ExportMarkdown)
|
||||
want := "# Banquet\n\n**User** · (2026-10-05 15:20)\n\nHow should guests be seated at the banquet?\n"
|
||||
if !strings.HasPrefix(md, want) || !strings.Contains(md, "**Lady Ann**") {
|
||||
t.Fatalf("markdown %q", md)
|
||||
}
|
||||
var doc struct {
|
||||
Title string
|
||||
Messages []struct{ Role, Author, Text, Time string }
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ExportConversation("Banquet", msgs, ExportJSON)), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.Title != "Banquet" || len(doc.Messages) != 3 || doc.Messages[1].Role != "assistant" || doc.Messages[2].Author != "Lady Ann" ||
|
||||
doc.Messages[0].Time != "2026-10-05T15:20:00Z" {
|
||||
t.Fatalf("json %+v", doc)
|
||||
}
|
||||
txt := ExportConversation("Banquet", msgs[:1], ExportText)
|
||||
if txt != "Banquet\n\nUser (2026-10-05 15:20):\nHow should guests be seated at the banquet?\n" {
|
||||
t.Fatalf("text %q", txt)
|
||||
}
|
||||
if got := ExportConversation("Empty", nil, ExportJSON); !strings.Contains(got, `"messages": []`) {
|
||||
t.Fatalf("empty json %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationExportRangeAndExport(t *testing.T) {
|
||||
var s ConversationExportState
|
||||
var last ConversationExportView
|
||||
selected := []int{2}
|
||||
tt := chatTester(560, 600, func(c *ui.Context) {
|
||||
if v := ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "Banquet", Selected: selected}); v.Exported {
|
||||
last = v
|
||||
}
|
||||
})
|
||||
_ = tt.Click("JSON")
|
||||
_ = tt.Click("Selected (1)")
|
||||
_ = tt.Click("Export")
|
||||
if !last.Exported || last.Format != ExportJSON || !strings.Contains(last.Content, "And the musicians?") ||
|
||||
strings.Contains(last.Content, "banquet?") {
|
||||
t.Fatalf("export %+v", last)
|
||||
}
|
||||
_ = tt.Click("Plain text")
|
||||
_ = tt.Click("Last")
|
||||
s.Last = 2
|
||||
tt.Frame()
|
||||
_ = tt.Click("Export")
|
||||
if last.Format != ExportText || strings.Contains(last.Content, "banquet?") || !strings.Contains(last.Content, "Seat by rank") {
|
||||
t.Fatalf("last-2 export %q", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationExportDisabledAndChecks(t *testing.T) {
|
||||
var s ConversationExportState
|
||||
exported := false
|
||||
tt := chatTester(560, 600, func(c *ui.Context) {
|
||||
if ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "T", Disabled: true}).Exported {
|
||||
exported = true
|
||||
}
|
||||
})
|
||||
_ = tt.Click("JSON")
|
||||
_ = tt.Click("Export")
|
||||
if exported || s.Format != ExportMarkdown {
|
||||
t.Fatalf("disabled export acted: %v %v", exported, s.Format)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a bad selection")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "T", Selected: []int{9}})
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
func TestConversationExportSnapshot(t *testing.T) {
|
||||
snapshot(t, "conversation_export", 560, 470, func(c *ui.Context) {
|
||||
s := &ConversationExportState{}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ConversationExport(c, s, chatMessages(), ConversationExportOptions{Title: "Banquet seating", Selected: []int{0, 2}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ConversationItemOptions configure a ConversationItem.
|
||||
type ConversationItemOptions struct {
|
||||
// Today decides the time stamp; zero uses the clock.
|
||||
Today core.Date
|
||||
Selected bool
|
||||
Disabled bool
|
||||
// Draggable lets the row be dropped on a ProjectList.
|
||||
Draggable bool
|
||||
}
|
||||
|
||||
// ConversationDrag is the value a dragged conversation carries.
|
||||
type ConversationDrag struct{ ID string }
|
||||
|
||||
// ConversationItemView is a built ConversationItem and its requests.
|
||||
type ConversationItemView struct {
|
||||
Element *ui.Element
|
||||
// Opened, Deleted, Exported and PinToggled are requests for the caller.
|
||||
Opened, Deleted, Exported, PinToggled bool
|
||||
// Renamed reports a confirmed new title, already in conv.Title.
|
||||
Renamed bool
|
||||
}
|
||||
|
||||
type conversationItemState struct {
|
||||
editing, fresh bool
|
||||
draft string
|
||||
}
|
||||
|
||||
type conversationItemKey struct{}
|
||||
|
||||
// ConversationItem is a conversation row with rename, pin, export, delete.
|
||||
func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationItemOptions) ConversationItemView {
|
||||
if conv == nil || conv.ID == "" {
|
||||
panic("mujica: ConversationItem needs a conversation with an ID")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
today := opts.Today
|
||||
if today.IsZero() {
|
||||
today = core.DateOf(c.Now())
|
||||
}
|
||||
row := ui.Row(c).Key(conv.ID).FillWidth().MinHeight(d.ControlHeight()).Gap(d.Unit()).AlignItems(ui.Center).
|
||||
Radius(theme.ControlRadius).Disabled(opts.Disabled)
|
||||
v := ConversationItemView{Element: row}
|
||||
st := ui.Local(row, conversationItemKey{}, func() conversationItemState { return conversationItemState{} })
|
||||
if row.IsDisabled() {
|
||||
st.editing = false
|
||||
}
|
||||
if opts.Selected {
|
||||
row.Background(k.Selection)
|
||||
}
|
||||
title := cmp.Or(conv.Title, core.Msg(c, "conversation_list.untitled"))
|
||||
rename := false
|
||||
row.Children(func() {
|
||||
if st.editing {
|
||||
conversationItemEditor(c, k, conv, st, &v)
|
||||
} else {
|
||||
main := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||
Justify(ui.Start).Radius(theme.ControlRadius).Label(title)
|
||||
internal.Ring(main, k.Focus, theme.ControlRadius)
|
||||
if opts.Draggable {
|
||||
main.Drag(ConversationDrag{ID: conv.ID})
|
||||
}
|
||||
if main.Hovered() && !opts.Selected {
|
||||
main.Background(k.SurfaceHover)
|
||||
}
|
||||
main.Transition(core.ColorFade)
|
||||
main.Children(func() {
|
||||
if conv.Pinned {
|
||||
ui.Icon(c, icons.Must("star")).Size(14, 14).Shrink(0).TextColor(k.AccentText).Label(core.Msg(c, "conversation_item.pinned"))
|
||||
}
|
||||
t := ui.Text(c, title).SingleLine().Grow(1).MinWidth(0)
|
||||
if opts.Selected {
|
||||
t.TextColor(k.AccentText)
|
||||
}
|
||||
ui.Text(c, DateSeparatorWhen(c, conv.Updated, today)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0)
|
||||
})
|
||||
if !main.IsDisabled() {
|
||||
v.Opened = main.Clicked()
|
||||
rename = main.Shortcut(0, ui.KeyF2)
|
||||
v.Deleted = main.Shortcut(0, ui.KeyDelete)
|
||||
}
|
||||
}
|
||||
conversationItemMenu(c, core.Msg(c, "conversation_item.actions"), func(m *overlay.PopupMenu) {
|
||||
rename = m.Item(core.Msg(c, "conversation_item.rename"), overlay.PopupMenuItemOptions{}) || rename
|
||||
pin := core.Msg(c, "conversation_item.pin")
|
||||
if conv.Pinned {
|
||||
pin = core.Msg(c, "conversation_item.unpin")
|
||||
}
|
||||
v.PinToggled = m.Item(pin, overlay.PopupMenuItemOptions{})
|
||||
v.Exported = m.Item(core.Msg(c, "conversation_item.export"), overlay.PopupMenuItemOptions{})
|
||||
m.Separator()
|
||||
v.Deleted = m.Item(core.Msg(c, "conversation_item.delete"), overlay.PopupMenuItemOptions{}) || v.Deleted
|
||||
})
|
||||
})
|
||||
if rename && !row.IsDisabled() {
|
||||
st.editing, st.fresh, st.draft = true, true, conv.Title
|
||||
}
|
||||
switch {
|
||||
case v.Opened:
|
||||
slog.Debug("mujica: conversation_item open", "id", conv.ID)
|
||||
case v.Deleted, v.Exported, v.PinToggled:
|
||||
slog.Debug("mujica: conversation_item action", "id", conv.ID, "delete", v.Deleted, "export", v.Exported, "pin", v.PinToggled)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// conversationItemEditor edits the title in place; Escape cancels.
|
||||
func conversationItemEditor(c *ui.Context, k theme.Tokens, conv *ChatConversation, st *conversationItemState, v *ConversationItemView) {
|
||||
d := core.Density(c)
|
||||
in := ui.TextInputBase(c, &st.draft).Grow(1).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.AccentText).
|
||||
Label(core.Msg(c, "conversation_item.rename"))
|
||||
internal.Ring(in, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case st.fresh:
|
||||
st.fresh = false
|
||||
in.Focus()
|
||||
case in.IsDisabled():
|
||||
case in.Submitted():
|
||||
if t := strings.TrimSpace(st.draft); t != "" {
|
||||
v.Renamed = t != conv.Title
|
||||
conv.Title = t
|
||||
}
|
||||
st.editing = false
|
||||
slog.Debug("mujica: conversation_item rename", "id", conv.ID, "changed", v.Renamed)
|
||||
case in.Shortcut(0, ui.KeyEscape):
|
||||
st.editing = false
|
||||
}
|
||||
}
|
||||
|
||||
// conversationItemMenu is a borderless ellipsis button opening a menu.
|
||||
func conversationItemMenu(c *ui.Context, label string, build func(m *overlay.PopupMenu)) *ui.Element {
|
||||
b := core.IconAction(c, icons.Must("ellipsis"), label).Role(ui.RoleMenuButton).Shrink(0)
|
||||
open := ui.Local(b, conversationItemKey{}, func() bool { return false })
|
||||
if b.IsDisabled() {
|
||||
*open = false
|
||||
} else if b.Clicked() || b.Shortcut(0, ui.KeyDown) {
|
||||
*open = true
|
||||
}
|
||||
overlay.PopupMenuOpen(c, b, open, build)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func conversationItemTester(conv *ChatConversation, opts ConversationItemOptions, got *ConversationItemView) *ui.Tester {
|
||||
return chatTester(420, 360, func(c *ui.Context) {
|
||||
opts.Today = core.DateOf(chatNow)
|
||||
v := ConversationItem(c, conv, opts)
|
||||
if v.Opened || v.Deleted || v.Exported || v.PinToggled || v.Renamed {
|
||||
*got = v
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationItemShowsTitleAndTime(t *testing.T) {
|
||||
convs := chatConversations()
|
||||
var got ConversationItemView
|
||||
tt := conversationItemTester(&convs[0], ConversationItemOptions{}, &got)
|
||||
if !tt.HasText("Court etiquette notes") || !tt.HasText("13:30") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
tt = conversationItemTester(&convs[1], ConversationItemOptions{}, &got)
|
||||
if !tt.HasText("Yesterday") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Ballroom seating plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Opened {
|
||||
t.Fatal("click did not open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationItemMenuActions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
item string
|
||||
want func(v ConversationItemView) bool
|
||||
}{
|
||||
{"Pin", func(v ConversationItemView) bool { return v.PinToggled }},
|
||||
{"Export", func(v ConversationItemView) bool { return v.Exported }},
|
||||
{"Delete", func(v ConversationItemView) bool { return v.Deleted }},
|
||||
} {
|
||||
conv := chatConversations()[0]
|
||||
var got ConversationItemView
|
||||
tt := conversationItemTester(&conv, ConversationItemOptions{}, &got)
|
||||
if err := tt.Click("Conversation actions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click(tc.item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.want(got) {
|
||||
t.Errorf("%s: %+v", tc.item, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationItemInlineRename(t *testing.T) {
|
||||
conv := chatConversations()[0]
|
||||
var got ConversationItemView
|
||||
tt := conversationItemTester(&conv, ConversationItemOptions{}, &got)
|
||||
_ = tt.Click("Conversation actions")
|
||||
_ = tt.Click("Rename")
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatal("rename field not focused")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Type(" II")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if conv.Title != "Court etiquette notes II" || !got.Renamed {
|
||||
t.Fatalf("title %q renamed %v", conv.Title, got.Renamed)
|
||||
}
|
||||
// F2 starts a rename; Escape keeps the title; a blank title is refused.
|
||||
_ = tt.Click("Court etiquette notes II")
|
||||
tt.Key(0, ui.KeyF2)
|
||||
tt.Type("xyz")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if conv.Title != "Court etiquette notes II" {
|
||||
t.Fatalf("Escape changed the title to %q", conv.Title)
|
||||
}
|
||||
tt.Key(0, ui.KeyF2)
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Key(ui.Ctrl, ui.KeyA)
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if conv.Title != "Court etiquette notes II" {
|
||||
t.Fatalf("blank rename gave %q", conv.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationItemDisabled(t *testing.T) {
|
||||
conv := chatConversations()[0]
|
||||
var got ConversationItemView
|
||||
tt := conversationItemTester(&conv, ConversationItemOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Court etiquette notes")
|
||||
_ = tt.Click("Conversation actions")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if got.Opened || got.Deleted || tt.HasText("Rename") {
|
||||
t.Fatalf("disabled item acted: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationItemSnapshot(t *testing.T) {
|
||||
snapshot(t, "conversation_item", 420, 140, func(c *ui.Context) {
|
||||
convs := chatConversations()
|
||||
ui.Column(c).Padding(16).Gap(4).Children(func() {
|
||||
ConversationItem(c, &convs[0], ConversationItemOptions{Today: core.DateOf(chatNow), Selected: true})
|
||||
ConversationItem(c, &convs[4], ConversationItemOptions{Today: core.DateOf(chatNow)})
|
||||
ConversationItem(c, &convs[3], ConversationItemOptions{Today: core.DateOf(chatNow), Disabled: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChatConversation is one saved conversation, as the caller stores it.
|
||||
type ChatConversation struct {
|
||||
ID, Title string
|
||||
Updated time.Time
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
// ConversationListOptions configure a ConversationList.
|
||||
type ConversationListOptions struct {
|
||||
// Now dates the groups; zero uses the window clock.
|
||||
Now time.Time
|
||||
// Label names the list for assistive technology.
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ConversationListState is the chosen conversation and the list's place.
|
||||
type ConversationListState struct {
|
||||
// Selected is the chosen conversation's ID; empty for none.
|
||||
Selected string
|
||||
rows ui.ListState
|
||||
cursor int
|
||||
plan conversationListPlan
|
||||
}
|
||||
|
||||
// conversationListPlan holds grouped rows, rebuilt on new data or day.
|
||||
type conversationListPlan struct {
|
||||
src []ChatConversation
|
||||
day core.Date
|
||||
rows []conversationListRow
|
||||
built bool
|
||||
}
|
||||
|
||||
// conversationListRow is a header (item < 0) or item index.
|
||||
type conversationListRow struct {
|
||||
group, item int
|
||||
}
|
||||
|
||||
// ConversationListView is a built ConversationList and this frame's events.
|
||||
type ConversationListView struct {
|
||||
Element *ui.Element
|
||||
changed bool
|
||||
submitted bool
|
||||
}
|
||||
|
||||
// Changed reports a newly chosen conversation, in State.Selected.
|
||||
func (v ConversationListView) Changed() bool { return v.changed }
|
||||
|
||||
// Submitted reports Enter or a double click on the choice.
|
||||
func (v ConversationListView) Submitted() bool { return v.submitted }
|
||||
|
||||
// ConversationList groups conversations by recency, building only visible rows.
|
||||
func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView {
|
||||
if s == nil {
|
||||
panic("mujica: ConversationList needs a state")
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
}
|
||||
conversationListPlanRows(&s.plan, items, core.DateOf(now))
|
||||
rows := s.plan.rows
|
||||
s.cursor = slices.IndexFunc(rows, func(r conversationListRow) bool { return r.item >= 0 && items[r.item].ID == s.Selected })
|
||||
before := s.cursor
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
cursor := &s.cursor
|
||||
if opts.Disabled {
|
||||
// A disabled list moves a copy of the cursor.
|
||||
cp := s.cursor
|
||||
cursor = &cp
|
||||
}
|
||||
s.rows.Key = func(i int) any {
|
||||
if r := rows[i]; r.item >= 0 {
|
||||
return items[r.item].ID
|
||||
}
|
||||
return conversationListGroups[rows[i].group]
|
||||
}
|
||||
s.rows.Header = func(i int) bool { return rows[i].item < 0 }
|
||||
s.rows.Label = func(i int) string { return conversationListTitle(c, items, rows[i]) }
|
||||
s.rows.Selected = cursor
|
||||
var list *ui.Element
|
||||
wrap := ui.Column(c).MinHeight(0).Disabled(opts.Disabled)
|
||||
wrap.Children(func() {
|
||||
restore := data.DataSelectTheme(c, 0)
|
||||
list = ui.List(c, &s.rows, len(rows), func(i int) {
|
||||
r := rows[i]
|
||||
if r.item < 0 {
|
||||
conversationListHeader(c, k, core.Msg(c, conversationListGroups[r.group]))
|
||||
return
|
||||
}
|
||||
conv := items[r.item]
|
||||
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) {
|
||||
if conv.ID == s.Selected {
|
||||
p.FillPath(internal.Diamond(b.X+b.W/2, b.Y+b.H/2, 8), k.AccentText)
|
||||
}
|
||||
})
|
||||
if row != nil {
|
||||
ui.Row(c).Grow(1).MinWidth(0).Children(func() { row(conv) })
|
||||
return
|
||||
}
|
||||
ui.Text(c, conversationListTitle(c, items, r)).SingleLine().Grow(1).MinWidth(0)
|
||||
})
|
||||
}).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.ControlRadius).Label(opts.Label)
|
||||
restore()
|
||||
internal.Ring(list, k.Focus, theme.ControlRadius)
|
||||
})
|
||||
v := ConversationListView{Element: wrap}
|
||||
if !wrap.IsDisabled() {
|
||||
if s.cursor != before && s.cursor >= 0 && rows[s.cursor].item >= 0 {
|
||||
s.Selected, v.changed = items[rows[s.cursor].item].ID, true
|
||||
slog.Debug("mujica: conversation_list select", "id", s.Selected)
|
||||
}
|
||||
v.submitted = list.Submitted() && s.Selected != ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func conversationListTitle(c *ui.Context, items []ChatConversation, r conversationListRow) string {
|
||||
if r.item < 0 {
|
||||
return core.Msg(c, conversationListGroups[r.group])
|
||||
}
|
||||
return cmp.Or(items[r.item].Title, core.Msg(c, "conversation_list.untitled"))
|
||||
}
|
||||
|
||||
func conversationListHeader(c *ui.Context, k theme.Tokens, title string) {
|
||||
d := core.Density(c)
|
||||
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Center).Background(k.Surface).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
// conversationListPlanRows regroups items when the slice or the day changed.
|
||||
func conversationListPlanRows(p *conversationListPlan, items []ChatConversation, today core.Date) {
|
||||
same := p.built && p.day == today && len(p.src) == len(items) && (len(items) == 0 || &p.src[0] == &items[0])
|
||||
if same {
|
||||
return
|
||||
}
|
||||
p.src, p.day, p.built = items, today, true
|
||||
order := make([]int, len(items))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
slices.SortStableFunc(order, func(a, b int) int { return items[b].Updated.Compare(items[a].Updated) })
|
||||
groups := [5][]int{}
|
||||
for _, i := range order {
|
||||
g := conversationListGroup(items[i], today)
|
||||
groups[g] = append(groups[g], i)
|
||||
}
|
||||
p.rows = p.rows[:0]
|
||||
for g, idx := range groups {
|
||||
if len(idx) == 0 {
|
||||
continue
|
||||
}
|
||||
p.rows = append(p.rows, conversationListRow{group: g, item: -1})
|
||||
for _, i := range idx {
|
||||
p.rows = append(p.rows, conversationListRow{item: i})
|
||||
}
|
||||
}
|
||||
slog.Debug("mujica: conversation_list regroup", "count", len(items))
|
||||
}
|
||||
|
||||
var conversationListGroups = [5]string{
|
||||
"conversation_list.pinned", "conversation_list.today", "conversation_list.yesterday",
|
||||
"conversation_list.week", "conversation_list.older",
|
||||
}
|
||||
|
||||
// conversationListGroup returns the recency group index of conv.
|
||||
func conversationListGroup(conv ChatConversation, today core.Date) int {
|
||||
day := core.DateOf(conv.Updated)
|
||||
switch {
|
||||
case conv.Pinned:
|
||||
return 0
|
||||
case day.Compare(today) >= 0:
|
||||
return 1
|
||||
case day == today.AddDays(-1):
|
||||
return 2
|
||||
case day.Compare(today.AddDays(-7)) >= 0:
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func chatTester(w, h int, view func(c *ui.Context)) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
|
||||
}, w, h)
|
||||
}
|
||||
|
||||
func TestConversationListGroups(t *testing.T) {
|
||||
var s ConversationListState
|
||||
items := chatConversations()
|
||||
tt := chatTester(400, 600, func(c *ui.Context) {
|
||||
ConversationList(c, &s, items, ConversationListOptions{Now: chatNow}, nil).Element.Height(560)
|
||||
})
|
||||
want := []string{"Pinned", "Royal letters", "Today", "Garden maze", "Court etiquette notes", "Yesterday",
|
||||
"Ballroom seating plan", "Previous 7 days", "Harpsichord repair", "Older", "Coronation menu"}
|
||||
got := slices.Compact(slices.DeleteFunc(tt.Texts(), func(s string) bool { return !slices.Contains(want, s) }))
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("rows %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationListSelectAndKeys(t *testing.T) {
|
||||
var s ConversationListState
|
||||
items := chatConversations()
|
||||
changes, opened := 0, 0
|
||||
tt := chatTester(400, 600, func(c *ui.Context) {
|
||||
v := ConversationList(c, &s, items, ConversationListOptions{Now: chatNow, Label: "History"}, nil)
|
||||
v.Element.Height(560)
|
||||
if v.Changed() {
|
||||
changes++
|
||||
}
|
||||
if v.Submitted() {
|
||||
opened++
|
||||
}
|
||||
})
|
||||
if err := tt.Click("Garden maze"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Selected != "f" || changes != 1 {
|
||||
t.Fatalf("selected %q changes %d", s.Selected, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if s.Selected != "a" {
|
||||
t.Fatalf("Down chose %q", s.Selected)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if s.Selected != "b" {
|
||||
t.Fatalf("Down over a header chose %q", s.Selected)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opened != 1 {
|
||||
t.Fatalf("Enter opened %d", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationListDisabled(t *testing.T) {
|
||||
var s ConversationListState
|
||||
items := chatConversations()
|
||||
tt := chatTester(400, 600, func(c *ui.Context) {
|
||||
ConversationList(c, &s, items, ConversationListOptions{Now: chatNow, Disabled: true}, nil).Element.Height(560)
|
||||
})
|
||||
_ = tt.Click("Garden maze")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if s.Selected != "" {
|
||||
t.Fatalf("disabled list chose %q", s.Selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationListVirtualizesAndGroupsOnce(t *testing.T) {
|
||||
items := make([]ChatConversation, 100000)
|
||||
for i := range items {
|
||||
items[i] = ChatConversation{ID: fmt.Sprint(i), Title: fmt.Sprintf("Conversation %d", i), Updated: chatNow.Add(-time.Duration(i) * time.Minute)}
|
||||
}
|
||||
var s ConversationListState
|
||||
tt := chatTester(400, 600, func(c *ui.Context) {
|
||||
ConversationList(c, &s, items, ConversationListOptions{Now: chatNow}, nil).Element.Height(560)
|
||||
})
|
||||
if n := len(tt.Texts()); n > 80 {
|
||||
t.Fatalf("built %d texts for 100000 conversations", n)
|
||||
}
|
||||
// A marked plan survives frames that bring no new data.
|
||||
s.plan.rows[1].item = -7
|
||||
tt.Frame()
|
||||
if s.plan.rows[1].item != -7 {
|
||||
t.Fatal("regrouped without a data change")
|
||||
}
|
||||
s.plan.rows[1].item = 0
|
||||
// Today, Yesterday, Previous 7 days and Older head the rows.
|
||||
if len(s.plan.rows) != len(items)+4 {
|
||||
t.Fatalf("%d rows", len(s.plan.rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationListSnapshot(t *testing.T) {
|
||||
items := chatConversations()
|
||||
snapshot(t, "conversation_list", 360, 420, func(c *ui.Context) {
|
||||
s := &ConversationListState{Selected: "b"}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ConversationList(c, s, items, ConversationListOptions{Now: chatNow, Label: "History"}, nil).Element.Height(380)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"unicode"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ConversationSearchHit is one conversation the caller found for the query.
|
||||
type ConversationSearchHit struct {
|
||||
ID, Title string
|
||||
// Snippet is matching message text, shown under the title.
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// ConversationSearchOptions configure a ConversationSearch.
|
||||
type ConversationSearchOptions struct {
|
||||
// Loading shows the caller's search is under way.
|
||||
Loading bool
|
||||
// Error describes a failed search, shown with a retry button.
|
||||
Error string
|
||||
Placeholder string
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ConversationSearchView is a built ConversationSearch and this frame's events.
|
||||
type ConversationSearchView struct {
|
||||
Element *ui.Element
|
||||
// QueryChanged asks the caller to search for the new query.
|
||||
QueryChanged bool
|
||||
// Chosen is the hit opened this frame, or empty.
|
||||
Chosen string
|
||||
Retry bool
|
||||
}
|
||||
|
||||
type conversationSearchState struct {
|
||||
cursor int
|
||||
rows ui.ListState
|
||||
enter bool
|
||||
}
|
||||
|
||||
type conversationSearchKey struct{}
|
||||
|
||||
// ConversationSearch shows caller-supplied hits for a query, highlighting matches.
|
||||
func ConversationSearch(c *ui.Context, query *string, hits []ConversationSearchHit, opts ConversationSearchOptions) ConversationSearchView {
|
||||
if query == nil {
|
||||
panic("mujica: ConversationSearch needs a query")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled)
|
||||
v := ConversationSearchView{Element: col}
|
||||
st := ui.Local(col, conversationSearchKey{}, func() *conversationSearchState { return &conversationSearchState{cursor: -1} })
|
||||
s := *st
|
||||
s.cursor = min(s.cursor, len(hits)-1)
|
||||
col.Children(func() {
|
||||
in := input.SearchInput(c, query, input.SearchInputOptions{Placeholder: opts.Placeholder, Label: opts.Label, Disabled: opts.Disabled})
|
||||
v.QueryChanged = in.Changed()
|
||||
if in.Input.Shortcut(0, ui.KeyDown) && !in.Input.IsDisabled() && len(hits) > 0 {
|
||||
s.enter, s.cursor = true, max(s.cursor, 0)
|
||||
}
|
||||
switch {
|
||||
case opts.Error != "":
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
|
||||
ui.Text(c, opts.Error).Role(ui.RoleStatus).Grow(1)
|
||||
v.Retry = input.Button(c, core.Msg(c, "conversation_search.retry"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("refresh-cw")}).Clicked()
|
||||
})
|
||||
case opts.Loading && len(hits) == 0:
|
||||
core.Spinner(c, core.SpinnerOptions{Label: core.Msg(c, "conversation_search.searching"), ShowLabel: true})
|
||||
case len(hits) == 0 && *query != "":
|
||||
ui.Text(c, core.Msg(c, "conversation_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||
case len(hits) > 0:
|
||||
v.Chosen = conversationSearchList(c, s, *query, hits, opts.Loading)
|
||||
}
|
||||
})
|
||||
if v.QueryChanged {
|
||||
s.cursor = -1
|
||||
slog.Debug("mujica: conversation_search query", "len", len(*query))
|
||||
}
|
||||
if v.Chosen != "" {
|
||||
slog.Debug("mujica: conversation_search choose", "id", v.Chosen)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func conversationSearchList(c *ui.Context, s *conversationSearchState, q string, hits []ConversationSearchHit, loading bool) string {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
chosen := ""
|
||||
s.rows.Key = func(i int) any { return hits[i].ID }
|
||||
s.rows.Label = func(i int) string { return hits[i].Title }
|
||||
s.rows.Selected = &s.cursor
|
||||
// Rows share one height, so the list sizes itself early.
|
||||
rowH := d.ControlHeight() + core.FontSize(c, theme.CaptionSize)*theme.LineHeight + 2
|
||||
restore := data.DataSelectTheme(c, 0)
|
||||
list := ui.List(c, &s.rows, len(hits), func(i int) {
|
||||
h := hits[i]
|
||||
row := ui.Column(c).Height(rowH).Padding(d.Unit(), d.Unit()*2).Gap(2).Justify(ui.Center).ClipY()
|
||||
if i == s.cursor {
|
||||
row.DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(r.X+3, r.Y+r.H/2, 6), k.AccentText)
|
||||
})
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.RichText(c, conversationSearchSpans(h.Title, q, k)...).SingleLine()
|
||||
if h.Snippet != "" {
|
||||
ui.RichText(c, conversationSearchSpans(h.Snippet, q, k)...).SingleLine().TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
if !row.IsDisabled() && row.Clicked() {
|
||||
chosen = h.ID
|
||||
}
|
||||
}).Height(min(360, float32(len(hits))*rowH+2*d.Unit()+2)).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.ControlRadius).Label(core.Msg(c, "conversation_search.results"))
|
||||
restore()
|
||||
internal.Ring(list, k.Focus, theme.ControlRadius)
|
||||
if s.enter {
|
||||
s.enter = false
|
||||
list.Focus()
|
||||
}
|
||||
if loading {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 12, Label: core.Msg(c, "conversation_search.searching")})
|
||||
}
|
||||
if list.Submitted() && !list.IsDisabled() && s.cursor >= 0 && s.cursor < len(hits) {
|
||||
chosen = hits[s.cursor].ID
|
||||
}
|
||||
return chosen
|
||||
}
|
||||
|
||||
// conversationSearchSpans marks each case-insensitive match of q in text.
|
||||
func conversationSearchSpans(text, q string, k theme.Tokens) []ui.Span {
|
||||
r := []rune(text)
|
||||
var spans []ui.Span
|
||||
at := 0
|
||||
for _, m := range conversationSearchMatches(text, q) {
|
||||
if m[0] > at {
|
||||
spans = append(spans, ui.Span{Text: string(r[at:m[0]])})
|
||||
}
|
||||
spans = append(spans, ui.Span{Text: string(r[m[0]:m[1]]), Weight: 700, Background: k.Selection, Underline: true})
|
||||
at = m[1]
|
||||
}
|
||||
if at < len(r) || len(spans) == 0 {
|
||||
spans = append(spans, ui.Span{Text: string(r[at:])})
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// conversationSearchMatches returns rune ranges where q matches, ignoring case.
|
||||
func conversationSearchMatches(text, q string) [][2]int {
|
||||
t, p := []rune(text), []rune(q)
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out [][2]int
|
||||
for i := 0; i+len(p) <= len(t); {
|
||||
ok := true
|
||||
for j := range p {
|
||||
if unicode.ToLower(t[i+j]) != unicode.ToLower(p[j]) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
out = append(out, [2]int{i, i + len(p)})
|
||||
i += len(p)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// chatSearchBackend answers queries one frame late, as an async caller does.
|
||||
type chatSearchBackend struct {
|
||||
query, asked string
|
||||
hits []ConversationSearchHit
|
||||
loading bool
|
||||
queries []string
|
||||
chosen string
|
||||
err string
|
||||
retries int
|
||||
}
|
||||
|
||||
func (b *chatSearchBackend) view(opts ConversationSearchOptions) func(c *ui.Context) {
|
||||
return func(c *ui.Context) {
|
||||
if b.loading && b.err == "" {
|
||||
b.loading, b.hits = false, nil
|
||||
for _, conv := range chatConversations() {
|
||||
if strings.Contains(strings.ToLower(conv.Title), strings.ToLower(b.asked)) {
|
||||
b.hits = append(b.hits, ConversationSearchHit{ID: conv.ID, Title: conv.Title, Snippet: "about " + conv.Title})
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.Loading, opts.Error = b.loading, b.err
|
||||
v := ConversationSearch(c, &b.query, b.hits, opts)
|
||||
if v.QueryChanged {
|
||||
b.queries = append(b.queries, b.query)
|
||||
b.asked, b.loading = b.query, true
|
||||
c.Invalidate()
|
||||
}
|
||||
if v.Chosen != "" {
|
||||
b.chosen = v.Chosen
|
||||
}
|
||||
if v.Retry {
|
||||
b.retries++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSearchAsyncAndChoose(t *testing.T) {
|
||||
b := &chatSearchBackend{}
|
||||
tt := chatTester(420, 400, b.view(ConversationSearchOptions{Label: "Search conversations"}))
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("garden")
|
||||
tt.Frame()
|
||||
if len(b.queries) == 0 || b.queries[len(b.queries)-1] != "garden" {
|
||||
t.Fatalf("queries %q", b.queries)
|
||||
}
|
||||
if !tt.HasText("Garden maze") {
|
||||
t.Fatalf("results %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Garden maze"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.chosen != "f" {
|
||||
t.Fatalf("chose %q", b.chosen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSearchKeyboard(t *testing.T) {
|
||||
b := &chatSearchBackend{}
|
||||
tt := chatTester(420, 400, b.view(ConversationSearchOptions{Label: "Search conversations"}))
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("o")
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if b.chosen == "" || b.chosen != b.hits[1].ID {
|
||||
t.Fatalf("keys chose %q of %v", b.chosen, b.hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSearchStates(t *testing.T) {
|
||||
b := &chatSearchBackend{query: "zzz", asked: "zzz"}
|
||||
tt := chatTester(420, 400, b.view(ConversationSearchOptions{}))
|
||||
if !tt.HasText("No conversations match") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
b.err = "Search is down"
|
||||
tt.Frame()
|
||||
if !tt.HasText("Search is down") {
|
||||
t.Fatal("error missing")
|
||||
}
|
||||
_ = tt.Click("Retry")
|
||||
if b.retries != 1 {
|
||||
t.Fatalf("retries %d", b.retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSearchHighlights(t *testing.T) {
|
||||
got := conversationSearchMatches("Ballroom SEATING and seats", "seat")
|
||||
if !slices.Equal(got, [][2]int{{9, 13}, {21, 25}}) {
|
||||
t.Fatalf("matches %v", got)
|
||||
}
|
||||
if got := conversationSearchMatches("宫廷礼仪与宫廷音乐", "宫廷"); !slices.Equal(got, [][2]int{{0, 2}, {5, 7}}) {
|
||||
t.Fatalf("CJK matches %v", got)
|
||||
}
|
||||
if conversationSearchMatches("abc", "") != nil {
|
||||
t.Fatal("empty query matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSearchDisabled(t *testing.T) {
|
||||
b := &chatSearchBackend{query: "o", hits: []ConversationSearchHit{{ID: "x", Title: "Royal"}}}
|
||||
tt := chatTester(420, 400, b.view(ConversationSearchOptions{Disabled: true}))
|
||||
_ = tt.Click("Royal")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("q")
|
||||
if b.chosen != "" || b.query != "o" || len(b.queries) != 0 {
|
||||
t.Fatalf("disabled search acted: %q %q %v", b.chosen, b.query, b.queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSearchSnapshot(t *testing.T) {
|
||||
snapshot(t, "conversation_search", 420, 260, func(c *ui.Context) {
|
||||
q := "seat"
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ConversationSearch(c, &q, []ConversationSearchHit{
|
||||
{ID: "b", Title: "Ballroom seating plan", Snippet: "…where to seat the duke…"},
|
||||
{ID: "c", Title: "Theatre seats", Snippet: "Box seats for the opera"},
|
||||
}, ConversationSearchOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CostEstimatorOptions configure a CostEstimator; prices are per million tokens.
|
||||
type CostEstimatorOptions struct {
|
||||
InputTokens, OutputTokens int
|
||||
InputPrice, OutputPrice float64
|
||||
// Currency prefixes amounts; empty is "$".
|
||||
Currency string
|
||||
// Digits is the decimals shown for amounts; zero is 4.
|
||||
Digits int
|
||||
}
|
||||
|
||||
// CostEstimate is the input, output and total cost.
|
||||
type CostEstimate struct{ Input, Output, Total float64 }
|
||||
|
||||
// EstimateCost prices token counts at per-million rates.
|
||||
func EstimateCost(inputTokens, outputTokens int, inputPrice, outputPrice float64) CostEstimate {
|
||||
in := float64(inputTokens) * inputPrice / 1e6
|
||||
out := float64(outputTokens) * outputPrice / 1e6
|
||||
return CostEstimate{in, out, in + out}
|
||||
}
|
||||
|
||||
// CostEstimator shows token counts and cost at caller prices.
|
||||
func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element {
|
||||
if opts.InputTokens < 0 || opts.OutputTokens < 0 {
|
||||
panic("mujica: CostEstimator token counts must not be negative")
|
||||
}
|
||||
for _, p := range []float64{opts.InputPrice, opts.OutputPrice} {
|
||||
if p < 0 || math.IsNaN(p) || math.IsInf(p, 0) {
|
||||
panic("mujica: CostEstimator prices must be finite and not negative")
|
||||
}
|
||||
}
|
||||
if opts.Digits < 0 {
|
||||
panic("mujica: CostEstimator Digits is negative")
|
||||
}
|
||||
digits := opts.Digits
|
||||
if digits == 0 {
|
||||
digits = 4
|
||||
}
|
||||
cur := opts.Currency
|
||||
if cur == "" {
|
||||
cur = "$"
|
||||
}
|
||||
money := func(v float64) string { return cur + strconv.FormatFloat(v, 'f', digits, 64) }
|
||||
est := EstimateCost(opts.InputTokens, opts.OutputTokens, opts.InputPrice, opts.OutputPrice)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
total := fmt.Sprintf(core.Msg(c, "cost_estimator.total_label"), money(est.Total))
|
||||
g := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.FitContent(), ui.FitContent()).GapX(d.Unit()*4).GapY(d.Unit()).
|
||||
Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
|
||||
Role(ui.RoleGroup).Label(total)
|
||||
cell := func(s string, ink ui.Color, right bool) {
|
||||
t := ui.Text(c, s).TextColor(ink).SingleLine().FontFeatures("tnum")
|
||||
if right {
|
||||
t.JustifySelf(ui.End)
|
||||
}
|
||||
}
|
||||
g.Children(func() {
|
||||
cell(core.Msg(c, "cost_estimator.kind"), k.TextMuted, false)
|
||||
cell(core.Msg(c, "cost_estimator.tokens"), k.TextMuted, true)
|
||||
cell(core.Msg(c, "cost_estimator.cost"), k.TextMuted, true)
|
||||
cell(core.Msg(c, "cost_estimator.input"), k.Text, false)
|
||||
cell(strconv.Itoa(opts.InputTokens), k.Text, true)
|
||||
cell(money(est.Input), k.Text, true)
|
||||
cell(core.Msg(c, "cost_estimator.output"), k.Text, false)
|
||||
cell(strconv.Itoa(opts.OutputTokens), k.Text, true)
|
||||
cell(money(est.Output), k.Text, true)
|
||||
ui.Box(c).ColumnSpan(3).Height(theme.BorderWidth).MarginY(d.Unit()).Background(k.Ornament)
|
||||
ui.Text(c, core.Msg(c, "cost_estimator.total")).Font(theme.SerifFont).TextColor(k.Text)
|
||||
cell(strconv.Itoa(opts.InputTokens+opts.OutputTokens), k.Text, true)
|
||||
ui.Text(c, money(est.Total)).Bold().TextColor(k.AccentText).JustifySelf(ui.End).FontFeatures("tnum")
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "cost_estimator.rates"), cur+strconv.FormatFloat(opts.InputPrice, 'f', -1, 64),
|
||||
cur+strconv.FormatFloat(opts.OutputPrice, 'f', -1, 64))).
|
||||
ColumnSpan(3).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
return g
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestCostEstimatorTable(t *testing.T) {
|
||||
tt := chatTester(420, 260, func(c *ui.Context) {
|
||||
CostEstimator(c, CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
|
||||
})
|
||||
for _, s := range []string{"12000", "3400", "15400", "$0.0360", "$0.0510", "$0.0870", "input $3, output $15"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("%q missing from %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt = chatTester(420, 260, func(c *ui.Context) {
|
||||
CostEstimator(c, CostEstimatorOptions{InputTokens: 1e6, InputPrice: 2.5, Currency: "¥", Digits: 2})
|
||||
})
|
||||
if !tt.HasText("¥2.50") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateCost(t *testing.T) {
|
||||
got := EstimateCost(1_000_000, 500_000, 3, 15)
|
||||
if got.Input != 3 || got.Output != 7.5 || got.Total != 10.5 {
|
||||
t.Fatalf("estimate %+v", got)
|
||||
}
|
||||
if z := EstimateCost(0, 0, 3, 15); z.Total != 0 {
|
||||
t.Fatalf("zero tokens cost %v", z.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostEstimatorRejectsBadInput(t *testing.T) {
|
||||
for _, opts := range []CostEstimatorOptions{{InputTokens: -1}, {InputPrice: -1}, {OutputPrice: math.NaN()}, {Digits: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: no panic", opts)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CostEstimator(c, opts)
|
||||
}, 100, 100, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostEstimatorSnapshot(t *testing.T) {
|
||||
snapshot(t, "cost_estimator", 420, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CostEstimator(c, CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DateSeparatorOptions configure a DateSeparator.
|
||||
type DateSeparatorOptions struct {
|
||||
// Today decides "Today" and "Yesterday"; zero uses the window clock.
|
||||
Today core.Date
|
||||
}
|
||||
|
||||
// DateSeparator marks where a day's messages begin, localized.
|
||||
func DateSeparator(c *ui.Context, day core.Date, opts DateSeparatorOptions) *ui.Element {
|
||||
if !day.Valid() {
|
||||
panic("mujica: DateSeparator needs a valid date")
|
||||
}
|
||||
today := opts.Today
|
||||
if today.IsZero() {
|
||||
today = core.DateOf(c.Now())
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
label := dateSeparatorLabel(c, day, today)
|
||||
row := ui.Row(c).FillWidth().Gap(u*3).AlignItems(ui.Center).Padding(u*2, 0).Role(ui.RoleText).Label(label)
|
||||
line := func() {
|
||||
ui.Box(c).Grow(1).Height(theme.BorderWidth).Background(k.Border)
|
||||
}
|
||||
row.Children(func() {
|
||||
line()
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
ui.Box(c).Size(6, 6).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(r.X+3, r.Y+3, 6), k.Ornament)
|
||||
})
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
line()
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// dateSeparatorLabel names day relative to today, in the window's language.
|
||||
func dateSeparatorLabel(c *ui.Context, day, today core.Date) string {
|
||||
switch day {
|
||||
case today:
|
||||
return core.Msg(c, "date_separator.today")
|
||||
case today.AddDays(-1):
|
||||
return core.Msg(c, "date_separator.yesterday")
|
||||
}
|
||||
return fmt.Sprintf(core.Msg(c, "calendar.day_label"), core.Msg(c, fmt.Sprintf("calendar.weekday_long_%d", day.Weekday())),
|
||||
core.Msg(c, fmt.Sprintf("calendar.month_%d", day.Month)), day.Day, day.Year)
|
||||
}
|
||||
|
||||
// DateSeparatorWhen is a short stamp: time, day or date.
|
||||
func DateSeparatorWhen(c *ui.Context, t time.Time, today core.Date) string {
|
||||
day := core.DateOf(t)
|
||||
if day == today {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
if day == today.AddDays(-1) {
|
||||
return core.Msg(c, "date_separator.yesterday")
|
||||
}
|
||||
return fmt.Sprintf(core.Msg(c, "date_separator.short"), core.Msg(c, fmt.Sprintf("calendar.month_%d", day.Month)), day.Day)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestDateSeparatorLabels(t *testing.T) {
|
||||
today := core.DateOf(chatNow)
|
||||
for _, tc := range []struct {
|
||||
locale core.Locale
|
||||
day core.Date
|
||||
want string
|
||||
}{
|
||||
{core.EnUS, today, "Today"},
|
||||
{core.EnUS, today.AddDays(-1), "Yesterday"},
|
||||
{core.EnUS, core.Date{Year: 2026, Month: 9, Day: 1}, "Tuesday, September 1, 2026"},
|
||||
{core.ZhCN, today, "今天"},
|
||||
{core.ZhCN, today.AddDays(-1), "昨天"},
|
||||
{core.ZhCN, core.Date{Year: 2026, Month: 9, Day: 1}, "2026年9月1日 星期二"},
|
||||
} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: tc.locale})
|
||||
DateSeparator(c, tc.day, DateSeparatorOptions{Today: today})
|
||||
}, 500, 80)
|
||||
if !tt.HasText(tc.want) {
|
||||
t.Errorf("%s %v: texts %q", tc.locale, tc.day, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateSeparatorShortStamp(t *testing.T) {
|
||||
var got []string
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
today := core.DateOf(chatNow)
|
||||
got = []string{DateSeparatorWhen(c, chatNow, today), DateSeparatorWhen(c, chatNow.AddDate(0, 0, -1), today),
|
||||
DateSeparatorWhen(c, chatNow.AddDate(0, -2, 0), today)}
|
||||
}, 100, 100, 1)
|
||||
if got[0] != "15:30" || got[1] != "Yesterday" || got[2] != "August 5" {
|
||||
t.Fatalf("stamps %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateSeparatorRejectsInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DateSeparator(c, core.Date{Year: 2026, Month: 2, Day: 30}, DateSeparatorOptions{})
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
func TestDateSeparatorSnapshot(t *testing.T) {
|
||||
snapshot(t, "date_separator", 520, 110, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
DateSeparator(c, core.DateOf(chatNow), DateSeparatorOptions{Today: core.DateOf(chatNow)})
|
||||
DateSeparator(c, core.Date{Year: 2026, Month: 9, Day: 1}, DateSeparatorOptions{Today: core.DateOf(chatNow)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package chat holds MujicaUI chat messages, the chat input and chat sessions.
|
||||
package chat
|
||||
@@ -0,0 +1,101 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DragDropOverlayOptions configure a DragDropOverlay.
|
||||
type DragDropOverlayOptions struct {
|
||||
// Hint replaces the built-in "Drop files to attach".
|
||||
Hint string
|
||||
// Accept checks each dropped path; nil accepts all.
|
||||
Accept func(path string) bool
|
||||
}
|
||||
|
||||
// DropResult carries the overlay and dropped files.
|
||||
type DropResult struct {
|
||||
Element *ui.Element
|
||||
accepted []string
|
||||
rejected []string
|
||||
}
|
||||
|
||||
// Accepted returns accepted paths; Rejected returns refused paths.
|
||||
func (r DropResult) Accepted() []string { return r.accepted }
|
||||
func (r DropResult) Rejected() []string { return r.rejected }
|
||||
|
||||
// DragDropOverlay covers content during external file dragging.
|
||||
func DragDropOverlay(c *ui.Context, opts DragDropOverlayOptions, content func()) DropResult {
|
||||
if content == nil {
|
||||
panic("mujica: DragDropOverlay needs content")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r DropResult
|
||||
r.Element = ui.Box(c).MinHeight(0).MinWidth(0)
|
||||
disabled := r.Element.IsDisabled()
|
||||
rejected := ui.Local(r.Element, "rejected", func() []string { return nil })
|
||||
over := !disabled && r.Element.FileDragOver()
|
||||
if files := r.Element.DroppedFiles(); len(files) > 0 && !disabled {
|
||||
r.accepted, r.rejected = dragDropSplit(files, opts.Accept)
|
||||
*rejected = r.rejected
|
||||
slog.Debug("mujica: drag_drop_overlay drop", "accepted", len(r.accepted), "rejected", len(r.rejected))
|
||||
}
|
||||
if over {
|
||||
*rejected = nil
|
||||
}
|
||||
hint := opts.Hint
|
||||
if hint == "" {
|
||||
hint = core.Msg(c, "drag_drop_overlay.hint")
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
content()
|
||||
if len(*rejected) > 0 && !over {
|
||||
names := make([]string, len(*rejected))
|
||||
for i, p := range *rejected {
|
||||
names[i] = filepath.Base(p)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "drag_drop_overlay.rejected"), strings.Join(names, ", "))).
|
||||
TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus).Padding(u, u*2)
|
||||
}
|
||||
if over {
|
||||
dragDropMask(c, hint)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// dragDropMask covers its parent with the drop hint.
|
||||
func dragDropMask(c *ui.Context, hint string) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
mask := ui.Column(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Gap(u * 2).
|
||||
Background(k.Background.Alpha(0.92)).Radius(theme.CardRadius).PassThrough().Role(ui.RoleStatus).Label(hint)
|
||||
mask.Opacity(core.OverlayEnter(c, mask))
|
||||
mask.Draw(func(p *ui.Painter, b ui.Rect) { internal.Frame(p, b, u*2, 5, k.Ornament) })
|
||||
mask.Children(func() {
|
||||
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
|
||||
ui.Text(c, hint).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
})
|
||||
}
|
||||
|
||||
// dragDropSplit sorts dropped paths by accept, nil accepting all.
|
||||
func dragDropSplit(files []string, accept func(string) bool) (ok, no []string) {
|
||||
for _, f := range files {
|
||||
if accept == nil || accept(f) {
|
||||
ok = append(ok, f)
|
||||
} else {
|
||||
no = append(no, f)
|
||||
}
|
||||
}
|
||||
return ok, no
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestDragDropOverlayIdleShowsContentOnly(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DragDropOverlay(c, DragDropOverlayOptions{}, func() { ui.Text(c, "Chat") })
|
||||
}, 300, 200)
|
||||
if !tt.HasText("Chat") || tt.HasText("Drop files to attach") {
|
||||
t.Fatalf("idle overlay: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDragDropOverlaySplitsByAccept(t *testing.T) {
|
||||
images := func(p string) bool { return strings.HasSuffix(p, ".png") }
|
||||
ok, no := dragDropSplit([]string{"/a.png", "/b.exe", "/c.png"}, images)
|
||||
if !slices.Equal(ok, []string{"/a.png", "/c.png"}) || !slices.Equal(no, []string{"/b.exe"}) {
|
||||
t.Fatalf("ok %v no %v", ok, no)
|
||||
}
|
||||
ok, no = dragDropSplit([]string{"/b.exe"}, nil)
|
||||
if len(ok) != 1 || no != nil {
|
||||
t.Fatalf("nil accept: ok %v no %v", ok, no)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDragDropOverlayNeedsContent(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without content")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DragDropOverlay(c, DragDropOverlayOptions{}, nil)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
// TestDragDropOverlaySnapshot captures the active drop mask.
|
||||
func TestDragDropOverlaySnapshot(t *testing.T) {
|
||||
snapshot(t, "drag_drop_overlay", 420, 220, func(c *ui.Context) {
|
||||
ui.Box(c).Fill().Padding(12).Children(func() {
|
||||
ui.Text(c, "A conversation sits under the mask.")
|
||||
dragDropMask(c, core.Msg(c, "drag_drop_overlay.hint"))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text)
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
|
||||
label := core.Msg(c, "error_message.retry")
|
||||
if wait > 0 {
|
||||
secs := int(math.Ceil(wait.Seconds()))
|
||||
label = fmt.Sprintf(core.Msg(c, "error_message.wait"), secs)
|
||||
// Wake when the shown second changes.
|
||||
c.After(wait - time.Duration(secs-1)*time.Second)
|
||||
}
|
||||
if input.Button(c, label, input.ButtonOptions{Variant: input.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, core.Msg(c, "error_message.limited")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,242 @@
|
||||
package chat_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chat"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func ExamplePromptComposer() {
|
||||
var prompt string
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chat.PromptComposer(c, &prompt, chat.PromptComposerOptions{Placeholder: "Ask the herald"})
|
||||
if r.Submitted() {
|
||||
fmt.Println("send", prompt)
|
||||
prompt = ""
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAttachmentChip() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chat.AttachmentChip(c, chat.Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4})
|
||||
if r.Removed() {
|
||||
fmt.Println("cancel the upload")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePasteImagePreview() {
|
||||
pic := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="4" height="3"/>`))
|
||||
images := []chat.PastedImage{{ID: "p1", Source: pic, Name: "screenshot"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if id, ok := chat.PasteImagePreview(c, images).Removed(); ok {
|
||||
fmt.Println("remove", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDragDropOverlay() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
images := func(path string) bool { return strings.HasSuffix(path, ".png") }
|
||||
r := chat.DragDropOverlay(c, chat.DragDropOverlayOptions{Accept: images}, func() {
|
||||
ui.Text(c, "Drag images into the chat")
|
||||
})
|
||||
for _, path := range r.Accepted() {
|
||||
fmt.Println("attach", path)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleVoiceInputButton() {
|
||||
var rec chat.VoiceInputOptions
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.VoiceInputButton(c, rec).Changed() {
|
||||
rec = chat.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleVoiceWaveform() {
|
||||
levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.VoiceWaveform(c, levels, chat.VoiceWaveformOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSlashCommandMenu() {
|
||||
var prompt string
|
||||
commands := []chat.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
composer := chat.PromptComposer(c, &prompt, chat.PromptComposerOptions{})
|
||||
if cmd, ok := chat.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {
|
||||
fmt.Println("run", cmd.Name)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleContextMentionMenu() {
|
||||
var prompt string
|
||||
var found []chat.ContextItem
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
composer := chat.PromptComposer(c, &prompt, chat.PromptComposerOptions{})
|
||||
r := chat.ContextMentionMenu(c, composer, &prompt, found, chat.ContextMentionOptions{})
|
||||
if r.QueryChanged() {
|
||||
found = []chat.ContextItem{{ID: "f1", Label: r.Query() + ".md", Kind: chat.ContextFile}}
|
||||
}
|
||||
if item, ok := r.Chosen(); ok {
|
||||
fmt.Println("attach", item.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleContextChips() {
|
||||
items := []chat.ContextItem{{ID: "f1", Label: "guests.csv", Kind: chat.ContextFile}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if id, ok := chat.ContextChips(c, items, chat.ContextChipsOptions{}).Removed(); ok {
|
||||
fmt.Println("detach", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleModelSelector() {
|
||||
model := "herald"
|
||||
models := []chat.ModelOption{{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision"}, ContextTokens: 200000}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.ModelSelector(c, &model, models, chat.ModelSelectorOptions{}).Changed() {
|
||||
fmt.Println("model", model)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleModeSelector() {
|
||||
mode := chat.ModeChat
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.ModeSelector(c, &mode, chat.ModeSelectorOptions{}).Changed() {
|
||||
fmt.Println("mode", mode)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolToggleMenu() {
|
||||
web := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
tools := []chat.ToolItem{{ID: "web", Label: "Web search", On: &web}}
|
||||
if id, ok := chat.ToolToggleMenu(c, tools).Changed(); ok {
|
||||
fmt.Println("toggled", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleParameterPanel() {
|
||||
temperature := 1.0
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
params := []chat.ModelParam{{Label: "Temperature", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}
|
||||
chat.ParameterPanel(c, params)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTokenCounter() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.TokenCounter(c, 1234, 8000)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleContextWindowMeter() {
|
||||
segments := []chat.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.ContextWindowMeter(c, segments, 128000, chat.ContextWindowMeterOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSendButton() {
|
||||
streaming := false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chat.SendButton(c, chat.SendButtonOptions{Streaming: streaming, Shortcut: "Enter"})
|
||||
if r.Sent() || r.Stopped() {
|
||||
streaming = r.Sent()
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCitationBadge() {
|
||||
var cites chat.CitationState
|
||||
src := chat.Source{Title: "The Palace of Versailles", URL: "https://example.com/versailles"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.CitationBadge(c, 1, src, &cites).Changed() {
|
||||
fmt.Println("show source", cites.Active)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSourceCard() {
|
||||
var cites chat.CitationState
|
||||
src := chat.Source{Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches."}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.SourceCard(c, 1, src, &cites).Clicked() {
|
||||
fmt.Println("open", src.URL)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSourcesPanel() {
|
||||
var cites chat.CitationState
|
||||
sources := []chat.Source{{Title: "Hall of Mirrors", URL: "https://example.org/hall"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if n, ok := chat.SourcesPanel(c, sources, &cites).Opened(); ok {
|
||||
fmt.Println("open", sources[n-1].URL)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSearchProgress() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
stages := []chat.SearchStage{{Label: "Searching the web", Done: true}, {Label: "Reading sources"}}
|
||||
chat.SearchProgress(c, chat.SearchProgressOptions{Query: "Versailles", Read: 3, Total: 8, Stages: stages})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package chat_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chat"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func ExampleChatContainer() {
|
||||
var thread chat.MessageListState
|
||||
msgs := []string{"Hello", "Bonjour"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.ChatContainer(c, &thread, len(msgs), chat.ChatContainerOptions{
|
||||
List: chat.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 chat.MessageListState
|
||||
ids := []string{"m1", "m2", "m3"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chat.MessageList(c, &list, len(ids), chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.MessageBubble(c, chat.MessageAssistant, chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.MessageHeader(c, chat.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: chat.MessageSent})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStreamingText() {
|
||||
reply := "The decree reads"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.StreamingText(c, reply, chat.StreamingTextOptions{Streaming: true, Smooth: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMarkdownView() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if url, ok := chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.CodeBlock(c, "func bow() {}", chat.CodeBlockOptions{Language: "go", LineNumbers: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTableBlock() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
if i, ok := chat.ImageGrid(c, []chat.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
|
||||
fmt.Println("reload", i)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFileMessage() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chat.FileMessage(c, chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chat.AudioMessage(c, chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
p := chat.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
|
||||
if chat.LinkPreviewCard(c, p).Clicked() {
|
||||
fmt.Println("open", p.URL)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleQuoteReply() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
|
||||
fmt.Println("jump to the quoted message")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageActions() {
|
||||
rating := chat.FeedbackNone
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if a, ok := chat.MessageActions(c, chat.MessageAssistant, chat.MessageActionsOptions{Feedback: &rating}).Action(); ok {
|
||||
fmt.Println("action", a)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMessageEditor() {
|
||||
draft := "Hello"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.MessageEditor(c, &draft, chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.BranchNavigator(c, &branch, 3).Changed() {
|
||||
fmt.Println("show branch", branch)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleErrorMessage() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
opts := chat.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
|
||||
if chat.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.ThinkingBlock(c, &open, chat.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
|
||||
ui.Text(c, "Weigh the guest list.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScrollToBottomButton() {
|
||||
var list chat.MessageListState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Box(c).Fill().Children(func() {
|
||||
if chat.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
|
||||
list.ScrollToEnd()
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTypingIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.TypingIndicator(c, "Herald")
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package chat_test
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chat"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
func ExampleConversationList() {
|
||||
var state chat.ConversationListState
|
||||
convs := []chat.ChatConversation{
|
||||
{ID: "1", Title: "Court etiquette", Updated: time.Now()},
|
||||
{ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.ConversationList(c, &state, convs, chat.ConversationListOptions{Label: "History"}, nil).Changed() {
|
||||
log.Println("open", state.Selected)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleConversationItem() {
|
||||
conv := chat.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
v := chat.ConversationItem(c, &conv, chat.ConversationItemOptions{})
|
||||
if v.Deleted {
|
||||
log.Println("delete", conv.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleConversationSearch() {
|
||||
query := "seat"
|
||||
hits := []chat.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
v := chat.ConversationSearch(c, &query, hits, chat.ConversationSearchOptions{})
|
||||
if v.QueryChanged {
|
||||
log.Println("search", query)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleProjectList() {
|
||||
var state chat.ProjectListState
|
||||
projects := []chat.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}}
|
||||
convs := func(id string) []chat.ChatConversation {
|
||||
return []chat.ChatConversation{{ID: "1", Title: "Menu"}}
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := chat.ProjectList(c, &state, projects, convs, chat.ProjectListOptions{}); v.Moved {
|
||||
log.Println("move", v.MoveID, "to", v.MoveTo)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleProjectKnowledgePanel() {
|
||||
files := []chat.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
v := chat.ProjectKnowledgePanel(c, files, chat.ProjectKnowledgePanelOptions{Capacity: 200000})
|
||||
if v.Add {
|
||||
log.Println("pick files")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleWelcomeScreen() {
|
||||
cards := []chat.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := chat.WelcomeScreen(c, chat.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" {
|
||||
log.Println("ask", v.Example)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSuggestionChips() {
|
||||
input := ""
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, chat.SuggestionChipsOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePromptLibrary() {
|
||||
var state chat.PromptLibraryState
|
||||
templates := []chat.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := chat.PromptLibrary(c, &state, templates, chat.PromptLibraryOptions{}); v.Inserted != "" {
|
||||
log.Println("insert", v.Inserted)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCapabilityCards() {
|
||||
cards := []chat.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := chat.CapabilityCards(c, cards, chat.CapabilityCardsOptions{}); v.Example != "" {
|
||||
log.Println("ask", v.Example)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFeedbackForm() {
|
||||
var value chat.FeedbackValue
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.FeedbackForm(c, &value, chat.FeedbackFormOptions{Rating: chat.FeedbackDown}).Submitted {
|
||||
log.Println("reasons", value.Reasons)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDateSeparator() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.DateSeparator(c, core.DateOf(time.Now()), chat.DateSeparatorOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleThinkingIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.ThinkingIndicator(c, chat.ThinkingIndicatorOptions{Phase: "Searching the archives"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStopGeneratingButton() {
|
||||
generating := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.StopGeneratingButton(c, chat.StopGeneratingButtonOptions{Generating: generating}).Changed() {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSystemPromptEditor() {
|
||||
prompt, saved := "You are helpful.", "You are helpful."
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.SystemPromptEditor(c, &prompt, chat.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save {
|
||||
saved = prompt
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCostEstimator() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chat.CostEstimator(c, chat.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSharedConversationView() {
|
||||
msgs := []chat.SessionMessage{{Role: chat.SessionUser, Text: "Hello"}, {Role: chat.SessionAssistant, Text: "Good day."}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.SharedConversationView(c, msgs, chat.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() {
|
||||
c.WriteClipboard("https://example.com/share/1")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleConversationExport() {
|
||||
var state chat.ConversationExportState
|
||||
msgs := []chat.SessionMessage{{Role: chat.SessionUser, Text: "Hello"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := chat.ConversationExport(c, &state, msgs, chat.ConversationExportOptions{Title: "Greeting"}); v.Exported {
|
||||
log.Println(len(v.Content), "bytes to save")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleRegenerateMenu() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := chat.RegenerateMenu(c, chat.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen {
|
||||
log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleArtifactCard() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.ArtifactCard(c, chat.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() {
|
||||
log.Println("open artifact panel")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChatEmptyState() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if chat.ChatEmptyState(c, chat.ChatEmptyStateOptions{Kind: chat.ChatEmptyOffline}).Action {
|
||||
log.Println("reconnect")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// FeedbackRating is the thumb given; zero is FeedbackUp.
|
||||
type FeedbackRating int
|
||||
|
||||
const (
|
||||
FeedbackUp FeedbackRating = iota
|
||||
FeedbackDown
|
||||
)
|
||||
|
||||
// FeedbackValue is the chosen reasons and a comment.
|
||||
type FeedbackValue struct {
|
||||
Reasons []string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// FeedbackFormOptions configure a FeedbackForm.
|
||||
type FeedbackFormOptions struct {
|
||||
Rating FeedbackRating
|
||||
// Reasons replaces the built-in reasons for the rating.
|
||||
Reasons []string
|
||||
// Submitting shows the caller sending; it blocks the form.
|
||||
Submitting bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// FeedbackFormView is a built FeedbackForm and this frame's outcome.
|
||||
type FeedbackFormView struct {
|
||||
Element *ui.Element
|
||||
Submitted, Cancelled bool
|
||||
}
|
||||
|
||||
var feedbackReasons = [2][]string{
|
||||
{"feedback_form.up.accurate", "feedback_form.up.helpful", "feedback_form.up.clear"},
|
||||
{"feedback_form.down.wrong", "feedback_form.down.unhelpful", "feedback_form.down.unsafe", "feedback_form.down.style"},
|
||||
}
|
||||
|
||||
// FeedbackForm asks for reasons and a comment after a rating.
|
||||
func FeedbackForm(c *ui.Context, value *FeedbackValue, opts FeedbackFormOptions) FeedbackFormView {
|
||||
if value == nil {
|
||||
panic("mujica: FeedbackForm needs a value")
|
||||
}
|
||||
if opts.Rating != FeedbackUp && opts.Rating != FeedbackDown {
|
||||
panic("mujica: FeedbackForm Rating out of range")
|
||||
}
|
||||
reasons := opts.Reasons
|
||||
if reasons == nil {
|
||||
for _, key := range feedbackReasons[opts.Rating] {
|
||||
reasons = append(reasons, core.Msg(c, key))
|
||||
}
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
blocked := opts.Disabled || opts.Submitting
|
||||
col := ui.Column(c).Gap(d.Unit()*3).Padding(d.Unit()*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Role(ui.RoleGroup).Disabled(blocked)
|
||||
v := FeedbackFormView{Element: col}
|
||||
title := map[FeedbackRating]string{FeedbackUp: "feedback_form.up.title", FeedbackDown: "feedback_form.down.title"}[opts.Rating]
|
||||
col.Label(core.Msg(c, title))
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, core.Msg(c, title)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
})
|
||||
ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Children(func() {
|
||||
for _, r := range reasons {
|
||||
on := slices.Contains(value.Reasons, r)
|
||||
was := on
|
||||
input.Toggle(c, &on, r, input.ToggleOptions{Disabled: blocked})
|
||||
if on != was && !col.IsDisabled() {
|
||||
if on {
|
||||
value.Reasons = append(slices.Clone(value.Reasons), r)
|
||||
} else {
|
||||
value.Reasons = slices.DeleteFunc(slices.Clone(value.Reasons), func(x string) bool { return x == r })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
input.TextArea(c, &value.Comment, input.TextAreaOptions{Placeholder: core.Msg(c, "feedback_form.comment"), Label: core.Msg(c, "feedback_form.comment"),
|
||||
MinHeight: 72, Disabled: blocked})
|
||||
ui.Row(c).Gap(d.Unit() * 2).Justify(ui.End).Children(func() {
|
||||
v.Cancelled = input.Button(c, core.Msg(c, "feedback_form.cancel"), input.ButtonOptions{Variant: input.Ghost, Disabled: blocked}).Clicked()
|
||||
v.Submitted = input.Button(c, core.Msg(c, "feedback_form.submit"), input.ButtonOptions{Icon: icons.Must("check"), Loading: opts.Submitting, Disabled: opts.Disabled}).Clicked()
|
||||
})
|
||||
})
|
||||
if !col.IsDisabled() && col.Shortcut(0, ui.KeyEscape) {
|
||||
v.Cancelled = true
|
||||
}
|
||||
if col.IsDisabled() {
|
||||
v.Submitted, v.Cancelled = false, false
|
||||
}
|
||||
switch {
|
||||
case v.Submitted:
|
||||
slog.Debug("mujica: feedback_form submit", "rating", opts.Rating, "reasons", len(value.Reasons))
|
||||
case v.Cancelled:
|
||||
slog.Debug("mujica: feedback_form cancel")
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestFeedbackFormReasonsAndSubmit(t *testing.T) {
|
||||
var v FeedbackValue
|
||||
var last FeedbackFormView
|
||||
tt := chatTester(480, 420, func(c *ui.Context) {
|
||||
if r := FeedbackForm(c, &v, FeedbackFormOptions{Rating: FeedbackDown}); r.Submitted || r.Cancelled {
|
||||
last = r
|
||||
}
|
||||
})
|
||||
if !tt.HasText("What went wrong?") || !tt.HasText("Harmful or unsafe") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Incorrect")
|
||||
_ = tt.Click("Not helpful")
|
||||
_ = tt.Click("Incorrect")
|
||||
if !slices.Equal(v.Reasons, []string{"Not helpful"}) {
|
||||
t.Fatalf("reasons %q", v.Reasons)
|
||||
}
|
||||
_ = tt.Click("Tell us more (optional)")
|
||||
tt.Type("Wrong date")
|
||||
_ = tt.Click("Submit")
|
||||
if !last.Submitted || v.Comment != "Wrong date" {
|
||||
t.Fatalf("submit %+v comment %q", last, v.Comment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackFormCancelAndUpReasons(t *testing.T) {
|
||||
var v FeedbackValue
|
||||
var last FeedbackFormView
|
||||
tt := chatTester(480, 420, func(c *ui.Context) {
|
||||
if r := FeedbackForm(c, &v, FeedbackFormOptions{Reasons: []string{"Elegant"}}); r.Submitted || r.Cancelled {
|
||||
last = r
|
||||
}
|
||||
})
|
||||
if !tt.HasText("What did you like?") || !tt.HasText("Elegant") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if !last.Cancelled {
|
||||
t.Fatal("Escape did not cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackFormBlocked(t *testing.T) {
|
||||
for _, opts := range []FeedbackFormOptions{{Disabled: true}, {Submitting: true}} {
|
||||
var v FeedbackValue
|
||||
submitted := false
|
||||
tt := chatTester(480, 420, func(c *ui.Context) {
|
||||
if FeedbackForm(c, &v, opts).Submitted {
|
||||
submitted = true
|
||||
}
|
||||
})
|
||||
_ = tt.Click("Accurate")
|
||||
_ = tt.Click("Submit")
|
||||
if submitted || len(v.Reasons) != 0 {
|
||||
t.Fatalf("%+v: blocked form acted", opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackFormSnapshot(t *testing.T) {
|
||||
snapshot(t, "feedback_form", 480, 300, func(c *ui.Context) {
|
||||
v := FeedbackValue{Reasons: []string{"Incorrect"}}
|
||||
ui.Column(c).Padding(16).Children(func() { FeedbackForm(c, &v, FeedbackFormOptions{Rating: FeedbackDown}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
})
|
||||
if open.Clicked() && !disabled {
|
||||
r.opened = true
|
||||
slog.Debug("mujica: file_message open")
|
||||
}
|
||||
if core.IconAction(c, icons.Must("download"), fmt.Sprintf(core.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(core.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,84 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestFileMessageOpenDownloadAndSize(t *testing.T) {
|
||||
opened, downloaded, off := 0, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,36 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
// snapshotAfter is snapshot with drive sending input before the capture.
|
||||
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
|
||||
}
|
||||
|
||||
func encode(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
return snaptest.Encode(t, img)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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 []data.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, data.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 := display.Image(c, im.Source, display.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)
|
||||
data.ImageViewer(c, &st.open, &st.index, viewable)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,50 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.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(core.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(core.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,64 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,127 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.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 := core.Tokens(c)
|
||||
u := core.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(core.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 := core.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,66 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,112 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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(core.Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).
|
||||
Role(ui.RoleToolbar).Label(core.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], core.Msg(c, ic[1]), &on) && !disabled {
|
||||
hit = true
|
||||
if on {
|
||||
*opts.Feedback = want
|
||||
} else {
|
||||
*opts.Feedback = FeedbackNone
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hit = core.IconAction(c, icons.Must(ic[0]), core.Msg(c, ic[1])).Tooltip(core.Msg(c, ic[1])).Clicked() && !disabled
|
||||
}
|
||||
if hit {
|
||||
r.action = a
|
||||
slog.Debug("mujica: message_actions action", "action", a)
|
||||
}
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,116 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
label := core.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(core.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(core.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,63 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
)
|
||||
|
||||
func TestMessageBubbleRolesAlignAndLimit(t *testing.T) {
|
||||
var user, bot, sys ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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() { display.Avatar(c, "Bot", display.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) {
|
||||
core.Use(c, core.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() { display.Avatar(c, "Herald", display.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,60 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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 := core.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 := input.TextArea(c, draft, input.TextAreaOptions{Label: core.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 input.Button(c, core.Msg(c, "message_editor.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() || escape {
|
||||
r.canceled = !disabled
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "message_editor.save"), input.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,64 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func messageEditorTester(draft *string, saves, cancels *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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,71 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.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(core.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 == "" {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 12, Label: core.Msg(c, key)})
|
||||
} else {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12)
|
||||
}
|
||||
ui.Text(c, core.Msg(c, key)).SingleLine()
|
||||
})
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.Settings{Locale: core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,109 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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 := core.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(core.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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
label := t.Format(core.Msg(c, "message_list.date"))
|
||||
now := c.Now().In(t.Location())
|
||||
switch {
|
||||
case messageListSameDay(t, now):
|
||||
label = core.Msg(c, "message_list.today")
|
||||
case messageListSameDay(t, now.AddDate(0, 0, -1)):
|
||||
label = core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
rule()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,91 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChatMode is how the assistant answers.
|
||||
type ChatMode int
|
||||
|
||||
const (
|
||||
ModeChat ChatMode = iota
|
||||
ModeSearch
|
||||
ModeResearch
|
||||
ModeAgent
|
||||
)
|
||||
|
||||
// chatModes names each mode's copy key and icon, in order.
|
||||
var chatModes = [...][2]string{
|
||||
{"mode_selector.chat", "message-square"},
|
||||
{"mode_selector.search", "globe"},
|
||||
{"mode_selector.research", "telescope"},
|
||||
{"mode_selector.agent", "bot"},
|
||||
}
|
||||
|
||||
// ModeSelectorOptions configure a ModeSelector.
|
||||
type ModeSelectorOptions struct {
|
||||
// IconsOnly hides the labels, which then name the icons.
|
||||
IconsOnly bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ModeSelector switches conversation modes through clicks or arrow keys.
|
||||
func ModeSelector(c *ui.Context, mode *ChatMode, opts ModeSelectorOptions) core.ChoiceResult {
|
||||
if mode == nil || *mode < ModeChat || *mode > ModeAgent {
|
||||
panic("mujica: unknown ChatMode")
|
||||
}
|
||||
k, u := core.Tokens(c), core.Density(c).Unit()
|
||||
old, pick := *mode, int(*mode)
|
||||
wrapper := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
|
||||
var track *ui.Element
|
||||
wrapper.Children(func() {
|
||||
parts := ui.SegmentedBase(c, &pick, len(chatModes))
|
||||
track = parts.Track.Padding(2).Gap(2).Radius(theme.ControlRadius+2).Background(k.Background).
|
||||
Border(theme.BorderWidth, k.ControlBorder).MinHeight(core.Density(c).ControlHeight()).Label(core.Msg(c, "mode_selector.label"))
|
||||
track.Children(func() {
|
||||
for i, m := range chatModes {
|
||||
label := core.Msg(c, m[0])
|
||||
seg := parts.Segment(i).MinWidth(theme.MinHitSize).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius).Label(label)
|
||||
on := i == pick
|
||||
fg := k.TextMuted
|
||||
switch {
|
||||
case on:
|
||||
seg.Background(k.Selection).FontWeight(600).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 3, W: r.W - 8, H: 2}, k.AccentText, 0)
|
||||
})
|
||||
fg = k.AccentText
|
||||
case seg.Pressed():
|
||||
seg.Background(k.Selection)
|
||||
fg = k.Text
|
||||
case seg.Hovered():
|
||||
seg.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
internal.Ease(seg, core.Motion(c, theme.StateDuration), fg)
|
||||
internal.Ring(seg, k.Focus, theme.ControlRadius)
|
||||
if opts.IconsOnly {
|
||||
seg.Tooltip(label)
|
||||
}
|
||||
seg.Children(func() {
|
||||
ui.Icon(c, icons.Must(m[1])).Size(16, 16)
|
||||
if !opts.IconsOnly {
|
||||
ui.Text(c, label).SingleLine()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
changed := ChatMode(pick) != old && !wrapper.IsDisabled()
|
||||
if changed {
|
||||
*mode = ChatMode(pick)
|
||||
slog.Debug("mujica: mode_selector changed", "mode", pick)
|
||||
}
|
||||
return core.NewChoiceResult(track, changed)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestModeSelectorClickAndArrows(t *testing.T) {
|
||||
mode, n, off := ModeChat, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if ModeSelector(c, &mode, ModeSelectorOptions{Disabled: off}).Changed() {
|
||||
n++
|
||||
}
|
||||
}, 600, 100)
|
||||
_ = tt.Click("Deep research")
|
||||
if mode != ModeResearch || n != 1 {
|
||||
t.Fatalf("click: mode %d changes %d", mode, n)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if mode != ModeAgent {
|
||||
t.Fatalf("Right: mode %d", mode)
|
||||
}
|
||||
off = true
|
||||
_ = tt.Click("Chat")
|
||||
if mode != ModeAgent {
|
||||
t.Fatal("disabled selector changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeSelectorIconsOnlyAndBadMode(t *testing.T) {
|
||||
mode := ModeSearch
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ModeSelector(c, &mode, ModeSelectorOptions{IconsOnly: true})
|
||||
}, 300, 100)
|
||||
if _, ok := tt.Find("Search"); !ok {
|
||||
t.Fatal("icon segments unnamed")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an unknown mode")
|
||||
}
|
||||
}()
|
||||
bad := ChatMode(9)
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ModeSelector(c, &bad, ModeSelectorOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestModeSelectorSnapshot(t *testing.T) {
|
||||
mode := ModeResearch
|
||||
snapshot(t, "mode_selector", 520, 120, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
ModeSelector(c, &mode, ModeSelectorOptions{})
|
||||
ModeSelector(c, &mode, ModeSelectorOptions{IconsOnly: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestModeSelectorSelectionSurface(t *testing.T) {
|
||||
mode := ModeResearch
|
||||
tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); ModeSelector(c, &mode, ModeSelectorOptions{}) }, 600, 100)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
b, _ := tt.Find("Deep research")
|
||||
pixel := tt.Image().RGBAAt(int(b.X+4), int(b.Y+4))
|
||||
k := theme.Light()
|
||||
if pixel.R != k.Selection.R || pixel.G != k.Selection.G || pixel.B != k.Selection.B {
|
||||
t.Fatalf("selected surface %v, want Selection %v", pixel, k.Selection)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ModelOption is one model a ModelSelector offers.
|
||||
type ModelOption struct {
|
||||
// ID is the value chosen; required.
|
||||
ID string
|
||||
Name string
|
||||
// Capabilities are short tags, as "Vision" or "Tools".
|
||||
Capabilities []string
|
||||
// ContextTokens is the context length; zero hides it.
|
||||
ContextTokens int
|
||||
// Unavailable explains disabled models; empty permits selection.
|
||||
Unavailable string
|
||||
}
|
||||
|
||||
// ModelSelectorOptions configure a ModelSelector.
|
||||
type ModelSelectorOptions struct {
|
||||
Disabled bool
|
||||
// Label names the selector; empty uses the built-in "Model".
|
||||
Label string
|
||||
}
|
||||
|
||||
// ModelSelector chooses models with capabilities and availability details.
|
||||
func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts ModelSelectorOptions) core.ChoiceResult {
|
||||
if value == nil {
|
||||
panic("mujica: ModelSelector needs value")
|
||||
}
|
||||
for _, m := range models {
|
||||
if m.ID == "" || m.Name == "" || m.ContextTokens < 0 {
|
||||
panic("mujica: ModelOption needs ID, Name and non-negative ContextTokens")
|
||||
}
|
||||
}
|
||||
k, u := core.Tokens(c), core.Density(c).Unit()
|
||||
var tr *ui.Element
|
||||
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { tr = ui.ButtonBase(c).Role(ui.RolePopUpButton) })
|
||||
st := ui.Local(tr, "model-selector", func() modelSelectorState { return modelSelectorState{hi: -1, lastHi: -1} })
|
||||
disabled := tr.IsDisabled()
|
||||
if disabled {
|
||||
st.open = false
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "model_selector.label")
|
||||
}
|
||||
tr.Label(label)
|
||||
old := *value
|
||||
choose := func(i int) {
|
||||
if !disabled && i >= 0 && i < len(models) && models[i].Unavailable == "" {
|
||||
*value = models[i].ID
|
||||
st.open = false
|
||||
tr.Focus()
|
||||
}
|
||||
}
|
||||
if tr.Clicked() && !disabled {
|
||||
if st.open {
|
||||
choose(st.hi)
|
||||
} else {
|
||||
st.open = true
|
||||
st.hi = modelSelectorIndex(models, *value)
|
||||
}
|
||||
}
|
||||
if !disabled {
|
||||
move := 0
|
||||
if tr.Shortcut(0, ui.KeyDown) {
|
||||
move = 1
|
||||
}
|
||||
if tr.Shortcut(0, ui.KeyUp) {
|
||||
move = -1
|
||||
}
|
||||
if move != 0 {
|
||||
if !st.open {
|
||||
st.open = true
|
||||
st.hi = modelSelectorIndex(models, *value)
|
||||
} else {
|
||||
for i := st.hi + move; i >= 0 && i < len(models); i += move {
|
||||
if models[i].Unavailable == "" {
|
||||
st.hi = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tr.Shortcut(0, ui.KeyHome) {
|
||||
st.open = true
|
||||
st.hi = modelSelectorIndex(models, "")
|
||||
}
|
||||
if tr.Shortcut(0, ui.KeyEnd) {
|
||||
st.open = true
|
||||
for i := len(models) - 1; i >= 0; i-- {
|
||||
if models[i].Unavailable == "" {
|
||||
st.hi = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.hi >= len(models) || st.hi >= 0 && models[st.hi].Unavailable != "" {
|
||||
st.hi = modelSelectorIndex(models, *value)
|
||||
}
|
||||
cur, has := modelSelectorFind(models, *value)
|
||||
input.SelectField(c, tr, st.open, func() {
|
||||
if has {
|
||||
ui.Text(c, cur.Name).SingleLine().FontWeight(600).Grow(1).Shrink(1)
|
||||
} else {
|
||||
ui.Text(c, core.Msg(c, "model_selector.placeholder")).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1)
|
||||
}
|
||||
})
|
||||
if st.open {
|
||||
ww, wh := c.Size()
|
||||
ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) {
|
||||
core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, tr.Bounds()))
|
||||
a := tr.Bounds()
|
||||
room := max(a.Y, wh-a.Y-a.H) - 8
|
||||
panel.Margin(4, 0, 0, 0).Padding(4).Width(min(float32(360), max(0, ww-8))).Height(min(float32(360), max(0, room))).Label(label)
|
||||
if st.hi != st.lastHi {
|
||||
st.list.ScrollIntoView(max(st.hi, 0))
|
||||
st.lastHi = st.hi
|
||||
}
|
||||
ui.List(c, &st.list, len(models), func(i int) {
|
||||
m := models[i]
|
||||
hi, chosen := i == st.hi, m.ID == *value
|
||||
row := ui.Row(c).MinHeight(core.Density(c).ControlHeight()).Disabled(m.Unavailable != "").Description(m.Unavailable).
|
||||
Padding(u*2, u*3, u*2, u*2).Gap(u * 2).AlignItems(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer).Selected(chosen)
|
||||
if chosen {
|
||||
row.Background(k.Selection).TextColor(k.Text)
|
||||
} else if hi || row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
if hi {
|
||||
row.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) })
|
||||
}
|
||||
row.Children(func() { input.SelectCheck(c, chosen); modelSelectorRow(c, m) })
|
||||
if row.Clicked() && !row.IsDisabled() {
|
||||
choose(i)
|
||||
}
|
||||
}).Fill()
|
||||
})
|
||||
}
|
||||
changed := *value != old
|
||||
if changed {
|
||||
slog.Debug("mujica: model_selector changed", "model", *value)
|
||||
}
|
||||
return core.NewChoiceResult(tr, changed)
|
||||
}
|
||||
|
||||
type modelSelectorState struct {
|
||||
open bool
|
||||
hi, lastHi int
|
||||
list ui.ListState
|
||||
}
|
||||
|
||||
func modelSelectorIndex(models []ModelOption, id string) int {
|
||||
first := -1
|
||||
for i, m := range models {
|
||||
if m.Unavailable != "" {
|
||||
continue
|
||||
}
|
||||
if first < 0 {
|
||||
first = i
|
||||
}
|
||||
if m.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// modelSelectorRow shows a model's name, tags, context and unavailability.
|
||||
func modelSelectorRow(c *ui.Context, m ModelOption) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Column(c).Gap(u).Grow(1).MinWidth(0).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, m.Name).FontWeight(600).SingleLine()
|
||||
if m.ContextTokens > 0 {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "model_selector.context"), modelSelectorTokens(m.ContextTokens))).
|
||||
FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
|
||||
}
|
||||
})
|
||||
if len(m.Capabilities) > 0 {
|
||||
ui.Row(c).Gap(u).Wrap().Children(func() {
|
||||
for _, cap := range m.Capabilities {
|
||||
ui.Text(c, cap).FontSize(small).Padding(0, u*1.5).Radius(theme.ControlRadius).
|
||||
Border(theme.BorderWidth, k.Border).TextColor(k.AccentText).SingleLine()
|
||||
}
|
||||
})
|
||||
}
|
||||
if m.Unavailable != "" {
|
||||
ui.Text(c, m.Unavailable).FontSize(small).TextColor(k.TextMuted).Italic()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func modelSelectorFind(models []ModelOption, id string) (ModelOption, bool) {
|
||||
for _, m := range models {
|
||||
if m.ID == id {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return ModelOption{}, false
|
||||
}
|
||||
|
||||
// modelSelectorTokens writes a token count as 8K, 200K or 1M.
|
||||
func modelSelectorTokens(n int) string {
|
||||
switch {
|
||||
case n >= 1_000_000 && n%100_000 == 0:
|
||||
return strconv.FormatFloat(float64(n)/1e6, 'f', -1, 64) + "M"
|
||||
case n >= 1000:
|
||||
return strconv.Itoa(n/1000) + "K"
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var modelsSample = []ModelOption{
|
||||
{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision", "Tools"}, ContextTokens: 200000},
|
||||
{ID: "page", Name: "Page Mini", Capabilities: []string{"Fast"}, ContextTokens: 32000},
|
||||
{ID: "oracle", Name: "Oracle", ContextTokens: 1000000, Unavailable: "Needs the Court plan"},
|
||||
}
|
||||
|
||||
func modelTester(v *string, changes *int, off *bool) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if ModelSelector(c, v, modelsSample, ModelSelectorOptions{Disabled: *off}).Changed() {
|
||||
*changes++
|
||||
}
|
||||
})
|
||||
}, 500, 500)
|
||||
}
|
||||
|
||||
func TestModelSelectorShowsDetailsAndChooses(t *testing.T) {
|
||||
v, n, off := "herald", 0, false
|
||||
tt := modelTester(&v, &n, &off)
|
||||
_ = tt.Click("Model")
|
||||
for _, s := range []string{"200K context", "Vision", "1M context", "Needs the Court plan", "32K context"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Oracle")
|
||||
if v != "herald" || n != 0 {
|
||||
t.Fatalf("unavailable model chosen: %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != "page" || n != 1 {
|
||||
t.Fatalf("keyboard choice %q changes %d (Down stops before the unavailable model)", v, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSelectorDisabled(t *testing.T) {
|
||||
v, n, off := "herald", 0, true
|
||||
tt := modelTester(&v, &n, &off)
|
||||
_ = tt.Click("Model")
|
||||
if tt.HasText("Page Mini") {
|
||||
t.Fatal("disabled selector opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSelectorTokens(t *testing.T) {
|
||||
for n, want := range map[int]string{512: "512", 8000: "8K", 200000: "200K", 1000000: "1M", 1500000: "1.5M"} {
|
||||
if got := modelSelectorTokens(n); got != want {
|
||||
t.Errorf("%d: %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSelectorSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "model_selector", 440, 330, func(c *ui.Context) {
|
||||
v := ui.Local(c.Root(), "v", func() string { return "herald" })
|
||||
ui.Column(c).Padding(16).Children(func() { ModelSelector(c, v, modelsSample, ModelSelectorOptions{}) })
|
||||
}, func(tt *ui.Tester) { _ = tt.Click("Model") })
|
||||
}
|
||||
|
||||
func TestModelSelectorAncestorDisabledCloses(t *testing.T) {
|
||||
v, off, changes := "herald", false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(off).Children(func() {
|
||||
if ModelSelector(c, &v, modelsSample, ModelSelectorOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
})
|
||||
}, 400, 400)
|
||||
_ = tt.Click("Model")
|
||||
off = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.HasText("Page Mini") || v != "herald" || changes != 0 {
|
||||
t.Fatalf("disabled popup: value %q changes %d texts %q", v, changes, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSelectorLargeDataAndEnd(t *testing.T) {
|
||||
models := make([]ModelOption, 100000)
|
||||
for i := range models {
|
||||
models[i] = ModelOption{ID: fmt.Sprint(i), Name: fmt.Sprintf("Model %d", i)}
|
||||
}
|
||||
v := "0"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ModelSelector(c, &v, models, ModelSelectorOptions{})
|
||||
}, 400, 300)
|
||||
_ = tt.Click("Model")
|
||||
if n := len(tt.Texts()); n > 200 {
|
||||
t.Errorf("built %d labels, want <= 200", n)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != "99999" {
|
||||
t.Errorf("End choice %q, want 99999", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSelectorPopupFitsWindow(t *testing.T) {
|
||||
value := "0"
|
||||
models := make([]ModelOption, 100)
|
||||
for i := range models {
|
||||
models[i] = ModelOption{ID: fmt.Sprint(i), Name: fmt.Sprintf("Model %d", i)}
|
||||
}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() { ModelSelector(c, &value, models, ModelSelectorOptions{}) })
|
||||
}, 440, 330)
|
||||
_ = tt.Click("Model")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
b, ok := tt.Find("Model 99")
|
||||
if !ok || b.H == 0 || b.Y < 0 || b.Y+b.H > 330 {
|
||||
t.Fatalf("last available model outside window %v found %v", b, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ModelParam is one generation parameter, as temperature or top-p.
|
||||
type ModelParam struct {
|
||||
// Label and Value are required.
|
||||
Label string
|
||||
Value *float64
|
||||
// Min, Max and Step define the grid; Default selects reset.
|
||||
Min, Max, Step, Default float64
|
||||
}
|
||||
|
||||
// ParameterPanel displays bounded generation sliders and reset actions.
|
||||
func ParameterPanel(c *ui.Context, params []ModelParam) core.ChoiceResult {
|
||||
for _, p := range params {
|
||||
if p.Label == "" || p.Value == nil || !(p.Min < p.Max) || !(p.Step > 0) || !(p.Default >= p.Min && p.Default <= p.Max) {
|
||||
panic("mujica: ModelParam needs Label, Value, Min < Max, Step > 0 and Default in range")
|
||||
}
|
||||
for _, v := range []float64{p.Min, p.Max, p.Step, p.Default, *p.Value} {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
panic("mujica: ModelParam values must be finite")
|
||||
}
|
||||
}
|
||||
if *p.Value < p.Min || *p.Value > p.Max {
|
||||
panic("mujica: ModelParam value must be in range")
|
||||
}
|
||||
spec := input.NumberInputResolve(input.NumberInputOptions{Min: p.Min, Max: p.Max, Step: p.Step, Precision: input.SliderDecimals(p.Min, p.Step)})
|
||||
if spec.StepBy(p.Default, 0) != p.Default {
|
||||
panic("mujica: ModelParam default must lie on the step grid")
|
||||
}
|
||||
if spec.StepBy(*p.Value, 0) != *p.Value {
|
||||
panic("mujica: ModelParam value must lie on the step grid")
|
||||
}
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Column(c).Gap(u * 4).MinWidth(240).Role(ui.RoleGroup).Label(core.Msg(c, "parameter_panel.label"))
|
||||
disabled := r.Element.IsDisabled()
|
||||
set := func(p ModelParam, v float64) {
|
||||
if *p.Value != v && !disabled {
|
||||
*p.Value, r = v, core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: parameter_panel set", "param", p.Label, "value", v)
|
||||
}
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
for _, p := range params {
|
||||
format := func(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', input.SliderDecimals(p.Min, p.Step, p.Default), 64)
|
||||
}
|
||||
ui.Column(c).Key(p.Label).Gap(u).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, p.Label).FontWeight(600).Grow(1).SingleLine()
|
||||
ui.Text(c, format(*p.Value)).FontFeatures("tnum").TextColor(k.AccentText).SingleLine()
|
||||
ui.Row(c).Disabled(*p.Value == p.Default).Children(func() {
|
||||
if core.IconAction(c, icons.Must("rotate-ccw"), fmt.Sprintf(core.Msg(c, "parameter_panel.reset"), p.Label)).
|
||||
Tooltip(fmt.Sprintf(core.Msg(c, "parameter_panel.default"), format(p.Default))).Clicked() {
|
||||
set(p, p.Default)
|
||||
}
|
||||
})
|
||||
})
|
||||
v := *p.Value
|
||||
if input.Slider(c, &v, input.SliderOptions{Min: p.Min, Max: p.Max, Step: p.Step, Label: p.Label, Disabled: disabled}).Changed() {
|
||||
set(p, v)
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
ui.Text(c, format(p.Min)).FontSize(small).TextColor(k.TextMuted).Grow(1)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "parameter_panel.default"), format(p.Default))).FontSize(small).TextColor(k.TextMuted)
|
||||
ui.Text(c, format(p.Max)).FontSize(small).TextColor(k.TextMuted).Grow(1).TextAlign(ui.End)
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
if input.Button(c, core.Msg(c, "parameter_panel.reset_all"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() {
|
||||
for _, p := range params {
|
||||
set(p, p.Default)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func paramSample(temp, topP *float64) []ModelParam {
|
||||
return []ModelParam{
|
||||
{Label: "Temperature", Value: temp, Min: 0, Max: 2, Step: 0.1, Default: 1},
|
||||
{Label: "Top-p", Value: topP, Min: 0, Max: 1, Step: 0.05, Default: 1},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterPanelAdjustAndReset(t *testing.T) {
|
||||
temp, topP, n, off := 1.0, 0.9, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Disabled(off).Children(func() {
|
||||
if ParameterPanel(c, paramSample(&temp, &topP)).Changed() {
|
||||
n++
|
||||
}
|
||||
})
|
||||
}, 500, 400)
|
||||
for _, s := range []string{"1.0", "0.90", "Default 1.0", "2.0"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if temp != 1.1 || n != 1 {
|
||||
t.Fatalf("Right: temp %v changes %d", temp, n)
|
||||
}
|
||||
_ = tt.Click("Reset Temperature")
|
||||
if temp != 1 {
|
||||
t.Fatalf("reset temp %v", temp)
|
||||
}
|
||||
_ = tt.Click("Reset all")
|
||||
if topP != 1 || n != 3 {
|
||||
t.Fatalf("reset all: topP %v changes %d", topP, n)
|
||||
}
|
||||
off = true
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if temp != 1 {
|
||||
t.Fatal("disabled panel changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterPanelBadParam(t *testing.T) {
|
||||
v := 0.0
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a default outside the range")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ParameterPanel(c, []ModelParam{{Label: "x", Value: &v, Min: 0, Max: 1, Step: 0.1, Default: 3}})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestParameterPanelSnapshot(t *testing.T) {
|
||||
temp, topP := 0.7, 1.0
|
||||
snapshot(t, "parameter_panel", 420, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ParameterPanel(c, paramSample(&temp, &topP)) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestParameterPanelRejectsNonFiniteAndOutOfRange(t *testing.T) {
|
||||
for _, v := range []float64{math.NaN(), math.Inf(1), -1, 3} {
|
||||
t.Run(fmt.Sprint(v), func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("invalid parameter value accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ParameterPanel(c, []ModelParam{{Label: "Temperature", Value: &v, Min: 0, Max: 2, Step: 0.1, Default: 1}})
|
||||
}, 400, 300)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterPanelRequiresDefaultOnStepGrid(t *testing.T) {
|
||||
v := 0.9
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("off-grid default accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ParameterPanel(c, []ModelParam{{Label: "Temperature", Value: &v, Min: 0, Max: 2, Step: 0.1, Default: 0.95}})
|
||||
}, 400, 300)
|
||||
}
|
||||
|
||||
func TestParameterPanelRequiresValueOnStepGrid(t *testing.T) {
|
||||
v := 0.5
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("off-grid value accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ParameterPanel(c, []ModelParam{{Label: "Temperature", Value: &v, Min: 0, Max: 1, Step: 0.2, Default: 0.4}})
|
||||
}, 400, 300)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PastedImage contains a caller-decoded clipboard or dropped image.
|
||||
type PastedImage struct {
|
||||
// ID is the image's stable identity; required.
|
||||
ID string
|
||||
Source ui.ImageSource
|
||||
// Name describes it for assistive technology and the remove button.
|
||||
Name string
|
||||
}
|
||||
|
||||
// pasteImageSize is the thumbnail's edge in DIPs.
|
||||
const pasteImageSize float32 = 72
|
||||
|
||||
// PasteImagePreview displays removable image thumbnails.
|
||||
func PasteImagePreview(c *ui.Context, images []PastedImage) RemoveResult {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r RemoveResult
|
||||
r.Element = ui.Row(c).Gap(u * 2).Wrap().Role(ui.RoleList).Label(core.Msg(c, "paste_image_preview.label"))
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
for _, im := range images {
|
||||
if im.ID == "" || im.Source == nil {
|
||||
panic("mujica: PastedImage needs ID and Source")
|
||||
}
|
||||
ui.Box(c).Key(im.ID).Size(pasteImageSize, pasteImageSize).Role(ui.RoleListItem).Children(func() {
|
||||
ui.Image(c, im.Source).Fit(ui.Cover).Size(pasteImageSize, pasteImageSize).Radius(theme.ControlRadius).
|
||||
Border(theme.BorderWidth, k.Border).Label(im.Name)
|
||||
x := core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "paste_image_preview.remove"), im.Name)).
|
||||
Absolute().Top(-u * 3).Right(-u * 3).Radius(theme.MinHitSize / 2)
|
||||
// A 20 DIP disc inside the 28 DIP hit area.
|
||||
x.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
d := ui.Rect{X: r.X + 4, Y: r.Y + 4, W: r.W - 8, H: r.H - 8}
|
||||
p.Fill(d, k.Surface, d.W/2)
|
||||
p.Stroke(d, k.ControlBorder, d.W/2, theme.BorderWidth)
|
||||
})
|
||||
if x.Clicked() && !disabled {
|
||||
r.removed = im.ID
|
||||
slog.Debug("mujica: paste_image_preview remove", "id", im.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func pasteImages() []PastedImage {
|
||||
return []PastedImage{
|
||||
{ID: "a", Source: imageGridPicture("#7D2034"), Name: "screenshot 1"},
|
||||
{ID: "b", Source: imageGridPicture("#355E80"), Name: "screenshot 2"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteImagePreviewRemoves(t *testing.T) {
|
||||
imgs, off := pasteImages(), false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Disabled(off).Children(func() {
|
||||
if id, ok := PasteImagePreview(c, imgs).Removed(); ok {
|
||||
for i, im := range imgs {
|
||||
if im.ID == id {
|
||||
imgs = append(imgs[:i], imgs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 400, 150)
|
||||
if _, ok := tt.Find("screenshot 2"); !ok {
|
||||
t.Fatal("thumbnail not named")
|
||||
}
|
||||
_ = tt.Click("Remove image screenshot 1")
|
||||
if len(imgs) != 1 || imgs[0].ID != "b" {
|
||||
t.Fatalf("left %v", imgs)
|
||||
}
|
||||
off = true
|
||||
_ = tt.Click("Remove image screenshot 2")
|
||||
if len(imgs) != 1 {
|
||||
t.Fatal("disabled preview removed an image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteImagePreviewNeedsSource(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without a source")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
PasteImagePreview(c, []PastedImage{{ID: "x"}})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestPasteImagePreviewSnapshot(t *testing.T) {
|
||||
snapshot(t, "paste_image_preview", 240, 110, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Children(func() { PasteImagePreview(c, pasteImages()) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasteImagePreviewKeyboard(t *testing.T) {
|
||||
removed := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if id, ok := PasteImagePreview(c, pasteImages()).Removed(); ok {
|
||||
removed = id
|
||||
}
|
||||
})
|
||||
}, 400, 160)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if removed != "a" {
|
||||
t.Fatalf("removed %q, want a", removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// KnowledgeFile is a project knowledge file, sized in tokens.
|
||||
type KnowledgeFile struct {
|
||||
ID, Name string
|
||||
Tokens int
|
||||
}
|
||||
|
||||
// ProjectKnowledgePanelOptions configure a ProjectKnowledgePanel. Capacity is required.
|
||||
type ProjectKnowledgePanelOptions struct {
|
||||
// Capacity is how many tokens the project's knowledge holds.
|
||||
Capacity int
|
||||
// Title replaces the built-in "Project knowledge".
|
||||
Title string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ProjectKnowledgePanelView is a built panel and its requests.
|
||||
type ProjectKnowledgePanelView struct {
|
||||
Element *ui.Element
|
||||
// Add asks the caller to pick files to add.
|
||||
Add bool
|
||||
// Removed is the ID of a file to remove.
|
||||
Removed string
|
||||
}
|
||||
|
||||
// ProjectKnowledgePanel lists knowledge files with add, remove and usage.
|
||||
func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKnowledgePanelOptions) ProjectKnowledgePanelView {
|
||||
if opts.Capacity <= 0 {
|
||||
panic("mujica: ProjectKnowledgePanel needs a positive Capacity")
|
||||
}
|
||||
used := 0
|
||||
for _, f := range files {
|
||||
if f.Tokens < 0 {
|
||||
panic("mujica: KnowledgeFile with negative Tokens")
|
||||
}
|
||||
used += f.Tokens
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := ui.Column(c).Gap(d.Unit()*2).Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Disabled(opts.Disabled)
|
||||
v := ProjectKnowledgePanelView{Element: col}
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = core.Msg(c, "project_knowledge_panel.title")
|
||||
}
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1)
|
||||
v.Add = input.Button(c, core.Msg(c, "project_knowledge_panel.add"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("plus"), Disabled: opts.Disabled}).Clicked()
|
||||
})
|
||||
ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
internal.RuleDiamond(p, r, 6, 5, k.Ornament)
|
||||
})
|
||||
projectKnowledgeMeter(c, k, used, opts.Capacity)
|
||||
if len(files) == 0 {
|
||||
ui.Text(c, core.Msg(c, "project_knowledge_panel.empty")).TextColor(k.TextMuted).Padding(d.Unit()*2, 0)
|
||||
return
|
||||
}
|
||||
ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() {
|
||||
for _, f := range files {
|
||||
ui.Row(c).Key(f.ID).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(0, d.Unit()).Children(func() {
|
||||
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(k.TextMuted)
|
||||
ui.Text(c, f.Name).SingleLine().Grow(1).MinWidth(0)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "project_knowledge_panel.tokens"), f.Tokens)).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
x := core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "project_knowledge_panel.remove"), f.Name))
|
||||
if x.Clicked() && !x.IsDisabled() {
|
||||
v.Removed = f.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
if v.Add || v.Removed != "" {
|
||||
slog.Debug("mujica: project_knowledge_panel action", "add", v.Add, "remove", v.Removed)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// projectKnowledgeMeter shows usage; limits get words and icons.
|
||||
func projectKnowledgeMeter(c *ui.Context, k theme.Tokens, used, capacity int) {
|
||||
d := core.Density(c)
|
||||
frac := float64(used) / float64(capacity)
|
||||
ink, note, icon := k.AccentText, "", ""
|
||||
switch {
|
||||
case used > capacity:
|
||||
ink, note, icon = k.Danger, core.Msg(c, "project_knowledge_panel.over"), "circle-alert"
|
||||
case frac >= 0.8:
|
||||
ink, note, icon = k.Warning, core.Msg(c, "project_knowledge_panel.near"), "triangle-alert"
|
||||
}
|
||||
pct := int(frac * 100)
|
||||
label := fmt.Sprintf(core.Msg(c, "project_knowledge_panel.usage"), used, capacity, pct)
|
||||
ui.Column(c).Gap(d.Unit()).Role(ui.RoleMeter).Label(label).Children(func() {
|
||||
ui.Box(c).FillWidth().Height(6).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, k.Border, 3)
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(min(frac, 1)), H: r.H}, ink, 3)
|
||||
})
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||
if icon != "" {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(ink)
|
||||
}
|
||||
t := ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
if note != "" {
|
||||
t.TextColor(ink)
|
||||
ui.Text(c, note).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(ink).Role(ui.RoleStatus)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func knowledgeFiles() []KnowledgeFile {
|
||||
return []KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 120000}, {ID: "2", Name: "menu.md", Tokens: 50000}}
|
||||
}
|
||||
|
||||
func TestProjectKnowledgePanelAddRemove(t *testing.T) {
|
||||
files := knowledgeFiles()
|
||||
adds := 0
|
||||
tt := chatTester(420, 400, func(c *ui.Context) {
|
||||
v := ProjectKnowledgePanel(c, files, ProjectKnowledgePanelOptions{Capacity: 400000})
|
||||
if v.Add {
|
||||
adds++
|
||||
}
|
||||
if v.Removed != "" {
|
||||
for i, f := range files {
|
||||
if f.ID == v.Removed {
|
||||
files = append(files[:i:i], files[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if !tt.HasText("170000 of 400000 tokens (42%)") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Add files")
|
||||
if adds != 1 {
|
||||
t.Fatalf("adds %d", adds)
|
||||
}
|
||||
if err := tt.Click("Remove menu.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 1 || files[0].ID != "1" || !tt.HasText("120000 of 400000 tokens (30%)") {
|
||||
t.Fatalf("files %v texts %q", files, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectKnowledgePanelCapacityStates(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
capacity int
|
||||
note string
|
||||
}{{200000, "Nearly full"}, {150000, "Over capacity"}} {
|
||||
tt := chatTester(420, 400, func(c *ui.Context) {
|
||||
ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: tc.capacity})
|
||||
})
|
||||
if !tt.HasText(tc.note) {
|
||||
t.Errorf("%d: texts %q", tc.capacity, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt := chatTester(420, 400, func(c *ui.Context) {
|
||||
ProjectKnowledgePanel(c, nil, ProjectKnowledgePanelOptions{Capacity: 10})
|
||||
})
|
||||
if !tt.HasText("No files yet") {
|
||||
t.Fatal("empty state missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectKnowledgePanelDisabledAndKeys(t *testing.T) {
|
||||
removed := ""
|
||||
tt := chatTester(420, 400, func(c *ui.Context) {
|
||||
if v := ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 400000, Disabled: true}); v.Removed != "" {
|
||||
removed = v.Removed
|
||||
}
|
||||
})
|
||||
_ = tt.Click("Remove menu.md")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if removed != "" {
|
||||
t.Fatalf("disabled panel removed %q", removed)
|
||||
}
|
||||
tt = chatTester(420, 400, func(c *ui.Context) {
|
||||
if v := ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 400000}); v.Removed != "" {
|
||||
removed = v.Removed
|
||||
}
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if removed != "2" {
|
||||
t.Fatalf("keys removed %q", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectKnowledgePanelRejectsBadCapacity(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ProjectKnowledgePanel(c, nil, ProjectKnowledgePanelOptions{})
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
func TestProjectKnowledgePanelSnapshot(t *testing.T) {
|
||||
snapshot(t, "project_knowledge_panel", 420, 240, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 200000})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChatProject holds conversations; Folder groups projects, empty for none.
|
||||
type ChatProject struct {
|
||||
ID, Name, Folder string
|
||||
}
|
||||
|
||||
// ProjectListOptions configure a ProjectList.
|
||||
type ProjectListOptions struct {
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ProjectListState is the chosen project and open folders and projects.
|
||||
type ProjectListState struct {
|
||||
// Selected is the chosen project's ID; empty for none.
|
||||
Selected string
|
||||
closed map[string]bool
|
||||
open map[string]bool
|
||||
}
|
||||
|
||||
// ProjectListView is a built ProjectList and this frame's events.
|
||||
type ProjectListView struct {
|
||||
Element *ui.Element
|
||||
// Changed reports a newly chosen project, in State.Selected.
|
||||
Changed bool
|
||||
// Opened is a conversation the user opened, empty for none.
|
||||
Opened string
|
||||
// Moved asks the caller to move MoveID into MoveTo.
|
||||
Moved bool
|
||||
MoveID, MoveTo string
|
||||
}
|
||||
|
||||
// ProjectList shows project folders and conversations; drops move them.
|
||||
func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, conversations func(projectID string) []ChatConversation, opts ProjectListOptions) ProjectListView {
|
||||
if s == nil || conversations == nil {
|
||||
panic("mujica: ProjectList needs a state and conversations")
|
||||
}
|
||||
if s.closed == nil {
|
||||
s.closed, s.open = map[string]bool{}, map[string]bool{}
|
||||
}
|
||||
d := core.Density(c)
|
||||
col := ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Role(ui.RoleTree).Label(opts.Label).Disabled(opts.Disabled)
|
||||
v := ProjectListView{Element: col}
|
||||
col.Children(func() {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range projects {
|
||||
if p.Folder == "" || seen[p.Folder] {
|
||||
continue
|
||||
}
|
||||
seen[p.Folder] = true
|
||||
open := !s.closed[p.Folder]
|
||||
ui.Column(c).Key("folder:" + p.Folder).Children(func() { projectListFolder(c, p.Folder, &open) })
|
||||
s.closed[p.Folder] = !open
|
||||
if !open {
|
||||
continue
|
||||
}
|
||||
for _, q := range projects {
|
||||
if q.Folder == p.Folder {
|
||||
projectListProject(c, s, q, projects, conversations, d.Unit()*6, &v)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range projects {
|
||||
if p.Folder == "" {
|
||||
projectListProject(c, s, p, projects, conversations, 0, &v)
|
||||
}
|
||||
}
|
||||
})
|
||||
if col.IsDisabled() {
|
||||
v.Changed, v.Opened, v.Moved = false, "", false
|
||||
}
|
||||
switch {
|
||||
case v.Moved:
|
||||
slog.Debug("mujica: project_list move", "conversation", v.MoveID, "project", v.MoveTo)
|
||||
case v.Changed:
|
||||
slog.Debug("mujica: project_list select", "project", s.Selected)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// projectListFolder is a folder heading on a CollapsibleBase trigger.
|
||||
func projectListFolder(c *ui.Context, title string, open *bool) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
parts := ui.CollapsibleBase(c, open)
|
||||
tr := parts.Trigger.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).
|
||||
Radius(theme.ControlRadius).Label(title)
|
||||
internal.Ring(tr, k.Focus, theme.ControlRadius)
|
||||
if tr.Hovered() {
|
||||
tr.Background(k.SurfaceHover)
|
||||
}
|
||||
chevron := "chevron-right"
|
||||
if *open {
|
||||
chevron = "chevron-down"
|
||||
}
|
||||
tr.Children(func() {
|
||||
ui.Icon(c, icons.Must(chevron)).Size(14, 14).TextColor(k.TextMuted)
|
||||
ui.Icon(c, icons.Must("folder")).Size(16, 16).TextColor(k.Ornament.Mix(k.Text, 0.3))
|
||||
ui.Text(c, title).Font(theme.SerifFont).SingleLine()
|
||||
})
|
||||
return tr
|
||||
}
|
||||
|
||||
func projectListProject(c *ui.Context, s *ProjectListState, p ChatProject, all []ChatProject, conversations func(string) []ChatConversation, indent float32, v *ProjectListView) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
convs := conversations(p.ID)
|
||||
chosen := s.Selected == p.ID
|
||||
var b *ui.Element
|
||||
line := ui.Row(c).Key("project:" + p.ID).AlignItems(ui.Center)
|
||||
line.Children(func() { b = ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()) })
|
||||
b.Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).
|
||||
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleTreeItem).Label(p.Name).Selected(chosen)
|
||||
b.Background(ui.Transparent).TextColor(k.Text)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
_, over := ui.DragOver[ConversationDrag](b)
|
||||
switch {
|
||||
case chosen:
|
||||
b.Background(k.Selection).TextColor(k.AccentText)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
if over {
|
||||
b.DrawOver(func(pt *ui.Painter, r ui.Rect) { internal.Frame(pt, r, 1, 5, k.AccentText) })
|
||||
}
|
||||
if drop, ok := ui.Drop[ConversationDrag](b); ok && !b.IsDisabled() {
|
||||
v.Moved, v.MoveID, v.MoveTo = true, drop.ID, p.ID
|
||||
}
|
||||
if b.Clicked() && !chosen {
|
||||
s.Selected, v.Changed = p.ID, true
|
||||
}
|
||||
open := s.open[p.ID]
|
||||
if !b.IsDisabled() && len(convs) > 0 {
|
||||
switch {
|
||||
case b.Shortcut(0, ui.KeyRight):
|
||||
s.open[p.ID] = true
|
||||
case b.Shortcut(0, ui.KeyLeft):
|
||||
s.open[p.ID] = false
|
||||
}
|
||||
}
|
||||
if len(convs) > 0 {
|
||||
b.Description(core.Msg(c, map[bool]string{true: "project_list.expanded", false: "project_list.collapsed"}[open]))
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
if chosen {
|
||||
pt.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText)
|
||||
}
|
||||
})
|
||||
ui.Text(c, p.Name).SingleLine().Grow(1).MinWidth(0)
|
||||
ui.Text(c, fmt.Sprint(len(convs))).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
if len(convs) > 0 {
|
||||
chevron := map[bool]string{true: "chevron-down", false: "chevron-right"}[open]
|
||||
line.Children(func() {
|
||||
t := core.IconAction(c, icons.Must(chevron), fmt.Sprintf(core.Msg(c, map[bool]string{true: "project_list.collapse", false: "project_list.expand"}[open]), p.Name))
|
||||
if t.Clicked() && !t.IsDisabled() {
|
||||
s.open[p.ID] = !open
|
||||
}
|
||||
})
|
||||
}
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
for _, conv := range convs {
|
||||
projectListConversation(c, conv, p, all, indent+d.Unit()*6, v)
|
||||
}
|
||||
}
|
||||
|
||||
func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProject, all []ChatProject, indent float32, v *ProjectListView) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
ui.Row(c).Key("conv:" + conv.ID).AlignItems(ui.Center).Children(func() {
|
||||
b := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).
|
||||
Justify(ui.Start).Radius(theme.ControlRadius).Label(conv.Title).Drag(ConversationDrag{ID: conv.ID})
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if b.Dragging() {
|
||||
b.Opacity(0.4)
|
||||
}
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Text(c, cmp.Or(conv.Title, core.Msg(c, "conversation_list.untitled"))).SingleLine().TextColor(k.TextMuted)
|
||||
})
|
||||
if b.Clicked() {
|
||||
v.Opened = conv.ID
|
||||
}
|
||||
conversationItemMenu(c, fmt.Sprintf(core.Msg(c, "project_list.move_to"), conv.Title), func(m *overlay.PopupMenu) {
|
||||
m.Group(core.Msg(c, "project_list.move_group"), func() {
|
||||
for _, p := range all {
|
||||
if p.ID != home.ID && m.Item(p.Name, overlay.PopupMenuItemOptions{}) {
|
||||
v.Moved, v.MoveID, v.MoveTo = true, conv.ID, p.ID
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type projectFixture struct {
|
||||
s ProjectListState
|
||||
projects []ChatProject
|
||||
owner map[string]string
|
||||
last ProjectListView
|
||||
moves int
|
||||
}
|
||||
|
||||
func newProjectFixture() *projectFixture {
|
||||
return &projectFixture{
|
||||
projects: []ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}, {ID: "p2", Name: "Gardens", Folder: "Court"}, {ID: "p3", Name: "Music"}},
|
||||
owner: map[string]string{"a": "p1", "b": "p1", "c": "p3"},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *projectFixture) view(opts ProjectListOptions) func(c *ui.Context) {
|
||||
return func(c *ui.Context) {
|
||||
convs := func(id string) []ChatConversation {
|
||||
var out []ChatConversation
|
||||
for _, conv := range chatConversations() {
|
||||
if f.owner[conv.ID] == id {
|
||||
out = append(out, conv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
v := ProjectList(c, &f.s, f.projects, convs, opts)
|
||||
if v.Moved {
|
||||
f.owner[v.MoveID] = v.MoveTo
|
||||
f.moves++
|
||||
}
|
||||
if v.Changed || v.Opened != "" || v.Moved {
|
||||
f.last = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListSelectAndExpand(t *testing.T) {
|
||||
f := newProjectFixture()
|
||||
tt := chatTester(420, 500, f.view(ProjectListOptions{Label: "Projects"}))
|
||||
if err := tt.Click("Gardens"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.s.Selected != "p2" || !f.last.Changed {
|
||||
t.Fatalf("selected %q", f.s.Selected)
|
||||
}
|
||||
if tt.HasText("Court etiquette notes") {
|
||||
t.Fatal("conversations shown before expanding")
|
||||
}
|
||||
_ = tt.Click("Show conversations in Coronation")
|
||||
if !tt.HasText("Court etiquette notes") || !tt.HasText("Ballroom seating plan") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Court etiquette notes")
|
||||
if f.last.Opened != "a" {
|
||||
t.Fatalf("opened %q", f.last.Opened)
|
||||
}
|
||||
// The folder collapses its projects.
|
||||
_ = tt.Click("Court")
|
||||
if tt.HasText("Gardens") {
|
||||
t.Fatal("folder did not collapse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListKeyboard(t *testing.T) {
|
||||
f := newProjectFixture()
|
||||
tt := chatTester(420, 500, f.view(ProjectListOptions{}))
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Court") {
|
||||
t.Fatal("Tab did not enter at the folder")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("Court etiquette notes") {
|
||||
t.Fatal("Right did not expand")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if f.s.Selected != "p1" {
|
||||
t.Fatalf("Enter chose %q", f.s.Selected)
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if tt.HasText("Court etiquette notes") {
|
||||
t.Fatal("Left did not collapse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListMoveByMenuAndDrag(t *testing.T) {
|
||||
f := newProjectFixture()
|
||||
tt := chatTester(420, 500, f.view(ProjectListOptions{}))
|
||||
_ = tt.Click("Show conversations in Coronation")
|
||||
if err := tt.Click("Move Court etiquette notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The menu opens on Gardens; Down reaches Music.
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if f.owner["a"] != "p3" || f.moves != 1 {
|
||||
t.Fatalf("menu move: owner %q moves %d", f.owner["a"], f.moves)
|
||||
}
|
||||
from, _ := tt.Find("Ballroom seating plan")
|
||||
to, _ := tt.Find("Gardens")
|
||||
tt.Press(from.X+5, from.Y+from.H/2)
|
||||
tt.Move(from.X+20, from.Y+from.H/2+5)
|
||||
tt.Move(to.X+10, to.Y+to.H/2)
|
||||
tt.Release(to.X+10, to.Y+to.H/2)
|
||||
if f.owner["b"] != "p2" || f.last.MoveTo != "p2" {
|
||||
t.Fatalf("drag move: owner %q", f.owner["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListDisabled(t *testing.T) {
|
||||
f := newProjectFixture()
|
||||
tt := chatTester(420, 500, f.view(ProjectListOptions{Disabled: true}))
|
||||
_ = tt.Click("Gardens")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if f.s.Selected != "" || f.moves != 0 {
|
||||
t.Fatalf("disabled list acted: %q", f.s.Selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListSnapshot(t *testing.T) {
|
||||
snapshot(t, "project_list", 380, 280, func(c *ui.Context) {
|
||||
f := newProjectFixture()
|
||||
f.s.Selected = "p1"
|
||||
f.s.open = map[string]bool{"p1": true}
|
||||
f.s.closed = map[string]bool{}
|
||||
ui.Column(c).Padding(16).Children(func() { f.view(ProjectListOptions{})(c) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// PromptComposerOptions configure a PromptComposer.
|
||||
type PromptComposerOptions struct {
|
||||
Placeholder string
|
||||
// Label names the field; empty uses the built-in "Message".
|
||||
Label string
|
||||
// CmdEnterSends sends with Cmd+Enter (Ctrl+Enter) and makes Enter a newline.
|
||||
CmdEnterSends bool
|
||||
// Composing blocks submission while the caller's input method composes.
|
||||
Composing bool
|
||||
// MaxHeight caps input height; zero means 200 DIPs.
|
||||
MaxHeight float32
|
||||
// Attachments builds the row above the field, as AttachmentChips.
|
||||
Attachments func()
|
||||
// Tools and Actions build opposite ends of the toolbar.
|
||||
Tools, Actions func()
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// promptHooks hold menu handlers for the current frame.
|
||||
type promptHooks struct{ list []func(ui.InputEvent) bool }
|
||||
|
||||
type promptHooksKey struct{}
|
||||
|
||||
// PromptComposer edits prompts with configurable send shortcuts.
|
||||
// Submitted never fires for blank text.
|
||||
func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) input.InputResult {
|
||||
if value == nil {
|
||||
panic("mujica: PromptComposer needs a value")
|
||||
}
|
||||
if opts.MaxHeight < 0 {
|
||||
panic("mujica: PromptComposer MaxHeight must not be negative")
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 200
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "prompt_composer.label")
|
||||
}
|
||||
var r input.InputResult
|
||||
r.Element = ui.Column(c).Gap(u * 2).MinWidth(0).Disabled(opts.Disabled)
|
||||
hooks := ui.Local(r.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} })
|
||||
h := *hooks
|
||||
h.list = h.list[:0]
|
||||
r.Element.Children(func() {
|
||||
if opts.Attachments != nil {
|
||||
ui.Row(c).Gap(u * 2).Wrap().Children(opts.Attachments)
|
||||
}
|
||||
ta := input.TextAreaOptions{Label: label, Placeholder: opts.Placeholder, MaxHeight: maxH,
|
||||
Keys: func(ev ui.InputEvent) bool {
|
||||
if r.Element.IsDisabled() {
|
||||
return true
|
||||
}
|
||||
if opts.Composing && ev.Kind == ui.InputKeyDown {
|
||||
return ev.Key == ui.KeyEnter
|
||||
}
|
||||
for _, f := range h.list {
|
||||
if f(ev) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}}
|
||||
if !opts.CmdEnterSends {
|
||||
ta.SubmitKey = ui.KeyEnter
|
||||
}
|
||||
area := input.TextArea(c, value, ta)
|
||||
r = input.NewInputResult(r.Element, area.Input, area.Changed(), area.Submitted() && !opts.Composing && strings.TrimSpace(*value) != "")
|
||||
if opts.Tools != nil || opts.Actions != nil {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Grow(1).Children(func() {
|
||||
if opts.Tools != nil {
|
||||
opts.Tools()
|
||||
}
|
||||
})
|
||||
if opts.Actions != nil {
|
||||
opts.Actions()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if r.Submitted() {
|
||||
slog.Debug("mujica: prompt_composer submit", "chars", len(*value))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// promptComposerHook registers a handler before editor input.
|
||||
func promptComposerHook(composer input.InputResult, f func(ui.InputEvent) bool) {
|
||||
if composer.Element == nil {
|
||||
panic("mujica: menus need a built PromptComposer")
|
||||
}
|
||||
h := *ui.Local(composer.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} })
|
||||
h.list = append(h.list, f)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
func promptTester(text *string, sends *int, opts PromptComposerOptions) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if PromptComposer(c, text, opts).Submitted() {
|
||||
*sends++
|
||||
}
|
||||
}, 500, 400)
|
||||
}
|
||||
|
||||
func TestPromptComposerEnterSendsShiftEnterBreaks(t *testing.T) {
|
||||
text, sends := "", 0
|
||||
tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt"})
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 0 {
|
||||
t.Fatal("blank prompt sent")
|
||||
}
|
||||
tt.Type("Bonjour")
|
||||
tt.Key(ui.Shift, ui.KeyEnter)
|
||||
tt.Type("Madame")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 1 || text != "Bonjour\nMadame" {
|
||||
t.Fatalf("sends %d text %q", sends, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptComposerCmdEnterMode(t *testing.T) {
|
||||
text, sends := "", 0
|
||||
tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt", CmdEnterSends: true})
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("a")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("b")
|
||||
tt.Key(ui.Cmd, ui.KeyEnter)
|
||||
if sends != 1 || text != "a\nb" {
|
||||
t.Fatalf("sends %d text %q", sends, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptComposerSlotsAndDisabled(t *testing.T) {
|
||||
text, sends := "hello", 0
|
||||
tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt", Disabled: true})
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("x")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 0 || text != "hello" {
|
||||
t.Fatalf("disabled composer: sends %d text %q", sends, text)
|
||||
}
|
||||
tt = ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
PromptComposer(c, &text, PromptComposerOptions{
|
||||
Attachments: func() { ui.Text(c, "report.pdf") },
|
||||
Tools: func() { ui.Text(c, "Web search") },
|
||||
Actions: func() { ui.Text(c, "Send it") },
|
||||
})
|
||||
}, 500, 400)
|
||||
for _, s := range []string{"report.pdf", "Web search", "Send it", "Message"} {
|
||||
if _, ok := tt.Find(s); !ok {
|
||||
t.Errorf("missing %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptComposerSnapshot(t *testing.T) {
|
||||
text := "Draft the invitation for the spring ball,\nin the style of the court."
|
||||
snapshot(t, "prompt_composer", 520, 220, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
PromptComposer(c, &text, PromptComposerOptions{
|
||||
Attachments: func() { AttachmentChip(c, Attachment{Name: "guests.csv", Size: 4096}) },
|
||||
Tools: func() { core.IconAction(c, icons.Must("paperclip"), "Attach") },
|
||||
Actions: func() { SendButton(c, SendButtonOptions{Shortcut: "Enter"}) },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPromptComposerCompositionDoesNotSend(t *testing.T) {
|
||||
text, sends := "draft", 0
|
||||
composing := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt", Composing: composing}).Submitted() {
|
||||
sends++
|
||||
}
|
||||
}, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
composing = true
|
||||
tt.Frame()
|
||||
tt.Compose("邀请", 2)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 0 || text != "draft" {
|
||||
t.Fatalf("composition submitted: sends %d text %q", sends, text)
|
||||
}
|
||||
tt.Type("邀请")
|
||||
composing = false
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 1 || text != "draft邀请" {
|
||||
t.Fatalf("committed input: sends %d text %q", sends, text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PromptTemplate is a reusable prompt; {{name}} marks a variable.
|
||||
type PromptTemplate struct {
|
||||
ID, Category, Title, Body string
|
||||
}
|
||||
|
||||
// PromptLibraryOptions configure a PromptLibrary.
|
||||
type PromptLibraryOptions struct {
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// PromptLibraryState holds category, query, chosen template and variable values.
|
||||
type PromptLibraryState struct {
|
||||
// Category filters templates; empty shows all.
|
||||
Category string
|
||||
Query string
|
||||
Selected string
|
||||
Values map[string]string
|
||||
shown promptLibraryShown
|
||||
}
|
||||
|
||||
// promptLibraryShown caches filtered templates until data, query or category change.
|
||||
type promptLibraryShown struct {
|
||||
src []PromptTemplate
|
||||
query, category string
|
||||
ids []int
|
||||
cats []string
|
||||
built bool
|
||||
}
|
||||
|
||||
// PromptLibraryView is a built PromptLibrary and inserted text.
|
||||
type PromptLibraryView struct {
|
||||
Element *ui.Element
|
||||
// Inserted is the filled template, or empty.
|
||||
Inserted string
|
||||
}
|
||||
|
||||
var promptLibraryVar = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`)
|
||||
|
||||
// PromptLibraryVariables lists a body's {{name}} variables in order, once each.
|
||||
func PromptLibraryVariables(body string) []string {
|
||||
var out []string
|
||||
for _, m := range promptLibraryVar.FindAllStringSubmatch(body, -1) {
|
||||
if !slices.Contains(out, m[1]) {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PromptLibraryFill replaces each {{name}} in body with values[name].
|
||||
func PromptLibraryFill(body string, values map[string]string) string {
|
||||
return promptLibraryVar.ReplaceAllStringFunc(body, func(m string) string {
|
||||
return values[promptLibraryVar.FindStringSubmatch(m)[1]]
|
||||
})
|
||||
}
|
||||
|
||||
// PromptLibrary browses, previews, fills and inserts prompt templates.
|
||||
func PromptLibrary(c *ui.Context, s *PromptLibraryState, templates []PromptTemplate, opts PromptLibraryOptions) PromptLibraryView {
|
||||
if s == nil {
|
||||
panic("mujica: PromptLibrary needs a state")
|
||||
}
|
||||
if s.Values == nil {
|
||||
s.Values = map[string]string{}
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
promptLibraryFilter(&s.shown, templates, s.Query, s.Category)
|
||||
row := ui.Row(c).Gap(d.Unit() * 4).AlignItems(ui.Stretch).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled)
|
||||
v := PromptLibraryView{Element: row}
|
||||
row.Children(func() {
|
||||
ui.Column(c).Width(150).Shrink(0).Gap(2).FocusGroup(ui.Vertical).Children(func() {
|
||||
promptLibraryCategory(c, k, s, "", core.Msg(c, "prompt_library.all"))
|
||||
for _, cat := range s.shown.cats {
|
||||
promptLibraryCategory(c, k, s, cat, cat)
|
||||
}
|
||||
})
|
||||
ui.Column(c).Width(240).Shrink(0).Gap(d.Unit() * 2).Children(func() {
|
||||
input.SearchInput(c, &s.Query, input.SearchInputOptions{Placeholder: core.Msg(c, "prompt_library.search"), Disabled: opts.Disabled})
|
||||
ui.Scroll(c).MaxHeight(320).Children(func() {
|
||||
ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() {
|
||||
if len(s.shown.ids) == 0 {
|
||||
ui.Text(c, core.Msg(c, "prompt_library.no_results")).TextColor(k.TextMuted).Padding(d.Unit() * 2)
|
||||
}
|
||||
for _, i := range s.shown.ids {
|
||||
t := templates[i]
|
||||
if selectableRow(c, k, t.ID, t.Title, s.Selected == t.ID) && s.Selected != t.ID {
|
||||
s.Selected = t.ID
|
||||
slog.Debug("mujica: prompt_library select", "id", t.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
ui.Column(c).Grow(1).MinWidth(200).Gap(d.Unit() * 2).Children(func() {
|
||||
i := slices.IndexFunc(templates, func(t PromptTemplate) bool { return t.ID == s.Selected })
|
||||
if i < 0 {
|
||||
ui.Text(c, core.Msg(c, "prompt_library.pick")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
v.Inserted = promptLibraryPreview(c, k, s, templates[i], opts.Disabled)
|
||||
})
|
||||
})
|
||||
if v.Inserted != "" {
|
||||
slog.Debug("mujica: prompt_library insert", "id", s.Selected)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func promptLibraryFilter(sh *promptLibraryShown, templates []PromptTemplate, query, category string) {
|
||||
same := sh.built && sh.query == query && sh.category == category && len(sh.src) == len(templates) &&
|
||||
(len(templates) == 0 || &sh.src[0] == &templates[0])
|
||||
if same {
|
||||
return
|
||||
}
|
||||
*sh = promptLibraryShown{src: templates, query: query, category: category, built: true}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
for i, t := range templates {
|
||||
if t.ID == "" || t.Title == "" {
|
||||
panic("mujica: PromptTemplate needs an ID and a Title")
|
||||
}
|
||||
if t.Category != "" && !slices.Contains(sh.cats, t.Category) {
|
||||
sh.cats = append(sh.cats, t.Category)
|
||||
}
|
||||
if category != "" && t.Category != category {
|
||||
continue
|
||||
}
|
||||
if q == "" || strings.Contains(strings.ToLower(t.Title+"\n"+t.Body), q) {
|
||||
sh.ids = append(sh.ids, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func promptLibraryCategory(c *ui.Context, k theme.Tokens, s *PromptLibraryState, cat, label string) {
|
||||
if selectableRow(c, k, "cat:"+cat, label, s.Category == cat) && s.Category != cat {
|
||||
s.Category = cat
|
||||
}
|
||||
}
|
||||
|
||||
// selectableRow is a focusable row marked chosen with a diamond.
|
||||
func selectableRow(c *ui.Context, k theme.Tokens, key, label string, chosen bool) bool {
|
||||
d := core.Density(c)
|
||||
b := ui.ButtonBase(c).Key(key).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).Justify(ui.Start).
|
||||
Radius(theme.ControlRadius).Label(label).Selected(chosen)
|
||||
b.Background(ui.Transparent).TextColor(k.Text)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case chosen:
|
||||
b.Background(k.Selection).TextColor(k.AccentText)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if chosen {
|
||||
p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText)
|
||||
}
|
||||
})
|
||||
ui.Text(c, label).SingleLine()
|
||||
})
|
||||
return b.Clicked() && !b.IsDisabled()
|
||||
}
|
||||
|
||||
// promptLibraryPreview shows the body, variable fields and Insert.
|
||||
func promptLibraryPreview(c *ui.Context, k theme.Tokens, s *PromptLibraryState, t PromptTemplate, disabled bool) string {
|
||||
d := core.Density(c)
|
||||
vars := PromptLibraryVariables(t.Body)
|
||||
ui.Text(c, t.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
missing := false
|
||||
var spans []ui.Span
|
||||
at := 0
|
||||
for _, m := range promptLibraryVar.FindAllStringSubmatchIndex(t.Body, -1) {
|
||||
spans = append(spans, ui.Span{Text: t.Body[at:m[0]]})
|
||||
name := t.Body[m[2]:m[3]]
|
||||
if val := s.Values[name]; val != "" {
|
||||
spans = append(spans, ui.Span{Text: val, Weight: 600, Color: k.AccentText})
|
||||
} else {
|
||||
missing = true
|
||||
spans = append(spans, ui.Span{Text: "[" + name + "]", Background: k.Selection, Color: k.AccentText, Underline: true})
|
||||
}
|
||||
at = m[1]
|
||||
}
|
||||
spans = append(spans, ui.Span{Text: t.Body[at:]})
|
||||
ui.Column(c).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).
|
||||
Children(func() { ui.RichText(c, spans...).Selectable() })
|
||||
for _, name := range vars {
|
||||
val := s.Values[name]
|
||||
ui.Column(c).Key("var:" + name).Children(func() {
|
||||
input.FormField(c, name, input.FormFieldOptions{Required: true}, func() *ui.Element {
|
||||
r := input.TextInput(c, &val, input.TextInputOptions{Label: name, Disabled: disabled})
|
||||
if r.Changed() {
|
||||
s.Values[name] = val
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
})
|
||||
}
|
||||
insert := input.Button(c, core.Msg(c, "prompt_library.insert"), input.ButtonOptions{Icon: icons.Must("plus"), Disabled: missing || disabled}).Clicked()
|
||||
if insert && !missing {
|
||||
return PromptLibraryFill(t.Body, s.Values)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func promptTemplates() []PromptTemplate {
|
||||
return []PromptTemplate{
|
||||
{ID: "t1", Category: "Letters", Title: "Invitation", Body: "Write an invitation to {{guest}} for the {{event}}."},
|
||||
{ID: "t2", Category: "Music", Title: "Recital program", Body: "List pieces for a {{ instrument }} recital, then thank {{guest}}."},
|
||||
{ID: "t3", Category: "Letters", Title: "Thank-you note", Body: "Thank the hosts."},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptLibraryVariables(t *testing.T) {
|
||||
if got := PromptLibraryVariables("{{a}} and {{ b }} and {{a}}"); !slices.Equal(got, []string{"a", "b"}) {
|
||||
t.Fatalf("variables %q", got)
|
||||
}
|
||||
if got := PromptLibraryFill("Hi {{ name }}, {{x}}!", map[string]string{"name": "Ann"}); got != "Hi Ann, !" {
|
||||
t.Fatalf("fill %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptLibraryBrowseFillInsert(t *testing.T) {
|
||||
var s PromptLibraryState
|
||||
templates := promptTemplates()
|
||||
inserted := ""
|
||||
tt := chatTester(940, 520, func(c *ui.Context) {
|
||||
if v := PromptLibrary(c, &s, templates, PromptLibraryOptions{Label: "Prompts"}); v.Inserted != "" {
|
||||
inserted = v.Inserted
|
||||
}
|
||||
})
|
||||
_ = tt.Click("Music")
|
||||
if tt.HasText("Invitation") || !tt.HasText("Recital program") {
|
||||
t.Fatalf("category filter: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("All")
|
||||
_ = tt.Click("Search prompts")
|
||||
tt.Type("thank")
|
||||
if tt.HasText("Invitation") || !tt.HasText("Thank-you note") || !tt.HasText("Recital program") {
|
||||
t.Fatalf("search covers titles and bodies: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Recital program")
|
||||
if !tt.HasText("[instrument]") {
|
||||
t.Fatal("placeholder not previewed")
|
||||
}
|
||||
_ = tt.Click("Insert")
|
||||
if inserted != "" {
|
||||
t.Fatal("inserted with empty variables")
|
||||
}
|
||||
// Each variable's field sits under its label.
|
||||
for _, f := range [][2]string{{"instrument", "harp"}, {"guest", "the duke"}} {
|
||||
r, _ := tt.Find(f[0])
|
||||
tt.ClickAt(r.X+20, r.Y+r.H+18)
|
||||
tt.Type(f[1])
|
||||
}
|
||||
_ = tt.Click("Insert")
|
||||
if inserted != "List pieces for a harp recital, then thank the duke." {
|
||||
t.Fatalf("inserted %q", inserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptLibraryKeyboardAndDisabled(t *testing.T) {
|
||||
var s PromptLibraryState
|
||||
tt := chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &s, promptTemplates(), PromptLibraryOptions{}) })
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if s.Category != "Letters" {
|
||||
t.Fatalf("keys chose category %q", s.Category)
|
||||
}
|
||||
var d PromptLibraryState
|
||||
tt = chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &d, promptTemplates(), PromptLibraryOptions{Disabled: true}) })
|
||||
_ = tt.Click("Invitation")
|
||||
_ = tt.Click("Music")
|
||||
if d.Selected != "" || d.Category != "" {
|
||||
t.Fatalf("disabled library chose %q %q", d.Selected, d.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptLibraryFiltersOnlyOnChange(t *testing.T) {
|
||||
var s PromptLibraryState
|
||||
templates := promptTemplates()
|
||||
tt := chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &s, templates, PromptLibraryOptions{}) })
|
||||
s.shown.cats = append(s.shown.cats, "Marker")
|
||||
tt.Frame()
|
||||
if !slices.Contains(s.shown.cats, "Marker") {
|
||||
t.Fatal("refiltered without a change")
|
||||
}
|
||||
s.Query = "invit"
|
||||
tt.Frame()
|
||||
if !slices.Equal(s.shown.ids, []int{0}) {
|
||||
t.Fatalf("ids %v", s.shown.ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptLibrarySnapshot(t *testing.T) {
|
||||
snapshot(t, "prompt_library", 900, 360, func(c *ui.Context) {
|
||||
s := &PromptLibraryState{Selected: "t1", Values: map[string]string{"guest": "Lady Ann"}}
|
||||
ui.Column(c).Padding(16).Children(func() { PromptLibrary(c, s, promptTemplates(), PromptLibraryOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.Msg(c, "quote_reply.label"), author, line)).Transition(core.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(core.FontSize(c, theme.CaptionSize)).SingleLine().Shrink(0)
|
||||
ui.Text(c, line).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().MinWidth(0).Shrink(1)
|
||||
})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestQuoteReplyJumpsAndFlattensExcerpt(t *testing.T) {
|
||||
jumps, off := 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,93 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
)
|
||||
|
||||
// RegenerateKind is what a RegenerateMenu choice asks for.
|
||||
type RegenerateKind int
|
||||
|
||||
const (
|
||||
// RegenerateAgain retries with the same model.
|
||||
RegenerateAgain RegenerateKind = iota
|
||||
// RegenerateModel retries with Choice.Model.
|
||||
RegenerateModel
|
||||
// RegenerateVariant retries asking for Choice.Variant, such as longer or shorter.
|
||||
RegenerateVariant
|
||||
)
|
||||
|
||||
// RegenerateChoice is the retry the user picked.
|
||||
type RegenerateChoice struct {
|
||||
Kind RegenerateKind
|
||||
Model string
|
||||
Variant string
|
||||
}
|
||||
|
||||
// RegenerateMenuOptions configure a RegenerateMenu.
|
||||
type RegenerateMenuOptions struct {
|
||||
// Models lists models to retry with; Current is left out.
|
||||
Models []string
|
||||
Current string
|
||||
// Variants replaces the built-in Longer, Shorter, Simpler and More detailed.
|
||||
Variants []string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// RegenerateMenuView is a built menu and the chosen retry.
|
||||
type RegenerateMenuView struct {
|
||||
Element *ui.Element
|
||||
Chosen bool
|
||||
Choice RegenerateChoice
|
||||
}
|
||||
|
||||
// RegenerateMenu offers retry, another model, and rewrite variants.
|
||||
func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuView {
|
||||
variants := opts.Variants
|
||||
if variants == nil {
|
||||
for _, key := range []string{"regenerate_menu.longer", "regenerate_menu.shorter", "regenerate_menu.simpler", "regenerate_menu.detailed"} {
|
||||
variants = append(variants, core.Msg(c, key))
|
||||
}
|
||||
}
|
||||
var v RegenerateMenuView
|
||||
pick := func(ch RegenerateChoice) { v.Chosen, v.Choice = true, ch }
|
||||
v.Element = overlay.DropdownMenu(c, core.Msg(c, "regenerate_menu.regenerate"), overlay.DropdownMenuOptions{Icon: icons.Must("refresh-cw"), Disabled: opts.Disabled}, func(m *overlay.PopupMenu) {
|
||||
if m.Item(core.Msg(c, "regenerate_menu.again"), overlay.PopupMenuItemOptions{}) {
|
||||
pick(RegenerateChoice{Kind: RegenerateAgain})
|
||||
}
|
||||
others := 0
|
||||
for _, model := range opts.Models {
|
||||
if model != opts.Current {
|
||||
others++
|
||||
}
|
||||
}
|
||||
m.Submenu(core.Msg(c, "regenerate_menu.model"), overlay.PopupMenuItemOptions{Disabled: others == 0}, func(m *overlay.PopupMenu) {
|
||||
for _, model := range opts.Models {
|
||||
if model != opts.Current && m.Item(model, overlay.PopupMenuItemOptions{}) {
|
||||
pick(RegenerateChoice{Kind: RegenerateModel, Model: model})
|
||||
}
|
||||
}
|
||||
})
|
||||
m.Separator()
|
||||
m.Group(core.Msg(c, "regenerate_menu.variants"), func() {
|
||||
for _, variant := range variants {
|
||||
if m.Item(variant, overlay.PopupMenuItemOptions{}) {
|
||||
pick(RegenerateChoice{Kind: RegenerateVariant, Variant: variant})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
v.Element.AlignSelf(ui.Start)
|
||||
if v.Element.IsDisabled() {
|
||||
v.Chosen = false
|
||||
}
|
||||
if v.Chosen {
|
||||
slog.Debug("mujica: regenerate_menu choose", "kind", v.Choice.Kind, "model", v.Choice.Model, "variant", v.Choice.Variant)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func regenerateTester(opts RegenerateMenuOptions, got *[]RegenerateChoice) *ui.Tester {
|
||||
return chatTester(500, 420, func(c *ui.Context) {
|
||||
if v := RegenerateMenu(c, opts); v.Chosen {
|
||||
*got = append(*got, v.Choice)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegenerateMenuChoices(t *testing.T) {
|
||||
var got []RegenerateChoice
|
||||
opts := RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}
|
||||
tt := regenerateTester(opts, &got)
|
||||
_ = tt.Click("Regenerate")
|
||||
_ = tt.Click("Try again")
|
||||
_ = tt.Click("Regenerate")
|
||||
_ = tt.Click("Shorter")
|
||||
if len(got) != 2 || got[0].Kind != RegenerateAgain || got[1] != (RegenerateChoice{Kind: RegenerateVariant, Variant: "Shorter"}) {
|
||||
t.Fatalf("choices %+v", got)
|
||||
}
|
||||
_ = tt.Click("Regenerate")
|
||||
_ = tt.Click("Try another model")
|
||||
if tt.HasText("Opus") || !tt.HasText("Sonnet") {
|
||||
t.Fatalf("model submenu %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Sonnet")
|
||||
if len(got) != 3 || got[2] != (RegenerateChoice{Kind: RegenerateModel, Model: "Sonnet"}) {
|
||||
t.Fatalf("choices %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateMenuKeyboardAndVariants(t *testing.T) {
|
||||
var got []RegenerateChoice
|
||||
tt := regenerateTester(RegenerateMenuOptions{Variants: []string{"In verse"}}, &got)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 1 || got[0].Kind != RegenerateAgain {
|
||||
t.Fatalf("keys chose %+v", got)
|
||||
}
|
||||
_ = tt.Click("Regenerate")
|
||||
if !tt.HasText("In verse") || tt.HasText("Longer") {
|
||||
t.Fatalf("variants %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateMenuDisabled(t *testing.T) {
|
||||
var got []RegenerateChoice
|
||||
tt := regenerateTester(RegenerateMenuOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Regenerate")
|
||||
_ = tt.Click("Try again")
|
||||
if len(got) != 0 || tt.HasText("Try again") {
|
||||
t.Fatalf("disabled menu chose %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateMenuSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "regenerate_menu", 360, 330, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
RegenerateMenu(c, RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"})
|
||||
})
|
||||
}, func(tt *ui.Tester) { _ = tt.Click("Regenerate") })
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
label := core.Msg(c, "scroll_to_bottom_button.label")
|
||||
if unread > 0 {
|
||||
label = fmt.Sprintf(core.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(core.ColorFade)
|
||||
b.Opacity(core.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,53 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestScrollToBottomButtonShowsWhenAway(t *testing.T) {
|
||||
away, unread, clicks := false, 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,97 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SearchStage describes one caller-owned search step.
|
||||
type SearchStage struct {
|
||||
Label string
|
||||
Done bool
|
||||
}
|
||||
|
||||
// SearchProgressOptions describe the caller's search.
|
||||
type SearchProgressOptions struct {
|
||||
// Query is the keyword searched; required.
|
||||
Query string
|
||||
// Read and Total count read and discovered sources.
|
||||
Read, Total int
|
||||
Stages []SearchStage
|
||||
// Done folds progress into a summary users can reopen.
|
||||
Done bool
|
||||
}
|
||||
|
||||
// searchProgressState tracks completion and manual folding.
|
||||
type searchProgressState struct {
|
||||
open, done bool
|
||||
}
|
||||
|
||||
// SearchProgress displays keywords, read counts and collapsible stages.
|
||||
func SearchProgress(c *ui.Context, opts SearchProgressOptions) core.ChoiceResult {
|
||||
if opts.Query == "" || opts.Read < 0 || opts.Total < 0 || opts.Read > opts.Total {
|
||||
panic("mujica: SearchProgress needs a Query and 0 <= Read <= Total")
|
||||
}
|
||||
r := core.ChoiceResult{Element: ui.Column(c).MinWidth(0)}
|
||||
st := ui.Local(r.Element, "search", func() searchProgressState { return searchProgressState{open: true} })
|
||||
if opts.Done && !st.done {
|
||||
// Finishing folds it once; the user may open it again.
|
||||
st.open = false
|
||||
slog.Debug("mujica: search_progress complete", "read", opts.Read)
|
||||
}
|
||||
if st.done && !opts.Done {
|
||||
st.open = true
|
||||
slog.Debug("mujica: search_progress restart")
|
||||
}
|
||||
st.done = opts.Done
|
||||
title := fmt.Sprintf(core.Msg(c, "search_progress.searching"), opts.Query)
|
||||
if opts.Done {
|
||||
title = fmt.Sprintf(core.Msg(c, "search_progress.done"), opts.Query, opts.Read)
|
||||
}
|
||||
r.Element.Children(func() { r = core.NewChoiceResult(r.Element, searchProgressBody(c, st, title, opts)) })
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: search_progress fold", "open", st.open)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// searchProgressBody is the collapsible header and the stages.
|
||||
func searchProgressBody(c *ui.Context, st *searchProgressState, title string, opts SearchProgressOptions) bool {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
return data.Collapsible(c, &st.open, data.CollapsibleOptions{Title: title, Trigger: func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
|
||||
if opts.Done {
|
||||
ui.Icon(c, icons.Must("search")).Size(14, 14)
|
||||
} else {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 14, Label: title})
|
||||
}
|
||||
ui.Text(c, title).FontSize(small).SingleLine()
|
||||
})
|
||||
}}, func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "search_progress.read"), opts.Read, opts.Total)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted)
|
||||
ui.Column(c).Gap(u).Role(ui.RoleList).Children(func() {
|
||||
for _, s := range opts.Stages {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
|
||||
if s.Done {
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.Success)
|
||||
} else {
|
||||
ui.Icon(c, icons.Must("circle")).Size(14, 14).TextColor(k.TextMuted)
|
||||
}
|
||||
t := ui.Text(c, s.Label).FontSize(small)
|
||||
if !s.Done {
|
||||
t.TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}).Changed()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func searchStages(done int) []SearchStage {
|
||||
labels := []string{"Planning queries", "Searching the web", "Reading sources", "Writing the answer"}
|
||||
out := make([]SearchStage, len(labels))
|
||||
for i, l := range labels {
|
||||
out[i] = SearchStage{Label: l, Done: i < done}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSearchProgressFoldsWhenDone(t *testing.T) {
|
||||
opts := SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SearchProgress(c, opts)
|
||||
}, 500, 300)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
for _, s := range []string{"Searching for “Versailles 1682”", "Read 3 of 8 sources", "Reading sources"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
opts.Done, opts.Read, opts.Stages = true, 8, searchStages(4)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Searched “Versailles 1682”, read 8 sources") || tt.HasText("Reading sources") {
|
||||
t.Fatalf("done: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Searched “Versailles 1682”, read 8 sources")
|
||||
if !tt.HasText("Reading sources") {
|
||||
t.Fatal("finished search does not open again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchProgressBadCounts(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for Read above Total")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SearchProgress(c, SearchProgressOptions{Query: "q", Read: 3, Total: 1})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestSearchProgressReopensForNextSearch(t *testing.T) {
|
||||
opts := SearchProgressOptions{Query: "first", Read: 1, Total: 2, Stages: []SearchStage{{Label: "First stage"}}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SearchProgress(c, opts)
|
||||
}, 400, 300)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
opts.Done = true
|
||||
tt.Frame()
|
||||
opts = SearchProgressOptions{Query: "second", Total: 3, Stages: []SearchStage{{Label: "Next stage"}}}
|
||||
tt.Frame()
|
||||
if !tt.HasText("Next stage") || !tt.HasText("Read 0 of 3 sources") {
|
||||
t.Fatalf("next search remains folded: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchProgressSnapshot(t *testing.T) {
|
||||
snapshot(t, "search_progress", 440, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
SearchProgress(c, SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)})
|
||||
SearchProgress(c, SearchProgressOptions{Query: "Hall of Mirrors", Read: 5, Total: 5, Stages: searchStages(4), Done: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SendButtonOptions configure a SendButton.
|
||||
type SendButtonOptions struct {
|
||||
// Streaming selects Stop while the caller generates a reply.
|
||||
Streaming bool
|
||||
// Disabled blocks sending; Stop remains available.
|
||||
Disabled bool
|
||||
// Shortcut supplies tooltip text; empty hides the hint.
|
||||
Shortcut string
|
||||
}
|
||||
|
||||
// SendResult carries the button and requested action.
|
||||
type SendResult struct {
|
||||
Element *ui.Element
|
||||
send, stop bool
|
||||
}
|
||||
|
||||
// Sent requests sending; Stopped requests generation cancellation.
|
||||
func (r SendResult) Sent() bool { return r.send }
|
||||
func (r SendResult) Stopped() bool { return r.stop }
|
||||
|
||||
// SendButton sends the prompt, or stops the reply while streaming.
|
||||
func SendButton(c *ui.Context, opts SendButtonOptions) SendResult {
|
||||
k := core.Tokens(c)
|
||||
h := core.Density(c).ControlHeight()
|
||||
label, icon := core.Msg(c, "send_button.send"), "arrow-up"
|
||||
if opts.Streaming {
|
||||
label, icon = core.Msg(c, "send_button.stop"), "square"
|
||||
}
|
||||
tip := label
|
||||
if opts.Shortcut != "" && !opts.Streaming {
|
||||
tip += " (" + opts.Shortcut + ")"
|
||||
}
|
||||
var r SendResult
|
||||
var b *ui.Element
|
||||
r.Element = ui.Row(c).Shrink(0).Disabled(opts.Disabled && !opts.Streaming)
|
||||
r.Element.Children(func() {
|
||||
b = ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(tip).Transition(core.ColorFade)
|
||||
internal.Ring(b, k.Focus, h/2)
|
||||
bg, fg := k.Accent, k.OnAccent
|
||||
switch {
|
||||
case opts.Streaming:
|
||||
bg, fg = k.Surface, k.Text
|
||||
b.Border(theme.BorderWidth, k.ControlBorder)
|
||||
if b.Hovered() {
|
||||
bg = k.SurfaceHover
|
||||
}
|
||||
case b.Pressed():
|
||||
bg = k.AccentPressed
|
||||
case b.Hovered():
|
||||
bg = k.AccentHover
|
||||
}
|
||||
b.Background(bg).TextColor(fg)
|
||||
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
|
||||
})
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
r.send, r.stop = !opts.Streaming, opts.Streaming
|
||||
slog.Debug("mujica: send_button press", "stop", r.stop)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestSendButtonSendStopDisabled(t *testing.T) {
|
||||
opts := SendButtonOptions{Shortcut: "Enter"}
|
||||
sends, stops := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := SendButton(c, opts)
|
||||
if r.Sent() {
|
||||
sends++
|
||||
}
|
||||
if r.Stopped() {
|
||||
stops++
|
||||
}
|
||||
}, 200, 100)
|
||||
_ = tt.Click("Send")
|
||||
opts.Streaming = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stop generating")
|
||||
opts = SendButtonOptions{Disabled: true}
|
||||
tt.Frame()
|
||||
_ = tt.Click("Send")
|
||||
opts.Streaming = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 1 || stops != 2 {
|
||||
t.Fatalf("sends %d stops %d (disabled blocks send, not stop)", sends, stops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "send_button", 200, 70, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Gap(12).Children(func() {
|
||||
SendButton(c, SendButtonOptions{})
|
||||
SendButton(c, SendButtonOptions{Streaming: true})
|
||||
SendButton(c, SendButtonOptions{Disabled: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SessionRole is who wrote a message; zero is SessionUser.
|
||||
type SessionRole int
|
||||
|
||||
const (
|
||||
SessionUser SessionRole = iota
|
||||
SessionAssistant
|
||||
SessionSystem
|
||||
)
|
||||
|
||||
// SessionMessage is one saved message, as plain text.
|
||||
type SessionMessage struct {
|
||||
Role SessionRole
|
||||
// Author names the writer; empty uses the role's name.
|
||||
Author string
|
||||
Text string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// SharedConversationViewOptions configure a SharedConversationView.
|
||||
type SharedConversationViewOptions struct {
|
||||
Title string
|
||||
SharedBy string
|
||||
SharedAt time.Time
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// SharedConversationView shows a shared conversation read-only with Copy link.
|
||||
func SharedConversationView(c *ui.Context, messages []SessionMessage, opts SharedConversationViewOptions) core.ChoiceResult {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: SharedConversationView needs a Title")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||
rows := *ui.Local(col, sharedConversationKey{}, func() *ui.ListState { return &ui.ListState{} })
|
||||
copied := false
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||
ui.Column(c).Grow(1).Gap(2).Children(func() {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).TextColor(k.Text)
|
||||
by := fmt.Sprintf(core.Msg(c, "shared_conversation_view.shared_by"), cmp.Or(opts.SharedBy, core.Msg(c, "shared_conversation_view.someone")))
|
||||
if !opts.SharedAt.IsZero() {
|
||||
by += " · " + dateSeparatorLabel(c, core.DateOf(opts.SharedAt), core.DateOf(c.Now()))
|
||||
}
|
||||
ui.Text(c, by).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||
ui.Icon(c, icons.Must("lock")).Size(14, 14).TextColor(k.TextMuted)
|
||||
ui.Text(c, core.Msg(c, "shared_conversation_view.read_only")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
copied = input.Button(c, core.Msg(c, "shared_conversation_view.copy_link"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("copy"),
|
||||
Disabled: opts.Disabled}).Clicked()
|
||||
})
|
||||
ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { internal.RuleDiamond(p, r, 6, 5, k.Ornament) })
|
||||
rows.Key = func(i int) any { return i }
|
||||
ui.List(c, rows, len(messages), func(i int) { sessionMessageView(c, k, messages[i]) }).Grow(1).MinHeight(0).Gap(d.Unit() * 3)
|
||||
})
|
||||
if copied {
|
||||
slog.Debug("mujica: shared_conversation_view copy_link")
|
||||
}
|
||||
return core.NewChoiceResult(col, copied)
|
||||
}
|
||||
|
||||
type sharedConversationKey struct{}
|
||||
|
||||
// sessionMessageView shows one read-only message with author and time.
|
||||
func sessionMessageView(c *ui.Context, k theme.Tokens, m SessionMessage) {
|
||||
d := core.Density(c)
|
||||
if m.Role < SessionUser || m.Role > SessionSystem {
|
||||
panic("mujica: SessionMessage Role out of range")
|
||||
}
|
||||
author := cmp.Or(m.Author, core.Msg(c, sessionRoleKeys[m.Role]))
|
||||
box := ui.Column(c).Gap(d.Unit()).Padding(d.Unit()*2, d.Unit()*3).Radius(theme.CardRadius)
|
||||
if m.Role == SessionUser {
|
||||
box.Background(k.Background).Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
box.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, author).Bold().TextColor(k.Text)
|
||||
if !m.Time.IsZero() {
|
||||
ui.Text(c, m.Time.Format("15:04")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
// Plain text: Markdown is not rendered here.
|
||||
display.Text(c, m.Text, display.TextOptions{Selectable: true})
|
||||
})
|
||||
}
|
||||
|
||||
var sessionRoleKeys = map[SessionRole]string{
|
||||
SessionUser: "shared_conversation_view.user", SessionAssistant: "shared_conversation_view.assistant",
|
||||
SessionSystem: "shared_conversation_view.system",
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestSharedConversationViewShowsAndCopies(t *testing.T) {
|
||||
copies := 0
|
||||
tt := chatTester(560, 460, func(c *ui.Context) {
|
||||
if SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "Banquet seating", SharedBy: "Lady Ann", SharedAt: chatNow}).Changed() {
|
||||
copies++
|
||||
}
|
||||
})
|
||||
for _, s := range []string{"Banquet seating", "Shared by Lady Ann", "Read only", "You", "Assistant", "Lady Ann", "And the musicians?"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("%q missing from %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Copy link")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if copies != 2 {
|
||||
t.Fatalf("copies %d", copies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedConversationViewVirtualizes(t *testing.T) {
|
||||
msgs := make([]SessionMessage, 100000)
|
||||
for i := range msgs {
|
||||
msgs[i] = SessionMessage{Role: SessionRole(i % 2), Text: fmt.Sprintf("Message %d", i)}
|
||||
}
|
||||
tt := chatTester(560, 460, func(c *ui.Context) {
|
||||
SharedConversationView(c, msgs, SharedConversationViewOptions{Title: "Long"})
|
||||
})
|
||||
if n := len(tt.Texts()); n > 80 {
|
||||
t.Fatalf("built %d texts for 100000 messages", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedConversationViewDisabledAndChecks(t *testing.T) {
|
||||
copies := 0
|
||||
tt := chatTester(560, 460, func(c *ui.Context) {
|
||||
if SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "T", Disabled: true}).Changed() {
|
||||
copies++
|
||||
}
|
||||
})
|
||||
_ = tt.Click("Copy link")
|
||||
if copies != 0 || !tt.HasText("Shared by someone") {
|
||||
t.Fatalf("copies %d texts %q", copies, tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a missing title")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SharedConversationView(c, nil, SharedConversationViewOptions{})
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
func TestSharedConversationViewSnapshot(t *testing.T) {
|
||||
snapshot(t, "shared_conversation_view", 560, 380, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Height(380).Children(func() {
|
||||
SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "Banquet seating", SharedBy: "Lady Ann", SharedAt: chatNow})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SlashCommand is one command of a SlashCommandMenu.
|
||||
type SlashCommand struct {
|
||||
// Name is typed after the slash; required, without spaces.
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// SlashCommandResult carries the popup and chosen command.
|
||||
type SlashCommandResult struct {
|
||||
Element *ui.Element
|
||||
chosen SlashCommand
|
||||
ok bool
|
||||
}
|
||||
|
||||
// Chosen returns the command picked by Enter or a click.
|
||||
func (r SlashCommandResult) Chosen() (SlashCommand, bool) { return r.chosen, r.ok }
|
||||
|
||||
// SlashCommandMenu filters commands after a slash trigger.
|
||||
// Arrows navigate; Enter chooses; Tab completes; Escape dismisses.
|
||||
func SlashCommandMenu(c *ui.Context, composer input.InputResult, value *string, commands []SlashCommand) SlashCommandResult {
|
||||
if composer.Element == nil || composer.Input == nil || value == nil {
|
||||
panic("mujica: SlashCommandMenu needs composer and value")
|
||||
}
|
||||
data := ui.Local(composer.Element, "slash-data", func() slashMenuData { return slashMenuData{} })
|
||||
query, typing := strings.CutPrefix(*value, "/")
|
||||
typing = typing && !strings.ContainsAny(query, " \t\n")
|
||||
if !data.built || query != data.query || typing != data.typing || !slices.Equal(data.commands, commands) {
|
||||
for _, cmd := range commands {
|
||||
if cmd.Name == "" || strings.ContainsAny(cmd.Name, " \t\n") {
|
||||
panic("mujica: SlashCommand Name must be one word")
|
||||
}
|
||||
}
|
||||
data.built, data.query, data.typing = true, query, typing
|
||||
data.commands = append(data.commands[:0], commands...)
|
||||
|
||||
data.shown, data.rows = data.shown[:0], data.rows[:0]
|
||||
if typing {
|
||||
q := strings.ToLower(query)
|
||||
for pass := range 2 {
|
||||
for _, cmd := range commands {
|
||||
n := strings.ToLower(cmd.Name)
|
||||
if pass == 0 && strings.HasPrefix(n, q) || pass == 1 && !strings.HasPrefix(n, q) && strings.Contains(n, q) {
|
||||
data.shown = append(data.shown, cmd)
|
||||
data.rows = append(data.rows, slashMenuRow{label: "/" + cmd.Name, detail: cmd.Description})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var r SlashCommandResult
|
||||
st := ui.Local(composer.Element, slashMenuKey{"slash"}, func() slashMenuState { return slashMenuState{} })
|
||||
shown, rows := data.shown, data.rows
|
||||
i, how, panel := slashMenuShow(c, composer, st, query, typing, rows, slashMenuOptions{empty: core.Msg(c, "slash_command_menu.empty"), label: core.Msg(c, "slash_command_menu.label")})
|
||||
r.Element = panel
|
||||
if i >= 0 {
|
||||
*value = "/" + shown[i].Name + " "
|
||||
if how == slashMenuChose {
|
||||
r.chosen, r.ok = shown[i], true
|
||||
}
|
||||
slog.Debug("mujica: slash_command_menu pick", "name", shown[i].Name, "chosen", r.ok)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type slashMenuData struct {
|
||||
commands, shown []SlashCommand
|
||||
query string
|
||||
built, typing bool
|
||||
rows []slashMenuRow
|
||||
}
|
||||
|
||||
type slashMenuKey struct{ name string }
|
||||
|
||||
// slashMenuRow represents an item or titled group.
|
||||
type slashMenuRow struct {
|
||||
label, detail, icon, group string
|
||||
}
|
||||
|
||||
type slashMenuOptions struct {
|
||||
empty, label string
|
||||
loading bool
|
||||
}
|
||||
|
||||
// slashMenuState tracks navigation, dismissal and pending choices.
|
||||
type slashMenuState struct {
|
||||
hi, lastHi int
|
||||
query string
|
||||
dismissed *string
|
||||
pick slashMenuHow
|
||||
held, open bool
|
||||
list ui.ListState
|
||||
}
|
||||
|
||||
type slashMenuHow int
|
||||
|
||||
const (
|
||||
slashMenuNone slashMenuHow = iota
|
||||
slashMenuChose
|
||||
slashMenuCompleted
|
||||
)
|
||||
|
||||
// slashMenuShow returns the selected row and activation method.
|
||||
func slashMenuShow(c *ui.Context, composer input.InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow, *ui.Element) {
|
||||
if !open {
|
||||
st.dismissed = nil
|
||||
}
|
||||
if query != st.query {
|
||||
st.hi, st.query = 0, query
|
||||
st.list.ScrollTo(0, ui.Start)
|
||||
if st.dismissed != nil && *st.dismissed != query {
|
||||
st.dismissed = nil
|
||||
}
|
||||
}
|
||||
// Panel presses preserve the menu while transferring focus.
|
||||
open = open && st.dismissed == nil && !composer.Element.IsDisabled() && (composer.Input.Focused() || st.held)
|
||||
pick, how := -1, slashMenuNone
|
||||
st.open = open
|
||||
if !open {
|
||||
st.pick = slashMenuNone
|
||||
return pick, how, nil
|
||||
}
|
||||
st.hi = min(max(st.hi, 0), len(rows)-1)
|
||||
if st.pick != slashMenuNone && st.hi >= 0 {
|
||||
pick, how = st.hi, st.pick
|
||||
}
|
||||
st.pick = slashMenuNone
|
||||
n := len(rows)
|
||||
promptComposerHook(composer, func(ev ui.InputEvent) bool {
|
||||
if composer.Element.IsDisabled() {
|
||||
return true
|
||||
}
|
||||
if ev.Kind != ui.InputKeyDown || ev.Mods != 0 {
|
||||
return false
|
||||
}
|
||||
switch ev.Key {
|
||||
case ui.KeyDown, ui.KeyUp:
|
||||
if n > 0 {
|
||||
d := map[ui.Key]int{ui.KeyDown: 1, ui.KeyUp: -1}[ev.Key]
|
||||
st.hi = (max(st.hi, 0) + d + n) % n
|
||||
}
|
||||
case ui.KeyEnter:
|
||||
st.pick = slashMenuChose
|
||||
case ui.KeyTab:
|
||||
st.pick = slashMenuCompleted
|
||||
case ui.KeyEscape:
|
||||
q := query
|
||||
st.dismissed = &q
|
||||
default:
|
||||
return false
|
||||
}
|
||||
c.Invalidate()
|
||||
return true
|
||||
})
|
||||
i, panel := slashMenuPanel(c, composer.Element.Bounds(), st, rows, o)
|
||||
if i >= 0 {
|
||||
pick, how = i, slashMenuChose
|
||||
}
|
||||
if pick >= 0 {
|
||||
composer.Input.Focus()
|
||||
}
|
||||
return pick, how, panel
|
||||
}
|
||||
|
||||
// slashMenuPanel builds visible rows and returns clicked choices.
|
||||
func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) (int, *ui.Element) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
rowH := core.Density(c).ControlHeight()
|
||||
headH := rowH * 0.75
|
||||
h := 2 * u
|
||||
group := ""
|
||||
for _, r := range rows {
|
||||
if r.group != group {
|
||||
h, group = h+headH, r.group
|
||||
}
|
||||
h += rowH
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
h += rowH
|
||||
}
|
||||
ww, wh := c.Size()
|
||||
h = min(h+2*theme.BorderWidth, 280, max(0, wh-8))
|
||||
w := min(max(a.W*0.6, 320), max(a.W, 200), max(0, ww-8))
|
||||
x, y := core.PlaceOverlay(c, a, w, h, u)
|
||||
clicked, pressed := -1, false
|
||||
var result *ui.Element
|
||||
ui.Overlay(c, func() {
|
||||
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Height(h).Role(ui.RoleList).Label(o.label)
|
||||
core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, a))
|
||||
result = panel
|
||||
panel.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind == ui.InputPointerDown {
|
||||
st.held = true
|
||||
}
|
||||
return false
|
||||
})
|
||||
panel.Children(func() {
|
||||
if st.lastHi != st.hi {
|
||||
st.lastHi = st.hi
|
||||
st.list.ScrollIntoView(max(st.hi, 0))
|
||||
}
|
||||
ui.List(c, &st.list, max(1, len(rows)), func(i int) {
|
||||
if len(rows) == 0 {
|
||||
ui.Row(c).MinHeight(rowH).Padding(0, u*3).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
|
||||
if o.loading {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 14})
|
||||
}
|
||||
ui.Text(c, o.empty).SingleLine()
|
||||
})
|
||||
return
|
||||
}
|
||||
r := rows[i]
|
||||
ui.Column(c).Children(func() {
|
||||
if r.group != "" && (i == 0 || r.group != rows[i-1].group) {
|
||||
ui.Text(c, r.group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(core.FontSize(c, theme.CaptionSize)).
|
||||
FontWeight(600).TextColor(k.TextMuted).Role(ui.RoleText)
|
||||
}
|
||||
row := ui.Row(c).Key(i).MinHeight(rowH).Padding(0, u*3, 0, u*2).Gap(u * 2).AlignItems(ui.Center).
|
||||
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer).Selected(i == st.hi).TextColor(k.Text)
|
||||
if row.Clicked() {
|
||||
clicked = i
|
||||
}
|
||||
pressed = pressed || row.Pressed()
|
||||
hi := i == st.hi
|
||||
if hi {
|
||||
row.Background(k.Selection).Draw(func(p *ui.Painter, b ui.Rect) {
|
||||
p.Fill(ui.Rect{X: b.X, Y: b.Y + 4, W: 2, H: b.H - 8}, k.AccentText, 1)
|
||||
})
|
||||
} else if row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
row.Children(func() {
|
||||
if r.icon != "" {
|
||||
ui.Icon(c, icons.Must(r.icon)).Size(16, 16).Shrink(0).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Text(c, r.label).SingleLine().Shrink(0).FontWeight(600)
|
||||
ui.Text(c, r.detail).SingleLine().MinWidth(0).Shrink(1).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
})
|
||||
}).Fill().Padding(u)
|
||||
})
|
||||
})
|
||||
st.held = st.held && pressed
|
||||
return clicked, result
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var slashCommandsSample = []SlashCommand{
|
||||
{Name: "summarize", Description: "Summarize the thread"},
|
||||
{Name: "search", Description: "Search the web"},
|
||||
{Name: "translate", Description: "Translate to French"},
|
||||
}
|
||||
|
||||
// slashTester records command choices and prompt submissions.
|
||||
type slashTester struct {
|
||||
text string
|
||||
chosen []string
|
||||
sends int
|
||||
off bool
|
||||
}
|
||||
|
||||
func (s *slashTester) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() {
|
||||
p := PromptComposer(c, &s.text, PromptComposerOptions{Label: "Prompt", Disabled: s.off})
|
||||
if p.Submitted() {
|
||||
s.sends++
|
||||
}
|
||||
if cmd, ok := SlashCommandMenu(c, p, &s.text, slashCommandsSample).Chosen(); ok {
|
||||
s.chosen = append(s.chosen, cmd.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuFilterAndKeys(t *testing.T) {
|
||||
s := &slashTester{}
|
||||
tt := ui.NewTester(s.view, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("/s")
|
||||
sr, _ := tt.Find("Search the web")
|
||||
tr, ok := tt.Find("Translate to French")
|
||||
if !ok || !tt.HasText("/summarize") || tr.Y <= sr.Y {
|
||||
t.Fatalf("filter /s does not list prefix matches first: %q", tt.Texts())
|
||||
}
|
||||
tt.Type("e")
|
||||
if tt.HasText("/summarize") || !tt.HasText("/search") {
|
||||
t.Fatalf("filter /se: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if s.text != "/search " || len(s.chosen) != 1 || s.chosen[0] != "search" || s.sends != 0 {
|
||||
t.Fatalf("Enter: text %q chosen %v sends %d", s.text, s.chosen, s.sends)
|
||||
}
|
||||
if tt.HasText("/summarize") {
|
||||
t.Fatal("menu stays open after choosing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuTabCompletesAndEscapeCloses(t *testing.T) {
|
||||
s := &slashTester{}
|
||||
tt := ui.NewTester(s.view, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("/tr")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if s.text != "/translate " || len(s.chosen) != 0 {
|
||||
t.Fatalf("Tab: text %q chosen %v", s.text, s.chosen)
|
||||
}
|
||||
s.text = ""
|
||||
tt.Frame()
|
||||
tt.Type("/")
|
||||
if !tt.HasText("/summarize") {
|
||||
t.Fatal("menu did not open on /")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("/summarize") {
|
||||
t.Fatal("Escape did not close the menu")
|
||||
}
|
||||
tt.Type("t")
|
||||
if !tt.HasText("/translate") {
|
||||
t.Fatal("menu did not reopen as the filter changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuClickAndEmpty(t *testing.T) {
|
||||
s := &slashTester{}
|
||||
tt := ui.NewTester(s.view, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("/zz")
|
||||
if !tt.HasText("No matching commands") {
|
||||
t.Fatalf("empty state: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
if err := tt.Click("/translate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.chosen) != 1 || s.chosen[0] != "translate" || !tt.Focused("Prompt") {
|
||||
t.Fatalf("click chose %v, composer focused %v", s.chosen, tt.Focused("Prompt"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuDisabledComposer(t *testing.T) {
|
||||
s := &slashTester{text: "/s", off: true}
|
||||
tt := ui.NewTester(s.view, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.HasText("/search") || len(s.chosen) != 0 {
|
||||
t.Fatal("disabled composer shows or uses the menu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "slash_command_menu", 520, 360, func(c *ui.Context) {
|
||||
(*ui.Local(c.Root(), "s", func() *slashTester { return &slashTester{} })).view(c)
|
||||
}, func(tt *ui.Tester) {
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("/")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuNarrowAndLargeData(t *testing.T) {
|
||||
cmds := make([]SlashCommand, 100000)
|
||||
for i := range cmds {
|
||||
cmds[i] = SlashCommand{Name: fmt.Sprintf("cmd%d", i)}
|
||||
}
|
||||
text := "/"
|
||||
var panel *ui.Element
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt"})
|
||||
panel = SlashCommandMenu(c, p, &text, cmds).Element
|
||||
}, 180, 120)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Frame()
|
||||
if panel == nil {
|
||||
t.Fatal("menu missing")
|
||||
}
|
||||
b := panel.Bounds()
|
||||
if b.X < 0 || b.Y < 0 || b.X+b.W > 180 || b.Y+b.H > 120 {
|
||||
t.Errorf("popup outside window: %v", b)
|
||||
}
|
||||
if n := len(tt.Texts()); n > 150 {
|
||||
t.Errorf("built %d labels for 100000 commands, want <= 150", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuReopensAfterTriggerRemoved(t *testing.T) {
|
||||
s := &slashTester{}
|
||||
tt := ui.NewTester(s.view, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("/")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Type("/")
|
||||
if !tt.HasText("/summarize") {
|
||||
t.Fatal("retyped slash stayed dismissed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandMenuQueryRevealsHighlight(t *testing.T) {
|
||||
commands := make([]SlashCommand, 100)
|
||||
for i := range commands {
|
||||
commands[i] = SlashCommand{Name: fmt.Sprintf("cmd%03d", i)}
|
||||
}
|
||||
text := "/"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Fill().Justify(ui.End).Children(func() {
|
||||
p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt"})
|
||||
SlashCommandMenu(c, p, &text, commands)
|
||||
})
|
||||
}, 500, 400)
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Scroll(100, 150, 0, 1500)
|
||||
tt.Type("c")
|
||||
b, ok := tt.Find("/cmd000")
|
||||
if !ok || b.H == 0 || b.Y < 0 || b.Y+b.H > 400 {
|
||||
t.Fatalf("highlight outside view: %v found %v", b, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SourceCard displays source metadata; Clicked requests opening.
|
||||
// Selection and an edge mark identify active or hovered sources.
|
||||
func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element {
|
||||
if n < 1 || st == nil || src.Title == "" || src.URL == "" {
|
||||
panic("mujica: SourceCard needs n >= 1, a state and a Source with Title and URL")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
b := ui.ButtonBase(c).Padding(u*2, u*3).AlignItems(ui.Start).Justify(ui.Start).Radius(theme.CardRadius).
|
||||
Label(fmt.Sprintf(core.Msg(c, "source_card.open"), src.Title)).Tooltip(src.URL).Transition(core.ColorFade)
|
||||
internal.Ring(b, k.Focus, theme.CardRadius)
|
||||
st.track(c, b, n)
|
||||
frame := c.Now()
|
||||
b.Draw(func(_ *ui.Painter, _ ui.Rect) { st.expire(c, frame) })
|
||||
switch {
|
||||
case st.lit(n):
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.Border).BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 3).
|
||||
BorderColor(k.AccentText)
|
||||
default:
|
||||
b.Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
b.Children(func() { sourceSummary(c, n, src) })
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
slog.Debug("mujica: source_card open", "n", n)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestSourceCardOpensAndLights(t *testing.T) {
|
||||
var st CitationState
|
||||
opened, off := 0, false
|
||||
var bg []byte
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Width(360).Disabled(off).Children(func() {
|
||||
if SourceCard(c, 1, sourcesSample[0], &st).Clicked() {
|
||||
opened++
|
||||
}
|
||||
})
|
||||
}, 400, 200)
|
||||
for _, s := range []string{"[1]", "example.com", "2025-05-04", "The Palace of Versailles", "Seat of the court from 1682."} {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
bg = encode(t, tt.Image())
|
||||
_ = tt.Click("Open The Palace of Versailles")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opened != 2 {
|
||||
t.Fatalf("opened %d", opened)
|
||||
}
|
||||
st.Active = 1
|
||||
tt.Move(390, 190)
|
||||
tt.Frame()
|
||||
if string(encode(t, tt.Image())) == string(bg) {
|
||||
t.Fatal("active card does not light up")
|
||||
}
|
||||
off = true
|
||||
_ = tt.Click("Open The Palace of Versailles")
|
||||
if opened != 2 {
|
||||
t.Fatal("disabled card opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCardNeedsURL(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without a URL")
|
||||
}
|
||||
}()
|
||||
var st CitationState
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SourceCard(c, 1, Source{Title: "x"}, &st)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestSourceCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "source_card", 400, 260, func(c *ui.Context) {
|
||||
st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} })
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
SourceCard(c, 1, sourcesSample[0], st)
|
||||
SourceCard(c, 2, sourcesSample[1], st)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// OpenResult carries the list and opened 1-based source number.
|
||||
type OpenResult struct {
|
||||
Element *ui.Element
|
||||
opened int
|
||||
}
|
||||
|
||||
// Opened returns the number of the source opened.
|
||||
func (r OpenResult) Opened() (int, bool) { return r.opened, r.opened > 0 }
|
||||
|
||||
// SourcesPanel lists citations and scrolls to activated sources.
|
||||
// It grows to fill its container, which needs a height.
|
||||
func SourcesPanel(c *ui.Context, sources []Source, st *CitationState) OpenResult {
|
||||
if st == nil || st.Active < 0 || st.Active > len(sources) {
|
||||
panic("mujica: SourcesPanel needs a state whose Active is 0 or a listed source")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r OpenResult
|
||||
r.Element = ui.Column(c).Gap(u * 2).Grow(1).MinHeight(0).Role(ui.RoleList).Label(core.Msg(c, "sources_panel.label"))
|
||||
shown := ui.Local(r.Element, "active", func() int { return 0 })
|
||||
located := ui.Local(r.Element, "locate", func() uint64 { return 0 })
|
||||
list := ui.Local(r.Element, "list", func() ui.ListState { return ui.ListState{} })
|
||||
if st.Active > 0 && (*shown != st.Active || *located != st.locate) {
|
||||
list.ScrollIntoView(st.Active - 1)
|
||||
slog.Debug("mujica: sources_panel locate", "source", st.Active)
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("%s (%d)", core.Msg(c, "sources_panel.label"), len(sources))).Font(theme.SerifFont).
|
||||
FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
if len(sources) == 0 {
|
||||
ui.Text(c, core.Msg(c, "sources_panel.empty")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
ui.List(c, list, len(sources), func(i int) {
|
||||
card := SourceCard(c, i+1, sources[i], st).MinHeight(core.Density(c).ControlHeight()).Role(ui.RoleListItem)
|
||||
if card.Clicked() && !card.IsDisabled() {
|
||||
r.opened = i + 1
|
||||
}
|
||||
}).Grow(1).MinHeight(0).Gap(u * 2)
|
||||
})
|
||||
*shown = st.Active
|
||||
*located = st.locate
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestSourcesPanelOrderOpenAndScroll(t *testing.T) {
|
||||
var many []Source
|
||||
for i := range 20 {
|
||||
many = append(many, Source{Title: fmt.Sprintf("Source title %d", i+1), URL: fmt.Sprintf("https://example.com/%d", i+1)})
|
||||
}
|
||||
var st CitationState
|
||||
opened := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Width(360).Height(300).Children(func() {
|
||||
if n, ok := SourcesPanel(c, many, &st).Opened(); ok {
|
||||
opened = n
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
first, _ := tt.Find("Source title 1")
|
||||
second, _ := tt.Find("Source title 2")
|
||||
if !tt.HasText("Sources (20)") || second.Y <= first.Y {
|
||||
t.Fatalf("order or header: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Open Source title 2")
|
||||
if opened != 2 {
|
||||
t.Fatalf("opened %d", opened)
|
||||
}
|
||||
st.Active = 18
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("Source title 18")
|
||||
if !ok || r.Y < 0 || r.Y+r.H > 300 {
|
||||
t.Fatalf("active source not scrolled into view: %v %v", r, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesPanelEmptyAndBadActive(t *testing.T) {
|
||||
var st CitationState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SourcesPanel(c, nil, &st)
|
||||
}, 300, 200)
|
||||
if !tt.HasText("No sources") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an Active past the list")
|
||||
}
|
||||
}()
|
||||
st.Active = 4
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestSourcesPanelSnapshot(t *testing.T) {
|
||||
snapshot(t, "sources_panel", 380, 420, func(c *ui.Context) {
|
||||
st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} })
|
||||
ui.Column(c).Padding(16).Height(420).Children(func() { SourcesPanel(c, sourcesSample, st) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestSourcesPanelLargeData(t *testing.T) {
|
||||
sources := make([]Source, 100000)
|
||||
for i := range sources {
|
||||
sources[i] = Source{Title: fmt.Sprintf("Archive %d", i), URL: "https://example.com"}
|
||||
}
|
||||
st := CitationState{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Width(360).Height(300).Children(func() { SourcesPanel(c, sources, &st) })
|
||||
}, 400, 300)
|
||||
if n := len(tt.Texts()); n > 250 {
|
||||
t.Fatalf("built %d labels for 100000 sources, want <= 250", n)
|
||||
}
|
||||
st.Active = 99999
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("Archive 99998")
|
||||
if !ok || r.Y < 0 || r.Y+r.H > 300 {
|
||||
t.Fatalf("active source bounds %v found %v", r, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesPanelRepeatsCitationLocate(t *testing.T) {
|
||||
sources := make([]Source, 100)
|
||||
for i := range sources {
|
||||
sources[i] = Source{Title: fmt.Sprintf("Archive %d", i+1), URL: "https://example.com"}
|
||||
}
|
||||
var st CitationState
|
||||
actions := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Fill().Children(func() {
|
||||
if CitationBadge(c, 90, sources[89], &st).Changed() {
|
||||
actions++
|
||||
}
|
||||
ui.Column(c).Height(250).Children(func() { SourcesPanel(c, sources, &st) })
|
||||
})
|
||||
}, 400, 300)
|
||||
for i := range 2 {
|
||||
if err := tt.Click("Source 90: Archive 90"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, ok := tt.Find("Archive 90")
|
||||
if !ok || b.H == 0 || b.Y < 0 || b.Y+b.H > 300 || actions != i+1 {
|
||||
t.Fatalf("locate %d: bounds %v found %v events %d", i+1, b, ok, actions)
|
||||
}
|
||||
tt.Scroll(100, 150, 0, -100000)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1822 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user