diff --git a/attachment_chip.go b/attachment_chip.go index 2aad33b..87cec24 100644 --- a/attachment_chip.go +++ b/attachment_chip.go @@ -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") diff --git a/attachment_chip_test.go b/attachment_chip_test.go index 3b9e1bd..9835d81 100644 --- a/attachment_chip_test.go +++ b/attachment_chip_test.go @@ -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") + } +} diff --git a/citation_badge.go b/citation_badge.go index e24e90b..cab90fb 100644 --- a/citation_badge.go +++ b/citation_badge.go @@ -20,20 +20,22 @@ type Source struct { 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) }) }) }) } diff --git a/citation_badge_test.go b/citation_badge_test.go index 52afd4a..90d83be 100644 --- a/citation_badge_test.go +++ b/citation_badge_test.go @@ -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) + } +} diff --git a/context_chips.go b/context_chips.go index fa0a11f..fc9d183 100644 --- a/context_chips.go +++ b/context_chips.go @@ -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) diff --git a/context_chips_test.go b/context_chips_test.go index de476f6..4542f5d 100644 --- a/context_chips_test.go +++ b/context_chips_test.go @@ -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") + } +} diff --git a/context_mention_menu.go b/context_mention_menu.go index 2f4b689..74518a7 100644 --- a/context_mention_menu.go +++ b/context_mention_menu.go @@ -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 +} diff --git a/context_mention_menu_test.go b/context_mention_menu_test.go index 081eb4e..703c658 100644 --- a/context_mention_menu_test.go +++ b/context_mention_menu_test.go @@ -14,8 +14,8 @@ var mentionSample = []ContextItem{ {ID: "f2", Label: "seating.xlsx", Detail: "~/ball", Kind: ContextFile}, } -// mentionMenuTester filters items as an async source would, one frame late. -type mentionMenuTester struct { +// contextMentionTester supplies filtered results one frame later. +type contextMentionTester struct { text string items []ContextItem loading bool @@ -23,7 +23,7 @@ type mentionMenuTester struct { queries []string } -func (m *mentionMenuTester) 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 *mentionMenuTester) view(c *ui.Context) { } func TestContextMentionMenuGroupsAndChooses(t *testing.T) { - m := &mentionMenuTester{} + 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 := &mentionMenuTester{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 := &mentionMenuTester{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() *mentionMenuTester { return &mentionMenuTester{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) + } +} diff --git a/context_window_meter.go b/context_window_meter.go index 068fc4e..3e56a28 100644 --- a/context_window_meter.go +++ b/context_window_meter.go @@ -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") diff --git a/docs/components/221-prompt-composer.md b/docs/components/221-prompt-composer.md new file mode 100644 index 0000000..935fb98 --- /dev/null +++ b/docs/components/221-prompt-composer.md @@ -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。 diff --git a/docs/components/222-attachment-chip.md b/docs/components/222-attachment-chip.md new file mode 100644 index 0000000..0d08e1f --- /dev/null +++ b/docs/components/222-attachment-chip.md @@ -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 激活。 + +## 限制 + +上传由调用方执行,组件只展示进度与结果。 diff --git a/docs/components/223-paste-image-preview.md b/docs/components/223-paste-image-preview.md new file mode 100644 index 0000000..7a8568a --- /dev/null +++ b/docs/components/223-paste-image-preview.md @@ -0,0 +1,39 @@ +# 223 PasteImagePreview 图片预览 + +## 用途 + +粘贴或拖入图片的预览缩略图;移除。 + +## 最小示例 + +```go +pic := ui.MustParseSVG([]byte(``)) +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 返回文件路径,原生图片剪贴板接口仍需上游扩展。 diff --git a/docs/components/224-drag-drop-overlay.md b/docs/components/224-drag-drop-overlay.md new file mode 100644 index 0000000..4010a02 --- /dev/null +++ b/docs/components/224-drag-drop-overlay.md @@ -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 通过平台事件驱动,公开接口未提供外部文件拖放注入;类型检查与遮罩分别测试。 diff --git a/docs/components/225-voice-input-button.md b/docs/components/225-voice-input-button.md new file mode 100644 index 0000000..22f3541 --- /dev/null +++ b/docs/components/225-voice-input-button.md @@ -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 开始或停止。 + +## 限制 + +录音与权限请求由调用方实现。 diff --git a/docs/components/226-voice-waveform.md b/docs/components/226-voice-waveform.md new file mode 100644 index 0000000..2d6c7e0 --- /dev/null +++ b/docs/components/226-voice-waveform.md @@ -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 顺序。 + +## 限制 + +电平采集由调用方提供;只绘制适合宽度的最新样本。 diff --git a/docs/components/227-slash-command-menu.md b/docs/components/227-slash-command-menu.md new file mode 100644 index 0000000..12d060c --- /dev/null +++ b/docs/components/227-slash-command-menu.md @@ -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 关闭。 + +## 限制 + +菜单按可见行虚拟化;筛选在查询或命令变化时更新。 diff --git a/docs/components/228-context-mention-menu.md b/docs/components/228-context-mention-menu.md new file mode 100644 index 0000000..223edb0 --- /dev/null +++ b/docs/components/228-context-mention-menu.md @@ -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(),调用方负责淘汰过期请求。 diff --git a/docs/components/229-context-chips.md b/docs/components/229-context-chips.md new file mode 100644 index 0000000..b2e4e74 --- /dev/null +++ b/docs/components/229-context-chips.md @@ -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 激活。 + +## 限制 + +上下文文件内容由调用方管理。 diff --git a/docs/components/230-model-selector.md b/docs/components/230-model-selector.md new file mode 100644 index 0000000..0d3206b --- /dev/null +++ b/docs/components/230-model-selector.md @@ -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 关闭。 + +## 限制 + +模型服务与可用性由调用方提供。 diff --git a/docs/components/231-mode-selector.md b/docs/components/231-mode-selector.md new file mode 100644 index 0000000..2fe54da --- /dev/null +++ b/docs/components/231-mode-selector.md @@ -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 切换模式。 + +## 限制 + +模式执行逻辑由调用方实现。 diff --git a/docs/components/232-tool-toggle-menu.md b/docs/components/232-tool-toggle-menu.md new file mode 100644 index 0000000..a28700f --- /dev/null +++ b/docs/components/232-tool-toggle-menu.md @@ -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 关闭。 + +## 限制 + +授权与工具执行由调用方完成。 diff --git a/docs/components/233-parameter-panel.md b/docs/components/233-parameter-panel.md new file mode 100644 index 0000000..c235f26 --- /dev/null +++ b/docs/components/233-parameter-panel.md @@ -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 的步长网格。 diff --git a/docs/components/234-token-counter.md b/docs/components/234-token-counter.md new file mode 100644 index 0000000..aa0db0f --- /dev/null +++ b/docs/components/234-token-counter.md @@ -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 分词与模型计算由调用方完成。 diff --git a/docs/components/235-context-window-meter.md b/docs/components/235-context-window-meter.md new file mode 100644 index 0000000..b8d31bb --- /dev/null +++ b/docs/components/235-context-window-meter.md @@ -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 顺序。 + +## 限制 + +上下文数据由调用方统计,分段同时提供文字与图形标记。 diff --git a/docs/components/236-send-button.md b/docs/components/236-send-button.md new file mode 100644 index 0000000..d80d5af --- /dev/null +++ b/docs/components/236-send-button.md @@ -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 激活;快捷键提示由调用方绑定。 + +## 限制 + +生成和停止逻辑由调用方实现。 diff --git a/docs/components/237-citation-badge.md b/docs/components/237-citation-badge.md new file mode 100644 index 0000000..cb4e3f4 --- /dev/null +++ b/docs/components/237-citation-badge.md @@ -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 关闭预览。 + +## 限制 + +编号按调用方引用顺序映射;预览内容来自调用方。 diff --git a/docs/components/238-source-card.md b/docs/components/238-source-card.md new file mode 100644 index 0000000..374d589 --- /dev/null +++ b/docs/components/238-source-card.md @@ -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 由调用方处理;组件返回事件。 diff --git a/docs/components/239-sources-panel.md b/docs/components/239-sources-panel.md new file mode 100644 index 0000000..ca911d8 --- /dev/null +++ b/docs/components/239-sources-panel.md @@ -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 变化定位来源。 + +## 限制 + +使用虚拟列表,只构建可见来源;调用方保证引用编号和来源顺序一致。 diff --git a/docs/components/240-search-progress.md b/docs/components/240-search-progress.md new file mode 100644 index 0000000..f171062 --- /dev/null +++ b/docs/components/240-search-progress.md @@ -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 展开或收起。 + +## 限制 + +网络搜索由调用方执行,阶段与来源数量由调用方更新。 diff --git a/drag_drop_overlay.go b/drag_drop_overlay.go index c4cbf45..1c00391 100644 --- a/drag_drop_overlay.go +++ b/drag_drop_overlay.go @@ -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") diff --git a/drag_drop_overlay_test.go b/drag_drop_overlay_test.go index 9ed981a..19fb1e7 100644 --- a/drag_drop_overlay_test.go +++ b/drag_drop_overlay_test.go @@ -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() { diff --git a/example_chatinput_test.go b/example_chatinput_test.go new file mode 100644 index 0000000..36e691c --- /dev/null +++ b/example_chatinput_test.go @@ -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(``)) + 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) +} diff --git a/examples/gallery/pages_chatinput.go b/examples/gallery/pages_chatinput.go index b4bd050..594ac10 100644 --- a/examples/gallery/pages_chatinput.go +++ b/examples/gallery/pages_chatinput.go @@ -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(``))\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)"}, +} diff --git a/examples/gallery/pages_chatinput_compose.go b/examples/gallery/pages_chatinput_compose.go new file mode 100644 index 0000000..a41b0b8 --- /dev/null +++ b/examples/gallery/pages_chatinput_compose.go @@ -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(``, 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{}) + }) +} diff --git a/examples/gallery/pages_chatinput_context.go b/examples/gallery/pages_chatinput_context.go new file mode 100644 index 0000000..1724647 --- /dev/null +++ b/examples/gallery/pages_chatinput_context.go @@ -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)) + } + }) + }) +} diff --git a/examples/gallery/pages_chatinput_sources.go b/examples/gallery/pages_chatinput_sources.go new file mode 100644 index 0000000..1595e78 --- /dev/null +++ b/examples/gallery/pages_chatinput_sources.go @@ -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") + } + }) + }) +} diff --git a/examples/gallery/pages_chatinput_test.go b/examples/gallery/pages_chatinput_test.go new file mode 100644 index 0000000..ae99bb7 --- /dev/null +++ b/examples/gallery/pages_chatinput_test.go @@ -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) + } + } +} diff --git a/messages_chatinput.go b/messages_chatinput.go index 3a9744f..5edeb29 100644 --- a/messages_chatinput.go +++ b/messages_chatinput.go @@ -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", "没有来源"}, diff --git a/mode_selector.go b/mode_selector.go index 953667a..a3ad842 100644 --- a/mode_selector.go +++ b/mode_selector.go @@ -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} } diff --git a/mode_selector_test.go b/mode_selector_test.go index 9464ec0..35c3542 100644 --- a/mode_selector_test.go +++ b/mode_selector_test.go @@ -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) + } +} diff --git a/model_selector.go b/model_selector.go index 2f53b9e..284af56 100644 --- a/model_selector.go +++ b/model_selector.go @@ -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) diff --git a/model_selector_test.go b/model_selector_test.go index 99f876a..e898356 100644 --- a/model_selector_test.go +++ b/model_selector_test.go @@ -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) + } +} diff --git a/parameter_panel.go b/parameter_panel.go index 6c01a3e..187c297 100644 --- a/parameter_panel.go +++ b/parameter_panel.go @@ -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() diff --git a/parameter_panel_test.go b/parameter_panel_test.go index 928a4d4..71ba177 100644 --- a/parameter_panel_test.go +++ b/parameter_panel_test.go @@ -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) +} diff --git a/paste_image_preview.go b/paste_image_preview.go index 0ef07e2..5b10791 100644 --- a/paste_image_preview.go +++ b/paste_image_preview.go @@ -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() diff --git a/paste_image_preview_test.go b/paste_image_preview_test.go index d58d9fd..98a8087 100644 --- a/paste_image_preview_test.go +++ b/paste_image_preview_test.go @@ -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) + } +} diff --git a/prompt_composer.go b/prompt_composer.go index 7221b67..0c71675 100644 --- a/prompt_composer.go +++ b/prompt_composer.go @@ -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") diff --git a/prompt_composer_test.go b/prompt_composer_test.go index abf9d04..8135c75 100644 --- a/prompt_composer_test.go +++ b/prompt_composer_test.go @@ -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) + } +} diff --git a/search_progress.go b/search_progress.go index e4569a6..1947378 100644 --- a/search_progress.go +++ b/search_progress.go @@ -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 } diff --git a/search_progress_test.go b/search_progress_test.go index d303d42..7afd323 100644 --- a/search_progress_test.go +++ b/search_progress_test.go @@ -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}) diff --git a/send_button.go b/send_button.go index 379b99c..ca7aae0 100644 --- a/send_button.go +++ b/send_button.go @@ -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 } diff --git a/slash_command_menu.go b/slash_command_menu.go index 5d6e1f1..a77e26e 100644 --- a/slash_command_menu.go +++ b/slash_command_menu.go @@ -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,7 +208,11 @@ 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() { if o.loading { @@ -190,16 +220,16 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe } 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 } diff --git a/slash_command_menu_test.go b/slash_command_menu_test.go index e4bcf89..f5bf8cd 100644 --- a/slash_command_menu_test.go +++ b/slash_command_menu_test.go @@ -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) + } +} diff --git a/source_card.go b/source_card.go index d8b2e4f..df857a4 100644 --- a/source_card.go +++ b/source_card.go @@ -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). diff --git a/sources_panel.go b/sources_panel.go index 8a7d0b4..b51aa86 100644 --- a/sources_panel.go +++ b/sources_panel.go @@ -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 } diff --git a/sources_panel_test.go b/sources_panel_test.go index d2ddd12..c97f582 100644 --- a/sources_panel_test.go +++ b/sources_panel_test.go @@ -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) + } +} diff --git a/testdata/snapshots/darwin/citation_badge-dark.png b/testdata/snapshots/darwin/citation_badge-dark.png index 6f4ef22..7c48ad5 100644 Binary files a/testdata/snapshots/darwin/citation_badge-dark.png and b/testdata/snapshots/darwin/citation_badge-dark.png differ diff --git a/testdata/snapshots/darwin/citation_badge-light.png b/testdata/snapshots/darwin/citation_badge-light.png index ad7f083..1deccd0 100644 Binary files a/testdata/snapshots/darwin/citation_badge-light.png and b/testdata/snapshots/darwin/citation_badge-light.png differ diff --git a/testdata/snapshots/darwin/context_chips-dark.png b/testdata/snapshots/darwin/context_chips-dark.png index e8df2ee..067c3fd 100644 Binary files a/testdata/snapshots/darwin/context_chips-dark.png and b/testdata/snapshots/darwin/context_chips-dark.png differ diff --git a/testdata/snapshots/darwin/context_chips-light.png b/testdata/snapshots/darwin/context_chips-light.png index b2e86f3..e6b030b 100644 Binary files a/testdata/snapshots/darwin/context_chips-light.png and b/testdata/snapshots/darwin/context_chips-light.png differ diff --git a/testdata/snapshots/darwin/mode_selector-dark.png b/testdata/snapshots/darwin/mode_selector-dark.png index 6f20d1c..8c05fa6 100644 Binary files a/testdata/snapshots/darwin/mode_selector-dark.png and b/testdata/snapshots/darwin/mode_selector-dark.png differ diff --git a/testdata/snapshots/darwin/mode_selector-light.png b/testdata/snapshots/darwin/mode_selector-light.png index 5af3fb0..7bc5f71 100644 Binary files a/testdata/snapshots/darwin/mode_selector-light.png and b/testdata/snapshots/darwin/mode_selector-light.png differ diff --git a/testdata/snapshots/darwin/model_selector-dark.png b/testdata/snapshots/darwin/model_selector-dark.png index 45239ea..01ee4d2 100644 Binary files a/testdata/snapshots/darwin/model_selector-dark.png and b/testdata/snapshots/darwin/model_selector-dark.png differ diff --git a/testdata/snapshots/darwin/model_selector-light.png b/testdata/snapshots/darwin/model_selector-light.png index 896bbc0..78703f3 100644 Binary files a/testdata/snapshots/darwin/model_selector-light.png and b/testdata/snapshots/darwin/model_selector-light.png differ diff --git a/testdata/snapshots/darwin/search_progress-dark.png b/testdata/snapshots/darwin/search_progress-dark.png index e8c0516..4ee5582 100644 Binary files a/testdata/snapshots/darwin/search_progress-dark.png and b/testdata/snapshots/darwin/search_progress-dark.png differ diff --git a/testdata/snapshots/darwin/search_progress-light.png b/testdata/snapshots/darwin/search_progress-light.png index 00b1d0c..e81985d 100644 Binary files a/testdata/snapshots/darwin/search_progress-light.png and b/testdata/snapshots/darwin/search_progress-light.png differ diff --git a/token_counter.go b/token_counter.go index d36043b..fdcd918 100644 --- a/token_counter.go +++ b/token_counter.go @@ -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") diff --git a/tool_toggle_menu.go b/tool_toggle_menu.go index 74d9e27..fc8c8a2 100644 --- a/tool_toggle_menu.go +++ b/tool_toggle_menu.go @@ -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() diff --git a/voice_input_button.go b/voice_input_button.go index 4eea543..4b6b7bc 100644 --- a/voice_input_button.go +++ b/voice_input_button.go @@ -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() { diff --git a/voice_waveform.go b/voice_waveform.go index 5526c60..754a88d 100644 --- a/voice_waveform.go +++ b/voice_waveform.go @@ -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")