WIP components (as built)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:51:57 -04:00
1 parent a22527cceb
commit ade219840e
70 files changed
+2610 -235

No files matched your search

+8 -6
View File
@@ -11,11 +11,11 @@ import (
"mujicaui/theme"
)
// Attachment is a file attached to a prompt; the caller uploads it.
// Attachment describes a caller-uploaded file.
type Attachment struct {
// Name is required.
Name string
// Type is the MIME type; empty picks the icon from the name.
// Type supplies MIME metadata; empty uses the filename.
Type string
Size int64
// Uploading shows Progress, 0 to 1.
@@ -25,7 +25,7 @@ type Attachment struct {
Err error
}
// AttachmentChipResult is a chip's element and the user's request this frame.
// AttachmentChipResult carries the chip and requested action.
type AttachmentChipResult struct {
Element *ui.Element
removed, retried bool
@@ -37,7 +37,7 @@ 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.
// AttachmentChip displays upload status, retry and removal controls.
func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult {
if a.Name == "" || a.Size < 0 {
panic("mujica: Attachment needs a Name and a non-negative Size")
@@ -52,7 +52,7 @@ func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult {
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).
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() {
@@ -72,7 +72,9 @@ func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult {
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) })
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)
}
+30
View File
@@ -60,3 +60,33 @@ func TestAttachmentChipSnapshot(t *testing.T) {
})
})
}
func TestAttachmentChipKeyboardAndDisabled(t *testing.T) {
off, removed, retried := false, 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Disabled(off).Children(func() {
r := AttachmentChip(c, Attachment{Name: "guests.csv", Size: 0, Err: errors.New("upload failed")})
if r.Removed() {
removed++
}
if r.Retried() {
retried++
}
})
}, 400, 160)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if retried != 1 || removed != 1 {
t.Fatalf("retry %d remove %d", retried, removed)
}
off = true
tt.Frame()
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Retry upload of guests.csv")
if retried != 1 || removed != 1 {
t.Fatal("disabled attachment handled input")
}
}
+39 -18
View File
@@ -15,25 +15,27 @@ import (
type Source struct {
// Title and URL are required.
Title, URL string
Site string
Snippet string
Published time.Time
Site string
Snippet string
Published time.Time
}
// CitationState links citations, source cards and the sources panel; the caller holds it.
// CitationState links citations and sources through caller-owned state.
type CitationState struct {
// Active is the 1-based number of the source the user chose; 0 for none.
// Active selects a 1-based source; zero clears selection.
Active int
hover int
owner uint64
seen time.Time
locate uint64
}
// lit reports whether source n is chosen or hovered anywhere.
func (s *CitationState) lit(n int) bool { return n > 0 && (s.Active == n || s.hover == n) }
// track records that e, showing source n, is or is no longer under the pointer.
// track refreshes pointer ownership for the current frame.
func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
switch over := e.Hovered(); {
switch over := e.Hovered() && !e.IsDisabled(); {
case over && s.hover != n:
s.hover, s.owner = n, e.ID()
c.Invalidate()
@@ -41,9 +43,19 @@ func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
s.hover, s.owner = 0, 0
c.Invalidate()
}
if s.owner == e.ID() {
s.seen = c.Now()
}
}
// CitationBadge is an inline source number; hovering previews the source, clicking makes it Active.
func (s *CitationState) expire(c *ui.Context, frame time.Time) {
if s.hover != 0 && s.seen != frame {
s.hover, s.owner = 0, 0
c.Invalidate()
}
}
// CitationBadge previews and locates a numbered source.
func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) 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")
@@ -51,7 +63,7 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceRe
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)
b := ui.ButtonBase(c).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).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) {
@@ -59,25 +71,33 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceRe
} else {
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted)
}
frame := c.Now()
b.Draw(func(p *ui.Painter, r ui.Rect) {
st.expire(c, frame)
if st.lit(n) {
p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 4, W: r.W - 8, H: 2}, k.AccentText, 0)
}
})
b.Children(func() { ui.Text(c, fmt.Sprint(n)).FontSize(size).FontFeatures("tnum").FontWeight(600).SingleLine() })
r := ChoiceResult{Element: b}
if b.Clicked() && !b.IsDisabled() && st.Active != n {
if b.Clicked() && !b.IsDisabled() {
st.Active, r.changed = n, true
st.locate++
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.
// citationPreviewDelay sets the hover and focus wait.
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.
// citationPreview displays a source after resting hover or focus.
// Pass-through preserves badge clicks; Escape dismisses the preview.
func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) {
st := ui.Local(b, "mujica.citation", func() tooltipState { return tooltipState{} })
size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} })
if !b.Hovered() && !b.FocusVisible() {
if b.IsDisabled() || !b.Hovered() && !b.FocusVisible() {
*st = tooltipState{}
return
}
@@ -96,13 +116,14 @@ func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) {
return
}
u := Density(c).Unit()
w := float32(300)
x, y := overlayPlace(c, b.Bounds(), w, size.H, 6)
ww, wh := c.Size()
w, h := min(float32(300), max(0, ww-8)), max(0, wh-8)
x, y := overlayPlace(c, b.Bounds(), w, min(size.H, h), 6)
ui.Overlay(c, func() {
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Padding(u * 3).PassThrough().Role(ui.RoleTooltip)
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(u * 3).PassThrough().Role(ui.RoleTooltip).Label(msg(c, "citation_badge.preview"))
overlayStyle(c, panel, overlayAnchored(c, panel, *size))
*size = panel.Bounds()
panel.Children(func() { sourceSummary(c, n, src) })
panel.Children(func() { ui.Scroll(c).Shrink(1).Children(func() { sourceSummary(c, n, src) }) })
})
}
+70 -1
View File
@@ -14,7 +14,7 @@ var sourcesSample = []Source{
{Title: "Court etiquette", URL: "https://example.net/etiquette", Site: "example.net"},
}
// citationApp is an answer with badges beside a sources panel, sharing one state.
// citationApp shares state between inline citations and sources.
type citationApp struct {
st CitationState
opened []int
@@ -97,3 +97,72 @@ func TestCitationBadgeSnapshot(t *testing.T) {
})
})
}
func TestCitationBadgeHitAreaAndDisabledPreview(t *testing.T) {
var st CitationState
off := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Padding(16).Disabled(off).Children(func() {
CitationBadge(c, 1, sourcesSample[0], &st)
})
}, 240, 160)
b, _ := tt.Find("Source 1: The Palace of Versailles")
if b.W < 28 || b.H < 28 {
t.Errorf("hit area %v, want at least 28x28", b)
}
tt.Key(0, ui.KeyTab)
time.Sleep(550 * time.Millisecond)
tt.Frame()
off = true
tt.Frame()
tt.Key(0, ui.KeyEnter)
if tt.HasText("Seat of the court from 1682.") || st.Active != 0 {
t.Errorf("disabled preview or activation: active %d texts %q", st.Active, tt.Texts())
}
}
func TestCitationBadgePreviewFitsNarrowWindow(t *testing.T) {
var st CitationState
var preview ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
src := sourcesSample[0]
ui.Row(c).Padding(8).Children(func() { CitationBadge(c, 1, src, &st) })
}, 180, 120)
tt.Key(0, ui.KeyTab)
time.Sleep(550 * time.Millisecond)
tt.Frame()
preview, ok := tt.Find("Source preview")
if !ok || preview.X < 0 || preview.X+preview.W > 180 || preview.Y+preview.H > 120 {
t.Fatalf("preview content outside window: %v found %v", preview, ok)
}
}
func TestCitationBadgeRemovedOwnerClearsHover(t *testing.T) {
show := true
st := CitationState{}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Children(func() {
ui.Row(c).Key("badge").Height(36).Children(func() {
if show {
CitationBadge(c, 1, sourcesSample[0], &st)
}
})
ui.Row(c).Key("card").Width(300).Children(func() { SourceCard(c, 1, sourcesSample[0], &st) })
})
}, 400, 300)
b, _ := tt.Find("Source 1: The Palace of Versailles")
tt.Move(b.X+b.W/2, b.Y+b.H/2)
tt.Frame()
if st.hover != 1 {
t.Fatal("hover did not activate")
}
show = false
tt.Move(390, 290)
tt.Frame()
if st.hover != 0 {
t.Fatalf("removed owner retained hover %d", st.hover)
}
}
+5 -5
View File
@@ -13,11 +13,11 @@ import (
// ContextChipsOptions configure ContextChips.
type ContextChipsOptions struct {
// Max is how many chips show before the rest fold under "+N"; zero means 4.
// Max sets visible chips before folding; zero means four.
Max int
}
// RemoveResult is a list's element and the ID the user removed this frame.
// RemoveResult carries the element and removed item ID.
type RemoveResult struct {
Element *ui.Element
removed string
@@ -26,7 +26,7 @@ type RemoveResult struct {
// 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.
// ContextChips displays removable context with folded overflow.
func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions) RemoveResult {
if opts.Max < 0 {
panic("mujica: ContextChips Max must not be negative")
@@ -47,7 +47,7 @@ func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions)
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).
ui.Row(c).Key(it.ID).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(Density(c).ControlHeight()).
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleListItem).
Children(func() {
ui.Icon(c, icons.Must(it.Kind.icon())).Size(14, 14).Shrink(0).TextColor(k.AccentText)
@@ -65,7 +65,7 @@ func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions)
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)
b := ui.ButtonBase(c).MinHeight(Density(c).ControlHeight()).Padding(0, u*2).Radius(theme.ControlRadius).Label(label).TextColor(k.AccentText)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
+20
View File
@@ -58,3 +58,23 @@ func TestContextChipsSnapshot(t *testing.T) {
ui.Column(c).Padding(16).Children(func() { ContextChips(c, mentionSample, ContextChipsOptions{Max: 3}) })
})
}
func TestContextChipsKeyboardAndEmpty(t *testing.T) {
items, removed := mentionSample, ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if id, ok := ContextChips(c, items, ContextChipsOptions{Max: 1}).Removed(); ok {
removed = id
}
}, 400, 160)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if removed != "w1" {
t.Fatalf("removed %q, want w1", removed)
}
items = nil
tt.Frame()
if tt.HasText("+3 more") || tt.HasText("Versailles") {
t.Fatal("empty chips retained content")
}
}
+43 -24
View File
@@ -2,6 +2,7 @@ package mujica
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
@@ -32,7 +33,7 @@ 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.
// ContextItem describes a file, document or webpage mention.
type ContextItem struct {
// ID is the item's stable identity; required.
ID string
@@ -43,11 +44,11 @@ type ContextItem struct {
// ContextMentionOptions configure a ContextMentionMenu.
type ContextMentionOptions struct {
// Loading shows that the caller is fetching items for the query.
// Loading indicates caller-owned asynchronous fetching.
Loading bool
}
// ContextMentionResult is the menu's popup, nil while closed, its query and the item chosen this frame.
// ContextMentionResult carries the popup, query and chosen item.
type ContextMentionResult struct {
Element *ui.Element
query string
@@ -56,46 +57,56 @@ type ContextMentionResult struct {
ok bool
}
// Query returns the text after "@"; QueryChanged reports a new one to fetch items for.
// Query returns mention text; QueryChanged requests fresh results.
func (r ContextMentionResult) Query() string { return r.query }
func (r ContextMentionResult) QueryChanged() bool { return r.queryChanged }
// Chosen returns the item picked by Enter, Tab or a click; its "@query" was taken out of the text.
// Chosen returns the selected item and consumes its trigger.
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.
// ContextMentionMenu groups results for the active mention query.
// The caller filters results synchronously or asynchronously.
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 composer.Element == nil || composer.Input == nil || value == nil {
panic("mujica: ContextMentionMenu needs composer and value")
}
data := ui.Local(composer.Element, "mention-data", func() mentionMenuData { return mentionMenuData{} })
locale := current(c).locale
if !data.built || locale != data.locale || !slices.Equal(data.items, items) {
data.built, data.locale = true, locale
data.items = append(data.items[:0], items...)
data.rows, data.order = data.rows[:0], data.order[:0]
for kind := ContextFile; kind <= ContextWeb; kind++ {
for i, it := range items {
if it.ID == "" || it.Label == "" {
panic("mujica: ContextItem needs ID and Label")
}
if it.Kind == kind {
data.rows = append(data.rows, slashMenuRow{label: it.Label, detail: it.Detail, icon: kind.icon(), group: msg(c, kind.group())})
data.order = append(data.order, i)
}
}
}
if len(data.order) != len(items) {
panic("mujica: unknown ContextKind")
}
}
if len(order) != len(items) {
panic("mujica: unknown ContextKind")
}
rows, order := data.rows, data.order
st := ui.Local(composer.Element, slashMenuKey{"mention"}, func() slashMenuState { return slashMenuState{} })
at := contextMentionAt(*value)
var r ContextMentionResult
if at >= 0 {
r.query = (*value)[at+1:]
}
// 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)
// Opening or changing queries requests caller-owned results.
r.queryChanged = at >= 0 && !composer.Element.IsDisabled() && composer.Input.Focused() && (r.query != st.query || !st.open && st.dismissed == nil)
empty := 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
i, _, panel := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: msg(c, "context_mention_menu.label"), loading: opts.Loading})
r.Element = panel
if i >= 0 {
r.chosen, r.ok = items[order[i]], true
*value = (*value)[:at]
@@ -112,3 +123,11 @@ func contextMentionAt(s string) int {
}
return at
}
type mentionMenuData struct {
built bool
locale Locale
items []ContextItem
rows []slashMenuRow
order []int
}
+37 -7
View File
@@ -14,8 +14,8 @@ var mentionSample = []ContextItem{
{ID: "f2", Label: "seating.xlsx", Detail: "~/ball", Kind: ContextFile},
}
// mentionTester filters items as an async source would, one frame late.
type mentionTester struct {
// contextMentionTester supplies filtered results one frame later.
type contextMentionTester struct {
text string
items []ContextItem
loading bool
@@ -23,7 +23,7 @@ type mentionTester struct {
queries []string
}
func (m *mentionTester) view(c *ui.Context) {
func (m *contextMentionTester) 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"})
@@ -44,7 +44,7 @@ func (m *mentionTester) view(c *ui.Context) {
}
func TestContextMentionMenuGroupsAndChooses(t *testing.T) {
m := &mentionTester{}
m := &contextMentionTester{}
tt := ui.NewTester(m.view, 600, 500)
_ = tt.Click("Prompt")
tt.Type("Read @")
@@ -68,7 +68,7 @@ func TestContextMentionMenuGroupsAndChooses(t *testing.T) {
}
func TestContextMentionMenuOnlyAfterSpaceAndLoading(t *testing.T) {
m := &mentionTester{loading: true}
m := &contextMentionTester{loading: true}
tt := ui.NewTester(m.view, 600, 500)
_ = tt.Click("Prompt")
tt.Type("mail@host")
@@ -93,16 +93,46 @@ func TestContextMentionMenuBadItem(t *testing.T) {
t.Fatal("no panic for an item without ID")
}
}()
m := &mentionTester{items: []ContextItem{{Label: "x"}}}
m := &contextMentionTester{items: []ContextItem{{Label: "x"}}}
ui.NewTester(m.view, 300, 300)
}
func TestContextMentionMenuSnapshot(t *testing.T) {
snapshotAfter(t, "context_mention_menu", 560, 420, func(c *ui.Context) {
(*ui.Local(c.Root(), "m", func() *mentionTester { return &mentionTester{items: mentionSample} })).view(c)
(*ui.Local(c.Root(), "m", func() *contextMentionTester { return &contextMentionTester{items: mentionSample} })).view(c)
}, func(tt *ui.Tester) {
_ = tt.Click("Prompt")
tt.Type("Compare @")
tt.Frame()
})
}
func TestContextMentionMenuAsyncFirstChoiceAndDisabledQuery(t *testing.T) {
text, off := "@", false
var items []ContextItem
picked, queries := "", 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt", Disabled: off})
r := ContextMentionMenu(c, p, &text, items, ContextMentionOptions{Loading: items == nil})
if r.QueryChanged() {
queries++
}
if it, ok := r.Chosen(); ok {
picked = it.ID
}
}, 400, 300)
_ = tt.Click("Prompt")
items = []ContextItem{{ID: "first", Label: "First", Kind: ContextDoc}}
tt.Frame()
tt.Key(0, ui.KeyEnter)
if picked != "first" || text != "" {
t.Errorf("async first choice: %q text %q", picked, text)
}
off, text = true, "@other"
before := queries
tt.Frame()
if queries != before {
t.Errorf("disabled menu issued %d queries", queries-before)
}
}
+4 -4
View File
@@ -11,7 +11,7 @@ import (
"mujicaui/theme"
)
// ContextSegment is one part of the context window, as system, history or attachments.
// ContextSegment describes one part of the context window.
type ContextSegment struct {
Label string
Tokens int
@@ -19,12 +19,12 @@ type ContextSegment struct {
// ContextWindowMeterOptions configure a ContextWindowMeter.
type ContextWindowMeterOptions struct {
// Warn is the used share that shows the warning; zero means 0.8.
// Warn sets the warning threshold; 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.
// ContextWindowMeter displays segmented usage and threshold warnings.
// Palette colors and shaped legend markers identify segments.
func ContextWindowMeter(c *ui.Context, segments []ContextSegment, limit int, opts ContextWindowMeterOptions) *ui.Element {
if limit <= 0 || opts.Warn < 0 || opts.Warn > 1 {
panic("mujica: ContextWindowMeter needs limit > 0 and 0 <= Warn <= 1")
+40
View File
@@ -0,0 +1,40 @@
# 221 PromptComposer 提示编辑器
## 用途
多行输入;Enter 发送、Shift+Enter 换行可配置;输入法组合期间不发送;附件与工具插槽。
## 最小示例
```go
var prompt string
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: "Ask the herald"})
if r.Submitted() {
fmt.Println("send", prompt)
prompt = ""
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`value` 为调用方持有的文本指针。`Placeholder`、`Label` 配置占位与名称;`CmdEnterSends=false` 使用 Enter 发送;`Composing=false`,调用方在组合输入时设为 true;`MaxHeight=0` 使用 200 DIP。`Attachments`、`Tools`、`Actions` 为内容插槽;`Disabled=false`。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
InputResult.Changed() 表示文本编辑;Submitted() 表示发送请求;Input 暴露编辑器。
## 键盘操作
Enter 发送,Shift+Enter 换行;CmdEnterSends 下使用 Command/Ctrl+Enter;组合保护期间 Enter 留给输入法。
## 限制
MyGo v0.2.10 的 ui/input.go:688–708 将组合事件直接送到私有编辑器,公开 HandleInput 无法观察组合状态。自动检测组合输入仍需上游 API;调用方须设置 Composing。
+38
View File
@@ -0,0 +1,38 @@
# 222 AttachmentChip 附件标签
## 用途
附件名、类型、大小、上传进度、移除;失败重试。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.AttachmentChip(c, mujica.Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4})
if r.Removed() {
fmt.Println("cancel the upload")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`Attachment.Name` 必填;`Type` 为 MIME 类型,空值按扩展名选择图标;`Size` 为非负字节数;`Uploading` 默认 false,`Progress` 为 0–1;`Err` 表示上传失败。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Removed() 表示移除;Retried() 表示重试。
## 键盘操作
Tab 到移除与重试;Enter、Space 激活。
## 限制
上传由调用方执行,组件只展示进度与结果。
@@ -0,0 +1,39 @@
# 223 PasteImagePreview 图片预览
## 用途
粘贴或拖入图片的预览缩略图;移除。
## 最小示例
```go
pic := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="4" height="3"/>`))
images := []mujica.PastedImage{{ID: "p1", Source: pic, Name: "screenshot"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.PasteImagePreview(c, images).Removed(); ok {
fmt.Println("remove", id)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`images` 为调用方解码的缩略图列表。每项 `ID` 为唯一稳定标识,`Source` 为必填图片,`Name` 为操作名称;缩略图边长为 72 DIP。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Removed() 返回待移除的稳定 ID 与事件标志。
## 键盘操作
Tab 到移除按钮;Enter、Space 移除。
## 限制
粘贴和拖入的图片解码、获取及去重由调用方完成。MyGo ui/headless.go:377–380 仅提供文本剪贴板;ui/drop.go:11 返回文件路径,原生图片剪贴板接口仍需上游扩展。
+41
View File
@@ -0,0 +1,41 @@
# 224 DragDropOverlay 拖放遮罩
## 用途
拖入文件时的全区域遮罩与提示;类型检查回调。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
images := func(path string) bool { return strings.HasSuffix(path, ".png") }
r := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: images}, func() {
ui.Text(c, "Drag images into the chat")
})
for _, path := range r.Accepted() {
fmt.Println("attach", path)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`content` 必填;`Hint` 默认“松开以添加文件”;`Accept` 接收路径并返回类型检查结果,nil 接受全部。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Accepted() 与 Rejected() 返回本帧接受和拒绝的文件路径。
## 键盘操作
内容中的操作按其自身键盘规则访问;文件拖入由操作系统驱动。
## 限制
读取文件与上传由调用方完成。MyGo ui/headless.go:93–163 的 Tester 通过平台事件驱动,公开接口未提供外部文件拖放注入;类型检查与遮罩分别测试。
+38
View File
@@ -0,0 +1,38 @@
# 225 VoiceInputButton 语音输入按钮
## 用途
录音开始与停止;录音时长;权限失败提示;录音由调用方实现。
## 最小示例
```go
var rec mujica.VoiceInputOptions
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.VoiceInputButton(c, rec).Changed() {
rec = mujica.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`Recording=false`;录音时 `Started` 必填;`Denied=false`,表示权限失败;`Disabled=false`。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
ChoiceResult.Changed() 表示开始、停止或重试录音请求。
## 键盘操作
Tab 到按钮;Enter、Space 开始或停止。
## 限制
录音与权限请求由调用方实现。
+36
View File
@@ -0,0 +1,36 @@
# 226 VoiceWaveform 输入波形
## 用途
输入电平波形;静音提示。
## 最小示例
```go
levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`levels` 为 0–1 的电平样本,最新样本放在末尾。`Silence=0` 使用 0.05;`SilentFor=0` 使用 20 个样本;`Width=0` 使用 200 DIP。
## 状态
展示由输入数据驱动;边界值与警告状态带文字说明。
## 事件
展示组件,样本变化由调用方更新。
## 键盘操作
展示组件,沿用外层 Tab 顺序。
## 限制
电平采集由调用方提供;只绘制适合宽度的最新样本。
+40
View File
@@ -0,0 +1,40 @@
# 227 SlashCommandMenu 斜杠命令菜单
## 用途
输入“/”弹出命令;筛选;方向键选择;Tab 补全。
## 最小示例
```go
var prompt string
commands := []mujica.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
if cmd, ok := mujica.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {
fmt.Println("run", cmd.Name)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`composer` 为本帧构建的 PromptComposer 结果;`value` 为同一文本指针;`commands` 每项 `Name` 为必填单词,`Description` 为调用方说明。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Chosen() 返回选中的命令;Tab 补全文本,Enter 选择命令。
## 键盘操作
编辑器内 Up/Down 选择,Enter 选中,Tab 补全,Escape 关闭。
## 限制
菜单按可见行虚拟化;筛选在查询或命令变化时更新。
@@ -0,0 +1,44 @@
# 228 ContextMentionMenu 上下文提及菜单
## 用途
输入“@”选择文件、文档、网页等上下文;分组;异步结果。
## 最小示例
```go
var prompt string
var found []mujica.ContextItem
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
r := mujica.ContextMentionMenu(c, composer, &prompt, found, mujica.ContextMentionOptions{})
if r.QueryChanged() {
found = []mujica.ContextItem{{ID: "f1", Label: r.Query() + ".md", Kind: mujica.ContextFile}}
}
if item, ok := r.Chosen(); ok {
fmt.Println("attach", item.ID)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`composer` 与 `value` 关联当前编辑器;`items` 每项 `ID`、`Label` 必填,`Detail` 可选,`Kind` 为 ContextFile、ContextDoc 或 ContextWeb;`Loading=false`。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
QueryChanged() 请求新查询;Query() 返回查询文本;Chosen() 返回上下文并移除尾部提及文字。
## 键盘操作
编辑器内 Up/Down 选择,Enter 或 Tab 附加,Escape 关闭。
## 限制
异步结果由调用方提供并调用 c.Invalidate(),调用方负责淘汰过期请求。
+38
View File
@@ -0,0 +1,38 @@
# 229 ContextChips 上下文标签
## 用途
已附加上下文列表;移除;溢出折叠。
## 最小示例
```go
items := []mujica.ContextItem{{ID: "f1", Label: "guests.csv", Kind: mujica.ContextFile}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.ContextChips(c, items, mujica.ContextChipsOptions{}).Removed(); ok {
fmt.Println("detach", id)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`items` 为已附加上下文,`ID`、`Label` 必填,`Kind` 指定图标;`Max=0` 默认显示 4 项,超出时可展开。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Removed() 返回待移除 ID;展开与折叠保留在组件状态。
## 键盘操作
Tab 到移除与展开;Enter、Space 激活。
## 限制
上下文文件内容由调用方管理。
+39
View File
@@ -0,0 +1,39 @@
# 230 ModelSelector 模型选择器
## 用途
模型列表;能力标签与上下文长度;不可用项说明。
## 最小示例
```go
model := "herald"
models := []mujica.ModelOption{{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision"}, ContextTokens: 200000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ModelSelector(c, &model, models, mujica.ModelSelectorOptions{}).Changed() {
fmt.Println("model", model)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`value` 为模型 ID 指针;`models` 每项 `ID`、`Name` 必填,`Capabilities` 为能力标签,`ContextTokens=0` 隐藏容量;`Unavailable` 提供不可用原因。`Label` 默认“模型”,`Disabled=false`。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
ChoiceResult.Changed() 表示模型 ID 已改变。
## 键盘操作
Tab 到选择器;Up/Down、Home/End 选择,Enter/Space 确认,Escape 关闭。
## 限制
模型服务与可用性由调用方提供。
+38
View File
@@ -0,0 +1,38 @@
# 231 ModeSelector 模式选择器
## 用途
对话、搜索、深度研究、Agent 模式切换。
## 最小示例
```go
mode := mujica.ModeChat
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ModeSelector(c, &mode, mujica.ModeSelectorOptions{}).Changed() {
fmt.Println("mode", mode)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`mode` 指向 ModeChat、ModeSearch、ModeResearch 或 ModeAgent;`IconsOnly=false` 保留文字;`Disabled=false`。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
ChoiceResult.Changed() 表示模式已改变。
## 键盘操作
Tab 进入模式组;Left/Right 切换模式。
## 限制
模式执行逻辑由调用方实现。
+39
View File
@@ -0,0 +1,39 @@
# 232 ToolToggleMenu 工具开关菜单
## 用途
联网、代码执行等工具开关;需授权项标记。
## 最小示例
```go
web := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
tools := []mujica.ToolItem{{ID: "web", Label: "Web search", On: &web}}
if id, ok := mujica.ToolToggleMenu(c, tools).Changed(); ok {
fmt.Println("toggled", id)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`tools` 每项 `ID`、`On` 必填;`Label`、`Description` 由调用方提供;`NeedsAuth=false`;需要授权时由 `Authorized` 控制开关可用性。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Changed() 返回切换工具 ID;Authorize() 返回授权请求 ID。
## 键盘操作
Tab 到菜单按钮;Enter/Space 打开;Tab 到开关与授权;Escape 关闭。
## 限制
授权与工具执行由调用方完成。
+37
View File
@@ -0,0 +1,37 @@
# 233 ParameterPanel 参数面板
## 用途
温度、Top-p、最大长度等参数;范围与默认值;重置。
## 最小示例
```go
temperature := 1.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
params := []mujica.ModelParam{{Label: "Temperature", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}
mujica.ParameterPanel(c, params)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`params` 每项 `Label`、`Value` 必填;`Min < Max`、`Step > 0`;`Default` 与当前值须为有限范围内数值,且位于 Min 起算的 Step 网格;范围与默认值显示在滑轨下。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
ChoiceResult.Changed() 表示参数或默认值重置改变了值。
## 键盘操作
Tab 到滑块与重置;方向键按 Step 调整,Home/End 到范围端点;Enter/Space 重置。
## 限制
参数校验失败 panic,数字步进复用 Slider 与 NumberInput 的步长网格。
+35
View File
@@ -0,0 +1,35 @@
# 234 TokenCounter Token计数
## 用途
当前输入 token 数与上限;估算值由调用方提供。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TokenCounter(c, 1234, 8000)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`used` 为调用方估算的非负 token 数;`limit` 为正数;达到 90% 时警告,超过上限时显示超额。
## 状态
展示由输入数据驱动;边界值与警告状态带文字说明。
## 事件
展示组件,调用方更新估算值。
## 键盘操作
展示组件,沿用外层 Tab 顺序。
## 限制
token 分词与模型计算由调用方完成。
@@ -0,0 +1,36 @@
# 235 ContextWindowMeter 上下文容量
## 用途
上下文使用率条;分段(系统、历史、附件);阈值警告。
## 最小示例
```go
segments := []mujica.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ContextWindowMeter(c, segments, 128000, mujica.ContextWindowMeterOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`segments` 每项 `Label` 必填、`Tokens` 非负;`limit` 为正数;`Warn=0` 默认阈值 0.8,允许配置 0–1。
## 状态
展示由输入数据驱动;边界值与警告状态带文字说明。
## 事件
展示组件,调用方更新各段用量。
## 键盘操作
展示组件,沿用外层 Tab 顺序。
## 限制
上下文数据由调用方统计,分段同时提供文字与图形标记。
+39
View File
@@ -0,0 +1,39 @@
# 236 SendButton 发送按钮
## 用途
发送与停止两态;禁用条件;快捷键提示。
## 最小示例
```go
streaming := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: streaming, Shortcut: "Enter"})
if r.Sent() || r.Stopped() {
streaming = r.Sent()
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`Streaming=false` 显示发送;true 显示停止;`Disabled=false`,禁用发送时停止仍可用;`Shortcut` 仅展示在提示中。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Sent() 表示发送请求;Stopped() 表示停止请求。
## 键盘操作
Tab 到按钮;Enter、Space 激活;快捷键提示由调用方绑定。
## 限制
生成和停止逻辑由调用方实现。
+39
View File
@@ -0,0 +1,39 @@
# 237 CitationBadge 引用编号
## 用途
行内引用编号;悬停预览来源;点击定位来源。
## 最小示例
```go
var cites mujica.CitationState
src := mujica.Source{Title: "The Palace of Versailles", URL: "https://example.com/versailles"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CitationBadge(c, 1, src, &cites).Changed() {
fmt.Println("show source", cites.Active)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`n` 为从 1 开始的引用编号;`src.Title`、`src.URL` 必填;`st` 为与来源卡片共享的 CitationState;`Active=0` 表示尚未选中。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
每次点击或键盘激活触发 ChoiceResult.Changed() 并定位来源;重复激活同一引用也会重新定位。
## 键盘操作
Tab 到引用;Enter、Space 定位;悬停或键盘焦点 500 毫秒后预览;Escape 关闭预览。
## 限制
编号按调用方引用顺序映射;预览内容来自调用方。
+39
View File
@@ -0,0 +1,39 @@
# 238 SourceCard 来源卡片
## 用途
来源标题、站点、摘要、时间;打开回调。
## 最小示例
```go
var cites mujica.CitationState
src := mujica.Source{Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches."}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SourceCard(c, 1, src, &cites).Clicked() {
fmt.Println("open", src.URL)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`n` 为引用编号;`src.Title`、`src.URL` 必填,`Site`、`Snippet`、`Published` 可选;`st` 与正文引用共享。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Element.Clicked() 表示打开来源请求。
## 键盘操作
Tab 到卡片;Enter、Space 请求打开。
## 限制
打开 URL 由调用方处理;组件返回事件。
+39
View File
@@ -0,0 +1,39 @@
# 239 SourcesPanel 来源面板
## 用途
来源列表;按引用顺序;与正文引用联动高亮。
## 最小示例
```go
var cites mujica.CitationState
sources := []mujica.Source{{Title: "Hall of Mirrors", URL: "https://example.org/hall"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if n, ok := mujica.SourcesPanel(c, sources, &cites).Opened(); ok {
fmt.Println("open", sources[n-1].URL)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`sources` 按引用顺序提供;`st.Active` 为 0 或列表内的引用编号;外层容器提供高度。
## 状态
默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
## 事件
Opened() 返回从 1 开始的来源编号。
## 键盘操作
Tab 到可见来源;Enter、Space 请求打开;滚轮滚动;Active 变化定位来源。
## 限制
使用虚拟列表,只构建可见来源;调用方保证引用编号和来源顺序一致。
+36
View File
@@ -0,0 +1,36 @@
# 240 SearchProgress 搜索进度
## 用途
搜索中关键词与已读来源数;阶段列表;完成后折叠。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
stages := []mujica.SearchStage{{Label: "Searching the web", Done: true}, {Label: "Reading sources"}}
mujica.SearchProgress(c, mujica.SearchProgressOptions{Query: "Versailles", Read: 3, Total: 8, Stages: stages})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`Query` 必填;`Read`、`Total` 满足 0 ≤ Read ≤ Total;`Stages` 每项提供 `Label` 与 `Done`;`Done=false`,完成时自动折叠一次。
## 状态
搜索中展开,完成后自动折叠;Done 从 true 切换为 false 时展开下一次搜索;支持键盘焦点与祖先禁用。
## 事件
ChoiceResult.Changed() 表示用户展开或折叠。
## 键盘操作
Tab 到折叠标题;Enter、Space 展开或收起。
## 限制
网络搜索由调用方执行,阶段与来源数量由调用方更新。
+3 -3
View File
@@ -21,18 +21,18 @@ type DragDropOverlayOptions struct {
Accept func(path string) bool
}
// DropResult is the overlay's element and the files dropped this frame.
// DropResult carries the overlay and dropped files.
type DropResult struct {
Element *ui.Element
accepted []string
rejected []string
}
// Accepted returns the dropped paths Accept took; Rejected those it refused.
// Accepted returns accepted paths; Rejected returns refused paths.
func (r DropResult) Accepted() []string { return r.accepted }
func (r DropResult) Rejected() []string { return r.rejected }
// DragDropOverlay wraps content; while files from other apps are dragged over it, a full mask shows the hint.
// DragDropOverlay covers content during external file dragging.
func DragDropOverlay(c *ui.Context, opts DragDropOverlayOptions, content func()) DropResult {
if content == nil {
panic("mujica: DragDropOverlay needs content")
+1 -1
View File
@@ -42,7 +42,7 @@ func TestDragDropOverlayNeedsContent(t *testing.T) {
}, 100, 100)
}
// TestDragDropOverlaySnapshot draws the mask shown while files are over the area.
// TestDragDropOverlaySnapshot captures the active drop mask.
func TestDragDropOverlaySnapshot(t *testing.T) {
snapshot(t, "drag_drop_overlay", 420, 220, func(c *ui.Context) {
ui.Box(c).Fill().Padding(12).Children(func() {
+241
View File
@@ -0,0 +1,241 @@
package mujica_test
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExamplePromptComposer() {
var prompt string
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: "Ask the herald"})
if r.Submitted() {
fmt.Println("send", prompt)
prompt = ""
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAttachmentChip() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.AttachmentChip(c, mujica.Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4})
if r.Removed() {
fmt.Println("cancel the upload")
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePasteImagePreview() {
pic := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="4" height="3"/>`))
images := []mujica.PastedImage{{ID: "p1", Source: pic, Name: "screenshot"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.PasteImagePreview(c, images).Removed(); ok {
fmt.Println("remove", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDragDropOverlay() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
images := func(path string) bool { return strings.HasSuffix(path, ".png") }
r := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: images}, func() {
ui.Text(c, "Drag images into the chat")
})
for _, path := range r.Accepted() {
fmt.Println("attach", path)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleVoiceInputButton() {
var rec mujica.VoiceInputOptions
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.VoiceInputButton(c, rec).Changed() {
rec = mujica.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleVoiceWaveform() {
levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSlashCommandMenu() {
var prompt string
commands := []mujica.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
if cmd, ok := mujica.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {
fmt.Println("run", cmd.Name)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleContextMentionMenu() {
var prompt string
var found []mujica.ContextItem
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
r := mujica.ContextMentionMenu(c, composer, &prompt, found, mujica.ContextMentionOptions{})
if r.QueryChanged() {
found = []mujica.ContextItem{{ID: "f1", Label: r.Query() + ".md", Kind: mujica.ContextFile}}
}
if item, ok := r.Chosen(); ok {
fmt.Println("attach", item.ID)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleContextChips() {
items := []mujica.ContextItem{{ID: "f1", Label: "guests.csv", Kind: mujica.ContextFile}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.ContextChips(c, items, mujica.ContextChipsOptions{}).Removed(); ok {
fmt.Println("detach", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleModelSelector() {
model := "herald"
models := []mujica.ModelOption{{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision"}, ContextTokens: 200000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ModelSelector(c, &model, models, mujica.ModelSelectorOptions{}).Changed() {
fmt.Println("model", model)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleModeSelector() {
mode := mujica.ModeChat
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ModeSelector(c, &mode, mujica.ModeSelectorOptions{}).Changed() {
fmt.Println("mode", mode)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolToggleMenu() {
web := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
tools := []mujica.ToolItem{{ID: "web", Label: "Web search", On: &web}}
if id, ok := mujica.ToolToggleMenu(c, tools).Changed(); ok {
fmt.Println("toggled", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleParameterPanel() {
temperature := 1.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
params := []mujica.ModelParam{{Label: "Temperature", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}
mujica.ParameterPanel(c, params)
}
ui.Render(view, 400, 300, 1)
}
func ExampleTokenCounter() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TokenCounter(c, 1234, 8000)
}
ui.Render(view, 400, 300, 1)
}
func ExampleContextWindowMeter() {
segments := []mujica.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ContextWindowMeter(c, segments, 128000, mujica.ContextWindowMeterOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSendButton() {
streaming := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: streaming, Shortcut: "Enter"})
if r.Sent() || r.Stopped() {
streaming = r.Sent()
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCitationBadge() {
var cites mujica.CitationState
src := mujica.Source{Title: "The Palace of Versailles", URL: "https://example.com/versailles"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CitationBadge(c, 1, src, &cites).Changed() {
fmt.Println("show source", cites.Active)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSourceCard() {
var cites mujica.CitationState
src := mujica.Source{Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches."}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SourceCard(c, 1, src, &cites).Clicked() {
fmt.Println("open", src.URL)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSourcesPanel() {
var cites mujica.CitationState
sources := []mujica.Source{{Title: "Hall of Mirrors", URL: "https://example.org/hall"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if n, ok := mujica.SourcesPanel(c, sources, &cites).Opened(); ok {
fmt.Println("open", sources[n-1].URL)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSearchProgress() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
stages := []mujica.SearchStage{{Label: "Searching the web", Done: true}, {Label: "Reading sources"}}
mujica.SearchProgress(c, mujica.SearchProgressOptions{Query: "Versailles", Read: 3, Total: 8, Stages: stages})
}
ui.Render(view, 400, 300, 1)
}
+64 -1
View File
@@ -1,3 +1,66 @@
package main
var chatinputPages = []page{}
const chatinputCategory = "Chat Input"
var chatinputPages = []page{
{ID: "221", Name: "PromptComposer", ZhName: "提示编辑器", Category: chatinputCategory, Demo: chatinputDemoPromptComposer,
Keys: "Enter 发送,Shift+Enter 换行;CmdEnterSends 下使用 Command/Ctrl+Enter;组合保护期间 Enter 留给输入法。",
Code: "var prompt string\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: \"Ask the herald\"})\n\tif r.Submitted() {\n\t\tfmt.Println(\"send\", prompt)\n\t\tprompt = \"\"\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "222", Name: "AttachmentChip", ZhName: "附件标签", Category: chatinputCategory, Demo: chatinputDemoAttachmentChip,
Keys: "Tab 到移除与重试;Enter、Space 激活。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.AttachmentChip(c, mujica.Attachment{Name: \"guests.csv\", Size: 4096, Uploading: true, Progress: 0.4})\n\tif r.Removed() {\n\t\tfmt.Println(\"cancel the upload\")\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "223", Name: "PasteImagePreview", ZhName: "图片预览", Category: chatinputCategory, Demo: chatinputDemoPasteImagePreview,
Keys: "Tab 到移除按钮;Enter、Space 移除。",
Code: "pic := ui.MustParseSVG([]byte(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"3\"/>`))\nimages := []mujica.PastedImage{{ID: \"p1\", Source: pic, Name: \"screenshot\"}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif id, ok := mujica.PasteImagePreview(c, images).Removed(); ok {\n\t\tfmt.Println(\"remove\", id)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "224", Name: "DragDropOverlay", ZhName: "拖放遮罩", Category: chatinputCategory, Demo: chatinputDemoDragDropOverlay,
Keys: "内容中的操作按其自身键盘规则访问;文件拖入由操作系统驱动。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\timages := func(path string) bool { return strings.HasSuffix(path, \".png\") }\n\tr := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: images}, func() {\n\t\tui.Text(c, \"Drag images into the chat\")\n\t})\n\tfor _, path := range r.Accepted() {\n\t\tfmt.Println(\"attach\", path)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "225", Name: "VoiceInputButton", ZhName: "语音输入按钮", Category: chatinputCategory, Demo: chatinputDemoVoiceInputButton,
Keys: "Tab 到按钮;Enter、Space 开始或停止。",
Code: "var rec mujica.VoiceInputOptions\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.VoiceInputButton(c, rec).Changed() {\n\t\trec = mujica.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "226", Name: "VoiceWaveform", ZhName: "输入波形", Category: chatinputCategory, Demo: chatinputDemoVoiceWaveform,
Keys: "展示组件,沿用外层 Tab 顺序。",
Code: "levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})\n}\nui.Render(view, 400, 300, 1)"},
{ID: "227", Name: "SlashCommandMenu", ZhName: "斜杠命令菜单", Category: chatinputCategory, Demo: chatinputDemoSlashCommandMenu,
Keys: "编辑器内 Up/Down 选择,Enter 选中,Tab 补全,Escape 关闭。",
Code: "var prompt string\ncommands := []mujica.SlashCommand{{Name: \"summarize\", Description: \"Summarize the thread\"}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tcomposer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})\n\tif cmd, ok := mujica.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {\n\t\tfmt.Println(\"run\", cmd.Name)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "228", Name: "ContextMentionMenu", ZhName: "上下文提及菜单", Category: chatinputCategory, Demo: chatinputDemoContextMentionMenu,
Keys: "编辑器内 Up/Down 选择,Enter 或 Tab 附加,Escape 关闭。",
Code: "var prompt string\nvar found []mujica.ContextItem\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tcomposer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})\n\tr := mujica.ContextMentionMenu(c, composer, &prompt, found, mujica.ContextMentionOptions{})\n\tif r.QueryChanged() {\n\t\tfound = []mujica.ContextItem{{ID: \"f1\", Label: r.Query() + \".md\", Kind: mujica.ContextFile}}\n\t}\n\tif item, ok := r.Chosen(); ok {\n\t\tfmt.Println(\"attach\", item.ID)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "229", Name: "ContextChips", ZhName: "上下文标签", Category: chatinputCategory, Demo: chatinputDemoContextChips,
Keys: "Tab 到移除与展开;Enter、Space 激活。",
Code: "items := []mujica.ContextItem{{ID: \"f1\", Label: \"guests.csv\", Kind: mujica.ContextFile}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif id, ok := mujica.ContextChips(c, items, mujica.ContextChipsOptions{}).Removed(); ok {\n\t\tfmt.Println(\"detach\", id)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "230", Name: "ModelSelector", ZhName: "模型选择器", Category: chatinputCategory, Demo: chatinputDemoModelSelector,
Keys: "Tab 到选择器;Up/Down、Home/End 选择,Enter/Space 确认,Escape 关闭。",
Code: "model := \"herald\"\nmodels := []mujica.ModelOption{{ID: \"herald\", Name: \"Herald 2\", Capabilities: []string{\"Vision\"}, ContextTokens: 200000}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.ModelSelector(c, &model, models, mujica.ModelSelectorOptions{}).Changed() {\n\t\tfmt.Println(\"model\", model)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "231", Name: "ModeSelector", ZhName: "模式选择器", Category: chatinputCategory, Demo: chatinputDemoModeSelector,
Keys: "Tab 进入模式组;Left/Right 切换模式。",
Code: "mode := mujica.ModeChat\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.ModeSelector(c, &mode, mujica.ModeSelectorOptions{}).Changed() {\n\t\tfmt.Println(\"mode\", mode)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "232", Name: "ToolToggleMenu", ZhName: "工具开关菜单", Category: chatinputCategory, Demo: chatinputDemoToolToggleMenu,
Keys: "Tab 到菜单按钮;Enter/Space 打开;Tab 到开关与授权;Escape 关闭。",
Code: "web := true\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\ttools := []mujica.ToolItem{{ID: \"web\", Label: \"Web search\", On: &web}}\n\tif id, ok := mujica.ToolToggleMenu(c, tools).Changed(); ok {\n\t\tfmt.Println(\"toggled\", id)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "233", Name: "ParameterPanel", ZhName: "参数面板", Category: chatinputCategory, Demo: chatinputDemoParameterPanel,
Keys: "Tab 到滑块与重置;方向键按 Step 调整,Home/End 到范围端点;Enter/Space 重置。",
Code: "temperature := 1.0\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tparams := []mujica.ModelParam{{Label: \"Temperature\", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}\n\tmujica.ParameterPanel(c, params)\n}\nui.Render(view, 400, 300, 1)"},
{ID: "234", Name: "TokenCounter", ZhName: "Token计数", Category: chatinputCategory, Demo: chatinputDemoTokenCounter,
Keys: "展示组件,沿用外层 Tab 顺序。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.TokenCounter(c, 1234, 8000)\n}\nui.Render(view, 400, 300, 1)"},
{ID: "235", Name: "ContextWindowMeter", ZhName: "上下文容量", Category: chatinputCategory, Demo: chatinputDemoContextWindowMeter,
Keys: "展示组件,沿用外层 Tab 顺序。",
Code: "segments := []mujica.ContextSegment{{Label: \"System\", Tokens: 1200}, {Label: \"History\", Tokens: 58000}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.ContextWindowMeter(c, segments, 128000, mujica.ContextWindowMeterOptions{})\n}\nui.Render(view, 400, 300, 1)"},
{ID: "236", Name: "SendButton", ZhName: "发送按钮", Category: chatinputCategory, Demo: chatinputDemoSendButton,
Keys: "Tab 到按钮;Enter、Space 激活;快捷键提示由调用方绑定。",
Code: "streaming := false\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: streaming, Shortcut: \"Enter\"})\n\tif r.Sent() || r.Stopped() {\n\t\tstreaming = r.Sent()\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "237", Name: "CitationBadge", ZhName: "引用编号", Category: chatinputCategory, Demo: chatinputDemoCitationBadge,
Keys: "Tab 到引用;Enter、Space 定位;悬停或键盘焦点 500 毫秒后预览;Escape 关闭预览。",
Code: "var cites mujica.CitationState\nsrc := mujica.Source{Title: \"The Palace of Versailles\", URL: \"https://example.com/versailles\"}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.CitationBadge(c, 1, src, &cites).Changed() {\n\t\tfmt.Println(\"show source\", cites.Active)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "238", Name: "SourceCard", ZhName: "来源卡片", Category: chatinputCategory, Demo: chatinputDemoSourceCard,
Keys: "Tab 到卡片;Enter、Space 请求打开。",
Code: "var cites mujica.CitationState\nsrc := mujica.Source{Title: \"Hall of Mirrors\", URL: \"https://example.org/hall\", Snippet: \"Seventeen arches.\"}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.SourceCard(c, 1, src, &cites).Clicked() {\n\t\tfmt.Println(\"open\", src.URL)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "239", Name: "SourcesPanel", ZhName: "来源面板", Category: chatinputCategory, Demo: chatinputDemoSourcesPanel,
Keys: "Tab 到可见来源;Enter、Space 请求打开;滚轮滚动;Active 变化定位来源。",
Code: "var cites mujica.CitationState\nsources := []mujica.Source{{Title: \"Hall of Mirrors\", URL: \"https://example.org/hall\"}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif n, ok := mujica.SourcesPanel(c, sources, &cites).Opened(); ok {\n\t\tfmt.Println(\"open\", sources[n-1].URL)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "240", Name: "SearchProgress", ZhName: "搜索进度", Category: chatinputCategory, Demo: chatinputDemoSearchProgress,
Keys: "Tab 到折叠标题;Enter、Space 展开或收起。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tstages := []mujica.SearchStage{{Label: \"Searching the web\", Done: true}, {Label: \"Reading sources\"}}\n\tmujica.SearchProgress(c, mujica.SearchProgressOptions{Query: \"Versailles\", Read: 3, Total: 8, Stages: stages})\n}\nui.Render(view, 400, 300, 1)"},
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func chatinputDemoPromptComposer(c *ui.Context, log func(string)) {
type state struct {
text string
disabled, cmd, composing, attached, web, streaming bool
}
s := ui.Local(c.Root(), "221", func() state { return state{attached: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.cmd, "Cmd+Enter sends", log)
choiceFlag(c, &s.composing, "Composition guard", log)
choiceFlag(c, &s.attached, "Attachment", log)
}, func() {
ui.Column(c).Width(520).Children(func() {
r := mujica.PromptComposer(c, &s.text, mujica.PromptComposerOptions{Label: "Prompt", Placeholder: "Ask the herald", Disabled: s.disabled, CmdEnterSends: s.cmd, Composing: s.composing,
Attachments: func() {
if s.attached && mujica.AttachmentChip(c, mujica.Attachment{Name: "guests.csv", Size: 4096}).Removed() {
s.attached = false
log("remove attachment")
}
},
Tools: func() {
if _, ok := mujica.ToolToggleMenu(c, []mujica.ToolItem{{ID: "web", Label: "Web search", On: &s.web}}).Changed(); ok {
log("toggle web search")
}
},
Actions: func() {
r := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: s.streaming, Disabled: strings.TrimSpace(s.text) == "" || s.composing, Shortcut: "Enter"})
if r.Sent() {
s.streaming = true
s.text = ""
log("send")
}
if r.Stopped() {
s.streaming = false
log("stop")
}
},
})
if r.Changed() {
log(fmt.Sprintf("edit %d characters", len(s.text)))
}
if r.Submitted() {
s.text = ""
s.streaming = true
log("submit")
}
})
})
}
func chatinputDemoAttachmentChip(c *ui.Context, log func(string)) {
type state struct {
upload, fail, disabled, removed bool
progress float64
}
s := ui.Local(c.Root(), "222", func() state { return state{upload: true, progress: 0.4} })
choiceDemo(c, func() {
choiceFlag(c, &s.upload, "Uploading", log)
choiceFlag(c, &s.fail, "Failed", log)
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.Slider(c, &s.progress, mujica.SliderOptions{Min: 0, Max: 1, Step: 0.1, Label: "Upload progress"}).Changed() {
log(fmt.Sprint("progress ", s.progress))
}
if mujica.Button(c, "Restore attachment", mujica.ButtonOptions{}).Clicked() {
s.removed = false
log("restore")
}
}, func() {
ui.Column(c).Disabled(s.disabled).Children(func() {
if s.removed {
ui.Text(c, "Attachment removed")
return
}
a := mujica.Attachment{Name: "guests.csv", Size: 4096, Uploading: s.upload, Progress: s.progress}
if s.fail {
a.Err = errors.New("upload rejected")
}
r := mujica.AttachmentChip(c, a)
if r.Removed() {
s.removed = true
log("remove")
}
if r.Retried() {
s.fail = false
s.upload = true
s.progress = 0
log("retry")
}
})
})
}
func chatinputPictures() []mujica.PastedImage {
var out []mujica.PastedImage
for i, col := range []string{"#7D2034", "#355E80"} {
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72"><rect width="72" height="72" fill="%s"/><circle cx="36" cy="36" r="18" fill="#F2EBDD"/></svg>`, col)
out = append(out, mujica.PastedImage{ID: fmt.Sprint(i), Name: fmt.Sprintf("Picture %d", i+1), Source: ui.MustParseSVG([]byte(svg))})
}
return out
}
func chatinputDemoPasteImagePreview(c *ui.Context, log func(string)) {
type state struct {
images []mujica.PastedImage
disabled bool
}
s := ui.Local(c.Root(), "223", func() state { return state{images: chatinputPictures()} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.Button(c, "Restore images", mujica.ButtonOptions{}).Clicked() {
s.images = chatinputPictures()
log("restore images")
}
}, func() {
ui.Column(c).Padding(12).Disabled(s.disabled).Children(func() {
if id, ok := mujica.PasteImagePreview(c, s.images).Removed(); ok {
for i, im := range s.images {
if im.ID == id {
s.images = append(s.images[:i], s.images[i+1:]...)
break
}
}
log("remove image " + id)
}
})
ui.Text(c, "Decoded images arrive from the caller's paste or drop handler.")
})
}
func chatinputDemoDragDropOverlay(c *ui.Context, log func(string)) {
type state struct {
imagesOnly, disabled bool
accepted, rejected int
}
s := ui.Local(c.Root(), "224", func() state { return state{imagesOnly: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.imagesOnly, "PNG only", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(520).Height(180).Disabled(s.disabled).Children(func() {
r := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: func(p string) bool { return !s.imagesOnly || strings.EqualFold(filepath.Ext(p), ".png") }}, func() {
ui.Box(c).Fill().Border(1, mujica.Tokens(c).ControlBorder).Center().Children(func() { ui.Text(c, "Drop files here") })
})
r.Element.Fill()
if n := len(r.Accepted()); n > 0 {
s.accepted += n
log(fmt.Sprintf("accept %d files", n))
}
if n := len(r.Rejected()); n > 0 {
s.rejected += n
log(fmt.Sprintf("reject %d files", n))
}
})
ui.Text(c, fmt.Sprintf("Accepted %d · Rejected %d", s.accepted, s.rejected))
})
}
func chatinputDemoVoiceInputButton(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "225", func() mujica.VoiceInputOptions { return mujica.VoiceInputOptions{} })
choiceDemo(c, func() {
choiceFlag(c, &s.Disabled, "Disabled", log)
if mujica.Button(c, "Permission failure", mujica.ButtonOptions{}).Clicked() {
s.Recording = false
s.Denied = true
log("permission denied")
}
}, func() {
if mujica.VoiceInputButton(c, *s).Changed() {
s.Recording = !s.Recording
s.Denied = false
s.Started = c.Now()
log(fmt.Sprintf("recording %v", s.Recording))
}
ui.Text(c, "The caller records audio; this demo changes recording state.")
})
}
func chatinputDemoVoiceWaveform(c *ui.Context, log func(string)) {
type state struct {
silent bool
level float64
}
s := ui.Local(c.Root(), "226", func() state { return state{level: 0.6} })
choiceDemo(c, func() {
choiceFlag(c, &s.silent, "Silent", log)
if mujica.Slider(c, &s.level, mujica.SliderOptions{Min: 0, Max: 1, Step: 0.1, Label: "Input level"}).Changed() {
log(fmt.Sprint("level ", s.level))
}
}, func() {
levels := make([]float32, 40)
for i := range levels {
if !s.silent {
levels[i] = float32(s.level) * float32(i%7+1) / 7
}
}
mujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})
})
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"strings"
)
func chatinputDemoSlashCommandMenu(c *ui.Context, log func(string)) {
type state struct {
text string
disabled, empty bool
}
s := ui.Local(c.Root(), "227", func() state { return state{text: "/"} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.empty, "Empty commands", log)
}, func() {
ui.Column(c).Width(520).Children(func() {
p := mujica.PromptComposer(c, &s.text, mujica.PromptComposerOptions{Label: "Command prompt", Disabled: s.disabled})
commands := []mujica.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}, {Name: "search", Description: "Search the web"}, {Name: "translate", Description: "Translate the message"}}
if s.empty {
commands = nil
}
before := s.text
if cmd, ok := mujica.SlashCommandMenu(c, p, &s.text, commands).Chosen(); ok {
log("command " + cmd.Name)
} else if before != s.text {
log("complete command")
}
if p.Changed() {
log("edit command query")
}
if p.Submitted() {
log("submit prompt")
}
})
})
}
func chatinputContextItems() []mujica.ContextItem {
return []mujica.ContextItem{{ID: "file", Label: "guests.csv", Kind: mujica.ContextFile}, {ID: "doc", Label: "Court protocol", Kind: mujica.ContextDoc}, {ID: "web", Label: "Versailles", Detail: "example.com", Kind: mujica.ContextWeb}}
}
func chatinputDemoContextMentionMenu(c *ui.Context, log func(string)) {
type state struct {
text, query string
items []mujica.ContextItem
loading, disabled bool
}
s := ui.Local(c.Root(), "228", func() state { return state{text: "@", items: chatinputContextItems()} })
choiceDemo(c, func() {
choiceFlag(c, &s.loading, "Loading results", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(520).Children(func() {
p := mujica.PromptComposer(c, &s.text, mujica.PromptComposerOptions{Label: "Context prompt", Disabled: s.disabled})
items := s.items
if s.loading {
items = nil
}
r := mujica.ContextMentionMenu(c, p, &s.text, items, mujica.ContextMentionOptions{Loading: s.loading})
if r.QueryChanged() {
s.query = r.Query()
s.items = nil
for _, it := range chatinputContextItems() {
if strings.Contains(strings.ToLower(it.Label), strings.ToLower(s.query)) {
s.items = append(s.items, it)
}
}
c.Invalidate()
log("context query changed")
}
if it, ok := r.Chosen(); ok {
log("attach context " + it.ID)
}
if p.Changed() {
log("edit context prompt")
}
if p.Submitted() {
log("submit prompt")
}
})
})
}
func chatinputDemoContextChips(c *ui.Context, log func(string)) {
type state struct {
items []mujica.ContextItem
max float64
disabled bool
}
s := ui.Local(c.Root(), "229", func() state { return state{items: chatinputContextItems(), max: 2} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 1, Max: 5, Step: 1, Label: "Visible chips"}).Changed() {
log(fmt.Sprint("max ", s.max))
}
if mujica.Button(c, "Restore context", mujica.ButtonOptions{}).Clicked() {
s.items = chatinputContextItems()
log("restore context")
}
}, func() {
ui.Column(c).Width(520).Disabled(s.disabled).Children(func() {
if id, ok := mujica.ContextChips(c, s.items, mujica.ContextChipsOptions{Max: int(s.max)}).Removed(); ok {
for i, it := range s.items {
if it.ID == id {
s.items = append(s.items[:i], s.items[i+1:]...)
break
}
}
log("remove context " + id)
}
})
})
}
func chatinputDemoModelSelector(c *ui.Context, log func(string)) {
type state struct {
model string
disabled, unavailable bool
}
s := ui.Local(c.Root(), "230", func() state { return state{model: "herald", unavailable: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.unavailable, "Restricted model", log)
}, func() {
models := []mujica.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}}
if s.unavailable {
models[2].Unavailable = "Needs the Court plan"
}
if mujica.ModelSelector(c, &s.model, models, mujica.ModelSelectorOptions{Disabled: s.disabled}).Changed() {
log("model " + s.model)
}
})
}
func chatinputDemoModeSelector(c *ui.Context, log func(string)) {
type state struct {
mode mujica.ChatMode
icons, disabled bool
}
s := ui.Local(c.Root(), "231", func() state { return state{} })
choiceDemo(c, func() { choiceFlag(c, &s.icons, "Icons only", log); choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
if mujica.ModeSelector(c, &s.mode, mujica.ModeSelectorOptions{IconsOnly: s.icons, Disabled: s.disabled}).Changed() {
log(fmt.Sprint("mode ", s.mode))
}
})
}
func chatinputDemoToolToggleMenu(c *ui.Context, log func(string)) {
type state struct{ web, code, mail, authorized, disabled bool }
s := ui.Local(c.Root(), "232", func() state { return state{web: true} })
choiceDemo(c, func() { choiceFlag(c, &s.authorized, "Authorized", log); choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
ui.Column(c).Disabled(s.disabled).Children(func() {
r := mujica.ToolToggleMenu(c, []mujica.ToolItem{{ID: "web", Label: "Web search", On: &s.web}, {ID: "code", Label: "Code execution", On: &s.code}, {ID: "mail", Label: "Mail", On: &s.mail, NeedsAuth: true, Authorized: s.authorized}})
if id, ok := r.Changed(); ok {
log("toggle " + id)
}
if id, ok := r.Authorize(); ok {
s.authorized = true
log("authorize " + id)
}
})
})
}
func chatinputDemoParameterPanel(c *ui.Context, log func(string)) {
type state struct {
temp, top, length float64
disabled bool
}
s := ui.Local(c.Root(), "233", func() state { return state{temp: 0.7, top: 1, length: 2048} })
choiceDemo(c, func() { choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
ui.Column(c).Width(420).Disabled(s.disabled).Children(func() {
if mujica.ParameterPanel(c, []mujica.ModelParam{{Label: "Temperature", Value: &s.temp, Min: 0, Max: 2, Step: 0.1, Default: 1}, {Label: "Top-p", Value: &s.top, Min: 0, Max: 1, Step: 0.05, Default: 1}, {Label: "Max tokens", Value: &s.length, Min: 256, Max: 8192, Step: 256, Default: 2048}}).Changed() {
log(fmt.Sprintf("parameters %.1f / %.2f / %.0f", s.temp, s.top, s.length))
}
})
})
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"time"
)
func chatinputDemoTokenCounter(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "234", func() float64 { return 1234 })
choiceDemo(c, func() {
if mujica.NumberInput(c, s, mujica.NumberInputOptions{Min: 0, Max: 12000, Step: 100, Label: "Estimated tokens"}).Changed() {
log(fmt.Sprint("estimate ", *s))
}
}, func() {
mujica.TokenCounter(c, int(*s), 8000)
mujica.TokenCounter(c, 7500, 8000)
mujica.TokenCounter(c, 9100, 8000)
})
}
func chatinputDemoContextWindowMeter(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "235", func() float64 { return 58000 })
choiceDemo(c, func() {
if mujica.NumberInput(c, s, mujica.NumberInputOptions{Min: 0, Max: 150000, Step: 1000, Label: "History tokens"}).Changed() {
log(fmt.Sprint("history ", *s))
}
}, func() {
ui.Column(c).Width(520).Children(func() {
mujica.ContextWindowMeter(c, []mujica.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: int(*s)}, {Label: "Attachments", Tokens: 24000}}, 128000, mujica.ContextWindowMeterOptions{})
})
})
}
func chatinputDemoSendButton(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "236", func() mujica.SendButtonOptions { return mujica.SendButtonOptions{Shortcut: "Enter"} })
choiceDemo(c, func() {
choiceFlag(c, &s.Streaming, "Streaming", log)
choiceFlag(c, &s.Disabled, "Disabled send", log)
}, func() {
r := mujica.SendButton(c, *s)
if r.Sent() {
s.Streaming = true
log("send")
}
if r.Stopped() {
s.Streaming = false
log("stop")
}
})
}
func chatinputSources() []mujica.Source {
return []mujica.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", Site: "example.org", Snippet: "Seventeen arches face the garden."}, {Title: "Court etiquette", URL: "https://example.net/etiquette", Snippet: "The ranks of the court."}}
}
func chatinputDemoCitationBadge(c *ui.Context, log func(string)) {
type state struct {
cites mujica.CitationState
disabled bool
}
s := ui.Local(c.Root(), "237", func() state { return state{} })
choiceDemo(c, func() { choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
ui.Row(c).Disabled(s.disabled).Gap(8).AlignItems(ui.Center).Children(func() {
ui.Text(c, "The court moved in 1682")
for i, src := range chatinputSources() {
if mujica.CitationBadge(c, i+1, src, &s.cites).Changed() {
log(fmt.Sprint("locate source ", s.cites.Active))
}
}
})
ui.Column(c).Width(360).Height(260).Disabled(s.disabled).Children(func() {
if n, ok := mujica.SourcesPanel(c, chatinputSources(), &s.cites).Opened(); ok {
log(fmt.Sprint("open source ", n))
}
})
})
}
func chatinputDemoSourceCard(c *ui.Context, log func(string)) {
type state struct {
cites mujica.CitationState
selected, disabled bool
}
s := ui.Local(c.Root(), "238", func() state { return state{} })
choiceDemo(c, func() { choiceFlag(c, &s.selected, "Selected", log); choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
s.cites.Active = 0
if s.selected {
s.cites.Active = 1
}
ui.Column(c).Width(400).Disabled(s.disabled).Children(func() {
if mujica.SourceCard(c, 1, chatinputSources()[0], &s.cites).Clicked() {
log("open source 1")
}
})
})
}
func chatinputDemoSourcesPanel(c *ui.Context, log func(string)) {
type state struct {
cites mujica.CitationState
sources []mujica.Source
disabled, empty, many bool
}
s := ui.Local(c.Root(), "239", func() state { return state{sources: chatinputSources()} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.empty, "Empty", log)
if mujica.Switch(c, &s.many, "100,000 sources", mujica.SwitchOptions{}).Changed() {
s.sources = chatinputSources()
s.cites.Active = 0
if s.many {
s.sources = make([]mujica.Source, 100000)
for i := range s.sources {
s.sources[i] = mujica.Source{Title: fmt.Sprintf("Archive %d", i+1), URL: "https://example.com/archive"}
}
}
log(fmt.Sprint("sources ", len(s.sources)))
}
if mujica.Button(c, "Locate last source", mujica.ButtonOptions{Disabled: s.empty}).Clicked() {
s.cites.Active = len(s.sources)
log("locate last source")
}
}, func() {
sources := s.sources
if s.empty {
sources = nil
s.cites.Active = 0
}
ui.Column(c).Width(400).Height(320).Disabled(s.disabled).Children(func() {
if n, ok := mujica.SourcesPanel(c, sources, &s.cites).Opened(); ok {
log(fmt.Sprint("open source ", n))
}
})
})
}
func chatinputDemoSearchProgress(c *ui.Context, log func(string)) {
type state struct {
done, disabled bool
read float64
}
s := ui.Local(c.Root(), "240", func() state { return state{read: 3} })
choiceDemo(c, func() {
choiceFlag(c, &s.done, "Done", log)
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.NumberInput(c, &s.read, mujica.NumberInputOptions{Min: 0, Max: 8, Step: 1, Label: "Read sources"}).Changed() {
log(fmt.Sprint("read ", s.read))
}
}, func() {
ui.Column(c).Width(440).Disabled(s.disabled).Children(func() {
if mujica.SearchProgress(c, mujica.SearchProgressOptions{Query: "Versailles 1682", Read: int(s.read), Total: 8, Done: s.done, Stages: []mujica.SearchStage{{Label: "Planning queries", Done: true}, {Label: "Searching the web", Done: true}, {Label: "Reading sources", Done: s.done}}}).Changed() {
log("toggle search details")
}
})
})
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"image/png"
"os"
"path/filepath"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func chatinputDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) {
t.Helper()
var p *page
for i := range chatinputPages {
if chatinputPages[i].ID == id {
p = &chatinputPages[i]
break
}
}
if p == nil {
t.Fatalf("missing page %s", id)
}
events := new([]string)
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
ui.Scroll(c).Fill().Padding(24).Gap(16).Children(func() { p.Demo(c, func(s string) { *events = append(*events, s) }) })
}, 900, 800)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
return tt, events
}
func TestChatinputDemosRender(t *testing.T) {
if len(chatinputPages) != 20 {
t.Fatalf("pages %d, want 20", len(chatinputPages))
}
for _, p := range chatinputPages {
if p.Category != "Chat Input" || p.Code == "" || p.Keys == "" {
t.Errorf("incomplete page %s", p.ID)
}
for _, mode := range []mujica.Mode{mujica.Light, mujica.Dark} {
tt, _ := chatinputDemoTester(t, p.ID, mode)
if tt.HasText("panic") {
t.Errorf("%s: panic shown", p.ID)
}
if dir := os.Getenv("CHATINPUT_DEMO_DIR"); dir != "" {
f, err := os.Create(filepath.Join(dir, p.ID+"-"+mode.String()+".png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, tt.Image()); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
}
}
}
func TestChatinputDemosEvents(t *testing.T) {
for _, tc := range []struct{ id, click string }{
{"221", "Prompt"}, {"222", "Remove guests.csv"}, {"223", "Remove image Picture 1"}, {"225", "Start voice input"},
{"227", "Command prompt"}, {"228", "Context prompt"}, {"229", "Remove guests.csv"}, {"230", "Model"},
{"231", "Deep research"}, {"232", "Tools, 1 on"}, {"233", "Reset Temperature"}, {"236", "Send"},
{"237", "Source 1: The Palace of Versailles"}, {"238", "Open The Palace of Versailles"},
{"239", "Locate last source"}, {"240", "Searching for “Versailles 1682”"},
} {
tt, events := chatinputDemoTester(t, tc.id, mujica.Light)
if err := tt.Click(tc.click); err != nil {
t.Fatalf("%s: %v", tc.id, err)
}
switch tc.id {
case "221":
tt.Type("hello")
tt.Key(0, ui.KeyEnter)
case "227":
tt.Type("s")
tt.Key(0, ui.KeyTab)
case "228":
tt.Key(0, ui.KeyEnter)
case "230":
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
case "232":
if err := tt.Click("Code execution"); err != nil {
t.Fatal(err)
}
}
if len(*events) == 0 {
t.Errorf("%s: action logged no event", tc.id)
}
}
}
+1
View File
@@ -54,6 +54,7 @@ func init() {
"send_button.send": {"Send", "发送"},
"send_button.stop": {"Stop generating", "停止生成"},
"citation_badge.label": {"Source %d: %s", "来源 %d:%s"},
"citation_badge.preview": {"Source preview", "来源预览"},
"source_card.open": {"Open %s", "打开 %s"},
"sources_panel.label": {"Sources", "来源"},
"sources_panel.empty": {"No sources", "没有来源"},
+50 -13
View File
@@ -6,6 +6,8 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ChatMode is how the assistant answers.
@@ -33,21 +35,56 @@ type ModeSelectorOptions struct {
Disabled bool
}
// ModeSelector switches among chat, search, deep research and agent modes; arrows move the choice.
// ModeSelector switches conversation modes through clicks or arrow keys.
func ModeSelector(c *ui.Context, mode *ChatMode, opts ModeSelectorOptions) ChoiceResult {
if *mode < ModeChat || *mode > ModeAgent {
if mode == nil || *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}
k, u := Tokens(c), Density(c).Unit()
old, pick := *mode, int(*mode)
wrapper := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
var track *ui.Element
wrapper.Children(func() {
parts := ui.SegmentedBase(c, &pick, len(chatModes))
track = parts.Track.Padding(2).Gap(2).Radius(theme.ControlRadius+2).Background(k.Background).
Border(theme.BorderWidth, k.ControlBorder).MinHeight(Density(c).ControlHeight()).Label(msg(c, "mode_selector.label"))
track.Children(func() {
for i, m := range chatModes {
label := msg(c, m[0])
seg := parts.Segment(i).MinWidth(theme.MinHitSize).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius).Label(label)
on := i == pick
fg := k.TextMuted
switch {
case on:
seg.Background(k.Selection).FontWeight(600).Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 3, W: r.W - 8, H: 2}, k.AccentText, 0)
})
fg = k.AccentText
case seg.Pressed():
seg.Background(k.Selection)
fg = k.Text
case seg.Hovered():
seg.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(seg, Motion(c, theme.StateDuration), fg)
internal.Ring(seg, k.Focus, theme.ControlRadius)
if opts.IconsOnly {
seg.Tooltip(label)
}
seg.Children(func() {
ui.Icon(c, icons.Must(m[1])).Size(16, 16)
if !opts.IconsOnly {
ui.Text(c, label).SingleLine()
}
})
}
})
})
changed := ChatMode(pick) != old && !wrapper.IsDisabled()
if changed {
*mode = ChatMode(pick)
slog.Debug("mujica: mode_selector changed", "mode", pick)
}
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
return ChoiceResult{Element: track, changed: changed}
}
+13
View File
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestModeSelectorClickAndArrows(t *testing.T) {
@@ -60,3 +61,15 @@ func TestModeSelectorSnapshot(t *testing.T) {
})
})
}
func TestModeSelectorSelectionSurface(t *testing.T) {
mode := ModeResearch
tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ModeSelector(c, &mode, ModeSelectorOptions{}) }, 600, 100)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
b, _ := tt.Find("Deep research")
pixel := tt.Image().RGBAAt(int(b.X+4), int(b.Y+4))
k := theme.Light()
if pixel.R != k.Selection.R || pixel.G != k.Selection.G || pixel.B != k.Selection.B {
t.Fatalf("selected surface %v, want Selection %v", pixel, k.Selection)
}
}
+115 -42
View File
@@ -19,7 +19,7 @@ type ModelOption struct {
Capabilities []string
// ContextTokens is the context length; zero hides it.
ContextTokens int
// Unavailable explains why the model cannot be chosen; empty means available.
// Unavailable explains disabled models; empty permits selection.
Unavailable string
}
@@ -30,70 +30,121 @@ type ModelSelectorOptions struct {
Label string
}
// ModelSelector chooses a model ID into *value from a list showing capabilities, context length and why some are unavailable.
// ModelSelector chooses models with capabilities and availability details.
func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts ModelSelectorOptions) ChoiceResult {
if value == nil {
panic("mujica: ModelSelector needs value")
}
for _, m := range models {
if m.ID == "" || m.Name == "" || m.ContextTokens < 0 {
panic("mujica: ModelOption needs ID, Name and a non-negative ContextTokens")
panic("mujica: ModelOption needs ID, Name and 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
k, u := Tokens(c), Density(c).Unit()
var tr *ui.Element
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { tr = ui.ButtonBase(c).Role(ui.RolePopUpButton) })
st := ui.Local(tr, "model-selector", func() modelSelectorState { return modelSelectorState{hi: -1, lastHi: -1} })
disabled := tr.IsDisabled()
if disabled {
st.open = false
}
label := opts.Label
if label == "" {
label = msg(c, "model_selector.label")
}
tr.Label(label)
old := *value
choose := func(i int) {
if !disabled && i >= 0 && i < len(models) && models[i].Unavailable == "" {
*value = models[i].ID
st.open = false
tr.Focus()
}
}
if tr.Clicked() && !disabled {
if st.open {
choose(st.hi)
} else {
st.open = true
st.hi = modelSelectorIndex(models, *value)
}
}
if !disabled {
move := 0
if tr.Shortcut(0, ui.KeyDown) {
move = 1
}
if tr.Shortcut(0, ui.KeyUp) {
move = -1
}
if move != 0 {
if !st.open {
st.open = true
st.hi = modelSelectorIndex(models, *value)
} else {
for i := st.hi + move; i >= 0 && i < len(models); i += move {
if models[i].Unavailable == "" {
st.hi = i
break
}
}
}
}
if tr.Shortcut(0, ui.KeyHome) {
st.open = true
st.hi = modelSelectorIndex(models, "")
}
if tr.Shortcut(0, ui.KeyEnd) {
st.open = true
for i := len(models) - 1; i >= 0; i-- {
if models[i].Unavailable == "" {
st.hi = i
break
}
}
}
}
if st.hi >= len(models) || st.hi >= 0 && models[st.hi].Unavailable != "" {
st.hi = modelSelectorIndex(models, *value)
}
cur, has := modelSelectorFind(models, *value)
selectField(c, tr, s.Open(), func() {
selectField(c, tr, st.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:
if st.open {
ww, wh := c.Size()
ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayAnchored(c, panel, tr.Bounds()))
a := tr.Bounds()
room := max(a.Y, wh-a.Y-a.H) - 8
panel.Margin(4, 0, 0, 0).Padding(4).Width(min(float32(360), max(0, ww-8))).Height(min(float32(360), max(0, room))).Label(label)
if st.hi != st.lastHi {
st.list.ScrollIntoView(max(st.hi, 0))
st.lastHi = st.hi
}
ui.List(c, &st.list, len(models), func(i int) {
m := models[i]
hi, chosen := i == st.hi, m.ID == *value
row := ui.Row(c).MinHeight(Density(c).ControlHeight()).Disabled(m.Unavailable != "").Description(m.Unavailable).
Padding(u*2, u*3, u*2, u*2).Gap(u * 2).AlignItems(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer).Selected(chosen)
if chosen {
row.Background(k.Selection).TextColor(k.Text)
} else if hi || row.Hovered() {
row.Background(k.SurfaceHover)
}
if hi {
row.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) })
}
row.Children(func() {
selectCheck(c, chosen)
modelSelectorRow(c, m)
})
}
row.Children(func() { selectCheck(c, chosen); modelSelectorRow(c, m) })
if row.Clicked() && !row.IsDisabled() {
choose(i)
}
}).Fill()
})
})
if pick != *value && !tr.IsDisabled() {
if m, ok := modelSelectorFind(models, pick); ok && m.Unavailable == "" {
*value = pick
}
}
changed := *value != old
if changed {
@@ -102,6 +153,28 @@ func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts Mode
return ChoiceResult{Element: tr, changed: changed}
}
type modelSelectorState struct {
open bool
hi, lastHi int
list ui.ListState
}
func modelSelectorIndex(models []ModelOption, id string) int {
first := -1
for i, m := range models {
if m.Unavailable != "" {
continue
}
if first < 0 {
first = i
}
if m.ID == id {
return i
}
}
return first
}
// modelSelectorRow shows a model's name, tags, context and unavailability.
func modelSelectorRow(c *ui.Context, m ModelOption) {
k := Tokens(c)
+57
View File
@@ -1,6 +1,7 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
@@ -70,3 +71,59 @@ func TestModelSelectorSnapshot(t *testing.T) {
ui.Column(c).Padding(16).Children(func() { ModelSelector(c, v, modelsSample, ModelSelectorOptions{}) })
}, func(tt *ui.Tester) { _ = tt.Click("Model") })
}
func TestModelSelectorAncestorDisabledCloses(t *testing.T) {
v, off, changes := "herald", false, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(off).Children(func() {
if ModelSelector(c, &v, modelsSample, ModelSelectorOptions{}).Changed() {
changes++
}
})
}, 400, 400)
_ = tt.Click("Model")
off = true
tt.Frame()
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if tt.HasText("Page Mini") || v != "herald" || changes != 0 {
t.Fatalf("disabled popup: value %q changes %d texts %q", v, changes, tt.Texts())
}
}
func TestModelSelectorLargeDataAndEnd(t *testing.T) {
models := make([]ModelOption, 100000)
for i := range models {
models[i] = ModelOption{ID: fmt.Sprint(i), Name: fmt.Sprintf("Model %d", i)}
}
v := "0"
tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ModelSelector(c, &v, models, ModelSelectorOptions{}) }, 400, 300)
_ = tt.Click("Model")
if n := len(tt.Texts()); n > 200 {
t.Errorf("built %d labels, want <= 200", n)
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if v != "99999" {
t.Errorf("End choice %q, want 99999", v)
}
}
func TestModelSelectorPopupFitsWindow(t *testing.T) {
value := "0"
models := make([]ModelOption, 100)
for i := range models {
models[i] = ModelOption{ID: fmt.Sprint(i), Name: fmt.Sprintf("Model %d", i)}
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() { ModelSelector(c, &value, models, ModelSelectorOptions{}) })
}, 440, 330)
_ = tt.Click("Model")
tt.Key(0, ui.KeyEnd)
b, ok := tt.Find("Model 99")
if !ok || b.H == 0 || b.Y < 0 || b.Y+b.H > 330 {
t.Fatalf("last available model outside window %v found %v", b, ok)
}
}
+22 -4
View File
@@ -3,6 +3,7 @@ package mujica
import (
"fmt"
"log/slog"
"math"
"strconv"
"github.com/egoist/mygo/ui"
@@ -16,16 +17,31 @@ 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 and Step define the grid; Default selects reset.
Min, Max, Step, Default float64
}
// ParameterPanel adjusts generation parameters with sliders; each shows its range and resets to its default.
// ParameterPanel displays bounded generation sliders and reset actions.
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 {
if p.Label == "" || p.Value == nil || !(p.Min < p.Max) || !(p.Step > 0) || !(p.Default >= p.Min && p.Default <= p.Max) {
panic("mujica: ModelParam needs Label, Value, Min < Max, Step > 0 and Default in range")
}
for _, v := range []float64{p.Min, p.Max, p.Step, p.Default, *p.Value} {
if math.IsNaN(v) || math.IsInf(v, 0) {
panic("mujica: ModelParam values must be finite")
}
}
if *p.Value < p.Min || *p.Value > p.Max {
panic("mujica: ModelParam value must be in range")
}
spec := numberInputResolve(NumberInputOptions{Min: p.Min, Max: p.Max, Step: p.Step, Precision: sliderDecimals(p.Min, p.Step)})
if spec.stepBy(p.Default, 0) != p.Default {
panic("mujica: ModelParam default must lie on the step grid")
}
if spec.stepBy(*p.Value, 0) != *p.Value {
panic("mujica: ModelParam value must lie on the step grid")
}
}
k := Tokens(c)
u := Density(c).Unit()
@@ -41,7 +57,9 @@ func ParameterPanel(c *ui.Context, params []ModelParam) ChoiceResult {
}
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) }
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()
+44
View File
@@ -1,6 +1,8 @@
package mujica
import (
"fmt"
"math"
"testing"
"github.com/egoist/mygo/ui"
@@ -67,3 +69,45 @@ func TestParameterPanelSnapshot(t *testing.T) {
ui.Column(c).Padding(16).Children(func() { ParameterPanel(c, paramSample(&temp, &topP)) })
})
}
func TestParameterPanelRejectsNonFiniteAndOutOfRange(t *testing.T) {
for _, v := range []float64{math.NaN(), math.Inf(1), -1, 3} {
t.Run(fmt.Sprint(v), func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("invalid parameter value accepted")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ParameterPanel(c, []ModelParam{{Label: "Temperature", Value: &v, Min: 0, Max: 2, Step: 0.1, Default: 1}})
}, 400, 300)
})
}
}
func TestParameterPanelRequiresDefaultOnStepGrid(t *testing.T) {
v := 0.9
defer func() {
if recover() == nil {
t.Fatal("off-grid default accepted")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ParameterPanel(c, []ModelParam{{Label: "Temperature", Value: &v, Min: 0, Max: 2, Step: 0.1, Default: 0.95}})
}, 400, 300)
}
func TestParameterPanelRequiresValueOnStepGrid(t *testing.T) {
v := 0.5
defer func() {
if recover() == nil {
t.Fatal("off-grid value accepted")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ParameterPanel(c, []ModelParam{{Label: "Temperature", Value: &v, Min: 0, Max: 1, Step: 0.2, Default: 0.4}})
}, 400, 300)
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"mujicaui/theme"
)
// PastedImage is an image pasted or dropped into a prompt; the caller decodes it.
// PastedImage contains a caller-decoded clipboard or dropped image.
type PastedImage struct {
// ID is the image's stable identity; required.
ID string
@@ -22,7 +22,7 @@ type PastedImage struct {
// 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.
// PasteImagePreview displays removable image thumbnails.
func PasteImagePreview(c *ui.Context, images []PastedImage) RemoveResult {
k := Tokens(c)
u := Density(c).Unit()
+17
View File
@@ -59,3 +59,20 @@ func TestPasteImagePreviewSnapshot(t *testing.T) {
ui.Column(c).Padding(20).Children(func() { PasteImagePreview(c, pasteImages()) })
})
}
func TestPasteImagePreviewKeyboard(t *testing.T) {
removed := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
if id, ok := PasteImagePreview(c, pasteImages()).Removed(); ok {
removed = id
}
})
}, 400, 160)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if removed != "a" {
t.Fatalf("removed %q, want a", removed)
}
}
+14 -6
View File
@@ -14,21 +14,23 @@ type PromptComposerOptions struct {
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.
// Composing blocks submission while the caller's input method composes.
Composing bool
// MaxHeight caps input height; zero means 200 DIPs.
MaxHeight float32
// Attachments builds the row above the field, as AttachmentChips.
Attachments func()
// Tools builds the left of the bar below the field; Actions its right, as a SendButton.
// Tools and Actions build opposite ends of the toolbar.
Tools, Actions func()
Disabled bool
}
// promptHooks are key handlers menus over a composer add each frame.
// promptHooks hold menu handlers for the current 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.
// PromptComposer edits prompts with configurable send shortcuts.
// Submitted never fires for blank text.
func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) InputResult {
if value == nil {
@@ -57,6 +59,12 @@ func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) In
}
ta := TextAreaOptions{Label: label, Placeholder: opts.Placeholder, MaxHeight: maxH,
keys: func(ev ui.InputEvent) bool {
if r.Element.IsDisabled() {
return true
}
if opts.Composing && ev.Kind == ui.InputKeyDown {
return ev.Key == ui.KeyEnter
}
for _, f := range h.list {
if f(ev) {
return true
@@ -69,7 +77,7 @@ func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) In
}
area := TextArea(c, value, ta)
r.Input, r.changed = area.Input, area.Changed()
r.submitted = area.Submitted() && strings.TrimSpace(*value) != ""
r.submitted = area.Submitted() && !opts.Composing && strings.TrimSpace(*value) != ""
if opts.Tools != nil || opts.Actions != nil {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Row(c).Gap(u).AlignItems(ui.Center).Grow(1).Children(func() {
@@ -89,7 +97,7 @@ func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) In
return r
}
// promptComposerHook adds a key handler that runs before the composer's editor this frame.
// promptComposerHook registers a handler before editor input.
func promptComposerHook(composer InputResult, f func(ui.InputEvent) bool) {
if composer.Element == nil {
panic("mujica: menus need a built PromptComposer")
+26
View File
@@ -83,3 +83,29 @@ func TestPromptComposerSnapshot(t *testing.T) {
})
})
}
func TestPromptComposerCompositionDoesNotSend(t *testing.T) {
text, sends := "draft", 0
composing := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt", Composing: composing}).Submitted() {
sends++
}
}, 500, 400)
_ = tt.Click("Prompt")
composing = true
tt.Frame()
tt.Compose("邀请", 2)
tt.Key(0, ui.KeyEnter)
if sends != 0 || text != "draft" {
t.Fatalf("composition submitted: sends %d text %q", sends, text)
}
tt.Type("邀请")
composing = false
tt.Frame()
tt.Key(0, ui.KeyEnter)
if sends != 1 || text != "draft邀请" {
t.Fatalf("committed input: sends %d text %q", sends, text)
}
}
+14 -5
View File
@@ -2,6 +2,7 @@ package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
@@ -9,7 +10,7 @@ import (
"mujicaui/theme"
)
// SearchStage is one step of a web search, as "Planning" or "Reading".
// SearchStage describes one caller-owned search step.
type SearchStage struct {
Label string
Done bool
@@ -19,19 +20,19 @@ type SearchStage struct {
type SearchProgressOptions struct {
// Query is the keyword searched; required.
Query string
// Read and Total count the sources read so far and found.
// Read and Total count read and discovered sources.
Read, Total int
Stages []SearchStage
// Done folds the block to a summary, which the user can open again.
// Done folds progress into a summary users can reopen.
Done bool
}
// searchProgressState is the fold, open while searching until the search ends.
// searchProgressState tracks completion and manual folding.
type searchProgressState struct {
open, done bool
}
// SearchProgress shows a search's keyword, sources read and stages; once Done it folds to one line.
// SearchProgress displays keywords, read counts and collapsible stages.
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")
@@ -41,6 +42,11 @@ func SearchProgress(c *ui.Context, opts SearchProgressOptions) ChoiceResult {
if opts.Done && !st.done {
// Finishing folds it once; the user may open it again.
st.open = false
slog.Debug("mujica: search_progress complete", "read", opts.Read)
}
if st.done && !opts.Done {
st.open = true
slog.Debug("mujica: search_progress restart")
}
st.done = opts.Done
title := fmt.Sprintf(msg(c, "search_progress.searching"), opts.Query)
@@ -48,6 +54,9 @@ func SearchProgress(c *ui.Context, opts SearchProgressOptions) ChoiceResult {
title = fmt.Sprintf(msg(c, "search_progress.done"), opts.Query, opts.Read)
}
r.Element.Children(func() { r.changed = searchProgressBody(c, st, title, opts) })
if r.changed {
slog.Debug("mujica: search_progress fold", "open", st.open)
}
return r
}
+17 -1
View File
@@ -50,8 +50,24 @@ func TestSearchProgressBadCounts(t *testing.T) {
}, 100, 100)
}
func TestSearchProgressReopensForNextSearch(t *testing.T) {
opts := SearchProgressOptions{Query: "first", Read: 1, Total: 2, Stages: []SearchStage{{Label: "First stage"}}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
SearchProgress(c, opts)
}, 400, 300)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
opts.Done = true
tt.Frame()
opts = SearchProgressOptions{Query: "second", Total: 3, Stages: []SearchStage{{Label: "Next stage"}}}
tt.Frame()
if !tt.HasText("Next stage") || !tt.HasText("Read 0 of 3 sources") {
t.Fatalf("next search remains folded: %q", tt.Texts())
}
}
func TestSearchProgressSnapshot(t *testing.T) {
snapshot(t, "search_progress", 440, 230, func(c *ui.Context) {
snapshot(t, "search_progress", 440, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Children(func() {
SearchProgress(c, SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)})
SearchProgress(c, SearchProgressOptions{Query: "Hall of Mirrors", Read: 5, Total: 5, Stages: searchStages(4), Done: true})
+5 -5
View File
@@ -12,21 +12,21 @@ import (
// SendButtonOptions configure a SendButton.
type SendButtonOptions struct {
// Streaming turns the button into Stop while a reply is generated.
// Streaming selects Stop while the caller generates a reply.
Streaming bool
// Disabled blocks sending, as for an empty prompt; Stop stays available.
// Disabled blocks sending; Stop remains available.
Disabled bool
// Shortcut is the send key shown in the tooltip, as "Enter"; empty shows none.
// Shortcut supplies tooltip text; empty hides the hint.
Shortcut string
}
// SendResult is the button and which action the user took this frame.
// SendResult carries the button and requested action.
type SendResult struct {
Element *ui.Element
send, stop bool
}
// Sent reports a press while idle; Stopped a press while streaming.
// Sent requests sending; Stopped requests generation cancellation.
func (r SendResult) Sent() bool { return r.send }
func (r SendResult) Stopped() bool { return r.stop }
+79 -53
View File
@@ -2,6 +2,7 @@ package mujica
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
@@ -17,7 +18,7 @@ type SlashCommand struct {
Description string
}
// SlashCommandResult is the menu's popup panel, nil while closed, and the command chosen this frame.
// SlashCommandResult carries the popup and chosen command.
type SlashCommandResult struct {
Element *ui.Element
chosen SlashCommand
@@ -27,34 +28,43 @@ type SlashCommandResult struct {
// 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.
// SlashCommandMenu filters commands after a slash trigger.
// Arrows navigate; Enter chooses; Tab completes; Escape dismisses.
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")
}
if composer.Element == nil || composer.Input == nil || value == nil {
panic("mujica: SlashCommandMenu needs composer and value")
}
var r SlashCommandResult
st := ui.Local(composer.Element, slashMenuKey{"slash"}, func() slashMenuState { return slashMenuState{} })
data := ui.Local(composer.Element, "slash-data", func() slashMenuData { return slashMenuData{} })
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})
if !data.built || query != data.query || typing != data.typing || !slices.Equal(data.commands, commands) {
for _, cmd := range commands {
if cmd.Name == "" || strings.ContainsAny(cmd.Name, " \t\n") {
panic("mujica: SlashCommand Name must be one word")
}
}
data.built, data.query, data.typing = true, query, typing
data.commands = append(data.commands[:0], commands...)
data.shown, data.rows = data.shown[:0], data.rows[:0]
if typing {
q := strings.ToLower(query)
for pass := range 2 {
for _, cmd := range commands {
n := strings.ToLower(cmd.Name)
if pass == 0 && strings.HasPrefix(n, q) || pass == 1 && !strings.HasPrefix(n, q) && strings.Contains(n, q) {
data.shown = append(data.shown, cmd)
data.rows = append(data.rows, slashMenuRow{label: "/" + cmd.Name, detail: cmd.Description})
}
}
}
}
}
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
var r SlashCommandResult
st := ui.Local(composer.Element, slashMenuKey{"slash"}, func() slashMenuState { return slashMenuState{} })
shown, rows := data.shown, data.rows
i, how, panel := slashMenuShow(c, composer, st, query, typing, rows, slashMenuOptions{empty: msg(c, "slash_command_menu.empty"), label: msg(c, "slash_command_menu.label")})
r.Element = panel
if i >= 0 {
*value = "/" + shown[i].Name + " "
if how == slashMenuChose {
@@ -65,9 +75,16 @@ func SlashCommandMenu(c *ui.Context, composer InputResult, value *string, comman
return r
}
type slashMenuData struct {
commands, shown []SlashCommand
query string
built, typing bool
rows []slashMenuRow
}
type slashMenuKey struct{ name string }
// slashMenuRow is one line of a composer menu; a group starts a titled section.
// slashMenuRow represents an item or titled group.
type slashMenuRow struct {
label, detail, icon, group string
}
@@ -77,14 +94,14 @@ type slashMenuOptions struct {
loading bool
}
// slashMenuState is a composer menu's highlight, Escape and pending key choice.
// slashMenuState tracks navigation, dismissal and pending choices.
type slashMenuState struct {
hi, lastHi int
query string
dismissed *string
pick slashMenuHow
panel *ui.Element
held, open bool
list ui.ListState
}
type slashMenuHow int
@@ -95,30 +112,36 @@ const (
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
// slashMenuShow returns the selected row and activation method.
func slashMenuShow(c *ui.Context, composer InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow, *ui.Element) {
if !open {
st.dismissed = nil
}
if query != st.query {
st.hi, st.query = 0, query
st.list.ScrollTo(0, ui.Start)
if st.dismissed != nil && *st.dismissed != query {
st.dismissed = nil
}
}
// A press on the panel takes the focus from the composer; the press keeps it open.
// Panel presses preserve the menu while transferring focus.
open = open && st.dismissed == nil && !composer.Element.IsDisabled() && (composer.Input.Focused() || st.held)
pick, how := -1, slashMenuNone
st.open = open
if !open {
st.pick = slashMenuNone
return pick, how
return pick, how, nil
}
st.hi = min(st.hi, len(rows)-1)
st.hi = min(max(st.hi, 0), len(rows)-1)
if st.pick != slashMenuNone && st.hi >= 0 {
pick, how = st.hi, st.pick
}
st.pick = slashMenuNone
n := len(rows)
promptComposerHook(composer, func(ev ui.InputEvent) bool {
if composer.Element.IsDisabled() {
return true
}
if ev.Kind != ui.InputKeyDown || ev.Mods != 0 {
return false
}
@@ -141,17 +164,18 @@ func slashMenuShow(c *ui.Context, composer InputResult, st *slashMenuState, quer
c.Invalidate()
return true
})
if i := slashMenuPanel(c, composer.Element.Bounds(), st, rows, o); i >= 0 {
i, panel := slashMenuPanel(c, composer.Element.Bounds(), st, rows, o)
if i >= 0 {
pick, how = i, slashMenuChose
}
if pick >= 0 {
composer.Input.Focus()
}
return pick, how
return pick, how, panel
}
// 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 {
// slashMenuPanel builds visible rows and returns clicked choices.
func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) (int, *ui.Element) {
k := Tokens(c)
u := Density(c).Unit()
rowH := Density(c).ControlHeight()
@@ -167,14 +191,16 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe
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))
ww, wh := c.Size()
h = min(h+2*theme.BorderWidth, 280, max(0, wh-8))
w := min(max(a.W*0.6, 320), max(a.W, 200), max(0, ww-8))
x, y := overlayPlace(c, a, w, h, u)
clicked, pressed := -1, false
var result *ui.Element
ui.Overlay(c, func() {
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Height(h).Role(ui.RoleList).Label(o.label)
overlayStyle(c, panel, overlayAnchored(c, panel, a))
st.panel = panel
result = panel
panel.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind == ui.InputPointerDown {
st.held = true
@@ -182,24 +208,28 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe
return false
})
panel.Children(func() {
ui.Scroll(c).Fill().Padding(u).Children(func() {
if st.lastHi != st.hi {
st.lastHi = st.hi
st.list.ScrollIntoView(max(st.hi, 0))
}
ui.List(c, &st.list, max(1, len(rows)), func(i int) {
if len(rows) == 0 {
ui.Row(c).MinHeight(rowH).Padding(0, u*3).Gap(u*2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
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()
})
return
}
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)).
r := rows[i]
ui.Column(c).Children(func() {
if r.group != "" && (i == 0 || r.group != rows[i-1].group) {
ui.Text(c, r.group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(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)
row := ui.Row(c).Key(i).MinHeight(rowH).Padding(0, u*3, 0, u*2).Gap(u * 2).AlignItems(ui.Center).
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer).Selected(i == st.hi).TextColor(k.Text)
if row.Clicked() {
clicked = i
}
@@ -209,10 +239,6 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe
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)
}
@@ -223,10 +249,10 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe
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))
})
}
})
})
}).Fill().Padding(u)
})
})
st.held = st.held && pressed
return clicked
return clicked, result
}
+63 -1
View File
@@ -1,6 +1,7 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
@@ -12,7 +13,7 @@ var slashCommandsSample = []SlashCommand{
{Name: "translate", Description: "Translate to French"},
}
// slashTester is a composer with a slash menu; it records chosen commands and sends.
// slashTester records command choices and prompt submissions.
type slashTester struct {
text string
chosen []string
@@ -120,3 +121,64 @@ func TestSlashCommandMenuSnapshot(t *testing.T) {
tt.Key(0, ui.KeyDown)
})
}
func TestSlashCommandMenuNarrowAndLargeData(t *testing.T) {
cmds := make([]SlashCommand, 100000)
for i := range cmds {
cmds[i] = SlashCommand{Name: fmt.Sprintf("cmd%d", i)}
}
text := "/"
var panel *ui.Element
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt"})
panel = SlashCommandMenu(c, p, &text, cmds).Element
}, 180, 120)
_ = tt.Click("Prompt")
tt.Frame()
if panel == nil {
t.Fatal("menu missing")
}
b := panel.Bounds()
if b.X < 0 || b.Y < 0 || b.X+b.W > 180 || b.Y+b.H > 120 {
t.Errorf("popup outside window: %v", b)
}
if n := len(tt.Texts()); n > 150 {
t.Errorf("built %d labels for 100000 commands, want <= 150", n)
}
}
func TestSlashCommandMenuReopensAfterTriggerRemoved(t *testing.T) {
s := &slashTester{}
tt := ui.NewTester(s.view, 500, 400)
_ = tt.Click("Prompt")
tt.Type("/")
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyBackspace)
tt.Type("/")
if !tt.HasText("/summarize") {
t.Fatal("retyped slash stayed dismissed")
}
}
func TestSlashCommandMenuQueryRevealsHighlight(t *testing.T) {
commands := make([]SlashCommand, 100)
for i := range commands {
commands[i] = SlashCommand{Name: fmt.Sprintf("cmd%03d", i)}
}
text := "/"
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Fill().Justify(ui.End).Children(func() {
p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt"})
SlashCommandMenu(c, p, &text, commands)
})
}, 500, 400)
_ = tt.Click("Prompt")
tt.Scroll(100, 150, 0, 1500)
tt.Type("c")
b, ok := tt.Find("/cmd000")
if !ok || b.H == 0 || b.Y < 0 || b.Y+b.H > 400 {
t.Fatalf("highlight outside view: %v found %v", b, ok)
}
}
+4 -2
View File
@@ -10,8 +10,8 @@ import (
"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.
// SourceCard displays source metadata; Clicked requests opening.
// Selection and an edge mark identify active or hovered sources.
func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element {
if n < 1 || st == nil || src.Title == "" || src.URL == "" {
panic("mujica: SourceCard needs n >= 1, a state and a Source with Title and URL")
@@ -22,6 +22,8 @@ func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element
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)
frame := c.Now()
b.Draw(func(_ *ui.Painter, _ ui.Rect) { st.expire(c, frame) })
switch {
case st.lit(n):
b.Background(k.Selection).Border(theme.BorderWidth, k.Border).BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 3).
+16 -15
View File
@@ -2,13 +2,14 @@ package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// OpenResult is a list's element and the 1-based item the user opened this frame.
// OpenResult carries the list and opened 1-based source number.
type OpenResult struct {
Element *ui.Element
opened int
@@ -17,7 +18,7 @@ type OpenResult struct {
// 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.
// SourcesPanel lists citations and scrolls to activated sources.
// It grows to fill its container, which needs a height.
func SourcesPanel(c *ui.Context, sources []Source, st *CitationState) OpenResult {
if st == nil || st.Active < 0 || st.Active > len(sources) {
@@ -28,6 +29,12 @@ func SourcesPanel(c *ui.Context, sources []Source, st *CitationState) OpenResult
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 })
located := ui.Local(r.Element, "locate", func() uint64 { return 0 })
list := ui.Local(r.Element, "list", func() ui.ListState { return ui.ListState{} })
if st.Active > 0 && (*shown != st.Active || *located != st.locate) {
list.ScrollIntoView(st.Active - 1)
slog.Debug("mujica: sources_panel locate", "source", st.Active)
}
r.Element.Children(func() {
ui.Text(c, fmt.Sprintf("%s (%d)", msg(c, "sources_panel.label"), len(sources))).Font(theme.SerifFont).
FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
@@ -35,20 +42,14 @@ func SourcesPanel(c *ui.Context, sources []Source, st *CitationState) OpenResult
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()
}
}
})
})
ui.List(c, list, len(sources), func(i int) {
card := SourceCard(c, i+1, sources[i], st).MinHeight(Density(c).ControlHeight()).Role(ui.RoleListItem)
if card.Clicked() && !card.IsDisabled() {
r.opened = i + 1
}
}).Grow(1).MinHeight(0).Gap(u * 2)
})
*shown = st.Active
*located = st.locate
return r
}
+49
View File
@@ -64,3 +64,52 @@ func TestSourcesPanelSnapshot(t *testing.T) {
ui.Column(c).Padding(16).Height(420).Children(func() { SourcesPanel(c, sourcesSample, st) })
})
}
func TestSourcesPanelLargeData(t *testing.T) {
sources := make([]Source, 100000)
for i := range sources {
sources[i] = Source{Title: fmt.Sprintf("Archive %d", i), URL: "https://example.com"}
}
st := CitationState{}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(360).Height(300).Children(func() { SourcesPanel(c, sources, &st) })
}, 400, 300)
if n := len(tt.Texts()); n > 250 {
t.Fatalf("built %d labels for 100000 sources, want <= 250", n)
}
st.Active = 99999
tt.Frame()
r, ok := tt.Find("Archive 99998")
if !ok || r.Y < 0 || r.Y+r.H > 300 {
t.Fatalf("active source bounds %v found %v", r, ok)
}
}
func TestSourcesPanelRepeatsCitationLocate(t *testing.T) {
sources := make([]Source, 100)
for i := range sources {
sources[i] = Source{Title: fmt.Sprintf("Archive %d", i+1), URL: "https://example.com"}
}
var st CitationState
actions := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Fill().Children(func() {
if CitationBadge(c, 90, sources[89], &st).Changed() {
actions++
}
ui.Column(c).Height(250).Children(func() { SourcesPanel(c, sources, &st) })
})
}, 400, 300)
for i := range 2 {
if err := tt.Click("Source 90: Archive 90"); err != nil {
t.Fatal(err)
}
b, ok := tt.Find("Archive 90")
if !ok || b.H == 0 || b.Y < 0 || b.Y+b.H > 300 || actions != i+1 {
t.Fatalf("locate %d: bounds %v found %v events %d", i+1, b, ok, actions)
}
tt.Scroll(100, 150, 0, -100000)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+2 -2
View File
@@ -10,10 +10,10 @@ import (
"mujicaui/theme"
)
// tokenWarnShare is the share of the limit from which the count warns.
// tokenWarnShare sets the warning threshold.
const tokenWarnShare = 0.9
// TokenCounter shows the caller's token estimate against the limit: muted, warning past 90%, danger over it.
// TokenCounter displays estimates, limits and threshold warnings.
func TokenCounter(c *ui.Context, used, limit int) *ui.Element {
if used < 0 || limit <= 0 {
panic("mujica: TokenCounter needs used >= 0 and limit > 0")
+7 -6
View File
@@ -18,23 +18,23 @@ type ToolItem struct {
Label string
Description string
On *bool
// NeedsAuth marks a tool the user must authorize before turning it on.
// NeedsAuth marks tools requiring caller-owned authorization.
NeedsAuth, Authorized bool
}
// ToolToggleResult is the menu's button and what the user did this frame.
// ToolToggleResult carries the trigger and requested action.
type ToolToggleResult struct {
Element *ui.Element
changed, authorize string
}
// Changed returns the ID of the tool turned on or off.
// Changed returns the toggled tool ID.
func (r ToolToggleResult) Changed() (string, bool) { return r.changed, r.changed != "" }
// Authorize returns the ID of a tool whose authorization the user asked for.
// Authorize returns the tool ID requesting authorization.
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.
// ToolToggleMenu displays tool switches and authorization actions.
func ToolToggleMenu(c *ui.Context, tools []ToolItem) ToolToggleResult {
on := 0
for _, t := range tools {
@@ -68,6 +68,7 @@ func ToolToggleMenu(c *ui.Context, tools []ToolItem) ToolToggleResult {
disabled := b.IsDisabled()
if b.Clicked() && !disabled {
*open = !*open
slog.Debug("mujica: tool_toggle_menu fold", "open", *open)
}
if disabled {
*open = false
@@ -83,7 +84,7 @@ func ToolToggleMenu(c *ui.Context, tools []ToolItem) ToolToggleResult {
return r
}
// toolToggleRow shows one tool: its switch, or its authorization mark and action.
// toolToggleRow displays a switch or authorization action.
func toolToggleRow(c *ui.Context, t ToolItem, r *ToolToggleResult) {
k := Tokens(c)
u := Density(c).Unit()
+6 -4
View File
@@ -22,8 +22,8 @@ type VoiceInputOptions struct {
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.
// VoiceInputButton requests recording changes and displays elapsed time.
// Clicked requests recording start or stop for the current state.
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")
@@ -40,7 +40,6 @@ func VoiceInputButton(c *ui.Context, opts VoiceInputOptions) ChoiceResult {
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)
@@ -64,7 +63,10 @@ func VoiceInputButton(c *ui.Context, opts VoiceInputOptions) ChoiceResult {
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()
ui.Text(c, elapsed).FontFeatures("tnum").TextColor(k.Danger).SingleLine().DrawOver(func(_ *ui.Painter, _ ui.Rect) {
d := max(c.Now().Sub(opts.Started), 0)
c.After(time.Second - d%time.Second)
})
})
case opts.Denied:
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
+4 -4
View File
@@ -9,15 +9,15 @@ import (
// VoiceWaveformOptions configure a VoiceWaveform.
type VoiceWaveformOptions struct {
// Silence is the level below which input counts as silent; zero means 0.05.
// Silence sets the quiet threshold; zero means 0.05.
Silence float32
// SilentFor is how many trailing samples under Silence show the hint; zero means 20.
// SilentFor sets consecutive quiet samples; zero means 20.
SilentFor int
// Width is the bar area width in DIPs; zero means 200.
// Width sets waveform width; zero means 200 DIPs.
Width float32
}
// VoiceWaveform draws recent input levels, 0 to 1, newest at the right, and hints at silence.
// VoiceWaveform draws recent input levels and a silence hint.
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")