Merge branch 'worktree-agent-a085d9a07ef787c18'
No files matched your search
@@ -0,0 +1,90 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Attachment is a file attached to a prompt; the caller uploads it.
|
||||
type Attachment struct {
|
||||
// Name is required.
|
||||
Name string
|
||||
// Type is the MIME type; empty picks the icon from the name.
|
||||
Type string
|
||||
Size int64
|
||||
// Uploading shows Progress, 0 to 1.
|
||||
Uploading bool
|
||||
Progress float64
|
||||
// Err shows a failed upload with retry.
|
||||
Err error
|
||||
}
|
||||
|
||||
// AttachmentChipResult is a chip's element and the user's request this frame.
|
||||
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 shows an attachment's type, name, size, upload progress or failure, and remove.
|
||||
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 := Tokens(c)
|
||||
u := 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(FontSize(c, theme.CaptionSize)).FontWeight(600)
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
switch {
|
||||
case a.Err != nil:
|
||||
ui.Text(c, 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(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(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 && iconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(msg(c, "attachment_chip.retry"), a.Name)).Clicked() && !disabled {
|
||||
r.retried = true
|
||||
slog.Debug("mujica: attachment_chip retry")
|
||||
}
|
||||
if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "attachment_chip.remove"), a.Name)).Clicked() && !disabled {
|
||||
r.removed = true
|
||||
slog.Debug("mujica: attachment_chip remove")
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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, source cards and the sources panel; the caller holds it.
|
||||
type CitationState struct {
|
||||
// Active is the 1-based number of the source the user chose; 0 for none.
|
||||
Active int
|
||||
hover int
|
||||
owner 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 records that e, showing source n, is or is no longer under the pointer.
|
||||
func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
|
||||
switch over := e.Hovered(); {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// CitationBadge is an inline source number; hovering previews the source, clicking makes it Active.
|
||||
func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) 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 := Tokens(c)
|
||||
label := fmt.Sprintf(msg(c, "citation_badge.label"), n, src.Title)
|
||||
size := FontSize(c, theme.CaptionSize-1)
|
||||
b := ui.ButtonBase(c).MinWidth(18).Height(18).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(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)
|
||||
}
|
||||
b.Children(func() { ui.Text(c, fmt.Sprint(n)).FontSize(size).FontFeatures("tnum").FontWeight(600).SingleLine() })
|
||||
r := ChoiceResult{Element: b}
|
||||
if b.Clicked() && !b.IsDisabled() && st.Active != n {
|
||||
st.Active, r.changed = n, true
|
||||
slog.Debug("mujica: citation_badge activate", "n", n)
|
||||
}
|
||||
citationPreview(c, b, n, src)
|
||||
return r
|
||||
}
|
||||
|
||||
// citationPreviewDelay is how long hover or focus rests before the preview shows.
|
||||
const citationPreviewDelay = 500 * time.Millisecond
|
||||
|
||||
// citationPreview shows the source by badge b once hover or keyboard focus rests on it.
|
||||
// It lets the pointer through, so the badge stays clickable under it; Escape hides it.
|
||||
func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) {
|
||||
st := ui.Local(b, "mujica.citation", func() tooltipState { return tooltipState{} })
|
||||
size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} })
|
||||
if !b.Hovered() && !b.FocusVisible() {
|
||||
*st = 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 overlayEscape(c, st) {
|
||||
st.dismissed = true
|
||||
return
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
w := float32(300)
|
||||
x, y := overlayPlace(c, b.Bounds(), w, size.H, 6)
|
||||
ui.Overlay(c, func() {
|
||||
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Padding(u * 3).PassThrough().Role(ui.RoleTooltip)
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, *size))
|
||||
*size = panel.Bounds()
|
||||
panel.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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := 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,99 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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 is an answer with badges beside a sources panel, sharing one state.
|
||||
type citationApp struct {
|
||||
st CitationState
|
||||
opened []int
|
||||
}
|
||||
|
||||
func (a *citationApp) view(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ContextChipsOptions configure ContextChips.
|
||||
type ContextChipsOptions struct {
|
||||
// Max is how many chips show before the rest fold under "+N"; zero means 4.
|
||||
Max int
|
||||
}
|
||||
|
||||
// RemoveResult is a list's element and the ID the user removed this frame.
|
||||
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 lists attached context as chips with remove buttons; past Max the rest fold.
|
||||
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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r RemoveResult
|
||||
r.Element = ui.Row(c).Gap(u * 2).Wrap().AlignItems(ui.Center).Role(ui.RoleList).Label(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(theme.MinHitSize).
|
||||
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(FontSize(c, theme.CaptionSize))
|
||||
if iconAction(c, icons.Must("x"), fmt.Sprintf(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(msg(c, "context_chips.more"), len(items)-limit)
|
||||
if *expanded {
|
||||
label = msg(c, "context_chips.less")
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).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(FontSize(c, theme.CaptionSize)).SingleLine() })
|
||||
if b.Clicked() && !disabled {
|
||||
*expanded = !*expanded
|
||||
slog.Debug("mujica: context_chips fold", "expanded", *expanded)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// 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 is a file, document or web page the user can 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 shows that the caller is fetching items for the query.
|
||||
Loading bool
|
||||
}
|
||||
|
||||
// ContextMentionResult is the menu's popup, nil while closed, its query and the item chosen this frame.
|
||||
type ContextMentionResult struct {
|
||||
Element *ui.Element
|
||||
query string
|
||||
queryChanged bool
|
||||
chosen ContextItem
|
||||
ok bool
|
||||
}
|
||||
|
||||
// Query returns the text after "@"; QueryChanged reports a new one to fetch items for.
|
||||
func (r ContextMentionResult) Query() string { return r.query }
|
||||
func (r ContextMentionResult) QueryChanged() bool { return r.queryChanged }
|
||||
|
||||
// Chosen returns the item picked by Enter, Tab or a click; its "@query" was taken out of the text.
|
||||
func (r ContextMentionResult) Chosen() (ContextItem, bool) { return r.chosen, r.ok }
|
||||
|
||||
// ContextMentionMenu pops up items grouped by kind while the composer's text ends in "@query".
|
||||
// The caller filters items for Query, possibly asynchronously, and attaches the chosen one.
|
||||
func ContextMentionMenu(c *ui.Context, composer InputResult, value *string, items []ContextItem, opts ContextMentionOptions) ContextMentionResult {
|
||||
var rows []slashMenuRow
|
||||
var order []int
|
||||
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 {
|
||||
rows = append(rows, slashMenuRow{label: it.Label, detail: it.Detail, icon: kind.icon(), group: msg(c, kind.group())})
|
||||
order = append(order, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(order) != len(items) {
|
||||
panic("mujica: unknown ContextKind")
|
||||
}
|
||||
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:]
|
||||
}
|
||||
// A new query, or the menu opening, asks the caller for items.
|
||||
r.queryChanged = at >= 0 && (r.query != st.query || !st.open && st.dismissed == nil)
|
||||
empty := msg(c, "context_mention_menu.empty")
|
||||
if opts.Loading {
|
||||
empty = msg(c, "context_mention_menu.loading")
|
||||
}
|
||||
i, _ := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: msg(c, "context_mention_menu.label"), loading: opts.Loading})
|
||||
r.Element = st.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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
// mentionTester filters items as an async source would, one frame late.
|
||||
type mentionTester struct {
|
||||
text string
|
||||
items []ContextItem
|
||||
loading bool
|
||||
chosen []string
|
||||
queries []string
|
||||
}
|
||||
|
||||
func (m *mentionTester) view(c *ui.Context) {
|
||||
Use(c, 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 := &mentionTester{}
|
||||
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 := &mentionTester{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 := &mentionTester{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() *mentionTester { return &mentionTester{items: mentionSample} })).view(c)
|
||||
}, func(tt *ui.Tester) {
|
||||
_ = tt.Click("Prompt")
|
||||
tt.Type("Compare @")
|
||||
tt.Frame()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ContextSegment is one part of the context window, as system, history or attachments.
|
||||
type ContextSegment struct {
|
||||
Label string
|
||||
Tokens int
|
||||
}
|
||||
|
||||
// ContextWindowMeterOptions configure a ContextWindowMeter.
|
||||
type ContextWindowMeterOptions struct {
|
||||
// Warn is the used share that shows the warning; zero means 0.8.
|
||||
Warn float64
|
||||
}
|
||||
|
||||
// ContextWindowMeter shows how the context window is used, segment by segment, and warns past a threshold.
|
||||
// Each segment has a palette color and a legend marker shape, so it never relies on color alone.
|
||||
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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
pal := internal.Palette(k)
|
||||
share := float64(used) / float64(limit)
|
||||
pct := int(math.Round(share * 100))
|
||||
summary := fmt.Sprintf(msg(c, "context_window_meter.used"), tokenThousands(used), tokenThousands(limit), pct)
|
||||
col := ui.Column(c).Gap(u * 2).MinWidth(200).Role(ui.RoleMeter).Label(msg(c, "context_window_meter.label")).Description(summary)
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, 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, 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(msg(c, "context_window_meter.warn"), pct)).FontSize(small)
|
||||
})
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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,100 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"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 is the overlay's element and the files dropped this frame.
|
||||
type DropResult struct {
|
||||
Element *ui.Element
|
||||
accepted []string
|
||||
rejected []string
|
||||
}
|
||||
|
||||
// Accepted returns the dropped paths Accept took; Rejected those it refused.
|
||||
func (r DropResult) Accepted() []string { return r.accepted }
|
||||
func (r DropResult) Rejected() []string { return r.rejected }
|
||||
|
||||
// DragDropOverlay wraps content; while files from other apps are dragged over it, a full mask shows the hint.
|
||||
func DragDropOverlay(c *ui.Context, opts DragDropOverlayOptions, content func()) DropResult {
|
||||
if content == nil {
|
||||
panic("mujica: DragDropOverlay needs content")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := 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 = 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(msg(c, "drag_drop_overlay.rejected"), strings.Join(names, ", "))).
|
||||
TextColor(k.Danger).FontSize(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 := Tokens(c)
|
||||
u := 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(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(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,53 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestDragDropOverlayIdleShowsContentOnly(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{})
|
||||
DragDropOverlay(c, DragDropOverlayOptions{}, nil)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
// TestDragDropOverlaySnapshot draws the mask shown while files are over the area.
|
||||
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, msg(c, "drag_drop_overlay.hint"))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-at-sign"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
class="lucide lucide-bot"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M15 13v2" />
|
||||
<path d="M9 13v2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
class="lucide lucide-globe"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-key-round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
class="lucide lucide-mic-off"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19v3" />
|
||||
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
|
||||
<path d="M16.95 16.95A7 7 0 0 1 5 12v-2" />
|
||||
<path d="M18.89 13.23A7 7 0 0 0 19 12v-2" />
|
||||
<path d="m2 2 20 20" />
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
class="lucide lucide-mic"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19v3" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<rect x="9" y="2" width="6" height="13" rx="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
class="lucide lucide-paperclip"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-shield-check"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
@@ -0,0 +1,22 @@
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 5H3" />
|
||||
<path d="M12 19H3" />
|
||||
<path d="M14 3v4" />
|
||||
<path d="M16 17v4" />
|
||||
<path d="M21 12h-9" />
|
||||
<path d="M21 19h-5" />
|
||||
<path d="M21 5h-7" />
|
||||
<path d="M8 10v4" />
|
||||
<path d="M8 12H3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
class="lucide lucide-square"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,20 @@
|
||||
<svg
|
||||
class="lucide lucide-telescope"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m10.065 12.493-6.18 1.318a.934.934 0 0 1-1.108-.702l-.537-2.15a1.07 1.07 0 0 1 .691-1.265l13.504-4.44" />
|
||||
<path d="m13.56 11.747 4.332-.924" />
|
||||
<path d="m16 21-3.105-6.21" />
|
||||
<path d="M16.485 5.94a2 2 0 0 1 1.455-2.425l1.09-.272a1 1 0 0 1 1.212.727l1.515 6.06a1 1 0 0 1-.727 1.213l-1.09.272a2 2 0 0 1-2.425-1.455z" />
|
||||
<path d="m6.158 8.633 1.114 4.456" />
|
||||
<path d="m8 21 3.105-6.21" />
|
||||
<circle cx="12" cy="13" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
class="lucide lucide-terminal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19h8" />
|
||||
<path d="m4 17 6-6-6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"prompt_composer.label": {"Message", "消息"},
|
||||
"slash_command_menu.label": {"Commands", "命令"},
|
||||
"slash_command_menu.empty": {"No matching commands", "没有匹配的命令"},
|
||||
"context_mention_menu.label": {"Add context", "添加上下文"},
|
||||
"context_mention_menu.empty": {"No matches", "无匹配"},
|
||||
"context_mention_menu.loading": {"Searching…", "搜索中…"},
|
||||
"context_mention_menu.file": {"Files", "文件"},
|
||||
"context_mention_menu.doc": {"Documents", "文档"},
|
||||
"context_mention_menu.web": {"Web pages", "网页"},
|
||||
"attachment_chip.remove": {"Remove %s", "移除 %s"},
|
||||
"attachment_chip.retry": {"Retry upload of %s", "重试上传 %s"},
|
||||
"attachment_chip.uploading": {"Uploading %d%%", "上传中 %d%%"},
|
||||
"attachment_chip.failed": {"Upload failed", "上传失败"},
|
||||
"paste_image_preview.remove": {"Remove image %s", "移除图片 %s"},
|
||||
"paste_image_preview.label": {"Pasted images", "粘贴的图片"},
|
||||
"drag_drop_overlay.hint": {"Drop files to attach", "松开以添加文件"},
|
||||
"drag_drop_overlay.rejected": {"Not accepted: %s", "不支持:%s"},
|
||||
"voice_input_button.start": {"Start voice input", "开始语音输入"},
|
||||
"voice_input_button.stop": {"Stop recording, %s", "停止录音,%s"},
|
||||
"voice_input_button.denied": {"Microphone access denied", "麦克风权限被拒绝"},
|
||||
"voice_waveform.label": {"Input level", "输入电平"},
|
||||
"voice_waveform.silent": {"No sound detected", "未检测到声音"},
|
||||
"context_chips.label": {"Attached context", "已附加的上下文"},
|
||||
"context_chips.remove": {"Remove %s", "移除 %s"},
|
||||
"context_chips.more": {"+%d more", "另有 %d 项"},
|
||||
"context_chips.less": {"Show less", "收起"},
|
||||
"model_selector.label": {"Model", "模型"},
|
||||
"model_selector.context": {"%s context", "%s 上下文"},
|
||||
"model_selector.placeholder": {"Choose a model", "选择模型"},
|
||||
"mode_selector.label": {"Mode", "模式"},
|
||||
"mode_selector.chat": {"Chat", "对话"},
|
||||
"mode_selector.search": {"Search", "搜索"},
|
||||
"mode_selector.research": {"Deep research", "深度研究"},
|
||||
"mode_selector.agent": {"Agent", "智能体"},
|
||||
"tool_toggle_menu.label": {"Tools", "工具"},
|
||||
"tool_toggle_menu.count": {"Tools, %d on", "工具,已开 %d 个"},
|
||||
"tool_toggle_menu.needs_auth": {"Needs authorization", "需要授权"},
|
||||
"tool_toggle_menu.authorize_short": {"Authorize", "授权"},
|
||||
"tool_toggle_menu.authorize": {"Authorize %s", "授权 %s"},
|
||||
"parameter_panel.label": {"Parameters", "参数"},
|
||||
"parameter_panel.reset": {"Reset %s", "重置 %s"},
|
||||
"parameter_panel.reset_all": {"Reset all", "全部重置"},
|
||||
"parameter_panel.default": {"Default %s", "默认 %s"},
|
||||
"token_counter.label": {"About %s of %s tokens", "约 %s / %s 个 token"},
|
||||
"token_counter.over": {"%s over the limit", "超出 %s"},
|
||||
"context_window_meter.label": {"Context window", "上下文窗口"},
|
||||
"context_window_meter.used": {"%s of %s tokens (%d%%)", "%s / %s token(%d%%)"},
|
||||
"context_window_meter.warn": {"Context is %d%% full", "上下文已用 %d%%"},
|
||||
"context_window_meter.free": {"Free", "剩余"},
|
||||
"send_button.send": {"Send", "发送"},
|
||||
"send_button.stop": {"Stop generating", "停止生成"},
|
||||
"citation_badge.label": {"Source %d: %s", "来源 %d:%s"},
|
||||
"source_card.open": {"Open %s", "打开 %s"},
|
||||
"sources_panel.label": {"Sources", "来源"},
|
||||
"sources_panel.empty": {"No sources", "没有来源"},
|
||||
"search_progress.searching": {"Searching for “%s”", "正在搜索“%s”"},
|
||||
"search_progress.read": {"Read %d of %d sources", "已读 %d / %d 个来源"},
|
||||
"search_progress.done": {"Searched “%s”, read %d sources", "已搜索“%s”,读了 %d 个来源"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// 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 among chat, search, deep research and agent modes; arrows move the choice.
|
||||
func ModeSelector(c *ui.Context, mode *ChatMode, opts ModeSelectorOptions) ChoiceResult {
|
||||
if *mode < ModeChat || *mode > ModeAgent {
|
||||
panic("mujica: unknown ChatMode")
|
||||
}
|
||||
segs := make([]Segment, len(chatModes))
|
||||
for i, m := range chatModes {
|
||||
segs[i] = Segment{Label: msg(c, m[0]), Icon: icons.Must(m[1]), HideLabel: opts.IconsOnly}
|
||||
}
|
||||
i := int(*mode)
|
||||
e := SegmentedControl(c, &i, segs, SegmentedControlOptions{Label: msg(c, "mode_selector.label"), Disabled: opts.Disabled})
|
||||
r := ChoiceResult{Element: e, changed: ChatMode(i) != *mode}
|
||||
if r.changed {
|
||||
*mode = ChatMode(i)
|
||||
slog.Debug("mujica: mode_selector changed", "mode", i)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestModeSelectorClickAndArrows(t *testing.T) {
|
||||
mode, n, off := ModeChat, 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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 why the model cannot be chosen; empty means available.
|
||||
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 a model ID into *value from a list showing capabilities, context length and why some are unavailable.
|
||||
func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts ModelSelectorOptions) ChoiceResult {
|
||||
for _, m := range models {
|
||||
if m.ID == "" || m.Name == "" || m.ContextTokens < 0 {
|
||||
panic("mujica: ModelOption needs ID, Name and a non-negative ContextTokens")
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
old := *value
|
||||
pick := old
|
||||
var s *ui.SelectParts[string]
|
||||
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, &pick) })
|
||||
tr := s.Trigger
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "model_selector.label")
|
||||
}
|
||||
tr.Label(label)
|
||||
cur, has := modelSelectorFind(models, *value)
|
||||
selectField(c, tr, s.Open(), func() {
|
||||
if has {
|
||||
ui.Text(c, cur.Name).SingleLine().FontWeight(600).Grow(1).Shrink(1)
|
||||
} else {
|
||||
ui.Text(c, msg(c, "model_selector.placeholder")).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1)
|
||||
}
|
||||
})
|
||||
if opts.Disabled {
|
||||
return ChoiceResult{Element: tr}
|
||||
}
|
||||
s.Popup(func(panel *ui.Element) {
|
||||
selectPanel(c, panel)
|
||||
panel.MinWidth(320)
|
||||
ui.Scroll(c).MaxHeight(360).Children(func() {
|
||||
for _, m := range models {
|
||||
var row *ui.Element
|
||||
if m.Unavailable != "" {
|
||||
row = ui.Row(c).Disabled(true).Description(m.Unavailable)
|
||||
} else {
|
||||
row = s.Item(m.ID)
|
||||
}
|
||||
hi := m.Unavailable == "" && row.Highlighted()
|
||||
chosen := m.ID == *value
|
||||
row.Padding(u*2, u*3, u*2, u*2).Gap(u * 2).AlignItems(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name)
|
||||
switch {
|
||||
case chosen:
|
||||
row.Background(k.Selection)
|
||||
case hi:
|
||||
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() {
|
||||
selectCheck(c, chosen)
|
||||
modelSelectorRow(c, m)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
if pick != *value && !tr.IsDisabled() {
|
||||
if m, ok := modelSelectorFind(models, pick); ok && m.Unavailable == "" {
|
||||
*value = pick
|
||||
}
|
||||
}
|
||||
changed := *value != old
|
||||
if changed {
|
||||
slog.Debug("mujica: model_selector changed", "model", *value)
|
||||
}
|
||||
return ChoiceResult{Element: tr, changed: changed}
|
||||
}
|
||||
|
||||
// modelSelectorRow shows a model's name, tags, context and unavailability.
|
||||
func modelSelectorRow(c *ui.Context, m ModelOption) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := 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(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,72 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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") })
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"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 bound the value; Default lies within them.
|
||||
Min, Max, Step, Default float64
|
||||
}
|
||||
|
||||
// ParameterPanel adjusts generation parameters with sliders; each shows its range and resets to its default.
|
||||
func ParameterPanel(c *ui.Context, params []ModelParam) 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")
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Column(c).Gap(u * 4).MinWidth(240).Role(ui.RoleGroup).Label(msg(c, "parameter_panel.label"))
|
||||
disabled := r.Element.IsDisabled()
|
||||
set := func(p ModelParam, v float64) {
|
||||
if *p.Value != v && !disabled {
|
||||
*p.Value, r.changed = v, 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', 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 iconAction(c, icons.Must("rotate-ccw"), fmt.Sprintf(msg(c, "parameter_panel.reset"), p.Label)).
|
||||
Tooltip(fmt.Sprintf(msg(c, "parameter_panel.default"), format(p.Default))).Clicked() {
|
||||
set(p, p.Default)
|
||||
}
|
||||
})
|
||||
})
|
||||
v := *p.Value
|
||||
if Slider(c, &v, 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(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 Button(c, msg(c, "parameter_panel.reset_all"), ButtonOptions{Variant: Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() {
|
||||
for _, p := range params {
|
||||
set(p, p.Default)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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)) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PastedImage is an image pasted or dropped into a prompt; the caller decodes it.
|
||||
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 shows pasted or dropped images as thumbnails, each with a remove button.
|
||||
func PasteImagePreview(c *ui.Context, images []PastedImage) RemoveResult {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r RemoveResult
|
||||
r.Element = ui.Row(c).Gap(u * 2).Wrap().Role(ui.RoleList).Label(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 := iconAction(c, icons.Must("x"), fmt.Sprintf(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,61 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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()) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// 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
|
||||
// MaxHeight caps the field in DIPs before it scrolls; zero means 200.
|
||||
MaxHeight float32
|
||||
// Attachments builds the row above the field, as AttachmentChips.
|
||||
Attachments func()
|
||||
// Tools builds the left of the bar below the field; Actions its right, as a SendButton.
|
||||
Tools, Actions func()
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// promptHooks are key handlers menus over a composer add each frame.
|
||||
type promptHooks struct{ list []func(ui.InputEvent) bool }
|
||||
|
||||
type promptHooksKey struct{}
|
||||
|
||||
// PromptComposer is a multi-line prompt: Enter sends and Shift+Enter breaks the line, or Cmd+Enter sends.
|
||||
// Submitted never fires for blank text.
|
||||
func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) InputResult {
|
||||
if value == nil {
|
||||
panic("mujica: PromptComposer needs a value")
|
||||
}
|
||||
if opts.MaxHeight < 0 {
|
||||
panic("mujica: PromptComposer MaxHeight must not be negative")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 200
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "prompt_composer.label")
|
||||
}
|
||||
var r 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 := TextAreaOptions{Label: label, Placeholder: opts.Placeholder, MaxHeight: maxH,
|
||||
keys: func(ev ui.InputEvent) bool {
|
||||
for _, f := range h.list {
|
||||
if f(ev) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}}
|
||||
if !opts.CmdEnterSends {
|
||||
ta.SubmitKey = ui.KeyEnter
|
||||
}
|
||||
area := TextArea(c, value, ta)
|
||||
r.Input, r.changed = area.Input, area.Changed()
|
||||
r.submitted = area.Submitted() && 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 adds a key handler that runs before the composer's editor this frame.
|
||||
func promptComposerHook(composer 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,85 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
func promptTester(text *string, sends *int, opts PromptComposerOptions) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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() { iconAction(c, icons.Must("paperclip"), "Attach") },
|
||||
Actions: func() { SendButton(c, SendButtonOptions{Shortcut: "Enter"}) },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// SearchStage is one step of a web search, as "Planning" or "Reading".
|
||||
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 the sources read so far and found.
|
||||
Read, Total int
|
||||
Stages []SearchStage
|
||||
// Done folds the block to a summary, which the user can open again.
|
||||
Done bool
|
||||
}
|
||||
|
||||
// searchProgressState is the fold, open while searching until the search ends.
|
||||
type searchProgressState struct {
|
||||
open, done bool
|
||||
}
|
||||
|
||||
// SearchProgress shows a search's keyword, sources read and stages; once Done it folds to one line.
|
||||
func SearchProgress(c *ui.Context, opts SearchProgressOptions) 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 := 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
|
||||
}
|
||||
st.done = opts.Done
|
||||
title := fmt.Sprintf(msg(c, "search_progress.searching"), opts.Query)
|
||||
if opts.Done {
|
||||
title = fmt.Sprintf(msg(c, "search_progress.done"), opts.Query, opts.Read)
|
||||
}
|
||||
r.Element.Children(func() { r.changed = searchProgressBody(c, st, title, opts) })
|
||||
return r
|
||||
}
|
||||
|
||||
// searchProgressBody is the collapsible header and the stages.
|
||||
func searchProgressBody(c *ui.Context, st *searchProgressState, title string, opts SearchProgressOptions) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
return Collapsible(c, &st.open, 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 {
|
||||
Spinner(c, SpinnerOptions{Size: 14, Label: title})
|
||||
}
|
||||
ui.Text(c, title).FontSize(small).SingleLine()
|
||||
})
|
||||
}}, func() {
|
||||
ui.Text(c, fmt.Sprintf(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,60 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{})
|
||||
SearchProgress(c, SearchProgressOptions{Query: "q", Read: 3, Total: 1})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestSearchProgressSnapshot(t *testing.T) {
|
||||
snapshot(t, "search_progress", 440, 230, 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,72 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// SendButtonOptions configure a SendButton.
|
||||
type SendButtonOptions struct {
|
||||
// Streaming turns the button into Stop while a reply is generated.
|
||||
Streaming bool
|
||||
// Disabled blocks sending, as for an empty prompt; Stop stays available.
|
||||
Disabled bool
|
||||
// Shortcut is the send key shown in the tooltip, as "Enter"; empty shows none.
|
||||
Shortcut string
|
||||
}
|
||||
|
||||
// SendResult is the button and which action the user took this frame.
|
||||
type SendResult struct {
|
||||
Element *ui.Element
|
||||
send, stop bool
|
||||
}
|
||||
|
||||
// Sent reports a press while idle; Stopped a press while streaming.
|
||||
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 := Tokens(c)
|
||||
h := Density(c).ControlHeight()
|
||||
label, icon := msg(c, "send_button.send"), "arrow-up"
|
||||
if opts.Streaming {
|
||||
label, icon = 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(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,46 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestSendButtonSendStopDisabled(t *testing.T) {
|
||||
opts := SendButtonOptions{Shortcut: "Enter"}
|
||||
sends, stops := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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,232 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"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 is the menu's popup panel, nil while closed, and the command chosen this frame.
|
||||
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 pops up commands while the composer's text is "/" plus a filter.
|
||||
// Up and Down move, Enter or a click chooses, Tab completes, Escape closes; typing stays in the composer.
|
||||
func SlashCommandMenu(c *ui.Context, composer InputResult, value *string, commands []SlashCommand) SlashCommandResult {
|
||||
for _, cmd := range commands {
|
||||
if cmd.Name == "" || strings.ContainsAny(cmd.Name, " \t\n") {
|
||||
panic("mujica: SlashCommand Name must be one word")
|
||||
}
|
||||
}
|
||||
var r SlashCommandResult
|
||||
st := ui.Local(composer.Element, slashMenuKey{"slash"}, func() slashMenuState { return slashMenuState{} })
|
||||
query, typing := strings.CutPrefix(*value, "/")
|
||||
typing = typing && !strings.ContainsAny(query, " \t\n")
|
||||
var shown []SlashCommand
|
||||
var rows []slashMenuRow
|
||||
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) {
|
||||
shown = append(shown, cmd)
|
||||
rows = append(rows, slashMenuRow{label: "/" + cmd.Name, detail: cmd.Description})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i, how := slashMenuShow(c, composer, st, query, typing, rows, slashMenuOptions{empty: msg(c, "slash_command_menu.empty"), label: msg(c, "slash_command_menu.label")})
|
||||
r.Element = st.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 slashMenuKey struct{ name string }
|
||||
|
||||
// slashMenuRow is one line of a composer menu; a group starts a titled section.
|
||||
type slashMenuRow struct {
|
||||
label, detail, icon, group string
|
||||
}
|
||||
|
||||
type slashMenuOptions struct {
|
||||
empty, label string
|
||||
loading bool
|
||||
}
|
||||
|
||||
// slashMenuState is a composer menu's highlight, Escape and pending key choice.
|
||||
type slashMenuState struct {
|
||||
hi, lastHi int
|
||||
query string
|
||||
dismissed *string
|
||||
pick slashMenuHow
|
||||
panel *ui.Element
|
||||
held, open bool
|
||||
}
|
||||
|
||||
type slashMenuHow int
|
||||
|
||||
const (
|
||||
slashMenuNone slashMenuHow = iota
|
||||
slashMenuChose
|
||||
slashMenuCompleted
|
||||
)
|
||||
|
||||
// slashMenuShow runs a composer menu while open and returns the row picked this frame and how.
|
||||
func slashMenuShow(c *ui.Context, composer InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow) {
|
||||
st.panel = nil
|
||||
if query != st.query {
|
||||
st.hi, st.query = 0, query
|
||||
if st.dismissed != nil && *st.dismissed != query {
|
||||
st.dismissed = nil
|
||||
}
|
||||
}
|
||||
// A press on the panel takes the focus from the composer; the press keeps it open.
|
||||
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
|
||||
}
|
||||
st.hi = min(st.hi, 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 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
|
||||
})
|
||||
if i := slashMenuPanel(c, composer.Element.Bounds(), st, rows, o); i >= 0 {
|
||||
pick, how = i, slashMenuChose
|
||||
}
|
||||
if pick >= 0 {
|
||||
composer.Input.Focus()
|
||||
}
|
||||
return pick, how
|
||||
}
|
||||
|
||||
// slashMenuPanel draws the rows over or under the composer a and returns a clicked row.
|
||||
func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) int {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
rowH := 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
|
||||
}
|
||||
h = min(h, 280) + 2*theme.BorderWidth
|
||||
w := min(max(a.W*0.6, 320), max(a.W, 200))
|
||||
x, y := overlayPlace(c, a, w, h, u)
|
||||
clicked, pressed := -1, false
|
||||
ui.Overlay(c, func() {
|
||||
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Height(h).Role(ui.RoleList).Label(o.label)
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, a))
|
||||
st.panel = panel
|
||||
panel.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind == ui.InputPointerDown {
|
||||
st.held = true
|
||||
}
|
||||
return false
|
||||
})
|
||||
panel.Children(func() {
|
||||
ui.Scroll(c).Fill().Padding(u).Children(func() {
|
||||
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 {
|
||||
Spinner(c, SpinnerOptions{Size: 14})
|
||||
}
|
||||
ui.Text(c, o.empty).SingleLine()
|
||||
})
|
||||
}
|
||||
group := ""
|
||||
for i, r := range rows {
|
||||
if r.group != group {
|
||||
group = r.group
|
||||
ui.Text(c, group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(FontSize(c, theme.CaptionSize)).
|
||||
FontWeight(600).TextColor(k.TextMuted).Role(ui.RoleText)
|
||||
}
|
||||
row := ui.Row(c).Key(i).Height(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)
|
||||
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)
|
||||
})
|
||||
if st.lastHi != i {
|
||||
st.lastHi = i
|
||||
row.ScrollIntoView()
|
||||
}
|
||||
} 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(FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
st.held = st.held && pressed
|
||||
return clicked
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var slashCommandsSample = []SlashCommand{
|
||||
{Name: "summarize", Description: "Summarize the thread"},
|
||||
{Name: "search", Description: "Search the web"},
|
||||
{Name: "translate", Description: "Translate to French"},
|
||||
}
|
||||
|
||||
// slashTester is a composer with a slash menu; it records chosen commands and sends.
|
||||
type slashTester struct {
|
||||
text string
|
||||
chosen []string
|
||||
sends int
|
||||
off bool
|
||||
}
|
||||
|
||||
func (s *slashTester) view(c *ui.Context) {
|
||||
Use(c, 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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// SourceCard shows source n's title, site, time and snippet; Clicked opens it.
|
||||
// It lights up with Selection and an accent edge while st holds n Active or hovered.
|
||||
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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := ui.ButtonBase(c).Padding(u*2, u*3).AlignItems(ui.Start).Justify(ui.Start).Radius(theme.CardRadius).
|
||||
Label(fmt.Sprintf(msg(c, "source_card.open"), src.Title)).Tooltip(src.URL).Transition(colorFade)
|
||||
internal.Ring(b, k.Focus, theme.CardRadius)
|
||||
st.track(c, b, n)
|
||||
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,66 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestSourceCardOpensAndLights(t *testing.T) {
|
||||
var st CitationState
|
||||
opened, off := 0, false
|
||||
var bg []byte
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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,54 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// OpenResult is a list's element and the 1-based item the user opened this frame.
|
||||
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 sources in citation order, numbered from 1; the Active one scrolls into view.
|
||||
// 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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r OpenResult
|
||||
r.Element = ui.Column(c).Gap(u * 2).Grow(1).MinHeight(0).Role(ui.RoleList).Label(msg(c, "sources_panel.label"))
|
||||
shown := ui.Local(r.Element, "active", func() int { return 0 })
|
||||
r.Element.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("%s (%d)", msg(c, "sources_panel.label"), len(sources))).Font(theme.SerifFont).
|
||||
FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
if len(sources) == 0 {
|
||||
ui.Text(c, msg(c, "sources_panel.empty")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
ui.Scroll(c).Grow(1).MinHeight(0).Children(func() {
|
||||
ui.Column(c).Gap(u * 2).Children(func() {
|
||||
for i, src := range sources {
|
||||
card := SourceCard(c, i+1, src, st).Role(ui.RoleListItem)
|
||||
if card.Clicked() && !card.IsDisabled() {
|
||||
r.opened = i + 1
|
||||
}
|
||||
if st.Active == i+1 && *shown != st.Active {
|
||||
card.ScrollIntoView()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
*shown = st.Active
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) })
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -27,6 +27,8 @@ type TextAreaOptions struct {
|
||||
ReadOnly bool
|
||||
// Error marks the value invalid and shows the message below.
|
||||
Error string
|
||||
// keys sees keys before the editor, for PromptComposer's menus.
|
||||
keys func(ui.InputEvent) bool
|
||||
}
|
||||
|
||||
// TextArea is a multi-line field editing *value.
|
||||
@@ -67,6 +69,9 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult {
|
||||
// Taken before the editor, which would make a plain Enter a newline.
|
||||
submit := ui.Local(frame, "submit", func() bool { return false })
|
||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if opts.keys != nil && opts.keys(ev) {
|
||||
return true
|
||||
}
|
||||
if ev.Kind == ui.InputKeyDown && ev.Key == key && ev.Mods == mods {
|
||||
*submit = true
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// tokenWarnShare is the share of the limit from which the count warns.
|
||||
const tokenWarnShare = 0.9
|
||||
|
||||
// TokenCounter shows the caller's token estimate against the limit: muted, warning past 90%, danger over it.
|
||||
func TokenCounter(c *ui.Context, used, limit int) *ui.Element {
|
||||
if used < 0 || limit <= 0 {
|
||||
panic("mujica: TokenCounter needs used >= 0 and limit > 0")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := fmt.Sprintf(msg(c, "token_counter.label"), tokenThousands(used), tokenThousands(limit))
|
||||
color, icon := k.TextMuted, ""
|
||||
switch {
|
||||
case used > limit:
|
||||
color, icon = k.Danger, "circle-alert"
|
||||
label += ", " + fmt.Sprintf(msg(c, "token_counter.over"), tokenThousands(used-limit))
|
||||
case float64(used) >= tokenWarnShare*float64(limit):
|
||||
color, icon = k.Warning, "triangle-alert"
|
||||
}
|
||||
row := ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).TextColor(color).Role(ui.RoleStatus).Label(label)
|
||||
row.Children(func() {
|
||||
if icon != "" {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12)
|
||||
}
|
||||
ui.Text(c, "≈ "+tokenThousands(used)+" / "+tokenThousands(limit)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// tokenThousands writes n with comma thousands separators.
|
||||
func tokenThousands(n int) string {
|
||||
s := strconv.Itoa(n)
|
||||
for i := len(s) - 3; i > 0; i -= 3 {
|
||||
s = s[:i] + "," + s[i:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestTokenCounterLevels(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
used int
|
||||
text string
|
||||
label string
|
||||
}{
|
||||
{1234, "≈ 1,234 / 8,000", "About 1,234 of 8,000 tokens"},
|
||||
{7500, "≈ 7,500 / 8,000", "About 7,500 of 8,000 tokens"},
|
||||
{9100, "≈ 9,100 / 8,000", "About 9,100 of 8,000 tokens, 1,100 over the limit"},
|
||||
} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
TokenCounter(c, tc.used, 8000)
|
||||
}, 300, 60)
|
||||
if !tt.HasText(tc.text) {
|
||||
t.Errorf("%d: texts %q", tc.used, tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find(tc.label); !ok {
|
||||
t.Errorf("%d: no %q", tc.used, tc.label)
|
||||
}
|
||||
}
|
||||
if tokenThousands(1234567) != "1,234,567" || tokenThousands(12) != "12" {
|
||||
t.Fatal("thousands separators")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCounterBadLimit(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a zero limit")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
TokenCounter(c, 1, 0)
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestTokenCounterSnapshot(t *testing.T) {
|
||||
snapshot(t, "token_counter", 240, 110, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Gap(8).Children(func() {
|
||||
TokenCounter(c, 1234, 8000)
|
||||
TokenCounter(c, 7500, 8000)
|
||||
TokenCounter(c, 9100, 8000)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ToolItem is one tool a ToolToggleMenu turns on or off.
|
||||
type ToolItem struct {
|
||||
// ID and On are required.
|
||||
ID string
|
||||
Label string
|
||||
Description string
|
||||
On *bool
|
||||
// NeedsAuth marks a tool the user must authorize before turning it on.
|
||||
NeedsAuth, Authorized bool
|
||||
}
|
||||
|
||||
// ToolToggleResult is the menu's button and what the user did this frame.
|
||||
type ToolToggleResult struct {
|
||||
Element *ui.Element
|
||||
changed, authorize string
|
||||
}
|
||||
|
||||
// Changed returns the ID of the tool turned on or off.
|
||||
func (r ToolToggleResult) Changed() (string, bool) { return r.changed, r.changed != "" }
|
||||
|
||||
// Authorize returns the ID of a tool whose authorization the user asked for.
|
||||
func (r ToolToggleResult) Authorize() (string, bool) { return r.authorize, r.authorize != "" }
|
||||
|
||||
// ToolToggleMenu is a button opening a panel of tool switches; tools needing authorization show a mark and an authorize action.
|
||||
func ToolToggleMenu(c *ui.Context, tools []ToolItem) ToolToggleResult {
|
||||
on := 0
|
||||
for _, t := range tools {
|
||||
if t.ID == "" || t.On == nil {
|
||||
panic("mujica: ToolItem needs ID and On")
|
||||
}
|
||||
if *t.On {
|
||||
on++
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := fmt.Sprintf(msg(c, "tool_toggle_menu.count"), on)
|
||||
var r ToolToggleResult
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted)
|
||||
r.Element = b
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
switch {
|
||||
case *open:
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Icon(c, icons.Must("sliders-horizontal")).Size(16, 16)
|
||||
if on > 0 {
|
||||
ui.Text(c, fmt.Sprint(on)).FontFeatures("tnum").FontWeight(600).TextColor(k.AccentText).SingleLine()
|
||||
}
|
||||
})
|
||||
disabled := b.IsDisabled()
|
||||
if b.Clicked() && !disabled {
|
||||
*open = !*open
|
||||
}
|
||||
if disabled {
|
||||
*open = false
|
||||
}
|
||||
Popover(c, b, open, PopoverOptions{Title: msg(c, "tool_toggle_menu.label"), Width: 320}, func() {
|
||||
for _, t := range tools {
|
||||
toolToggleRow(c, t, &r)
|
||||
}
|
||||
})
|
||||
if r.changed != "" || r.authorize != "" {
|
||||
slog.Debug("mujica: tool_toggle_menu", "changed", r.changed, "authorize", r.authorize)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// toolToggleRow shows one tool: its switch, or its authorization mark and action.
|
||||
func toolToggleRow(c *ui.Context, t ToolItem, r *ToolToggleResult) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
locked := t.NeedsAuth && !t.Authorized
|
||||
ui.Row(c).Key(t.ID).Gap(u * 3).AlignItems(ui.Center).Children(func() {
|
||||
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() {
|
||||
if locked {
|
||||
ui.Text(c, t.Label).FontWeight(600).SingleLine()
|
||||
} else if Switch(c, t.On, t.Label, SwitchOptions{}).Changed() {
|
||||
r.changed = t.ID
|
||||
}
|
||||
if t.NeedsAuth {
|
||||
icon := "key-round"
|
||||
if t.Authorized {
|
||||
icon = "shield-check"
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(k.Warning).Label(msg(c, "tool_toggle_menu.needs_auth"))
|
||||
}
|
||||
})
|
||||
if t.Description != "" {
|
||||
ui.Text(c, t.Description).FontSize(small).TextColor(k.TextMuted)
|
||||
}
|
||||
if locked {
|
||||
ui.Text(c, msg(c, "tool_toggle_menu.needs_auth")).FontSize(small).TextColor(k.Warning)
|
||||
}
|
||||
})
|
||||
if locked {
|
||||
full := fmt.Sprintf(msg(c, "tool_toggle_menu.authorize"), t.Label)
|
||||
if Button(c, msg(c, "tool_toggle_menu.authorize_short"), ButtonOptions{Variant: Outline, Label: full}).Clicked() {
|
||||
r.authorize = t.ID
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// toolMenuApp holds tools and what the menu reported.
|
||||
type toolMenuApp struct {
|
||||
web, code, mail bool
|
||||
authorized bool
|
||||
changed, auth []string
|
||||
off bool
|
||||
}
|
||||
|
||||
func (a *toolMenuApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(16).AlignItems(ui.Start).Disabled(a.off).Children(func() {
|
||||
r := ToolToggleMenu(c, []ToolItem{
|
||||
{ID: "web", Label: "Web search", Description: "Search the open web", On: &a.web},
|
||||
{ID: "code", Label: "Code execution", On: &a.code},
|
||||
{ID: "mail", Label: "Mail", Description: "Read your inbox", On: &a.mail, NeedsAuth: true, Authorized: a.authorized},
|
||||
})
|
||||
if id, ok := r.Changed(); ok {
|
||||
a.changed = append(a.changed, id)
|
||||
}
|
||||
if id, ok := r.Authorize(); ok {
|
||||
a.auth = append(a.auth, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolToggleMenuSwitchesAndAuthorization(t *testing.T) {
|
||||
a := &toolMenuApp{web: true}
|
||||
tt := ui.NewTester(a.view, 500, 500)
|
||||
if err := tt.Click("Tools, 1 on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = tt.Click("Code execution")
|
||||
if !a.code || len(a.changed) != 1 || a.changed[0] != "code" {
|
||||
t.Fatalf("code %v changed %v", a.code, a.changed)
|
||||
}
|
||||
if !tt.HasText("Needs authorization") {
|
||||
t.Fatalf("auth mark missing: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Authorize Mail")
|
||||
if a.mail || len(a.auth) != 1 {
|
||||
t.Fatalf("mail %v auth %v", a.mail, a.auth)
|
||||
}
|
||||
a.authorized = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Mail")
|
||||
if !a.mail {
|
||||
t.Fatal("authorized tool did not turn on")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Search the open web") {
|
||||
t.Fatal("Escape did not close the panel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolToggleMenuClosesWhenDisabled(t *testing.T) {
|
||||
a := &toolMenuApp{}
|
||||
tt := ui.NewTester(a.view, 500, 500)
|
||||
_ = tt.Click("Tools, 0 on")
|
||||
a.off = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Search the open web") {
|
||||
t.Fatal("panel stayed open under a disabled control")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolToggleMenuSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "tool_toggle_menu", 400, 360, func(c *ui.Context) {
|
||||
(*ui.Local(c.Root(), "a", func() *toolMenuApp { return &toolMenuApp{web: true} })).view(c)
|
||||
}, func(tt *ui.Tester) { _ = tt.Click("Tools, 1 on") })
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// VoiceInputOptions describe the caller's recording.
|
||||
type VoiceInputOptions struct {
|
||||
// Recording shows the stop state and the time since Started.
|
||||
Recording bool
|
||||
Started time.Time
|
||||
// Denied shows that microphone access failed; the button then retries.
|
||||
Denied bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// VoiceInputButton asks the caller to start or stop recording; while recording it shows the time.
|
||||
// Clicked reports a press: start when idle or denied, stop when recording.
|
||||
func VoiceInputButton(c *ui.Context, opts VoiceInputOptions) ChoiceResult {
|
||||
if opts.Recording && (opts.Started.IsZero() || opts.Denied) {
|
||||
panic("mujica: VoiceInputButton recording needs Started and no Denied")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Disabled(opts.Disabled)
|
||||
r.Element.Children(func() {
|
||||
elapsed := ""
|
||||
label := msg(c, "voice_input_button.start")
|
||||
icon := "mic"
|
||||
if opts.Recording {
|
||||
d := max(c.Now().Sub(opts.Started), 0)
|
||||
elapsed = audioClock(d.Truncate(time.Second))
|
||||
label, icon = fmt.Sprintf(msg(c, "voice_input_button.stop"), elapsed), "square"
|
||||
c.After(time.Second - d%time.Second)
|
||||
}
|
||||
h := Density(c).ControlHeight()
|
||||
b := ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(label).Transition(colorFade)
|
||||
internal.Ring(b, k.Focus, h/2)
|
||||
switch {
|
||||
case opts.Recording:
|
||||
b.Background(k.DangerFill).TextColor(k.OnDanger)
|
||||
case b.Pressed():
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
default:
|
||||
b.TextColor(k.TextMuted)
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
r.changed = true
|
||||
slog.Debug("mujica: voice_input_button press", "recording", opts.Recording)
|
||||
}
|
||||
switch {
|
||||
case opts.Recording:
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Size(8, 8).Radius(4).Background(k.Danger)
|
||||
ui.Text(c, elapsed).FontFeatures("tnum").TextColor(k.Danger).SingleLine()
|
||||
})
|
||||
case opts.Denied:
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("mic-off")).Size(14, 14)
|
||||
ui.Text(c, msg(c, "voice_input_button.denied")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestVoiceInputButtonStartStopAndTime(t *testing.T) {
|
||||
var opts VoiceInputOptions
|
||||
presses := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if VoiceInputButton(c, opts).Changed() {
|
||||
presses++
|
||||
if opts.Recording {
|
||||
opts = VoiceInputOptions{}
|
||||
} else {
|
||||
opts = VoiceInputOptions{Recording: true, Started: c.Now().Add(-65 * time.Second)}
|
||||
}
|
||||
}
|
||||
}, 300, 100)
|
||||
_ = tt.Click("Start voice input")
|
||||
tt.Frame()
|
||||
if !opts.Recording || !tt.HasText("1:05") {
|
||||
t.Fatalf("recording %v texts %q", opts.Recording, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if opts.Recording || presses != 2 {
|
||||
t.Fatalf("stop: recording %v presses %d", opts.Recording, presses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceInputButtonDeniedAndDisabled(t *testing.T) {
|
||||
opts := VoiceInputOptions{Denied: true}
|
||||
presses := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if VoiceInputButton(c, opts).Changed() {
|
||||
presses++
|
||||
}
|
||||
}, 400, 100)
|
||||
if !tt.HasText("Microphone access denied") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Start voice input")
|
||||
opts.Disabled = true
|
||||
_ = tt.Click("Start voice input")
|
||||
if presses != 1 {
|
||||
t.Fatalf("presses %d, want 1 (retry once, then disabled)", presses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceInputButtonRecordingNeedsStart(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for recording without Started")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
VoiceInputButton(c, VoiceInputOptions{Recording: true})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestVoiceInputButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "voice_input_button", 360, 170, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Gap(8).Children(func() {
|
||||
VoiceInputButton(c, VoiceInputOptions{})
|
||||
VoiceInputButton(c, VoiceInputOptions{Recording: true, Started: c.Now().Add(-12 * time.Second)})
|
||||
VoiceInputButton(c, VoiceInputOptions{Denied: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// VoiceWaveformOptions configure a VoiceWaveform.
|
||||
type VoiceWaveformOptions struct {
|
||||
// Silence is the level below which input counts as silent; zero means 0.05.
|
||||
Silence float32
|
||||
// SilentFor is how many trailing samples under Silence show the hint; zero means 20.
|
||||
SilentFor int
|
||||
// Width is the bar area width in DIPs; zero means 200.
|
||||
Width float32
|
||||
}
|
||||
|
||||
// VoiceWaveform draws recent input levels, 0 to 1, newest at the right, and hints at silence.
|
||||
func VoiceWaveform(c *ui.Context, levels []float32, opts VoiceWaveformOptions) *ui.Element {
|
||||
if opts.Silence < 0 || opts.Silence >= 1 || opts.SilentFor < 0 || opts.Width < 0 {
|
||||
panic("mujica: VoiceWaveform needs 0 <= Silence < 1 and non-negative SilentFor and Width")
|
||||
}
|
||||
for _, v := range levels {
|
||||
if !(v >= 0 && v <= 1) {
|
||||
panic("mujica: VoiceWaveform levels must be from 0 to 1")
|
||||
}
|
||||
}
|
||||
silence, need, w := opts.Silence, opts.SilentFor, opts.Width
|
||||
if silence == 0 {
|
||||
silence = 0.05
|
||||
}
|
||||
if need == 0 {
|
||||
need = 20
|
||||
}
|
||||
if w == 0 {
|
||||
w = 200
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
silent := len(levels) >= need
|
||||
for _, v := range levels[max(0, len(levels)-need):] {
|
||||
silent = silent && v < silence
|
||||
}
|
||||
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleMeter).Label(msg(c, "voice_waveform.label"))
|
||||
row.Children(func() {
|
||||
ui.Box(c).Size(w, 28).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) {
|
||||
n := int((b.W + audioBarGap) / (audioBarWidth + audioBarGap))
|
||||
cy := b.Y + b.H/2
|
||||
for i := range n {
|
||||
// The newest sample sits at the right edge.
|
||||
j := len(levels) - n + i
|
||||
v := float32(0)
|
||||
if j >= 0 {
|
||||
v = levels[j]
|
||||
}
|
||||
h := max(2, v*(b.H-4))
|
||||
col := k.AccentText
|
||||
if v < silence {
|
||||
col = k.TextMuted.Alpha(0.55)
|
||||
}
|
||||
x := b.X + float32(i)*(audioBarWidth+audioBarGap)
|
||||
p.Fill(ui.Rect{X: x, Y: cy - h/2, W: audioBarWidth, H: h}, col, 1)
|
||||
}
|
||||
})
|
||||
if silent {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Warning).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("mic-off")).Size(14, 14)
|
||||
ui.Text(c, msg(c, "voice_waveform.silent")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func voiceLevels(n int, v float32) []float32 {
|
||||
out := make([]float32, n)
|
||||
for i := range out {
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestVoiceWaveformSilenceHint(t *testing.T) {
|
||||
levels := append(voiceLevels(30, 0.6), voiceLevels(10, 0.01)...)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
VoiceWaveform(c, levels, VoiceWaveformOptions{SilentFor: 12})
|
||||
}, 400, 100)
|
||||
if tt.HasText("No sound detected") {
|
||||
t.Fatal("hint before 12 silent samples")
|
||||
}
|
||||
levels = append(levels, voiceLevels(2, 0.02)...)
|
||||
tt.Frame()
|
||||
if !tt.HasText("No sound detected") {
|
||||
t.Fatal("no hint after 12 silent samples")
|
||||
}
|
||||
if _, ok := tt.Find("Input level"); !ok {
|
||||
t.Fatal("meter not named")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceWaveformBadLevels(t *testing.T) {
|
||||
for _, l := range [][]float32{{-0.1}, {1.5}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%v: no panic", l)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
VoiceWaveform(c, l, VoiceWaveformOptions{})
|
||||
}, 100, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceWaveformSnapshot(t *testing.T) {
|
||||
levels := make([]float32, 60)
|
||||
for i := range levels {
|
||||
levels[i] = float32((i*7)%10) / 10
|
||||
}
|
||||
snapshot(t, "voice_waveform", 420, 110, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Gap(12).Children(func() {
|
||||
VoiceWaveform(c, levels, VoiceWaveformOptions{})
|
||||
VoiceWaveform(c, voiceLevels(40, 0), VoiceWaveformOptions{Width: 160})
|
||||
})
|
||||
})
|
||||
}
|
||||