From ade219840e4affa523055f2bbf6ad64a4967a40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 15:51:57 -0400 Subject: [PATCH] WIP components (as built) --- attachment_chip.go | 14 +- attachment_chip_test.go | 30 +++ citation_badge.go | 57 +++-- citation_badge_test.go | 71 +++++- context_chips.go | 10 +- context_chips_test.go | 20 ++ context_mention_menu.go | 67 +++-- context_mention_menu_test.go | 44 +++- context_window_meter.go | 8 +- docs/components/221-prompt-composer.md | 40 +++ docs/components/222-attachment-chip.md | 38 +++ docs/components/223-paste-image-preview.md | 39 +++ docs/components/224-drag-drop-overlay.md | 41 +++ docs/components/225-voice-input-button.md | 38 +++ docs/components/226-voice-waveform.md | 36 +++ docs/components/227-slash-command-menu.md | 40 +++ docs/components/228-context-mention-menu.md | 44 ++++ docs/components/229-context-chips.md | 38 +++ docs/components/230-model-selector.md | 39 +++ docs/components/231-mode-selector.md | 38 +++ docs/components/232-tool-toggle-menu.md | 39 +++ docs/components/233-parameter-panel.md | 37 +++ docs/components/234-token-counter.md | 35 +++ docs/components/235-context-window-meter.md | 36 +++ docs/components/236-send-button.md | 39 +++ docs/components/237-citation-badge.md | 39 +++ docs/components/238-source-card.md | 39 +++ docs/components/239-sources-panel.md | 39 +++ docs/components/240-search-progress.md | 36 +++ drag_drop_overlay.go | 6 +- drag_drop_overlay_test.go | 2 +- example_chatinput_test.go | 241 ++++++++++++++++++ examples/gallery/pages_chatinput.go | 65 ++++- examples/gallery/pages_chatinput_compose.go | 210 +++++++++++++++ examples/gallery/pages_chatinput_context.go | 182 +++++++++++++ examples/gallery/pages_chatinput_sources.go | 158 ++++++++++++ examples/gallery/pages_chatinput_test.go | 98 +++++++ messages_chatinput.go | 1 + mode_selector.go | 63 ++++- mode_selector_test.go | 13 + model_selector.go | 157 +++++++++--- model_selector_test.go | 57 +++++ parameter_panel.go | 26 +- parameter_panel_test.go | 44 ++++ paste_image_preview.go | 4 +- paste_image_preview_test.go | 17 ++ prompt_composer.go | 20 +- prompt_composer_test.go | 26 ++ search_progress.go | 19 +- search_progress_test.go | 18 +- send_button.go | 10 +- slash_command_menu.go | 132 ++++++---- slash_command_menu_test.go | 64 ++++- source_card.go | 6 +- sources_panel.go | 31 +-- sources_panel_test.go | 49 ++++ .../snapshots/darwin/citation_badge-dark.png | Bin 3429 -> 3463 bytes .../snapshots/darwin/citation_badge-light.png | Bin 3290 -> 3325 bytes .../snapshots/darwin/context_chips-dark.png | Bin 6610 -> 6769 bytes .../snapshots/darwin/context_chips-light.png | Bin 6359 -> 6468 bytes .../snapshots/darwin/mode_selector-dark.png | Bin 9877 -> 9749 bytes .../snapshots/darwin/mode_selector-light.png | Bin 9729 -> 9465 bytes .../snapshots/darwin/model_selector-dark.png | Bin 17254 -> 17072 bytes .../snapshots/darwin/model_selector-light.png | Bin 18707 -> 17797 bytes .../snapshots/darwin/search_progress-dark.png | Bin 18338 -> 18899 bytes .../darwin/search_progress-light.png | Bin 17464 -> 18026 bytes token_counter.go | 4 +- tool_toggle_menu.go | 13 +- voice_input_button.go | 10 +- voice_waveform.go | 8 +- 70 files changed, 2610 insertions(+), 235 deletions(-) create mode 100644 docs/components/221-prompt-composer.md create mode 100644 docs/components/222-attachment-chip.md create mode 100644 docs/components/223-paste-image-preview.md create mode 100644 docs/components/224-drag-drop-overlay.md create mode 100644 docs/components/225-voice-input-button.md create mode 100644 docs/components/226-voice-waveform.md create mode 100644 docs/components/227-slash-command-menu.md create mode 100644 docs/components/228-context-mention-menu.md create mode 100644 docs/components/229-context-chips.md create mode 100644 docs/components/230-model-selector.md create mode 100644 docs/components/231-mode-selector.md create mode 100644 docs/components/232-tool-toggle-menu.md create mode 100644 docs/components/233-parameter-panel.md create mode 100644 docs/components/234-token-counter.md create mode 100644 docs/components/235-context-window-meter.md create mode 100644 docs/components/236-send-button.md create mode 100644 docs/components/237-citation-badge.md create mode 100644 docs/components/238-source-card.md create mode 100644 docs/components/239-sources-panel.md create mode 100644 docs/components/240-search-progress.md create mode 100644 example_chatinput_test.go create mode 100644 examples/gallery/pages_chatinput_compose.go create mode 100644 examples/gallery/pages_chatinput_context.go create mode 100644 examples/gallery/pages_chatinput_sources.go create mode 100644 examples/gallery/pages_chatinput_test.go diff --git a/attachment_chip.go b/attachment_chip.go index 1fc9ff5..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") @@ -52,7 +52,7 @@ func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult { if a.Err != nil { border = k.Danger } - r.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Padding(u, u, u, u*2).Width(220). + r.Element = ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u, u, u*2).Width(220). Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleGroup).Label(a.Name) disabled := r.Element.IsDisabled() r.Element.Children(func() { @@ -72,7 +72,9 @@ func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult { ui.Text(c, fmt.Sprintf(msg(c, "attachment_chip.uploading"), pct)).SingleLine().FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum") frac := float32(a.Progress) ui.Box(c).Height(3).Radius(1.5).Background(k.Border).Role(ui.RoleProgress).Label(fmt.Sprintf(msg(c, "attachment_chip.uploading"), pct)). - Draw(func(p *ui.Painter, b ui.Rect) { p.Fill(ui.Rect{X: b.X, Y: b.Y, W: b.W * frac, H: b.H}, k.AccentText, 1.5) }) + Draw(func(p *ui.Painter, b ui.Rect) { + p.Fill(ui.Rect{X: b.X, Y: b.Y, W: b.W * frac, H: b.H}, k.AccentText, 1.5) + }) default: ui.Text(c, fileMessageSize(c, a.Size)).SingleLine().FontSize(small).TextColor(k.TextMuted) } 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 83e9c88..cab90fb 100644 --- a/citation_badge.go +++ b/citation_badge.go @@ -15,25 +15,27 @@ import ( type Source struct { // Title and URL are required. Title, URL string - Site string - Snippet string - Published time.Time + Site string + Snippet string + Published time.Time } -// CitationState links citations, source cards and the sources panel; the caller holds it. +// CitationState links citations and sources through caller-owned state. type CitationState struct { - // Active is the 1-based number of the source the user chose; 0 for none. + // Active selects a 1-based source; zero clears selection. Active int hover int owner uint64 + seen time.Time + locate uint64 } // lit reports whether source n is chosen or hovered anywhere. func (s *CitationState) lit(n int) bool { return n > 0 && (s.Active == n || s.hover == n) } -// track records that e, showing source n, is or is no longer under the pointer. +// track refreshes pointer ownership for the current frame. func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) { - switch over := e.Hovered(); { + switch over := e.Hovered() && !e.IsDisabled(); { case over && s.hover != n: s.hover, s.owner = n, e.ID() c.Invalidate() @@ -41,9 +43,19 @@ func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) { s.hover, s.owner = 0, 0 c.Invalidate() } + if s.owner == e.ID() { + s.seen = c.Now() + } } -// CitationBadge is an inline source number; hovering previews the source, clicking makes it Active. +func (s *CitationState) expire(c *ui.Context, frame time.Time) { + if s.hover != 0 && s.seen != frame { + s.hover, s.owner = 0, 0 + c.Invalidate() + } +} + +// CitationBadge previews and locates a numbered source. func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceResult { if n < 1 || st == nil || src.Title == "" || src.URL == "" { panic("mujica: CitationBadge needs n >= 1, a state and a Source with Title and URL") @@ -51,7 +63,7 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceRe k := Tokens(c) label := fmt.Sprintf(msg(c, "citation_badge.label"), n, src.Title) size := FontSize(c, theme.CaptionSize-1) - b := ui.ButtonBase(c).MinWidth(18).Height(18).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(colorFade) + b := ui.ButtonBase(c).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(colorFade) internal.Ring(b, k.Focus, theme.ControlRadius) st.track(c, b, n) if st.lit(n) { @@ -59,25 +71,33 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceRe } else { b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted) } + frame := c.Now() + b.Draw(func(p *ui.Painter, r ui.Rect) { + st.expire(c, frame) + if st.lit(n) { + p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 4, W: r.W - 8, H: 2}, k.AccentText, 0) + } + }) b.Children(func() { ui.Text(c, fmt.Sprint(n)).FontSize(size).FontFeatures("tnum").FontWeight(600).SingleLine() }) r := ChoiceResult{Element: b} - if b.Clicked() && !b.IsDisabled() && st.Active != n { + if b.Clicked() && !b.IsDisabled() { st.Active, r.changed = n, true + st.locate++ slog.Debug("mujica: citation_badge activate", "n", n) } citationPreview(c, b, n, src) return r } -// citationPreviewDelay is how long hover or focus rests before the preview shows. +// citationPreviewDelay sets the hover and focus wait. const citationPreviewDelay = 500 * time.Millisecond -// citationPreview shows the source by badge b once hover or keyboard focus rests on it. -// It lets the pointer through, so the badge stays clickable under it; Escape hides it. +// citationPreview displays a source after resting hover or focus. +// Pass-through preserves badge clicks; Escape dismisses the preview. func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) { st := ui.Local(b, "mujica.citation", func() tooltipState { return tooltipState{} }) size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} }) - if !b.Hovered() && !b.FocusVisible() { + if b.IsDisabled() || !b.Hovered() && !b.FocusVisible() { *st = tooltipState{} return } @@ -96,13 +116,14 @@ func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) { return } u := Density(c).Unit() - w := float32(300) - x, y := overlayPlace(c, b.Bounds(), w, size.H, 6) + ww, wh := c.Size() + w, h := min(float32(300), max(0, ww-8)), max(0, wh-8) + x, y := overlayPlace(c, b.Bounds(), w, min(size.H, h), 6) ui.Overlay(c, func() { - panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Padding(u * 3).PassThrough().Role(ui.RoleTooltip) + panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(u * 3).PassThrough().Role(ui.RoleTooltip).Label(msg(c, "citation_badge.preview")) overlayStyle(c, panel, overlayAnchored(c, panel, *size)) *size = panel.Bounds() - panel.Children(func() { sourceSummary(c, n, src) }) + panel.Children(func() { ui.Scroll(c).Shrink(1).Children(func() { sourceSummary(c, n, src) }) }) }) } 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 4640122..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}, } -// mentionTester filters items as an async source would, one frame late. -type mentionTester struct { +// contextMentionTester supplies filtered results one frame later. +type contextMentionTester struct { text string items []ContextItem loading bool @@ -23,7 +23,7 @@ type mentionTester struct { queries []string } -func (m *mentionTester) view(c *ui.Context) { +func (m *contextMentionTester) view(c *ui.Context) { Use(c, Settings{}) ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() { p := PromptComposer(c, &m.text, PromptComposerOptions{Label: "Prompt"}) @@ -44,7 +44,7 @@ func (m *mentionTester) view(c *ui.Context) { } func TestContextMentionMenuGroupsAndChooses(t *testing.T) { - m := &mentionTester{} + m := &contextMentionTester{} tt := ui.NewTester(m.view, 600, 500) _ = tt.Click("Prompt") tt.Type("Read @") @@ -68,7 +68,7 @@ func TestContextMentionMenuGroupsAndChooses(t *testing.T) { } func TestContextMentionMenuOnlyAfterSpaceAndLoading(t *testing.T) { - m := &mentionTester{loading: true} + m := &contextMentionTester{loading: true} tt := ui.NewTester(m.view, 600, 500) _ = tt.Click("Prompt") tt.Type("mail@host") @@ -93,16 +93,46 @@ func TestContextMentionMenuBadItem(t *testing.T) { t.Fatal("no panic for an item without ID") } }() - m := &mentionTester{items: []ContextItem{{Label: "x"}}} + m := &contextMentionTester{items: []ContextItem{{Label: "x"}}} ui.NewTester(m.view, 300, 300) } func TestContextMentionMenuSnapshot(t *testing.T) { snapshotAfter(t, "context_mention_menu", 560, 420, func(c *ui.Context) { - (*ui.Local(c.Root(), "m", func() *mentionTester { return &mentionTester{items: mentionSample} })).view(c) + (*ui.Local(c.Root(), "m", func() *contextMentionTester { return &contextMentionTester{items: mentionSample} })).view(c) }, func(tt *ui.Tester) { _ = tt.Click("Prompt") tt.Type("Compare @") tt.Frame() }) } + +func TestContextMentionMenuAsyncFirstChoiceAndDisabledQuery(t *testing.T) { + text, off := "@", false + var items []ContextItem + picked, queries := "", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt", Disabled: off}) + r := ContextMentionMenu(c, p, &text, items, ContextMentionOptions{Loading: items == nil}) + if r.QueryChanged() { + queries++ + } + if it, ok := r.Chosen(); ok { + picked = it.ID + } + }, 400, 300) + _ = tt.Click("Prompt") + items = []ContextItem{{ID: "first", Label: "First", Kind: ContextDoc}} + tt.Frame() + tt.Key(0, ui.KeyEnter) + if picked != "first" || text != "" { + t.Errorf("async first choice: %q text %q", picked, text) + } + off, text = true, "@other" + before := queries + tt.Frame() + if queries != before { + t.Errorf("disabled menu issued %d queries", queries-before) + } +} 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 044f68a..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() @@ -41,7 +57,9 @@ func ParameterPanel(c *ui.Context, params []ModelParam) ChoiceResult { } r.Element.Children(func() { for _, p := range params { - format := func(v float64) string { return strconv.FormatFloat(v, 'f', sliderDecimals(p.Min, p.Step, p.Default), 64) } + format := func(v float64) string { + return strconv.FormatFloat(v, 'f', sliderDecimals(p.Min, p.Step, p.Default), 64) + } ui.Column(c).Key(p.Label).Gap(u).Children(func() { ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { ui.Text(c, p.Label).FontWeight(600).Grow(1).SingleLine() 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 6b3c190..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,24 +208,28 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe return false }) panel.Children(func() { - ui.Scroll(c).Fill().Padding(u).Children(func() { + if st.lastHi != st.hi { + st.lastHi = st.hi + st.list.ScrollIntoView(max(st.hi, 0)) + } + ui.List(c, &st.list, max(1, len(rows)), func(i int) { if len(rows) == 0 { - ui.Row(c).MinHeight(rowH).Padding(0, u*3).Gap(u*2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() { + ui.Row(c).MinHeight(rowH).Padding(0, u*3).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() { if o.loading { Spinner(c, SpinnerOptions{Size: 14}) } ui.Text(c, o.empty).SingleLine() }) + return } - group := "" - for i, r := range rows { - if r.group != group { - group = r.group - ui.Text(c, group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(FontSize(c, theme.CaptionSize)). + r := rows[i] + ui.Column(c).Children(func() { + if r.group != "" && (i == 0 || r.group != rows[i-1].group) { + ui.Text(c, r.group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(FontSize(c, theme.CaptionSize)). FontWeight(600).TextColor(k.TextMuted).Role(ui.RoleText) } - row := ui.Row(c).Key(i).Height(rowH).Padding(0, u*3, 0, u*2).Gap(u*2).AlignItems(ui.Center). - Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer) + row := ui.Row(c).Key(i).MinHeight(rowH).Padding(0, u*3, 0, u*2).Gap(u * 2).AlignItems(ui.Center). + Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer).Selected(i == st.hi).TextColor(k.Text) if row.Clicked() { clicked = i } @@ -209,10 +239,6 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe row.Background(k.Selection).Draw(func(p *ui.Painter, b ui.Rect) { p.Fill(ui.Rect{X: b.X, Y: b.Y + 4, W: 2, H: b.H - 8}, k.AccentText, 1) }) - if st.lastHi != i { - st.lastHi = i - row.ScrollIntoView() - } } else if row.Hovered() { row.Background(k.SurfaceHover) } @@ -223,10 +249,10 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe ui.Text(c, r.label).SingleLine().Shrink(0).FontWeight(600) ui.Text(c, r.detail).SingleLine().MinWidth(0).Shrink(1).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) }) - } - }) + }) + }).Fill().Padding(u) }) }) st.held = st.held && pressed - return clicked + return clicked, result } 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 6f4ef224c2120c16833d7ed5c15f4d157e4bd87f..7c48ad5c3af1f53ab33fd9e33405823c5800f624 100644 GIT binary patch literal 3463 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 RpE6)k-PbnMs)9WY`yYuoX?_3z delta 3141 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 diff --git a/testdata/snapshots/darwin/citation_badge-light.png b/testdata/snapshots/darwin/citation_badge-light.png index ad7f083a291a0ef65b0d3a6a0c49b179aa953a44..1deccd02e0960ef07385753738c3300fa3076e77 100644 GIT binary patch literal 3325 zcmcgvc{tSV*Z(4sJvG*uEQvCT9w`Q+g{BdsMwY=ep(jgZ$-bm0Ldd?3v5#ej1`~xy zL@^}BmMta>1|{pD-#owf{qyn3Lz{YBLDz^F!Guq z8UVPQIldl0H)m~IsT%?Sp*E!9rCR|xOXGodN33f3Ry8pfbLF4rRjVqBiQRJke&~5S z=m7a*v7#$zllO~+5mbsSn>Wqvz)O8VAEd_Ip&D};y&?}2CH|H?d{pS^iAzW1y(Uy5 z^TVH3kBkOATI=n`XJ&WFtg@W8eZzf~y=m}wPW{Oc%b#e~;!&71EIGfje`vJp z2alX&x$yUk+G6ox+#>cMTwHN+(O87*JbGc#uvK+`(erLjy><{&Z!bThG_og3=~GgJ zD7rvY5xfEd2UPqzGf{r6IB6Np1Ei!_+->i=e;8gdFQLT%#XU)VmPg8Q?()XGRT z_1?XE>UJ7kgM%`_(#Uz-%1BoF=1V1Hxco5*309m=PNEmW%;b$}@4g!rJ zH1zeHP?^)YIZ0`$sTJh$xvtdf?rx;g=CZSgB&)!1?`dYg)6Nd8CN$KpBOx&XAuq<= zLcPy~MsO>eNNvs(x79e!JoFY65*D8A$vo@ISS`uw>`kv3(dk;( z(|N5{p8 zKj%`AZ(#J-bc+b9&smv#uW6{Q}_c1)sc$5Vg5Igo6d#v`%IX5fo0Hr8LJ+JV!lvB%} zjg67Wvfcntx+vnlYc<1MLB$L&cVX}o-PbLo)eIW3%QZDuG$2Zfa=yQbY4*yFXkzVD zS#q^x^c}aeBPyv(ZVlWQ2TCg{SYwpLPj};G*?s&(J8pp-qifU1DKi)zRO_!x_&O?ehQU7b=ux8Tppe~ z-)QgHR#@P${W1q$jiZc-0zS3ugKD;T0?v7OGLQcATCsXyV~mktC?rbsAsF7xJRW@B zHd*FcL%}Zt)tcFmgOJjju-Befb-l0Za$mgpar|?K8A

c`#?*6xMihI-#@An#dUo zudT(+9H}&Pn_OG0sw?!5a++hDm3o11keTd`y`Gzv2Im1T)KS66XI!5d31&d%~HMMgIIp?Qf4$fQrFT42hp;qHgV$M?}4Yo_VBtQ~;TDG*-rg zOS>d)hU+njG}e1BM75cm*U$9cV#GdsCQWa4D=x8ldA!H!+%YJ$ise-LM?4 zn}4G;YH4MqRg)U8XCF7cDpcbsjif4o`4LXvo!{nYm+&~Tcb-PFV7{>Ujta5pHdXx; zjM~UIU7%Cl3Xr-p+&eb*n*$h`CpVPDbrWPyHK?^u-)_2kzQ~1 z+f%0U@sARQoUcR}BFiWQ3Bly|&V6CBGtuL+UfbSiZ)2Og1N)t&du)f-U2~5o@TWlI|;Qi*Npfaw`pohe$^jg2+`T4pd9Ro^2M1>OzA?OW@MiUxQl!Cf@y^Jx8D z`i{F|;`7mwzhXX`TW<}!qy!Hv@9Et(djFw7Qr&Qm%`{i>RXZT&F_U}Zf(}-VL=N0p z;`U$Mo_f^=p^)XGKzx>97D=1KCO4C|g(=h6z3@`opfz{fyM&o}r@T5xcI0zbaEU4I zT2fIHo>n4^J5Q4z9=0BDCa@R``ELhTGZ!!zOyK+%|8I%~si~|Yz z2cpZXs-T1$0s5EiPbYtx?<>Uq`4H1vfzg1{Vx_qTZVL-yI zW?iJ-Cuejaa>);W%x6)F`s|;sd)xw>#%5)eXJQsNn_OMX2NrNd{6jHQkJ+k<{CS@f zCrS!Vb-zAN2y-#BR*Sp$&HZX$0BlWzHzJVl@?r}`O6cI4>izDRfPz!HO63VB{ tQ#arim;TYU{~}rcPdDSkM8SUyfFYyr8s}F`1v%du0BM9Wth{_D{69m6NG|{Y delta 3022 zcmV;<3o-Ql8QK|;B!8hvL_t(|ob23rSQFQ~2k>{YfLuWcL1k4$ARwsF!W9sUD+pSF zN=4Kv?hBwTidHaMH$cS�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 y0ri(zAqJ!3EmWpQ`+g~i>a_~P#FELvRO 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 PM*teHbzjvf+rIlRZUVvg literal 6359 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 Q|6u{p)_|y2KCz4W57foL1^@s6 diff --git a/testdata/snapshots/darwin/mode_selector-dark.png b/testdata/snapshots/darwin/mode_selector-dark.png index 6f20d1c46a8b8656e331df34c40162e99a639e37..8c05fa65429fa255694dece570fb61d4dd63297d 100644 GIT binary patch literal 9749 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 B-kAUZ literal 9877 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 literal 9729 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%0DMCJ0sk`}>j zb}BsZXw7|iO!hx(OMmH0SwjCeed%Aag6g0G1{un5Mj{br|0C5k^bvM|hfKNZc3Ke3 zhJuWee<&^BayU1@$0wTY5m+fY`-->EM*k)gPfQ}(`&XU&3Z%~I{e#S;USHW*J2Nv2 z%vvu`r4*0@aB*?BY=KbyWg$;E?9}K1Jv-mkrqsHaCd(r*BY<%x^1arrsQ^eb)BZ+B zd)F>AY=A#cfW5YnS+6(0&dLqQ1vcRO+%%H^msnGW^r5WU#omwt6(B!81b#?rAE}|p zFRC){Z|r%C`0v#!Ou~MDLKZ;XbZ+YqAfRFWw&1m5U9I_diG`Th?NfG=+Qgn>QU7x9QWT#5DJX&_C>8VJ5ONGVhdYM;UPR zwx&980F0k7U*#NKi910=Sd3P)2~8H#zvt5p6gT9t^Kt!nJ%|Vcs8q1Ai|4IY1h&s? zRsORoTw~`aPb;cMn@C!n2aYHz|6xZme&V}LrU-yO*!Y50u2O>N1*X1zYZEad#zR-vV8@vWFMow|A~V( zGV1qt=;BJ<;{iPZ@58ka#itrZ7peoZ)R->a!!Kv|xCL=!u~2%_P=HXYMwTaDO+$rB z_r;pMQ;$wIA$SM8Vu%6M1$yE$FqN{o?keU_z1y_m&W7kvTZRIal2wtlvsDv-B~5V} zJ)`(%?_fsgeX7LyKN&&v0Kq>vR!I@R_N(naI=Vr+7`hURD_${I0wnOuhX_TZziG3; zuJVmqYV;nB8E(s{u)m2jy6t!W{5#-@q^s*MlJy0_OQREujg{H9gN-($o&I#)^bdG* z)3ENUpKN{}Ej8f|A3h^N6Jph~v_CI-Q13$;By1{X>LbVdlQ!+jjaruTDl`||)D}MA zT^?o`TG)LN^Y}^Mn1w1H$M7i35lsXUc60<(M)R``G(^N(gM}yalgIZNf=~Q0*-iv> z!S^99v%Y3|QqbS3>PR+=*Jlq6ii4t3VF>&+Qo84ursNjioq8PIE2Q?c4OB#Zj7 zyp&VX+mtZDWgOOPTJjfF{!Z9?uj_Cy_iniy%ioJW|dvA9x1vc2& zEx^0ZuC1+lOS(&4Z=Z$serJ>sO{waQoBT!e(Se^Lf!@^4^z-LR1@odCy1NQM@`&-aXZKD+kvM6ATL3!bgs6^wJU75`807CkL|Obr?18E9k9WDZdTZk!DeS zYgwb`wc1@jP0v6^sGS@d5<0`R=U8LCY8Ztr!C9AP&a!C674IMB5LlzfTx34;q4&eM zZ%jAkDXMW>;lPAqH01e!YYUQ~&xa~CCZ5FG-p$8TuI#?MTLPLOi`+q?-r14a;qYPT zlUPyP=)+y|cht_@!08sd9hKp^vzxQs}LnyfA3m?67t6LFXBvp&^{)d{y^2Ni(y^=k$RtA>VBpFjrt7WsMa128PRG?9U zL5k5S5d|W5SMIGMM#n9wyy^G44{VQTx7YEe9+$hjeMN-BPQ$y$#deW)!aVv1pWFLW z{xBqFNuzz}p3ch~@6@xu^)y)H^WIa-Erq?@x?}pYw6Y^=e=FKzpk=+t@yJ9M`l-^} z;n(?2N`IeI&5{l9nLDuy0UK!J!QEDMu*PWOh_l4Af);l7hjqe} z?CRwTvpVgu3UL$d^*;j>yM~wD4`>DNOY|w;Pu8wLS=S%V^S*(k)f;YiXlR1!)zYy7 z>80VH-&NpgCqptaqkSTxac!OL^8(2!S9gC%qVWkfPj~<8t1lP0wtx5hzn*b|#5q9e z)BKxIqJE31vHAmXo)V3+2=I{~&R<@(-~gF?R9ENFDXQ_*ODCl?I?xXcw9+pvWvV@Y zE{6S=AC52smyEQ1&$Xgpmim6zUwxcXYz;Pv^19E&CS%-SothUjd7);JAEOp4M_kn# z%W6NnxU`iP74eBZ89#lIucDbAsX7Y1vK6b>jDji4U8ZJw^}h~EC~0z}mJ=rlGI!A=msd_HF${8!KX g_q{$v#D?M^VBxt}YKr8%LI4iwj|vi%?+pX~7trBCqW}N^ diff --git a/testdata/snapshots/darwin/model_selector-dark.png b/testdata/snapshots/darwin/model_selector-dark.png index 45239ea0c4f832e94170f533689506ded8fcb21a..01ee4d2e68680077390338e5512ac69615c901f1 100644 GIT binary patch delta 14658 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 DYYNBn delta 14840 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 diff --git a/testdata/snapshots/darwin/model_selector-light.png b/testdata/snapshots/darwin/model_selector-light.png index 896bbc0e3c9b74d49549ced5eb69a15bf13a0e13..78703f376b5f8127e5d2c8d736537b5a14275982 100644 GIT binary patch literal 17797 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 b6BkUu7Rwpzv$+doJHccnl_YA#jD!CN^M&QL literal 18707 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 delta 3908 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%0A5P8@J(Q8rWEQe9YGh$|E=7qwu_PEEQ^ z&thP1oK|~Jm*pPg8>*$6y^rg6#>TtHw6={FZ9X+2d?&Av-tkzFu=*Pb;@j z(r?UWzRt zks7xfWg9IZBOTPr<%Tv?T$?Wg^MS8W@4v&|F(d05xL+p@6MSXZIgy%%CZp%VIc9Z) z^PJpM7Ecrtz>^rS8&;t4_y+|%FhuBEuRa$#uhZ2qUS&Dpv25){>2 zXjJkh-nROAl7#um044Yx8`M*|k77Nnp{R{%z|9)2>6qqw+{+B;)3*{xawCU4{b|2O zN9v|sX=&)#9*lQSp;74^n+1Ic?NeI9-bHAAS&hd`Zg`j%>3d-Y%rDuS_vK#K70ml& z8e?X~)&A^1zyItJmNe9dy=sIHM$Ab5xW`*Wy z3NOKM7M<>1#K1I~IDK{$)PiJ#ENOJmzB_rvU!{<*pzg!EmT31jyJMh8a-_!t{6>WO zA%kzFuY8ldiW*l3ug+%I(ei|+8G3{sBsL!ELgI$V0~=GD+kunYe1iP_C*tm>*iHSD zYth{c^|*vEMc}x1d*hGEQAt@oH&tZE91HEYxqsfgLJ4Q)M_BTrx6IYSm%w8-uDUWYr@t~y92UDd~n(NiYJS|lkAC) zh?*q8cX8I4GPsF^DkE3i3 zR)6Sdc&^+H`^!LstOibxPeiBES6ZP+ksT$FvX%c>L)3jQl3yoMn>(2oCDEdVFL~AK*K|1VRrT#*+O~{S8D9z$J#Yk zWtETl8~JX<$m(0h^4Q&u3#dJQ-X0H{TrTNk*d;?kNL?xB{h9kn@cPa1N^C8A`-kwD z#elt+UthwKP|r72wN=Aa>ncNC1XGt<;7`ars&8nr^%kz4)$XP>-0YTQArV%61K2ja z%Tiq}p_`T`(#`B$8OQFK?iJS}B)~UcPx#oW;}bGS!@d+Z%a@_!WTlZQ=*-E1=wI^u z-SnZpxE@oiUS<}@GoQl4Zy)t-#HLJvc!UBeTM+DS@a)`#=-LY4QOh|!XxH(dGe1LE zmk*fAojLI1HQ~&AGLlCZTe$q5WNS_o#=JbbMZb4~Igtd`woI>UA(%|wRf{Lc_6k7?$HO3g;cN;-BQN7hOqGYu;mNE9e zQakc485kVa9TmX=+|0z@J|hwhtaYsk_+_4Qkv7@{N2x4w$GVY5*x9Pf&V9&275T-f z^10JHsUg*kpY(TVHLjwb0d~DMvSkVV3yNo^Vp;f*<_I;trbdRff2KdO4bAPQ*%qYZ zozQJP%kokxRb!ZUQ*z3|O`0W+vIaaFu(%8%Hd*92bsX!s$KqxNB8kCZxD-hPE zOoVRyY9s##FHwjA>zvOfxvy#)L^7o0gLwFqssEpcz<(je{}lrGZvp+!A>vho|C3kF ef7??(OkT2}AKd%<2vz>iO~BOk)UYZ~BmV{5?X~g% delta 3732 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 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")