Merge branch 'worktree-agent-a085d9a07ef787c18'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:45:20 -04:00
commit 48dbbac799
94 files changed
+3543

No files matched your search

+90
View File
@@ -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
}
+62
View File
@@ -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})
})
})
}
+131
View File
@@ -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)
}
})
}
+99
View File
@@ -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)
})
})
}
+80
View File
@@ -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
}
+60
View File
@@ -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}) })
})
}
+114
View File
@@ -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
}
+108
View File
@@ -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()
})
}
+95
View File
@@ -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
}
+63
View File
@@ -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{})
})
})
}
+100
View File
@@ -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
}
+53
View File
@@ -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"))
})
})
}
+15
View File
@@ -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

+19
View File
@@ -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

+16
View File
@@ -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

+15
View File
@@ -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

+19
View File
@@ -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

+16
View File
@@ -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

+14
View File
@@ -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

+15
View File
@@ -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

+22
View File
@@ -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

+14
View File
@@ -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

+20
View File
@@ -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

+15
View File
@@ -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

+64
View File
@@ -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 个来源"},
})
}
+53
View File
@@ -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
}
+62
View File
@@ -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})
})
})
}
+150
View File
@@ -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)
}
+72
View File
@@ -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") })
}
+76
View File
@@ -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
}
+69
View File
@@ -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)) })
})
}
+56
View File
@@ -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
}
+61
View File
@@ -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()) })
})
}
+99
View File
@@ -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)
}
+85
View File
@@ -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"}) },
})
})
})
}
+86
View File
@@ -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()
}
+60
View File
@@ -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})
})
})
}
+72
View File
@@ -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
}
+46
View File
@@ -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})
})
})
}
+232
View File
@@ -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
}
+122
View File
@@ -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)
})
}
+37
View File
@@ -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
}
+66
View File
@@ -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)
})
})
}
+54
View File
@@ -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
}
+66
View File
@@ -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) })
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+5
View File
@@ -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
+49
View File
@@ -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
}
+55
View File
@@ -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)
})
})
}
+122
View File
@@ -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
}
}
})
}
+78
View File
@@ -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") })
}
+77
View File
@@ -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
}
+76
View File
@@ -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})
})
})
}
+75
View File
@@ -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
}
+63
View File
@@ -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})
})
})
}