diff --git a/attachment_chip.go b/attachment_chip.go new file mode 100644 index 0000000..1fc9ff5 --- /dev/null +++ b/attachment_chip.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Attachment is a file attached to a prompt; the caller uploads it. +type Attachment struct { + // Name is required. + Name string + // Type is the MIME type; empty picks the icon from the name. + Type string + Size int64 + // Uploading shows Progress, 0 to 1. + Uploading bool + Progress float64 + // Err shows a failed upload with retry. + Err error +} + +// AttachmentChipResult is a chip's element and the user's request this frame. +type AttachmentChipResult struct { + Element *ui.Element + removed, retried bool +} + +// Removed reports a press of the remove button. +func (r AttachmentChipResult) Removed() bool { return r.removed } + +// Retried reports a press of retry on a failed upload. +func (r AttachmentChipResult) Retried() bool { return r.retried } + +// AttachmentChip shows an attachment's type, name, size, upload progress or failure, and remove. +func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult { + if a.Name == "" || a.Size < 0 { + panic("mujica: Attachment needs a Name and a non-negative Size") + } + if a.Uploading && !(a.Progress >= 0 && a.Progress <= 1) { + panic("mujica: Attachment Progress must be from 0 to 1") + } + k := Tokens(c) + u := Density(c).Unit() + var r AttachmentChipResult + border := k.Border + if a.Err != nil { + border = k.Danger + } + r.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Padding(u, u, u, u*2).Width(220). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleGroup).Label(a.Name) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + icon, color := fileMessageIcon(a.Name, a.Type), k.AccentText + if a.Err != nil { + icon, color = "circle-alert", k.Danger + } + ui.Icon(c, icons.Must(icon)).Size(20, 20).Shrink(0).TextColor(color) + ui.Column(c).Grow(1).MinWidth(0).Gap(u / 2).Children(func() { + ui.Text(c, a.Name).SingleLine().FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600) + small := FontSize(c, theme.CaptionSize) + switch { + case a.Err != nil: + ui.Text(c, msg(c, "attachment_chip.failed")).SingleLine().FontSize(small).TextColor(k.Danger) + case a.Uploading: + pct := int(math.Round(a.Progress * 100)) + ui.Text(c, fmt.Sprintf(msg(c, "attachment_chip.uploading"), pct)).SingleLine().FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum") + frac := float32(a.Progress) + ui.Box(c).Height(3).Radius(1.5).Background(k.Border).Role(ui.RoleProgress).Label(fmt.Sprintf(msg(c, "attachment_chip.uploading"), pct)). + Draw(func(p *ui.Painter, b ui.Rect) { p.Fill(ui.Rect{X: b.X, Y: b.Y, W: b.W * frac, H: b.H}, k.AccentText, 1.5) }) + default: + ui.Text(c, fileMessageSize(c, a.Size)).SingleLine().FontSize(small).TextColor(k.TextMuted) + } + }) + if a.Err != nil && iconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(msg(c, "attachment_chip.retry"), a.Name)).Clicked() && !disabled { + r.retried = true + slog.Debug("mujica: attachment_chip retry") + } + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "attachment_chip.remove"), a.Name)).Clicked() && !disabled { + r.removed = true + slog.Debug("mujica: attachment_chip remove") + } + }) + return r +} diff --git a/attachment_chip_test.go b/attachment_chip_test.go new file mode 100644 index 0000000..3b9e1bd --- /dev/null +++ b/attachment_chip_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "errors" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestAttachmentChipStatesAndActions(t *testing.T) { + a := Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4} + removed, retried := 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := AttachmentChip(c, a) + if r.Removed() { + removed++ + } + if r.Retried() { + retried++ + } + }, 400, 120) + if !tt.HasText("Uploading 40%") { + t.Fatalf("progress: %q", tt.Texts()) + } + if _, ok := tt.Find("Retry upload of guests.csv"); ok { + t.Fatal("retry shown while uploading") + } + a.Uploading, a.Err = false, errors.New("413") + tt.Frame() + _ = tt.Click("Retry upload of guests.csv") + _ = tt.Click("Remove guests.csv") + if retried != 1 || removed != 1 || !tt.HasText("Upload failed") { + t.Fatalf("retried %d removed %d texts %q", retried, removed, tt.Texts()) + } + a.Err = nil + tt.Frame() + if !tt.HasText("4.0 KB") { + t.Fatalf("size: %q", tt.Texts()) + } +} + +func TestAttachmentChipBadProgress(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for progress 2") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + AttachmentChip(c, Attachment{Name: "a", Uploading: true, Progress: 2}) + }, 100, 100) +} + +func TestAttachmentChipSnapshot(t *testing.T) { + snapshot(t, "attachment_chip", 520, 80, func(c *ui.Context) { + ui.Row(c).Padding(12).Gap(8).Children(func() { + AttachmentChip(c, Attachment{Name: "guests.csv", Size: 4096}) + AttachmentChip(c, Attachment{Name: "portrait.png", Size: 812000, Uploading: true, Progress: 0.62}) + }) + }) +} diff --git a/citation_badge.go b/citation_badge.go new file mode 100644 index 0000000..83e9c88 --- /dev/null +++ b/citation_badge.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// Source is a document an answer cites. +type Source struct { + // Title and URL are required. + Title, URL string + Site string + Snippet string + Published time.Time +} + +// CitationState links citations, source cards and the sources panel; the caller holds it. +type CitationState struct { + // Active is the 1-based number of the source the user chose; 0 for none. + Active int + hover int + owner uint64 +} + +// lit reports whether source n is chosen or hovered anywhere. +func (s *CitationState) lit(n int) bool { return n > 0 && (s.Active == n || s.hover == n) } + +// track records that e, showing source n, is or is no longer under the pointer. +func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) { + switch over := e.Hovered(); { + case over && s.hover != n: + s.hover, s.owner = n, e.ID() + c.Invalidate() + case !over && s.owner == e.ID() && s.hover != 0: + s.hover, s.owner = 0, 0 + c.Invalidate() + } +} + +// CitationBadge is an inline source number; hovering previews the source, clicking makes it Active. +func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceResult { + if n < 1 || st == nil || src.Title == "" || src.URL == "" { + panic("mujica: CitationBadge needs n >= 1, a state and a Source with Title and URL") + } + k := Tokens(c) + label := fmt.Sprintf(msg(c, "citation_badge.label"), n, src.Title) + size := FontSize(c, theme.CaptionSize-1) + b := ui.ButtonBase(c).MinWidth(18).Height(18).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(colorFade) + internal.Ring(b, k.Focus, theme.ControlRadius) + st.track(c, b, n) + if st.lit(n) { + b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).TextColor(k.AccentText) + } else { + b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted) + } + b.Children(func() { ui.Text(c, fmt.Sprint(n)).FontSize(size).FontFeatures("tnum").FontWeight(600).SingleLine() }) + r := ChoiceResult{Element: b} + if b.Clicked() && !b.IsDisabled() && st.Active != n { + st.Active, r.changed = n, true + slog.Debug("mujica: citation_badge activate", "n", n) + } + citationPreview(c, b, n, src) + return r +} + +// citationPreviewDelay is how long hover or focus rests before the preview shows. +const citationPreviewDelay = 500 * time.Millisecond + +// citationPreview shows the source by badge b once hover or keyboard focus rests on it. +// It lets the pointer through, so the badge stays clickable under it; Escape hides it. +func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) { + st := ui.Local(b, "mujica.citation", func() tooltipState { return tooltipState{} }) + size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} }) + if !b.Hovered() && !b.FocusVisible() { + *st = tooltipState{} + return + } + if st.dismissed { + return + } + if st.since.IsZero() { + st.since = c.Now() + } + if wait := citationPreviewDelay - c.Now().Sub(st.since); wait > 0 { + c.After(wait) + return + } + if overlayEscape(c, st) { + st.dismissed = true + return + } + u := Density(c).Unit() + w := float32(300) + x, y := overlayPlace(c, b.Bounds(), w, size.H, 6) + ui.Overlay(c, func() { + panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Padding(u * 3).PassThrough().Role(ui.RoleTooltip) + overlayStyle(c, panel, overlayAnchored(c, panel, *size)) + *size = panel.Bounds() + panel.Children(func() { sourceSummary(c, n, src) }) + }) +} + +// sourceSummary shows a source's number, site, title, time and snippet. +func sourceSummary(c *ui.Context, n int, src Source) { + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + ui.Column(c).Gap(u).MinWidth(0).Grow(1).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf("[%d]", n)).FontSize(small).FontFeatures("tnum").TextColor(k.AccentText).SingleLine() + site := src.Site + if site == "" { + site = src.URL + } + ui.Text(c, site).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(1).MinWidth(0) + if !src.Published.IsZero() { + ui.Text(c, src.Published.Format("2006-01-02")).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine() + } + }) + ui.Text(c, src.Title).FontWeight(600).MaxLines(2) + if src.Snippet != "" { + ui.Text(c, src.Snippet).FontSize(small).TextColor(k.TextMuted).MaxLines(3) + } + }) +} diff --git a/citation_badge_test.go b/citation_badge_test.go new file mode 100644 index 0000000..52afd4a --- /dev/null +++ b/citation_badge_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var sourcesSample = []Source{ + {Title: "The Palace of Versailles", URL: "https://example.com/versailles", Site: "example.com", + Snippet: "Seat of the court from 1682.", Published: time.Date(2025, 5, 4, 0, 0, 0, 0, time.UTC)}, + {Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches of mirrors face the garden."}, + {Title: "Court etiquette", URL: "https://example.net/etiquette", Site: "example.net"}, +} + +// citationApp is an answer with badges beside a sources panel, sharing one state. +type citationApp struct { + st CitationState + opened []int +} + +func (a *citationApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Fill().Padding(16).Gap(16).AlignItems(ui.Start).Children(func() { + ui.Row(c).Gap(4).Children(func() { + ui.Text(c, "The court moved in 1682") + CitationBadge(c, 1, sourcesSample[0], &a.st) + CitationBadge(c, 3, sourcesSample[2], &a.st) + }) + ui.Column(c).Width(300).Height(400).Children(func() { + if n, ok := SourcesPanel(c, sourcesSample, &a.st).Opened(); ok { + a.opened = append(a.opened, n) + } + }) + }) +} + +func TestCitationBadgeActivatesAndPreviews(t *testing.T) { + a := &citationApp{} + tt := ui.NewTester(a.view, 900, 500) + r, ok := tt.Find("Source 3: Court etiquette") + if !ok { + t.Fatal("badge not named") + } + tt.Move(r.X+r.W/2, r.Y+r.H/2) + tt.Frame() + if a.st.hover != 3 { + t.Fatalf("hover %d, want 3", a.st.hover) + } + time.Sleep(600 * time.Millisecond) + tt.Frame() + if n := len(tt.Texts()); n == 0 || !tt.HasText("example.net") { + t.Fatalf("no preview card: %q", tt.Texts()) + } + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + if a.st.Active != 3 { + t.Fatalf("active %d", a.st.Active) + } + tt.Move(5, 480) + tt.Frame() + if a.st.hover != 0 { + t.Fatalf("hover %d after leaving", a.st.hover) + } +} + +func TestCitationBadgeKeyboard(t *testing.T) { + a := &citationApp{} + tt := ui.NewTester(a.view, 900, 500) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if a.st.Active != 1 { + t.Fatalf("Enter on the first badge: active %d", a.st.Active) + } +} + +func TestCitationBadgeBadNumber(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for number 0") + } + }() + var st CitationState + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CitationBadge(c, 0, sourcesSample[0], &st) + }, 100, 100) +} + +func TestCitationBadgeSnapshot(t *testing.T) { + snapshot(t, "citation_badge", 360, 60, func(c *ui.Context) { + st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} }) + ui.Row(c).Padding(16).Gap(4).Children(func() { + ui.Text(c, "Seventeen arches") + CitationBadge(c, 1, sourcesSample[0], st) + CitationBadge(c, 2, sourcesSample[1], st) + }) + }) +} diff --git a/context_chips.go b/context_chips.go new file mode 100644 index 0000000..fa0a11f --- /dev/null +++ b/context_chips.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ContextChipsOptions configure ContextChips. +type ContextChipsOptions struct { + // Max is how many chips show before the rest fold under "+N"; zero means 4. + Max int +} + +// RemoveResult is a list's element and the ID the user removed this frame. +type RemoveResult struct { + Element *ui.Element + removed string +} + +// Removed returns the ID whose remove button was pressed. +func (r RemoveResult) Removed() (string, bool) { return r.removed, r.removed != "" } + +// ContextChips lists attached context as chips with remove buttons; past Max the rest fold. +func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions) RemoveResult { + if opts.Max < 0 { + panic("mujica: ContextChips Max must not be negative") + } + limit := opts.Max + if limit == 0 { + limit = 4 + } + k := Tokens(c) + u := Density(c).Unit() + var r RemoveResult + r.Element = ui.Row(c).Gap(u * 2).Wrap().AlignItems(ui.Center).Role(ui.RoleList).Label(msg(c, "context_chips.label")) + expanded := ui.Local(r.Element, "expanded", func() bool { return false }) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + shown := len(items) + if !*expanded { + shown = min(shown, limit) + } + for _, it := range items[:shown] { + ui.Row(c).Key(it.ID).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(theme.MinHitSize). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleListItem). + Children(func() { + ui.Icon(c, icons.Must(it.Kind.icon())).Size(14, 14).Shrink(0).TextColor(k.AccentText) + ui.Text(c, it.Label).SingleLine().MaxWidth(180).FontSize(FontSize(c, theme.CaptionSize)) + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "context_chips.remove"), it.Label)).Clicked() && !disabled { + r.removed = it.ID + slog.Debug("mujica: context_chips remove", "id", it.ID) + } + }) + } + if len(items) <= limit { + return + } + label := fmt.Sprintf(msg(c, "context_chips.more"), len(items)-limit) + if *expanded { + label = msg(c, "context_chips.less") + } + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Radius(theme.ControlRadius).Label(label).TextColor(k.AccentText) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() }) + if b.Clicked() && !disabled { + *expanded = !*expanded + slog.Debug("mujica: context_chips fold", "expanded", *expanded) + } + }) + return r +} diff --git a/context_chips_test.go b/context_chips_test.go new file mode 100644 index 0000000..de476f6 --- /dev/null +++ b/context_chips_test.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestContextChipsRemoveAndFold(t *testing.T) { + items := append([]ContextItem{}, mentionSample...) + items = append(items, ContextItem{ID: "w2", Label: "Hall of Mirrors", Kind: ContextWeb}) + off := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(off).Children(func() { + if id, ok := ContextChips(c, items, ContextChipsOptions{Max: 3}).Removed(); ok { + for i, it := range items { + if it.ID == id { + items = append(items[:i], items[i+1:]...) + break + } + } + } + }) + }, 600, 200) + if tt.HasText("seating.xlsx") || !tt.HasText("+2 more") { + t.Fatalf("fold: %q", tt.Texts()) + } + _ = tt.Click("+2 more") + if !tt.HasText("Hall of Mirrors") || !tt.HasText("Show less") { + t.Fatalf("expand: %q", tt.Texts()) + } + _ = tt.Click("Remove guests.csv") + if len(items) != 4 || tt.HasText("guests.csv") { + t.Fatalf("remove left %d items", len(items)) + } + off = true + _ = tt.Click("Remove Protocol") + if len(items) != 4 { + t.Fatal("disabled chips removed an item") + } +} + +func TestContextChipsNegativeMax(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a negative Max") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ContextChips(c, nil, ContextChipsOptions{Max: -1}) + }, 100, 100) +} + +func TestContextChipsSnapshot(t *testing.T) { + snapshot(t, "context_chips", 560, 90, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { ContextChips(c, mentionSample, ContextChipsOptions{Max: 3}) }) + }) +} diff --git a/context_mention_menu.go b/context_mention_menu.go new file mode 100644 index 0000000..2f4b689 --- /dev/null +++ b/context_mention_menu.go @@ -0,0 +1,114 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" +) + +// ContextKind is what a piece of context is. +type ContextKind int + +const ( + ContextFile ContextKind = iota + ContextDoc + ContextWeb +) + +func (k ContextKind) icon() string { + switch k { + case ContextFile: + return "file" + case ContextDoc: + return "file-text" + case ContextWeb: + return "globe" + } + panic("mujica: unknown ContextKind") +} + +func (k ContextKind) group() string { + return [...]string{"context_mention_menu.file", "context_mention_menu.doc", "context_mention_menu.web"}[k] +} + +// ContextItem is a file, document or web page the user can mention. +type ContextItem struct { + // ID is the item's stable identity; required. + ID string + Label string + Detail string + Kind ContextKind +} + +// ContextMentionOptions configure a ContextMentionMenu. +type ContextMentionOptions struct { + // Loading shows that the caller is fetching items for the query. + Loading bool +} + +// ContextMentionResult is the menu's popup, nil while closed, its query and the item chosen this frame. +type ContextMentionResult struct { + Element *ui.Element + query string + queryChanged bool + chosen ContextItem + ok bool +} + +// Query returns the text after "@"; QueryChanged reports a new one to fetch items for. +func (r ContextMentionResult) Query() string { return r.query } +func (r ContextMentionResult) QueryChanged() bool { return r.queryChanged } + +// Chosen returns the item picked by Enter, Tab or a click; its "@query" was taken out of the text. +func (r ContextMentionResult) Chosen() (ContextItem, bool) { return r.chosen, r.ok } + +// ContextMentionMenu pops up items grouped by kind while the composer's text ends in "@query". +// The caller filters items for Query, possibly asynchronously, and attaches the chosen one. +func ContextMentionMenu(c *ui.Context, composer InputResult, value *string, items []ContextItem, opts ContextMentionOptions) ContextMentionResult { + var rows []slashMenuRow + var order []int + for kind := ContextFile; kind <= ContextWeb; kind++ { + for i, it := range items { + if it.ID == "" || it.Label == "" { + panic("mujica: ContextItem needs ID and Label") + } + if it.Kind == kind { + rows = append(rows, slashMenuRow{label: it.Label, detail: it.Detail, icon: kind.icon(), group: msg(c, kind.group())}) + order = append(order, i) + } + } + } + if len(order) != len(items) { + panic("mujica: unknown ContextKind") + } + st := ui.Local(composer.Element, slashMenuKey{"mention"}, func() slashMenuState { return slashMenuState{} }) + at := contextMentionAt(*value) + var r ContextMentionResult + if at >= 0 { + r.query = (*value)[at+1:] + } + // A new query, or the menu opening, asks the caller for items. + r.queryChanged = at >= 0 && (r.query != st.query || !st.open && st.dismissed == nil) + empty := msg(c, "context_mention_menu.empty") + if opts.Loading { + empty = msg(c, "context_mention_menu.loading") + } + i, _ := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: msg(c, "context_mention_menu.label"), loading: opts.Loading}) + r.Element = st.panel + if i >= 0 { + r.chosen, r.ok = items[order[i]], true + *value = (*value)[:at] + slog.Debug("mujica: context_mention_menu choose", "id", r.chosen.ID) + } + return r +} + +// contextMentionAt returns where the trailing "@query" starts, or -1. +func contextMentionAt(s string) int { + at := strings.LastIndexByte(s, '@') + if at < 0 || strings.ContainsAny(s[at+1:], " \t\n") || at > 0 && !strings.ContainsRune(" \t\n", rune(s[at-1])) { + return -1 + } + return at +} diff --git a/context_mention_menu_test.go b/context_mention_menu_test.go new file mode 100644 index 0000000..4640122 --- /dev/null +++ b/context_mention_menu_test.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +var mentionSample = []ContextItem{ + {ID: "w1", Label: "Versailles — Wikipedia", Detail: "en.wikipedia.org", Kind: ContextWeb}, + {ID: "f1", Label: "guests.csv", Detail: "~/ball", Kind: ContextFile}, + {ID: "d1", Label: "Protocol", Detail: "Notion", Kind: ContextDoc}, + {ID: "f2", Label: "seating.xlsx", Detail: "~/ball", Kind: ContextFile}, +} + +// mentionTester filters items as an async source would, one frame late. +type mentionTester struct { + text string + items []ContextItem + loading bool + chosen []string + queries []string +} + +func (m *mentionTester) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() { + p := PromptComposer(c, &m.text, PromptComposerOptions{Label: "Prompt"}) + r := ContextMentionMenu(c, p, &m.text, m.items, ContextMentionOptions{Loading: m.loading}) + if r.QueryChanged() { + m.queries = append(m.queries, r.Query()) + m.items = nil + for _, it := range mentionSample { + if strings.Contains(strings.ToLower(it.Label), strings.ToLower(r.Query())) { + m.items = append(m.items, it) + } + } + } + if it, ok := r.Chosen(); ok { + m.chosen = append(m.chosen, it.ID) + } + }) +} + +func TestContextMentionMenuGroupsAndChooses(t *testing.T) { + m := &mentionTester{} + tt := ui.NewTester(m.view, 600, 500) + _ = tt.Click("Prompt") + tt.Type("Read @") + tt.Frame() + files, _ := tt.Find("Files") + docs, _ := tt.Find("Documents") + web, ok := tt.Find("Web pages") + if !ok || !(files.Y < docs.Y && docs.Y < web.Y) { + t.Fatalf("groups not in file, doc, web order: %q", tt.Texts()) + } + tt.Type("s") + tt.Frame() + if len(m.queries) < 2 || m.queries[len(m.queries)-1] != "s" { + t.Fatalf("queries %q", m.queries) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(m.chosen) != 1 || m.text != "Read " { + t.Fatalf("chosen %v text %q", m.chosen, m.text) + } +} + +func TestContextMentionMenuOnlyAfterSpaceAndLoading(t *testing.T) { + m := &mentionTester{loading: true} + tt := ui.NewTester(m.view, 600, 500) + _ = tt.Click("Prompt") + tt.Type("mail@host") + if tt.HasText("Files") || len(m.queries) != 0 { + t.Fatal("an @ inside a word opened the menu") + } + tt.Type(" @x") + m.items = nil + tt.Frame() + if !tt.HasText("Searching…") { + t.Fatalf("loading state missing: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if len(m.chosen) != 0 { + t.Fatal("Enter chose from an empty list") + } +} + +func TestContextMentionMenuBadItem(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an item without ID") + } + }() + m := &mentionTester{items: []ContextItem{{Label: "x"}}} + ui.NewTester(m.view, 300, 300) +} + +func TestContextMentionMenuSnapshot(t *testing.T) { + snapshotAfter(t, "context_mention_menu", 560, 420, func(c *ui.Context) { + (*ui.Local(c.Root(), "m", func() *mentionTester { return &mentionTester{items: mentionSample} })).view(c) + }, func(tt *ui.Tester) { + _ = tt.Click("Prompt") + tt.Type("Compare @") + tt.Frame() + }) +} diff --git a/context_window_meter.go b/context_window_meter.go new file mode 100644 index 0000000..068fc4e --- /dev/null +++ b/context_window_meter.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ContextSegment is one part of the context window, as system, history or attachments. +type ContextSegment struct { + Label string + Tokens int +} + +// ContextWindowMeterOptions configure a ContextWindowMeter. +type ContextWindowMeterOptions struct { + // Warn is the used share that shows the warning; zero means 0.8. + Warn float64 +} + +// ContextWindowMeter shows how the context window is used, segment by segment, and warns past a threshold. +// Each segment has a palette color and a legend marker shape, so it never relies on color alone. +func ContextWindowMeter(c *ui.Context, segments []ContextSegment, limit int, opts ContextWindowMeterOptions) *ui.Element { + if limit <= 0 || opts.Warn < 0 || opts.Warn > 1 { + panic("mujica: ContextWindowMeter needs limit > 0 and 0 <= Warn <= 1") + } + used := 0 + for _, s := range segments { + if s.Label == "" || s.Tokens < 0 { + panic("mujica: ContextSegment needs a Label and non-negative Tokens") + } + used += s.Tokens + } + warn := opts.Warn + if warn == 0 { + warn = 0.8 + } + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + pal := internal.Palette(k) + share := float64(used) / float64(limit) + pct := int(math.Round(share * 100)) + summary := fmt.Sprintf(msg(c, "context_window_meter.used"), tokenThousands(used), tokenThousands(limit), pct) + col := ui.Column(c).Gap(u * 2).MinWidth(200).Role(ui.RoleMeter).Label(msg(c, "context_window_meter.label")).Description(summary) + col.Children(func() { + ui.Row(c).Gap(u * 2).Children(func() { + ui.Text(c, msg(c, "context_window_meter.label")).FontWeight(600).Grow(1).SingleLine() + ui.Text(c, summary).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).SingleLine() + }) + ui.Box(c).Height(10).Radius(theme.ControlRadius).Background(k.Border).Clip().Draw(func(p *ui.Painter, r ui.Rect) { + x := r.X + for i, s := range segments { + w := r.W * float32(min(float64(s.Tokens)/float64(limit), 1)) + w = min(w, r.X+r.W-x) + p.Fill(ui.Rect{X: x, Y: r.Y, W: w, H: r.H}, pal[i%len(pal)], 0) + x += w + // A surface gap parts neighbouring segments. + if i < len(segments)-1 && w > 0 { + p.Fill(ui.Rect{X: internal.Snap(p, x-1), Y: r.Y, W: 1, H: r.H}, k.Surface, 0) + } + } + mark := r.X + r.W*float32(warn) + p.Fill(ui.Rect{X: internal.Snap(p, mark), Y: r.Y, W: 1, H: r.H}, k.Text, 0) + }) + ui.Row(c).Gap(u * 4).Wrap().Children(func() { + for i, s := range segments { + ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { + internal.Marker(p, i, r.X+r.W/2, r.Y+r.H/2, 9, pal[i%len(pal)]) + }) + ui.Text(c, s.Label+" "+tokenThousands(s.Tokens)).FontSize(small).FontFeatures("tnum").SingleLine() + }) + } + ui.Text(c, msg(c, "context_window_meter.free")+" "+tokenThousands(max(limit-used, 0))).FontSize(small). + FontFeatures("tnum").TextColor(k.TextMuted).SingleLine() + }) + if share >= warn { + color := k.Warning + if used > limit { + color = k.Danger + } + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(color).Role(ui.RoleStatus).Children(func() { + ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14) + ui.Text(c, fmt.Sprintf(msg(c, "context_window_meter.warn"), pct)).FontSize(small) + }) + } + }) + return col +} diff --git a/context_window_meter_test.go b/context_window_meter_test.go new file mode 100644 index 0000000..f961478 --- /dev/null +++ b/context_window_meter_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +var contextSegmentsSample = []ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}, {Label: "Attachments", Tokens: 24000}} + +func TestContextWindowMeterLegendAndWarning(t *testing.T) { + segs := contextSegmentsSample + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ContextWindowMeter(c, segs, 128000, ContextWindowMeterOptions{}) + }, 600, 200) + for _, s := range []string{"System 1,200", "History 58,000", "Attachments 24,000", "Free 44,800", "83,200 of 128,000 tokens (65%)"} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + if tt.HasText("Context is") { + t.Fatal("warning below the threshold") + } + segs = append(segs, ContextSegment{Label: "Draft", Tokens: 30000}) + tt.Frame() + if !tt.HasText("Context is 88% full") { + t.Fatalf("warning missing: %q", tt.Texts()) + } +} + +func TestContextWindowMeterPaletteContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + for i, col := range internal.Palette(k) { + if r := theme.Contrast(col, k.Surface); r < 3 { + t.Errorf("dark %v series %d contrast %.2f", k.Dark, i, r) + } + } + } +} + +func TestContextWindowMeterBadOptions(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a zero limit") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ContextWindowMeter(c, nil, 0, ContextWindowMeterOptions{}) + }, 100, 100) +} + +func TestContextWindowMeterSnapshot(t *testing.T) { + snapshot(t, "context_window_meter", 520, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ContextWindowMeter(c, append(contextSegmentsSample, ContextSegment{Label: "Draft", Tokens: 30000}), 128000, ContextWindowMeterOptions{}) + }) + }) +} diff --git a/drag_drop_overlay.go b/drag_drop_overlay.go new file mode 100644 index 0000000..c4cbf45 --- /dev/null +++ b/drag_drop_overlay.go @@ -0,0 +1,100 @@ +package mujica + +import ( + "fmt" + "log/slog" + "path/filepath" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// DragDropOverlayOptions configure a DragDropOverlay. +type DragDropOverlayOptions struct { + // Hint replaces the built-in "Drop files to attach". + Hint string + // Accept checks each dropped path; nil accepts all. + Accept func(path string) bool +} + +// DropResult is the overlay's element and the files dropped this frame. +type DropResult struct { + Element *ui.Element + accepted []string + rejected []string +} + +// Accepted returns the dropped paths Accept took; Rejected those it refused. +func (r DropResult) Accepted() []string { return r.accepted } +func (r DropResult) Rejected() []string { return r.rejected } + +// DragDropOverlay wraps content; while files from other apps are dragged over it, a full mask shows the hint. +func DragDropOverlay(c *ui.Context, opts DragDropOverlayOptions, content func()) DropResult { + if content == nil { + panic("mujica: DragDropOverlay needs content") + } + k := Tokens(c) + u := Density(c).Unit() + var r DropResult + r.Element = ui.Box(c).MinHeight(0).MinWidth(0) + disabled := r.Element.IsDisabled() + rejected := ui.Local(r.Element, "rejected", func() []string { return nil }) + over := !disabled && r.Element.FileDragOver() + if files := r.Element.DroppedFiles(); len(files) > 0 && !disabled { + r.accepted, r.rejected = dragDropSplit(files, opts.Accept) + *rejected = r.rejected + slog.Debug("mujica: drag_drop_overlay drop", "accepted", len(r.accepted), "rejected", len(r.rejected)) + } + if over { + *rejected = nil + } + hint := opts.Hint + if hint == "" { + hint = msg(c, "drag_drop_overlay.hint") + } + r.Element.Children(func() { + content() + if len(*rejected) > 0 && !over { + names := make([]string, len(*rejected)) + for i, p := range *rejected { + names[i] = filepath.Base(p) + } + ui.Text(c, fmt.Sprintf(msg(c, "drag_drop_overlay.rejected"), strings.Join(names, ", "))). + TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus).Padding(u, u*2) + } + if over { + dragDropMask(c, hint) + } + }) + return r +} + +// dragDropMask covers its parent with the drop hint. +func dragDropMask(c *ui.Context, hint string) { + k := Tokens(c) + u := Density(c).Unit() + mask := ui.Column(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Gap(u * 2). + Background(k.Background.Alpha(0.92)).Radius(theme.CardRadius).PassThrough().Role(ui.RoleStatus).Label(hint) + mask.Opacity(overlayEnter(c, mask)) + mask.Draw(func(p *ui.Painter, b ui.Rect) { internal.Frame(p, b, u*2, 5, k.Ornament) }) + mask.Children(func() { + ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText) + ui.Text(c, hint).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) + }) +} + +// dragDropSplit sorts dropped paths by accept, nil accepting all. +func dragDropSplit(files []string, accept func(string) bool) (ok, no []string) { + for _, f := range files { + if accept == nil || accept(f) { + ok = append(ok, f) + } else { + no = append(no, f) + } + } + return ok, no +} diff --git a/drag_drop_overlay_test.go b/drag_drop_overlay_test.go new file mode 100644 index 0000000..9ed981a --- /dev/null +++ b/drag_drop_overlay_test.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestDragDropOverlayIdleShowsContentOnly(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DragDropOverlay(c, DragDropOverlayOptions{}, func() { ui.Text(c, "Chat") }) + }, 300, 200) + if !tt.HasText("Chat") || tt.HasText("Drop files to attach") { + t.Fatalf("idle overlay: %q", tt.Texts()) + } +} + +func TestDragDropOverlaySplitsByAccept(t *testing.T) { + images := func(p string) bool { return strings.HasSuffix(p, ".png") } + ok, no := dragDropSplit([]string{"/a.png", "/b.exe", "/c.png"}, images) + if !slices.Equal(ok, []string{"/a.png", "/c.png"}) || !slices.Equal(no, []string{"/b.exe"}) { + t.Fatalf("ok %v no %v", ok, no) + } + ok, no = dragDropSplit([]string{"/b.exe"}, nil) + if len(ok) != 1 || no != nil { + t.Fatalf("nil accept: ok %v no %v", ok, no) + } +} + +func TestDragDropOverlayNeedsContent(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without content") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DragDropOverlay(c, DragDropOverlayOptions{}, nil) + }, 100, 100) +} + +// TestDragDropOverlaySnapshot draws the mask shown while files are over the area. +func TestDragDropOverlaySnapshot(t *testing.T) { + snapshot(t, "drag_drop_overlay", 420, 220, func(c *ui.Context) { + ui.Box(c).Fill().Padding(12).Children(func() { + ui.Text(c, "A conversation sits under the mask.") + dragDropMask(c, msg(c, "drag_drop_overlay.hint")) + }) + }) +} diff --git a/icons/svg/at-sign.svg b/icons/svg/at-sign.svg new file mode 100644 index 0000000..215e899 --- /dev/null +++ b/icons/svg/at-sign.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/bot.svg b/icons/svg/bot.svg new file mode 100644 index 0000000..fd35df9 --- /dev/null +++ b/icons/svg/bot.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/icons/svg/globe.svg b/icons/svg/globe.svg new file mode 100644 index 0000000..736ac9b --- /dev/null +++ b/icons/svg/globe.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/key-round.svg b/icons/svg/key-round.svg new file mode 100644 index 0000000..7e73cc9 --- /dev/null +++ b/icons/svg/key-round.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/mic-off.svg b/icons/svg/mic-off.svg new file mode 100644 index 0000000..2c76f1b --- /dev/null +++ b/icons/svg/mic-off.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/icons/svg/mic.svg b/icons/svg/mic.svg new file mode 100644 index 0000000..0fa775a --- /dev/null +++ b/icons/svg/mic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/paperclip.svg b/icons/svg/paperclip.svg new file mode 100644 index 0000000..9f77f61 --- /dev/null +++ b/icons/svg/paperclip.svg @@ -0,0 +1,14 @@ + + + diff --git a/icons/svg/shield-check.svg b/icons/svg/shield-check.svg new file mode 100644 index 0000000..f7002a9 --- /dev/null +++ b/icons/svg/shield-check.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/sliders-horizontal.svg b/icons/svg/sliders-horizontal.svg new file mode 100644 index 0000000..d0323e8 --- /dev/null +++ b/icons/svg/sliders-horizontal.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/icons/svg/square.svg b/icons/svg/square.svg new file mode 100644 index 0000000..0addb4f --- /dev/null +++ b/icons/svg/square.svg @@ -0,0 +1,14 @@ + + + diff --git a/icons/svg/telescope.svg b/icons/svg/telescope.svg new file mode 100644 index 0000000..38cf16b --- /dev/null +++ b/icons/svg/telescope.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/svg/terminal.svg b/icons/svg/terminal.svg new file mode 100644 index 0000000..63f090f --- /dev/null +++ b/icons/svg/terminal.svg @@ -0,0 +1,15 @@ + + + + diff --git a/messages_chatinput.go b/messages_chatinput.go new file mode 100644 index 0000000..3a9744f --- /dev/null +++ b/messages_chatinput.go @@ -0,0 +1,64 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "prompt_composer.label": {"Message", "消息"}, + "slash_command_menu.label": {"Commands", "命令"}, + "slash_command_menu.empty": {"No matching commands", "没有匹配的命令"}, + "context_mention_menu.label": {"Add context", "添加上下文"}, + "context_mention_menu.empty": {"No matches", "无匹配"}, + "context_mention_menu.loading": {"Searching…", "搜索中…"}, + "context_mention_menu.file": {"Files", "文件"}, + "context_mention_menu.doc": {"Documents", "文档"}, + "context_mention_menu.web": {"Web pages", "网页"}, + "attachment_chip.remove": {"Remove %s", "移除 %s"}, + "attachment_chip.retry": {"Retry upload of %s", "重试上传 %s"}, + "attachment_chip.uploading": {"Uploading %d%%", "上传中 %d%%"}, + "attachment_chip.failed": {"Upload failed", "上传失败"}, + "paste_image_preview.remove": {"Remove image %s", "移除图片 %s"}, + "paste_image_preview.label": {"Pasted images", "粘贴的图片"}, + "drag_drop_overlay.hint": {"Drop files to attach", "松开以添加文件"}, + "drag_drop_overlay.rejected": {"Not accepted: %s", "不支持:%s"}, + "voice_input_button.start": {"Start voice input", "开始语音输入"}, + "voice_input_button.stop": {"Stop recording, %s", "停止录音,%s"}, + "voice_input_button.denied": {"Microphone access denied", "麦克风权限被拒绝"}, + "voice_waveform.label": {"Input level", "输入电平"}, + "voice_waveform.silent": {"No sound detected", "未检测到声音"}, + "context_chips.label": {"Attached context", "已附加的上下文"}, + "context_chips.remove": {"Remove %s", "移除 %s"}, + "context_chips.more": {"+%d more", "另有 %d 项"}, + "context_chips.less": {"Show less", "收起"}, + "model_selector.label": {"Model", "模型"}, + "model_selector.context": {"%s context", "%s 上下文"}, + "model_selector.placeholder": {"Choose a model", "选择模型"}, + "mode_selector.label": {"Mode", "模式"}, + "mode_selector.chat": {"Chat", "对话"}, + "mode_selector.search": {"Search", "搜索"}, + "mode_selector.research": {"Deep research", "深度研究"}, + "mode_selector.agent": {"Agent", "智能体"}, + "tool_toggle_menu.label": {"Tools", "工具"}, + "tool_toggle_menu.count": {"Tools, %d on", "工具,已开 %d 个"}, + "tool_toggle_menu.needs_auth": {"Needs authorization", "需要授权"}, + "tool_toggle_menu.authorize_short": {"Authorize", "授权"}, + "tool_toggle_menu.authorize": {"Authorize %s", "授权 %s"}, + "parameter_panel.label": {"Parameters", "参数"}, + "parameter_panel.reset": {"Reset %s", "重置 %s"}, + "parameter_panel.reset_all": {"Reset all", "全部重置"}, + "parameter_panel.default": {"Default %s", "默认 %s"}, + "token_counter.label": {"About %s of %s tokens", "约 %s / %s 个 token"}, + "token_counter.over": {"%s over the limit", "超出 %s"}, + "context_window_meter.label": {"Context window", "上下文窗口"}, + "context_window_meter.used": {"%s of %s tokens (%d%%)", "%s / %s token(%d%%)"}, + "context_window_meter.warn": {"Context is %d%% full", "上下文已用 %d%%"}, + "context_window_meter.free": {"Free", "剩余"}, + "send_button.send": {"Send", "发送"}, + "send_button.stop": {"Stop generating", "停止生成"}, + "citation_badge.label": {"Source %d: %s", "来源 %d:%s"}, + "source_card.open": {"Open %s", "打开 %s"}, + "sources_panel.label": {"Sources", "来源"}, + "sources_panel.empty": {"No sources", "没有来源"}, + "search_progress.searching": {"Searching for “%s”", "正在搜索“%s”"}, + "search_progress.read": {"Read %d of %d sources", "已读 %d / %d 个来源"}, + "search_progress.done": {"Searched “%s”, read %d sources", "已搜索“%s”,读了 %d 个来源"}, + }) +} diff --git a/mode_selector.go b/mode_selector.go new file mode 100644 index 0000000..953667a --- /dev/null +++ b/mode_selector.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// ChatMode is how the assistant answers. +type ChatMode int + +const ( + ModeChat ChatMode = iota + ModeSearch + ModeResearch + ModeAgent +) + +// chatModes names each mode's copy key and icon, in order. +var chatModes = [...][2]string{ + {"mode_selector.chat", "message-square"}, + {"mode_selector.search", "globe"}, + {"mode_selector.research", "telescope"}, + {"mode_selector.agent", "bot"}, +} + +// ModeSelectorOptions configure a ModeSelector. +type ModeSelectorOptions struct { + // IconsOnly hides the labels, which then name the icons. + IconsOnly bool + Disabled bool +} + +// ModeSelector switches among chat, search, deep research and agent modes; arrows move the choice. +func ModeSelector(c *ui.Context, mode *ChatMode, opts ModeSelectorOptions) ChoiceResult { + if *mode < ModeChat || *mode > ModeAgent { + panic("mujica: unknown ChatMode") + } + segs := make([]Segment, len(chatModes)) + for i, m := range chatModes { + segs[i] = Segment{Label: msg(c, m[0]), Icon: icons.Must(m[1]), HideLabel: opts.IconsOnly} + } + i := int(*mode) + e := SegmentedControl(c, &i, segs, SegmentedControlOptions{Label: msg(c, "mode_selector.label"), Disabled: opts.Disabled}) + r := ChoiceResult{Element: e, changed: ChatMode(i) != *mode} + if r.changed { + *mode = ChatMode(i) + slog.Debug("mujica: mode_selector changed", "mode", i) + } + return r +} diff --git a/mode_selector_test.go b/mode_selector_test.go new file mode 100644 index 0000000..9464ec0 --- /dev/null +++ b/mode_selector_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestModeSelectorClickAndArrows(t *testing.T) { + mode, n, off := ModeChat, 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ModeSelector(c, &mode, ModeSelectorOptions{Disabled: off}).Changed() { + n++ + } + }, 600, 100) + _ = tt.Click("Deep research") + if mode != ModeResearch || n != 1 { + t.Fatalf("click: mode %d changes %d", mode, n) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if mode != ModeAgent { + t.Fatalf("Right: mode %d", mode) + } + off = true + _ = tt.Click("Chat") + if mode != ModeAgent { + t.Fatal("disabled selector changed") + } +} + +func TestModeSelectorIconsOnlyAndBadMode(t *testing.T) { + mode := ModeSearch + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ModeSelector(c, &mode, ModeSelectorOptions{IconsOnly: true}) + }, 300, 100) + if _, ok := tt.Find("Search"); !ok { + t.Fatal("icon segments unnamed") + } + defer func() { + if recover() == nil { + t.Fatal("no panic for an unknown mode") + } + }() + bad := ChatMode(9) + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ModeSelector(c, &bad, ModeSelectorOptions{}) + }, 100, 100) +} + +func TestModeSelectorSnapshot(t *testing.T) { + mode := ModeResearch + snapshot(t, "mode_selector", 520, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() { + ModeSelector(c, &mode, ModeSelectorOptions{}) + ModeSelector(c, &mode, ModeSelectorOptions{IconsOnly: true}) + }) + }) +} diff --git a/model_selector.go b/model_selector.go new file mode 100644 index 0000000..2f53b9e --- /dev/null +++ b/model_selector.go @@ -0,0 +1,150 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ModelOption is one model a ModelSelector offers. +type ModelOption struct { + // ID is the value chosen; required. + ID string + Name string + // Capabilities are short tags, as "Vision" or "Tools". + Capabilities []string + // ContextTokens is the context length; zero hides it. + ContextTokens int + // Unavailable explains why the model cannot be chosen; empty means available. + Unavailable string +} + +// ModelSelectorOptions configure a ModelSelector. +type ModelSelectorOptions struct { + Disabled bool + // Label names the selector; empty uses the built-in "Model". + Label string +} + +// ModelSelector chooses a model ID into *value from a list showing capabilities, context length and why some are unavailable. +func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts ModelSelectorOptions) ChoiceResult { + for _, m := range models { + if m.ID == "" || m.Name == "" || m.ContextTokens < 0 { + panic("mujica: ModelOption needs ID, Name and a non-negative ContextTokens") + } + } + k := Tokens(c) + u := Density(c).Unit() + old := *value + pick := old + var s *ui.SelectParts[string] + ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, &pick) }) + tr := s.Trigger + label := opts.Label + if label == "" { + label = msg(c, "model_selector.label") + } + tr.Label(label) + cur, has := modelSelectorFind(models, *value) + selectField(c, tr, s.Open(), func() { + if has { + ui.Text(c, cur.Name).SingleLine().FontWeight(600).Grow(1).Shrink(1) + } else { + ui.Text(c, msg(c, "model_selector.placeholder")).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1) + } + }) + if opts.Disabled { + return ChoiceResult{Element: tr} + } + s.Popup(func(panel *ui.Element) { + selectPanel(c, panel) + panel.MinWidth(320) + ui.Scroll(c).MaxHeight(360).Children(func() { + for _, m := range models { + var row *ui.Element + if m.Unavailable != "" { + row = ui.Row(c).Disabled(true).Description(m.Unavailable) + } else { + row = s.Item(m.ID) + } + hi := m.Unavailable == "" && row.Highlighted() + chosen := m.ID == *value + row.Padding(u*2, u*3, u*2, u*2).Gap(u * 2).AlignItems(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name) + switch { + case chosen: + row.Background(k.Selection) + case hi: + row.Background(k.SurfaceHover) + } + if hi { + row.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) }) + } + row.Children(func() { + selectCheck(c, chosen) + modelSelectorRow(c, m) + }) + } + }) + }) + if pick != *value && !tr.IsDisabled() { + if m, ok := modelSelectorFind(models, pick); ok && m.Unavailable == "" { + *value = pick + } + } + changed := *value != old + if changed { + slog.Debug("mujica: model_selector changed", "model", *value) + } + return ChoiceResult{Element: tr, changed: changed} +} + +// modelSelectorRow shows a model's name, tags, context and unavailability. +func modelSelectorRow(c *ui.Context, m ModelOption) { + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + ui.Column(c).Gap(u).Grow(1).MinWidth(0).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, m.Name).FontWeight(600).SingleLine() + if m.ContextTokens > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "model_selector.context"), modelSelectorTokens(m.ContextTokens))). + FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine() + } + }) + if len(m.Capabilities) > 0 { + ui.Row(c).Gap(u).Wrap().Children(func() { + for _, cap := range m.Capabilities { + ui.Text(c, cap).FontSize(small).Padding(0, u*1.5).Radius(theme.ControlRadius). + Border(theme.BorderWidth, k.Border).TextColor(k.AccentText).SingleLine() + } + }) + } + if m.Unavailable != "" { + ui.Text(c, m.Unavailable).FontSize(small).TextColor(k.TextMuted).Italic() + } + }) +} + +func modelSelectorFind(models []ModelOption, id string) (ModelOption, bool) { + for _, m := range models { + if m.ID == id { + return m, true + } + } + return ModelOption{}, false +} + +// modelSelectorTokens writes a token count as 8K, 200K or 1M. +func modelSelectorTokens(n int) string { + switch { + case n >= 1_000_000 && n%100_000 == 0: + return strconv.FormatFloat(float64(n)/1e6, 'f', -1, 64) + "M" + case n >= 1000: + return strconv.Itoa(n/1000) + "K" + } + return strconv.Itoa(n) +} diff --git a/model_selector_test.go b/model_selector_test.go new file mode 100644 index 0000000..99f876a --- /dev/null +++ b/model_selector_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var modelsSample = []ModelOption{ + {ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision", "Tools"}, ContextTokens: 200000}, + {ID: "page", Name: "Page Mini", Capabilities: []string{"Fast"}, ContextTokens: 32000}, + {ID: "oracle", Name: "Oracle", ContextTokens: 1000000, Unavailable: "Needs the Court plan"}, +} + +func modelTester(v *string, changes *int, off *bool) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Children(func() { + if ModelSelector(c, v, modelsSample, ModelSelectorOptions{Disabled: *off}).Changed() { + *changes++ + } + }) + }, 500, 500) +} + +func TestModelSelectorShowsDetailsAndChooses(t *testing.T) { + v, n, off := "herald", 0, false + tt := modelTester(&v, &n, &off) + _ = tt.Click("Model") + for _, s := range []string{"200K context", "Vision", "1M context", "Needs the Court plan", "32K context"} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + _ = tt.Click("Oracle") + if v != "herald" || n != 0 { + t.Fatalf("unavailable model chosen: %q", v) + } + tt.Key(0, ui.KeyEscape) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if v != "page" || n != 1 { + t.Fatalf("keyboard choice %q changes %d (Down stops before the unavailable model)", v, n) + } +} + +func TestModelSelectorDisabled(t *testing.T) { + v, n, off := "herald", 0, true + tt := modelTester(&v, &n, &off) + _ = tt.Click("Model") + if tt.HasText("Page Mini") { + t.Fatal("disabled selector opened") + } +} + +func TestModelSelectorTokens(t *testing.T) { + for n, want := range map[int]string{512: "512", 8000: "8K", 200000: "200K", 1000000: "1M", 1500000: "1.5M"} { + if got := modelSelectorTokens(n); got != want { + t.Errorf("%d: %q, want %q", n, got, want) + } + } +} + +func TestModelSelectorSnapshot(t *testing.T) { + snapshotAfter(t, "model_selector", 440, 330, func(c *ui.Context) { + v := ui.Local(c.Root(), "v", func() string { return "herald" }) + ui.Column(c).Padding(16).Children(func() { ModelSelector(c, v, modelsSample, ModelSelectorOptions{}) }) + }, func(tt *ui.Tester) { _ = tt.Click("Model") }) +} diff --git a/parameter_panel.go b/parameter_panel.go new file mode 100644 index 0000000..044f68a --- /dev/null +++ b/parameter_panel.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ModelParam is one generation parameter, as temperature or top-p. +type ModelParam struct { + // Label and Value are required. + Label string + Value *float64 + // Min, Max and Step bound the value; Default lies within them. + Min, Max, Step, Default float64 +} + +// ParameterPanel adjusts generation parameters with sliders; each shows its range and resets to its default. +func ParameterPanel(c *ui.Context, params []ModelParam) ChoiceResult { + for _, p := range params { + if p.Label == "" || p.Value == nil || !(p.Min < p.Max) || p.Step <= 0 || p.Default < p.Min || p.Default > p.Max { + panic("mujica: ModelParam needs Label, Value, Min < Max, Step > 0 and Default in range") + } + } + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + var r ChoiceResult + r.Element = ui.Column(c).Gap(u * 4).MinWidth(240).Role(ui.RoleGroup).Label(msg(c, "parameter_panel.label")) + disabled := r.Element.IsDisabled() + set := func(p ModelParam, v float64) { + if *p.Value != v && !disabled { + *p.Value, r.changed = v, true + slog.Debug("mujica: parameter_panel set", "param", p.Label, "value", v) + } + } + r.Element.Children(func() { + for _, p := range params { + format := func(v float64) string { return strconv.FormatFloat(v, 'f', sliderDecimals(p.Min, p.Step, p.Default), 64) } + ui.Column(c).Key(p.Label).Gap(u).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, p.Label).FontWeight(600).Grow(1).SingleLine() + ui.Text(c, format(*p.Value)).FontFeatures("tnum").TextColor(k.AccentText).SingleLine() + ui.Row(c).Disabled(*p.Value == p.Default).Children(func() { + if iconAction(c, icons.Must("rotate-ccw"), fmt.Sprintf(msg(c, "parameter_panel.reset"), p.Label)). + Tooltip(fmt.Sprintf(msg(c, "parameter_panel.default"), format(p.Default))).Clicked() { + set(p, p.Default) + } + }) + }) + v := *p.Value + if Slider(c, &v, SliderOptions{Min: p.Min, Max: p.Max, Step: p.Step, Label: p.Label, Disabled: disabled}).Changed() { + set(p, v) + } + ui.Row(c).Children(func() { + ui.Text(c, format(p.Min)).FontSize(small).TextColor(k.TextMuted).Grow(1) + ui.Text(c, fmt.Sprintf(msg(c, "parameter_panel.default"), format(p.Default))).FontSize(small).TextColor(k.TextMuted) + ui.Text(c, format(p.Max)).FontSize(small).TextColor(k.TextMuted).Grow(1).TextAlign(ui.End) + }) + }) + } + ui.Row(c).Justify(ui.End).Children(func() { + if Button(c, msg(c, "parameter_panel.reset_all"), ButtonOptions{Variant: Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() { + for _, p := range params { + set(p, p.Default) + } + } + }) + }) + return r +} diff --git a/parameter_panel_test.go b/parameter_panel_test.go new file mode 100644 index 0000000..928a4d4 --- /dev/null +++ b/parameter_panel_test.go @@ -0,0 +1,69 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func paramSample(temp, topP *float64) []ModelParam { + return []ModelParam{ + {Label: "Temperature", Value: temp, Min: 0, Max: 2, Step: 0.1, Default: 1}, + {Label: "Top-p", Value: topP, Min: 0, Max: 1, Step: 0.05, Default: 1}, + } +} + +func TestParameterPanelAdjustAndReset(t *testing.T) { + temp, topP, n, off := 1.0, 0.9, 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Disabled(off).Children(func() { + if ParameterPanel(c, paramSample(&temp, &topP)).Changed() { + n++ + } + }) + }, 500, 400) + for _, s := range []string{"1.0", "0.90", "Default 1.0", "2.0"} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if temp != 1.1 || n != 1 { + t.Fatalf("Right: temp %v changes %d", temp, n) + } + _ = tt.Click("Reset Temperature") + if temp != 1 { + t.Fatalf("reset temp %v", temp) + } + _ = tt.Click("Reset all") + if topP != 1 || n != 3 { + t.Fatalf("reset all: topP %v changes %d", topP, n) + } + off = true + tt.Key(0, ui.KeyRight) + if temp != 1 { + t.Fatal("disabled panel changed") + } +} + +func TestParameterPanelBadParam(t *testing.T) { + v := 0.0 + defer func() { + if recover() == nil { + t.Fatal("no panic for a default outside the range") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ParameterPanel(c, []ModelParam{{Label: "x", Value: &v, Min: 0, Max: 1, Step: 0.1, Default: 3}}) + }, 100, 100) +} + +func TestParameterPanelSnapshot(t *testing.T) { + temp, topP := 0.7, 1.0 + snapshot(t, "parameter_panel", 420, 260, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { ParameterPanel(c, paramSample(&temp, &topP)) }) + }) +} diff --git a/paste_image_preview.go b/paste_image_preview.go new file mode 100644 index 0000000..0ef07e2 --- /dev/null +++ b/paste_image_preview.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PastedImage is an image pasted or dropped into a prompt; the caller decodes it. +type PastedImage struct { + // ID is the image's stable identity; required. + ID string + Source ui.ImageSource + // Name describes it for assistive technology and the remove button. + Name string +} + +// pasteImageSize is the thumbnail's edge in DIPs. +const pasteImageSize float32 = 72 + +// PasteImagePreview shows pasted or dropped images as thumbnails, each with a remove button. +func PasteImagePreview(c *ui.Context, images []PastedImage) RemoveResult { + k := Tokens(c) + u := Density(c).Unit() + var r RemoveResult + r.Element = ui.Row(c).Gap(u * 2).Wrap().Role(ui.RoleList).Label(msg(c, "paste_image_preview.label")) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + for _, im := range images { + if im.ID == "" || im.Source == nil { + panic("mujica: PastedImage needs ID and Source") + } + ui.Box(c).Key(im.ID).Size(pasteImageSize, pasteImageSize).Role(ui.RoleListItem).Children(func() { + ui.Image(c, im.Source).Fit(ui.Cover).Size(pasteImageSize, pasteImageSize).Radius(theme.ControlRadius). + Border(theme.BorderWidth, k.Border).Label(im.Name) + x := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "paste_image_preview.remove"), im.Name)). + Absolute().Top(-u * 3).Right(-u * 3).Radius(theme.MinHitSize / 2) + // A 20 DIP disc inside the 28 DIP hit area. + x.Draw(func(p *ui.Painter, r ui.Rect) { + d := ui.Rect{X: r.X + 4, Y: r.Y + 4, W: r.W - 8, H: r.H - 8} + p.Fill(d, k.Surface, d.W/2) + p.Stroke(d, k.ControlBorder, d.W/2, theme.BorderWidth) + }) + if x.Clicked() && !disabled { + r.removed = im.ID + slog.Debug("mujica: paste_image_preview remove", "id", im.ID) + } + }) + } + }) + return r +} diff --git a/paste_image_preview_test.go b/paste_image_preview_test.go new file mode 100644 index 0000000..d58d9fd --- /dev/null +++ b/paste_image_preview_test.go @@ -0,0 +1,61 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func pasteImages() []PastedImage { + return []PastedImage{ + {ID: "a", Source: imageGridPicture("#7D2034"), Name: "screenshot 1"}, + {ID: "b", Source: imageGridPicture("#355E80"), Name: "screenshot 2"}, + } +} + +func TestPasteImagePreviewRemoves(t *testing.T) { + imgs, off := pasteImages(), false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Disabled(off).Children(func() { + if id, ok := PasteImagePreview(c, imgs).Removed(); ok { + for i, im := range imgs { + if im.ID == id { + imgs = append(imgs[:i], imgs[i+1:]...) + break + } + } + } + }) + }, 400, 150) + if _, ok := tt.Find("screenshot 2"); !ok { + t.Fatal("thumbnail not named") + } + _ = tt.Click("Remove image screenshot 1") + if len(imgs) != 1 || imgs[0].ID != "b" { + t.Fatalf("left %v", imgs) + } + off = true + _ = tt.Click("Remove image screenshot 2") + if len(imgs) != 1 { + t.Fatal("disabled preview removed an image") + } +} + +func TestPasteImagePreviewNeedsSource(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without a source") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PasteImagePreview(c, []PastedImage{{ID: "x"}}) + }, 100, 100) +} + +func TestPasteImagePreviewSnapshot(t *testing.T) { + snapshot(t, "paste_image_preview", 240, 110, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { PasteImagePreview(c, pasteImages()) }) + }) +} diff --git a/prompt_composer.go b/prompt_composer.go new file mode 100644 index 0000000..7221b67 --- /dev/null +++ b/prompt_composer.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" +) + +// PromptComposerOptions configure a PromptComposer. +type PromptComposerOptions struct { + Placeholder string + // Label names the field; empty uses the built-in "Message". + Label string + // CmdEnterSends sends with Cmd+Enter (Ctrl+Enter) and makes Enter a newline. + CmdEnterSends bool + // MaxHeight caps the field in DIPs before it scrolls; zero means 200. + MaxHeight float32 + // Attachments builds the row above the field, as AttachmentChips. + Attachments func() + // Tools builds the left of the bar below the field; Actions its right, as a SendButton. + Tools, Actions func() + Disabled bool +} + +// promptHooks are key handlers menus over a composer add each frame. +type promptHooks struct{ list []func(ui.InputEvent) bool } + +type promptHooksKey struct{} + +// PromptComposer is a multi-line prompt: Enter sends and Shift+Enter breaks the line, or Cmd+Enter sends. +// Submitted never fires for blank text. +func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) InputResult { + if value == nil { + panic("mujica: PromptComposer needs a value") + } + if opts.MaxHeight < 0 { + panic("mujica: PromptComposer MaxHeight must not be negative") + } + u := Density(c).Unit() + maxH := opts.MaxHeight + if maxH == 0 { + maxH = 200 + } + label := opts.Label + if label == "" { + label = msg(c, "prompt_composer.label") + } + var r InputResult + r.Element = ui.Column(c).Gap(u * 2).MinWidth(0).Disabled(opts.Disabled) + hooks := ui.Local(r.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} }) + h := *hooks + h.list = h.list[:0] + r.Element.Children(func() { + if opts.Attachments != nil { + ui.Row(c).Gap(u * 2).Wrap().Children(opts.Attachments) + } + ta := TextAreaOptions{Label: label, Placeholder: opts.Placeholder, MaxHeight: maxH, + keys: func(ev ui.InputEvent) bool { + for _, f := range h.list { + if f(ev) { + return true + } + } + return false + }} + if !opts.CmdEnterSends { + ta.SubmitKey = ui.KeyEnter + } + area := TextArea(c, value, ta) + r.Input, r.changed = area.Input, area.Changed() + r.submitted = area.Submitted() && strings.TrimSpace(*value) != "" + if opts.Tools != nil || opts.Actions != nil { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c).Gap(u).AlignItems(ui.Center).Grow(1).Children(func() { + if opts.Tools != nil { + opts.Tools() + } + }) + if opts.Actions != nil { + opts.Actions() + } + }) + } + }) + if r.submitted { + slog.Debug("mujica: prompt_composer submit", "chars", len(*value)) + } + return r +} + +// promptComposerHook adds a key handler that runs before the composer's editor this frame. +func promptComposerHook(composer InputResult, f func(ui.InputEvent) bool) { + if composer.Element == nil { + panic("mujica: menus need a built PromptComposer") + } + h := *ui.Local(composer.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} }) + h.list = append(h.list, f) +} diff --git a/prompt_composer_test.go b/prompt_composer_test.go new file mode 100644 index 0000000..abf9d04 --- /dev/null +++ b/prompt_composer_test.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func promptTester(text *string, sends *int, opts PromptComposerOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if PromptComposer(c, text, opts).Submitted() { + *sends++ + } + }, 500, 400) +} + +func TestPromptComposerEnterSendsShiftEnterBreaks(t *testing.T) { + text, sends := "", 0 + tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt"}) + _ = tt.Click("Prompt") + tt.Key(0, ui.KeyEnter) + if sends != 0 { + t.Fatal("blank prompt sent") + } + tt.Type("Bonjour") + tt.Key(ui.Shift, ui.KeyEnter) + tt.Type("Madame") + tt.Key(0, ui.KeyEnter) + if sends != 1 || text != "Bonjour\nMadame" { + t.Fatalf("sends %d text %q", sends, text) + } +} + +func TestPromptComposerCmdEnterMode(t *testing.T) { + text, sends := "", 0 + tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt", CmdEnterSends: true}) + _ = tt.Click("Prompt") + tt.Type("a") + tt.Key(0, ui.KeyEnter) + tt.Type("b") + tt.Key(ui.Cmd, ui.KeyEnter) + if sends != 1 || text != "a\nb" { + t.Fatalf("sends %d text %q", sends, text) + } +} + +func TestPromptComposerSlotsAndDisabled(t *testing.T) { + text, sends := "hello", 0 + tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt", Disabled: true}) + _ = tt.Click("Prompt") + tt.Type("x") + tt.Key(0, ui.KeyEnter) + if sends != 0 || text != "hello" { + t.Fatalf("disabled composer: sends %d text %q", sends, text) + } + tt = ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PromptComposer(c, &text, PromptComposerOptions{ + Attachments: func() { ui.Text(c, "report.pdf") }, + Tools: func() { ui.Text(c, "Web search") }, + Actions: func() { ui.Text(c, "Send it") }, + }) + }, 500, 400) + for _, s := range []string{"report.pdf", "Web search", "Send it", "Message"} { + if _, ok := tt.Find(s); !ok { + t.Errorf("missing %q", s) + } + } +} + +func TestPromptComposerSnapshot(t *testing.T) { + text := "Draft the invitation for the spring ball,\nin the style of the court." + snapshot(t, "prompt_composer", 520, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + PromptComposer(c, &text, PromptComposerOptions{ + Attachments: func() { AttachmentChip(c, Attachment{Name: "guests.csv", Size: 4096}) }, + Tools: func() { iconAction(c, icons.Must("paperclip"), "Attach") }, + Actions: func() { SendButton(c, SendButtonOptions{Shortcut: "Enter"}) }, + }) + }) + }) +} diff --git a/search_progress.go b/search_progress.go new file mode 100644 index 0000000..e4569a6 --- /dev/null +++ b/search_progress.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SearchStage is one step of a web search, as "Planning" or "Reading". +type SearchStage struct { + Label string + Done bool +} + +// SearchProgressOptions describe the caller's search. +type SearchProgressOptions struct { + // Query is the keyword searched; required. + Query string + // Read and Total count the sources read so far and found. + Read, Total int + Stages []SearchStage + // Done folds the block to a summary, which the user can open again. + Done bool +} + +// searchProgressState is the fold, open while searching until the search ends. +type searchProgressState struct { + open, done bool +} + +// SearchProgress shows a search's keyword, sources read and stages; once Done it folds to one line. +func SearchProgress(c *ui.Context, opts SearchProgressOptions) ChoiceResult { + if opts.Query == "" || opts.Read < 0 || opts.Total < 0 || opts.Read > opts.Total { + panic("mujica: SearchProgress needs a Query and 0 <= Read <= Total") + } + r := ChoiceResult{Element: ui.Column(c).MinWidth(0)} + st := ui.Local(r.Element, "search", func() searchProgressState { return searchProgressState{open: true} }) + if opts.Done && !st.done { + // Finishing folds it once; the user may open it again. + st.open = false + } + st.done = opts.Done + title := fmt.Sprintf(msg(c, "search_progress.searching"), opts.Query) + if opts.Done { + title = fmt.Sprintf(msg(c, "search_progress.done"), opts.Query, opts.Read) + } + r.Element.Children(func() { r.changed = searchProgressBody(c, st, title, opts) }) + return r +} + +// searchProgressBody is the collapsible header and the stages. +func searchProgressBody(c *ui.Context, st *searchProgressState, title string, opts SearchProgressOptions) bool { + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + return Collapsible(c, &st.open, CollapsibleOptions{Title: title, Trigger: func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() { + if opts.Done { + ui.Icon(c, icons.Must("search")).Size(14, 14) + } else { + Spinner(c, SpinnerOptions{Size: 14, Label: title}) + } + ui.Text(c, title).FontSize(small).SingleLine() + }) + }}, func() { + ui.Text(c, fmt.Sprintf(msg(c, "search_progress.read"), opts.Read, opts.Total)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted) + ui.Column(c).Gap(u).Role(ui.RoleList).Children(func() { + for _, s := range opts.Stages { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { + if s.Done { + ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.Success) + } else { + ui.Icon(c, icons.Must("circle")).Size(14, 14).TextColor(k.TextMuted) + } + t := ui.Text(c, s.Label).FontSize(small) + if !s.Done { + t.TextColor(k.TextMuted) + } + }) + } + }) + }).Changed() +} diff --git a/search_progress_test.go b/search_progress_test.go new file mode 100644 index 0000000..d303d42 --- /dev/null +++ b/search_progress_test.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func searchStages(done int) []SearchStage { + labels := []string{"Planning queries", "Searching the web", "Reading sources", "Writing the answer"} + out := make([]SearchStage, len(labels)) + for i, l := range labels { + out[i] = SearchStage{Label: l, Done: i < done} + } + return out +} + +func TestSearchProgressFoldsWhenDone(t *testing.T) { + opts := SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SearchProgress(c, opts) + }, 500, 300) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + for _, s := range []string{"Searching for “Versailles 1682”", "Read 3 of 8 sources", "Reading sources"} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + opts.Done, opts.Read, opts.Stages = true, 8, searchStages(4) + tt.Frame() + if !tt.HasText("Searched “Versailles 1682”, read 8 sources") || tt.HasText("Reading sources") { + t.Fatalf("done: %q", tt.Texts()) + } + _ = tt.Click("Searched “Versailles 1682”, read 8 sources") + if !tt.HasText("Reading sources") { + t.Fatal("finished search does not open again") + } +} + +func TestSearchProgressBadCounts(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for Read above Total") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SearchProgress(c, SearchProgressOptions{Query: "q", Read: 3, Total: 1}) + }, 100, 100) +} + +func TestSearchProgressSnapshot(t *testing.T) { + snapshot(t, "search_progress", 440, 230, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + SearchProgress(c, SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)}) + SearchProgress(c, SearchProgressOptions{Query: "Hall of Mirrors", Read: 5, Total: 5, Stages: searchStages(4), Done: true}) + }) + }) +} diff --git a/send_button.go b/send_button.go new file mode 100644 index 0000000..379b99c --- /dev/null +++ b/send_button.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SendButtonOptions configure a SendButton. +type SendButtonOptions struct { + // Streaming turns the button into Stop while a reply is generated. + Streaming bool + // Disabled blocks sending, as for an empty prompt; Stop stays available. + Disabled bool + // Shortcut is the send key shown in the tooltip, as "Enter"; empty shows none. + Shortcut string +} + +// SendResult is the button and which action the user took this frame. +type SendResult struct { + Element *ui.Element + send, stop bool +} + +// Sent reports a press while idle; Stopped a press while streaming. +func (r SendResult) Sent() bool { return r.send } +func (r SendResult) Stopped() bool { return r.stop } + +// SendButton sends the prompt, or stops the reply while streaming. +func SendButton(c *ui.Context, opts SendButtonOptions) SendResult { + k := Tokens(c) + h := Density(c).ControlHeight() + label, icon := msg(c, "send_button.send"), "arrow-up" + if opts.Streaming { + label, icon = msg(c, "send_button.stop"), "square" + } + tip := label + if opts.Shortcut != "" && !opts.Streaming { + tip += " (" + opts.Shortcut + ")" + } + var r SendResult + var b *ui.Element + r.Element = ui.Row(c).Shrink(0).Disabled(opts.Disabled && !opts.Streaming) + r.Element.Children(func() { + b = ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(tip).Transition(colorFade) + internal.Ring(b, k.Focus, h/2) + bg, fg := k.Accent, k.OnAccent + switch { + case opts.Streaming: + bg, fg = k.Surface, k.Text + b.Border(theme.BorderWidth, k.ControlBorder) + if b.Hovered() { + bg = k.SurfaceHover + } + case b.Pressed(): + bg = k.AccentPressed + case b.Hovered(): + bg = k.AccentHover + } + b.Background(bg).TextColor(fg) + b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) }) + }) + if b.Clicked() && !b.IsDisabled() { + r.send, r.stop = !opts.Streaming, opts.Streaming + slog.Debug("mujica: send_button press", "stop", r.stop) + } + return r +} diff --git a/send_button_test.go b/send_button_test.go new file mode 100644 index 0000000..0d8efe4 --- /dev/null +++ b/send_button_test.go @@ -0,0 +1,46 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSendButtonSendStopDisabled(t *testing.T) { + opts := SendButtonOptions{Shortcut: "Enter"} + sends, stops := 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := SendButton(c, opts) + if r.Sent() { + sends++ + } + if r.Stopped() { + stops++ + } + }, 200, 100) + _ = tt.Click("Send") + opts.Streaming = true + tt.Frame() + _ = tt.Click("Stop generating") + opts = SendButtonOptions{Disabled: true} + tt.Frame() + _ = tt.Click("Send") + opts.Streaming = true + tt.Frame() + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if sends != 1 || stops != 2 { + t.Fatalf("sends %d stops %d (disabled blocks send, not stop)", sends, stops) + } +} + +func TestSendButtonSnapshot(t *testing.T) { + snapshot(t, "send_button", 200, 70, func(c *ui.Context) { + ui.Row(c).Padding(12).Gap(12).Children(func() { + SendButton(c, SendButtonOptions{}) + SendButton(c, SendButtonOptions{Streaming: true}) + SendButton(c, SendButtonOptions{Disabled: true}) + }) + }) +} diff --git a/slash_command_menu.go b/slash_command_menu.go new file mode 100644 index 0000000..6b3c190 --- /dev/null +++ b/slash_command_menu.go @@ -0,0 +1,232 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SlashCommand is one command of a SlashCommandMenu. +type SlashCommand struct { + // Name is typed after the slash; required, without spaces. + Name string + Description string +} + +// SlashCommandResult is the menu's popup panel, nil while closed, and the command chosen this frame. +type SlashCommandResult struct { + Element *ui.Element + chosen SlashCommand + ok bool +} + +// Chosen returns the command picked by Enter or a click. +func (r SlashCommandResult) Chosen() (SlashCommand, bool) { return r.chosen, r.ok } + +// SlashCommandMenu pops up commands while the composer's text is "/" plus a filter. +// Up and Down move, Enter or a click chooses, Tab completes, Escape closes; typing stays in the composer. +func SlashCommandMenu(c *ui.Context, composer InputResult, value *string, commands []SlashCommand) SlashCommandResult { + for _, cmd := range commands { + if cmd.Name == "" || strings.ContainsAny(cmd.Name, " \t\n") { + panic("mujica: SlashCommand Name must be one word") + } + } + var r SlashCommandResult + st := ui.Local(composer.Element, slashMenuKey{"slash"}, func() slashMenuState { return slashMenuState{} }) + query, typing := strings.CutPrefix(*value, "/") + typing = typing && !strings.ContainsAny(query, " \t\n") + var shown []SlashCommand + var rows []slashMenuRow + if typing { + q := strings.ToLower(query) + for pass := range 2 { + for _, cmd := range commands { + n := strings.ToLower(cmd.Name) + if pass == 0 && strings.HasPrefix(n, q) || pass == 1 && !strings.HasPrefix(n, q) && strings.Contains(n, q) { + shown = append(shown, cmd) + rows = append(rows, slashMenuRow{label: "/" + cmd.Name, detail: cmd.Description}) + } + } + } + } + i, how := slashMenuShow(c, composer, st, query, typing, rows, slashMenuOptions{empty: msg(c, "slash_command_menu.empty"), label: msg(c, "slash_command_menu.label")}) + r.Element = st.panel + if i >= 0 { + *value = "/" + shown[i].Name + " " + if how == slashMenuChose { + r.chosen, r.ok = shown[i], true + } + slog.Debug("mujica: slash_command_menu pick", "name", shown[i].Name, "chosen", r.ok) + } + return r +} + +type slashMenuKey struct{ name string } + +// slashMenuRow is one line of a composer menu; a group starts a titled section. +type slashMenuRow struct { + label, detail, icon, group string +} + +type slashMenuOptions struct { + empty, label string + loading bool +} + +// slashMenuState is a composer menu's highlight, Escape and pending key choice. +type slashMenuState struct { + hi, lastHi int + query string + dismissed *string + pick slashMenuHow + panel *ui.Element + held, open bool +} + +type slashMenuHow int + +const ( + slashMenuNone slashMenuHow = iota + slashMenuChose + slashMenuCompleted +) + +// slashMenuShow runs a composer menu while open and returns the row picked this frame and how. +func slashMenuShow(c *ui.Context, composer InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow) { + st.panel = nil + if query != st.query { + st.hi, st.query = 0, query + if st.dismissed != nil && *st.dismissed != query { + st.dismissed = nil + } + } + // A press on the panel takes the focus from the composer; the press keeps it open. + open = open && st.dismissed == nil && !composer.Element.IsDisabled() && (composer.Input.Focused() || st.held) + pick, how := -1, slashMenuNone + st.open = open + if !open { + st.pick = slashMenuNone + return pick, how + } + st.hi = min(st.hi, len(rows)-1) + if st.pick != slashMenuNone && st.hi >= 0 { + pick, how = st.hi, st.pick + } + st.pick = slashMenuNone + n := len(rows) + promptComposerHook(composer, func(ev ui.InputEvent) bool { + if ev.Kind != ui.InputKeyDown || ev.Mods != 0 { + return false + } + switch ev.Key { + case ui.KeyDown, ui.KeyUp: + if n > 0 { + d := map[ui.Key]int{ui.KeyDown: 1, ui.KeyUp: -1}[ev.Key] + st.hi = (max(st.hi, 0) + d + n) % n + } + case ui.KeyEnter: + st.pick = slashMenuChose + case ui.KeyTab: + st.pick = slashMenuCompleted + case ui.KeyEscape: + q := query + st.dismissed = &q + default: + return false + } + c.Invalidate() + return true + }) + if i := slashMenuPanel(c, composer.Element.Bounds(), st, rows, o); i >= 0 { + pick, how = i, slashMenuChose + } + if pick >= 0 { + composer.Input.Focus() + } + return pick, how +} + +// slashMenuPanel draws the rows over or under the composer a and returns a clicked row. +func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) int { + k := Tokens(c) + u := Density(c).Unit() + rowH := Density(c).ControlHeight() + headH := rowH * 0.75 + h := 2 * u + group := "" + for _, r := range rows { + if r.group != group { + h, group = h+headH, r.group + } + h += rowH + } + if len(rows) == 0 { + h += rowH + } + h = min(h, 280) + 2*theme.BorderWidth + w := min(max(a.W*0.6, 320), max(a.W, 200)) + x, y := overlayPlace(c, a, w, h, u) + clicked, pressed := -1, false + ui.Overlay(c, func() { + panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Height(h).Role(ui.RoleList).Label(o.label) + overlayStyle(c, panel, overlayAnchored(c, panel, a)) + st.panel = panel + panel.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind == ui.InputPointerDown { + st.held = true + } + return false + }) + panel.Children(func() { + ui.Scroll(c).Fill().Padding(u).Children(func() { + if len(rows) == 0 { + ui.Row(c).MinHeight(rowH).Padding(0, u*3).Gap(u*2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() { + if o.loading { + Spinner(c, SpinnerOptions{Size: 14}) + } + ui.Text(c, o.empty).SingleLine() + }) + } + group := "" + for i, r := range rows { + if r.group != group { + group = r.group + ui.Text(c, group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(FontSize(c, theme.CaptionSize)). + FontWeight(600).TextColor(k.TextMuted).Role(ui.RoleText) + } + row := ui.Row(c).Key(i).Height(rowH).Padding(0, u*3, 0, u*2).Gap(u*2).AlignItems(ui.Center). + Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer) + if row.Clicked() { + clicked = i + } + pressed = pressed || row.Pressed() + hi := i == st.hi + if hi { + row.Background(k.Selection).Draw(func(p *ui.Painter, b ui.Rect) { + p.Fill(ui.Rect{X: b.X, Y: b.Y + 4, W: 2, H: b.H - 8}, k.AccentText, 1) + }) + if st.lastHi != i { + st.lastHi = i + row.ScrollIntoView() + } + } else if row.Hovered() { + row.Background(k.SurfaceHover) + } + row.Children(func() { + if r.icon != "" { + ui.Icon(c, icons.Must(r.icon)).Size(16, 16).Shrink(0).TextColor(k.TextMuted) + } + ui.Text(c, r.label).SingleLine().Shrink(0).FontWeight(600) + ui.Text(c, r.detail).SingleLine().MinWidth(0).Shrink(1).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + }) + } + }) + }) + }) + st.held = st.held && pressed + return clicked +} diff --git a/slash_command_menu_test.go b/slash_command_menu_test.go new file mode 100644 index 0000000..e4bcf89 --- /dev/null +++ b/slash_command_menu_test.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var slashCommandsSample = []SlashCommand{ + {Name: "summarize", Description: "Summarize the thread"}, + {Name: "search", Description: "Search the web"}, + {Name: "translate", Description: "Translate to French"}, +} + +// slashTester is a composer with a slash menu; it records chosen commands and sends. +type slashTester struct { + text string + chosen []string + sends int + off bool +} + +func (s *slashTester) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() { + p := PromptComposer(c, &s.text, PromptComposerOptions{Label: "Prompt", Disabled: s.off}) + if p.Submitted() { + s.sends++ + } + if cmd, ok := SlashCommandMenu(c, p, &s.text, slashCommandsSample).Chosen(); ok { + s.chosen = append(s.chosen, cmd.Name) + } + }) +} + +func TestSlashCommandMenuFilterAndKeys(t *testing.T) { + s := &slashTester{} + tt := ui.NewTester(s.view, 500, 400) + _ = tt.Click("Prompt") + tt.Type("/s") + sr, _ := tt.Find("Search the web") + tr, ok := tt.Find("Translate to French") + if !ok || !tt.HasText("/summarize") || tr.Y <= sr.Y { + t.Fatalf("filter /s does not list prefix matches first: %q", tt.Texts()) + } + tt.Type("e") + if tt.HasText("/summarize") || !tt.HasText("/search") { + t.Fatalf("filter /se: %q", tt.Texts()) + } + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if s.text != "/search " || len(s.chosen) != 1 || s.chosen[0] != "search" || s.sends != 0 { + t.Fatalf("Enter: text %q chosen %v sends %d", s.text, s.chosen, s.sends) + } + if tt.HasText("/summarize") { + t.Fatal("menu stays open after choosing") + } +} + +func TestSlashCommandMenuTabCompletesAndEscapeCloses(t *testing.T) { + s := &slashTester{} + tt := ui.NewTester(s.view, 500, 400) + _ = tt.Click("Prompt") + tt.Type("/tr") + tt.Key(0, ui.KeyTab) + if s.text != "/translate " || len(s.chosen) != 0 { + t.Fatalf("Tab: text %q chosen %v", s.text, s.chosen) + } + s.text = "" + tt.Frame() + tt.Type("/") + if !tt.HasText("/summarize") { + t.Fatal("menu did not open on /") + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("/summarize") { + t.Fatal("Escape did not close the menu") + } + tt.Type("t") + if !tt.HasText("/translate") { + t.Fatal("menu did not reopen as the filter changed") + } +} + +func TestSlashCommandMenuClickAndEmpty(t *testing.T) { + s := &slashTester{} + tt := ui.NewTester(s.view, 500, 400) + _ = tt.Click("Prompt") + tt.Type("/zz") + if !tt.HasText("No matching commands") { + t.Fatalf("empty state: %q", tt.Texts()) + } + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) + if err := tt.Click("/translate"); err != nil { + t.Fatal(err) + } + if len(s.chosen) != 1 || s.chosen[0] != "translate" || !tt.Focused("Prompt") { + t.Fatalf("click chose %v, composer focused %v", s.chosen, tt.Focused("Prompt")) + } +} + +func TestSlashCommandMenuDisabledComposer(t *testing.T) { + s := &slashTester{text: "/s", off: true} + tt := ui.NewTester(s.view, 500, 400) + _ = tt.Click("Prompt") + tt.Key(0, ui.KeyEnter) + if tt.HasText("/search") || len(s.chosen) != 0 { + t.Fatal("disabled composer shows or uses the menu") + } +} + +func TestSlashCommandMenuSnapshot(t *testing.T) { + snapshotAfter(t, "slash_command_menu", 520, 360, func(c *ui.Context) { + (*ui.Local(c.Root(), "s", func() *slashTester { return &slashTester{} })).view(c) + }, func(tt *ui.Tester) { + _ = tt.Click("Prompt") + tt.Type("/") + tt.Key(0, ui.KeyDown) + }) +} diff --git a/source_card.go b/source_card.go new file mode 100644 index 0000000..d8b2e4f --- /dev/null +++ b/source_card.go @@ -0,0 +1,37 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SourceCard shows source n's title, site, time and snippet; Clicked opens it. +// It lights up with Selection and an accent edge while st holds n Active or hovered. +func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element { + if n < 1 || st == nil || src.Title == "" || src.URL == "" { + panic("mujica: SourceCard needs n >= 1, a state and a Source with Title and URL") + } + k := Tokens(c) + u := Density(c).Unit() + b := ui.ButtonBase(c).Padding(u*2, u*3).AlignItems(ui.Start).Justify(ui.Start).Radius(theme.CardRadius). + Label(fmt.Sprintf(msg(c, "source_card.open"), src.Title)).Tooltip(src.URL).Transition(colorFade) + internal.Ring(b, k.Focus, theme.CardRadius) + st.track(c, b, n) + switch { + case st.lit(n): + b.Background(k.Selection).Border(theme.BorderWidth, k.Border).BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 3). + BorderColor(k.AccentText) + default: + b.Background(k.Surface).Border(theme.BorderWidth, k.Border) + } + b.Children(func() { sourceSummary(c, n, src) }) + if b.Clicked() && !b.IsDisabled() { + slog.Debug("mujica: source_card open", "n", n) + } + return b +} diff --git a/source_card_test.go b/source_card_test.go new file mode 100644 index 0000000..4d6a804 --- /dev/null +++ b/source_card_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSourceCardOpensAndLights(t *testing.T) { + var st CitationState + opened, off := 0, false + var bg []byte + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Width(360).Disabled(off).Children(func() { + if SourceCard(c, 1, sourcesSample[0], &st).Clicked() { + opened++ + } + }) + }, 400, 200) + for _, s := range []string{"[1]", "example.com", "2025-05-04", "The Palace of Versailles", "Seat of the court from 1682."} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + bg = encode(t, tt.Image()) + _ = tt.Click("Open The Palace of Versailles") + tt.Key(0, ui.KeyEnter) + if opened != 2 { + t.Fatalf("opened %d", opened) + } + st.Active = 1 + tt.Move(390, 190) + tt.Frame() + if string(encode(t, tt.Image())) == string(bg) { + t.Fatal("active card does not light up") + } + off = true + _ = tt.Click("Open The Palace of Versailles") + if opened != 2 { + t.Fatal("disabled card opened") + } +} + +func TestSourceCardNeedsURL(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without a URL") + } + }() + var st CitationState + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SourceCard(c, 1, Source{Title: "x"}, &st) + }, 100, 100) +} + +func TestSourceCardSnapshot(t *testing.T) { + snapshot(t, "source_card", 400, 260, func(c *ui.Context) { + st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} }) + ui.Column(c).Padding(16).Gap(8).Children(func() { + SourceCard(c, 1, sourcesSample[0], st) + SourceCard(c, 2, sourcesSample[1], st) + }) + }) +} diff --git a/sources_panel.go b/sources_panel.go new file mode 100644 index 0000000..8a7d0b4 --- /dev/null +++ b/sources_panel.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// OpenResult is a list's element and the 1-based item the user opened this frame. +type OpenResult struct { + Element *ui.Element + opened int +} + +// Opened returns the number of the source opened. +func (r OpenResult) Opened() (int, bool) { return r.opened, r.opened > 0 } + +// SourcesPanel lists sources in citation order, numbered from 1; the Active one scrolls into view. +// It grows to fill its container, which needs a height. +func SourcesPanel(c *ui.Context, sources []Source, st *CitationState) OpenResult { + if st == nil || st.Active < 0 || st.Active > len(sources) { + panic("mujica: SourcesPanel needs a state whose Active is 0 or a listed source") + } + k := Tokens(c) + u := Density(c).Unit() + var r OpenResult + r.Element = ui.Column(c).Gap(u * 2).Grow(1).MinHeight(0).Role(ui.RoleList).Label(msg(c, "sources_panel.label")) + shown := ui.Local(r.Element, "active", func() int { return 0 }) + r.Element.Children(func() { + ui.Text(c, fmt.Sprintf("%s (%d)", msg(c, "sources_panel.label"), len(sources))).Font(theme.SerifFont). + FontSize(FontSize(c, theme.H3Size)).FontWeight(600) + if len(sources) == 0 { + ui.Text(c, msg(c, "sources_panel.empty")).TextColor(k.TextMuted) + return + } + ui.Scroll(c).Grow(1).MinHeight(0).Children(func() { + ui.Column(c).Gap(u * 2).Children(func() { + for i, src := range sources { + card := SourceCard(c, i+1, src, st).Role(ui.RoleListItem) + if card.Clicked() && !card.IsDisabled() { + r.opened = i + 1 + } + if st.Active == i+1 && *shown != st.Active { + card.ScrollIntoView() + } + } + }) + }) + }) + *shown = st.Active + return r +} diff --git a/sources_panel_test.go b/sources_panel_test.go new file mode 100644 index 0000000..d2ddd12 --- /dev/null +++ b/sources_panel_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSourcesPanelOrderOpenAndScroll(t *testing.T) { + var many []Source + for i := range 20 { + many = append(many, Source{Title: fmt.Sprintf("Source title %d", i+1), URL: fmt.Sprintf("https://example.com/%d", i+1)}) + } + var st CitationState + opened := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(360).Height(300).Children(func() { + if n, ok := SourcesPanel(c, many, &st).Opened(); ok { + opened = n + } + }) + }, 400, 300) + first, _ := tt.Find("Source title 1") + second, _ := tt.Find("Source title 2") + if !tt.HasText("Sources (20)") || second.Y <= first.Y { + t.Fatalf("order or header: %q", tt.Texts()) + } + _ = tt.Click("Open Source title 2") + if opened != 2 { + t.Fatalf("opened %d", opened) + } + st.Active = 18 + tt.Frame() + tt.Frame() + r, ok := tt.Find("Source title 18") + if !ok || r.Y < 0 || r.Y+r.H > 300 { + t.Fatalf("active source not scrolled into view: %v %v", r, ok) + } +} + +func TestSourcesPanelEmptyAndBadActive(t *testing.T) { + var st CitationState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SourcesPanel(c, nil, &st) + }, 300, 200) + if !tt.HasText("No sources") { + t.Fatalf("texts %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for an Active past the list") + } + }() + st.Active = 4 + tt.Frame() +} + +func TestSourcesPanelSnapshot(t *testing.T) { + snapshot(t, "sources_panel", 380, 420, func(c *ui.Context) { + st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} }) + ui.Column(c).Padding(16).Height(420).Children(func() { SourcesPanel(c, sourcesSample, st) }) + }) +} diff --git a/testdata/snapshots/darwin/attachment_chip-dark.png b/testdata/snapshots/darwin/attachment_chip-dark.png new file mode 100644 index 0000000..93f2af5 Binary files /dev/null and b/testdata/snapshots/darwin/attachment_chip-dark.png differ diff --git a/testdata/snapshots/darwin/attachment_chip-light.png b/testdata/snapshots/darwin/attachment_chip-light.png new file mode 100644 index 0000000..c8422e4 Binary files /dev/null and b/testdata/snapshots/darwin/attachment_chip-light.png differ diff --git a/testdata/snapshots/darwin/citation_badge-dark.png b/testdata/snapshots/darwin/citation_badge-dark.png new file mode 100644 index 0000000..6f4ef22 Binary files /dev/null and b/testdata/snapshots/darwin/citation_badge-dark.png differ diff --git a/testdata/snapshots/darwin/citation_badge-light.png b/testdata/snapshots/darwin/citation_badge-light.png new file mode 100644 index 0000000..ad7f083 Binary files /dev/null and b/testdata/snapshots/darwin/citation_badge-light.png differ diff --git a/testdata/snapshots/darwin/context_chips-dark.png b/testdata/snapshots/darwin/context_chips-dark.png new file mode 100644 index 0000000..e8df2ee Binary files /dev/null and b/testdata/snapshots/darwin/context_chips-dark.png differ diff --git a/testdata/snapshots/darwin/context_chips-light.png b/testdata/snapshots/darwin/context_chips-light.png new file mode 100644 index 0000000..b2e86f3 Binary files /dev/null and b/testdata/snapshots/darwin/context_chips-light.png differ diff --git a/testdata/snapshots/darwin/context_mention_menu-dark.png b/testdata/snapshots/darwin/context_mention_menu-dark.png new file mode 100644 index 0000000..d231ea5 Binary files /dev/null and b/testdata/snapshots/darwin/context_mention_menu-dark.png differ diff --git a/testdata/snapshots/darwin/context_mention_menu-light.png b/testdata/snapshots/darwin/context_mention_menu-light.png new file mode 100644 index 0000000..9dacf40 Binary files /dev/null and b/testdata/snapshots/darwin/context_mention_menu-light.png differ diff --git a/testdata/snapshots/darwin/context_window_meter-dark.png b/testdata/snapshots/darwin/context_window_meter-dark.png new file mode 100644 index 0000000..c6d0217 Binary files /dev/null and b/testdata/snapshots/darwin/context_window_meter-dark.png differ diff --git a/testdata/snapshots/darwin/context_window_meter-light.png b/testdata/snapshots/darwin/context_window_meter-light.png new file mode 100644 index 0000000..579e6d7 Binary files /dev/null and b/testdata/snapshots/darwin/context_window_meter-light.png differ diff --git a/testdata/snapshots/darwin/drag_drop_overlay-dark.png b/testdata/snapshots/darwin/drag_drop_overlay-dark.png new file mode 100644 index 0000000..a8de49e Binary files /dev/null and b/testdata/snapshots/darwin/drag_drop_overlay-dark.png differ diff --git a/testdata/snapshots/darwin/drag_drop_overlay-light.png b/testdata/snapshots/darwin/drag_drop_overlay-light.png new file mode 100644 index 0000000..96c3711 Binary files /dev/null and b/testdata/snapshots/darwin/drag_drop_overlay-light.png differ diff --git a/testdata/snapshots/darwin/mode_selector-dark.png b/testdata/snapshots/darwin/mode_selector-dark.png new file mode 100644 index 0000000..6f20d1c Binary files /dev/null and b/testdata/snapshots/darwin/mode_selector-dark.png differ diff --git a/testdata/snapshots/darwin/mode_selector-light.png b/testdata/snapshots/darwin/mode_selector-light.png new file mode 100644 index 0000000..5af3fb0 Binary files /dev/null and b/testdata/snapshots/darwin/mode_selector-light.png differ diff --git a/testdata/snapshots/darwin/model_selector-dark.png b/testdata/snapshots/darwin/model_selector-dark.png new file mode 100644 index 0000000..45239ea Binary files /dev/null and b/testdata/snapshots/darwin/model_selector-dark.png differ diff --git a/testdata/snapshots/darwin/model_selector-light.png b/testdata/snapshots/darwin/model_selector-light.png new file mode 100644 index 0000000..896bbc0 Binary files /dev/null and b/testdata/snapshots/darwin/model_selector-light.png differ diff --git a/testdata/snapshots/darwin/parameter_panel-dark.png b/testdata/snapshots/darwin/parameter_panel-dark.png new file mode 100644 index 0000000..2562ac0 Binary files /dev/null and b/testdata/snapshots/darwin/parameter_panel-dark.png differ diff --git a/testdata/snapshots/darwin/parameter_panel-light.png b/testdata/snapshots/darwin/parameter_panel-light.png new file mode 100644 index 0000000..0c4913f Binary files /dev/null and b/testdata/snapshots/darwin/parameter_panel-light.png differ diff --git a/testdata/snapshots/darwin/paste_image_preview-dark.png b/testdata/snapshots/darwin/paste_image_preview-dark.png new file mode 100644 index 0000000..09071e8 Binary files /dev/null and b/testdata/snapshots/darwin/paste_image_preview-dark.png differ diff --git a/testdata/snapshots/darwin/paste_image_preview-light.png b/testdata/snapshots/darwin/paste_image_preview-light.png new file mode 100644 index 0000000..ff1e9dd Binary files /dev/null and b/testdata/snapshots/darwin/paste_image_preview-light.png differ diff --git a/testdata/snapshots/darwin/prompt_composer-dark.png b/testdata/snapshots/darwin/prompt_composer-dark.png new file mode 100644 index 0000000..61d4127 Binary files /dev/null and b/testdata/snapshots/darwin/prompt_composer-dark.png differ diff --git a/testdata/snapshots/darwin/prompt_composer-light.png b/testdata/snapshots/darwin/prompt_composer-light.png new file mode 100644 index 0000000..50313d7 Binary files /dev/null and b/testdata/snapshots/darwin/prompt_composer-light.png differ diff --git a/testdata/snapshots/darwin/search_progress-dark.png b/testdata/snapshots/darwin/search_progress-dark.png new file mode 100644 index 0000000..e8c0516 Binary files /dev/null and b/testdata/snapshots/darwin/search_progress-dark.png differ diff --git a/testdata/snapshots/darwin/search_progress-light.png b/testdata/snapshots/darwin/search_progress-light.png new file mode 100644 index 0000000..00b1d0c Binary files /dev/null and b/testdata/snapshots/darwin/search_progress-light.png differ diff --git a/testdata/snapshots/darwin/send_button-dark.png b/testdata/snapshots/darwin/send_button-dark.png new file mode 100644 index 0000000..126b20a Binary files /dev/null and b/testdata/snapshots/darwin/send_button-dark.png differ diff --git a/testdata/snapshots/darwin/send_button-light.png b/testdata/snapshots/darwin/send_button-light.png new file mode 100644 index 0000000..213ae4c Binary files /dev/null and b/testdata/snapshots/darwin/send_button-light.png differ diff --git a/testdata/snapshots/darwin/slash_command_menu-dark.png b/testdata/snapshots/darwin/slash_command_menu-dark.png new file mode 100644 index 0000000..7d4ab32 Binary files /dev/null and b/testdata/snapshots/darwin/slash_command_menu-dark.png differ diff --git a/testdata/snapshots/darwin/slash_command_menu-light.png b/testdata/snapshots/darwin/slash_command_menu-light.png new file mode 100644 index 0000000..af12dde Binary files /dev/null and b/testdata/snapshots/darwin/slash_command_menu-light.png differ diff --git a/testdata/snapshots/darwin/source_card-dark.png b/testdata/snapshots/darwin/source_card-dark.png new file mode 100644 index 0000000..3d90e99 Binary files /dev/null and b/testdata/snapshots/darwin/source_card-dark.png differ diff --git a/testdata/snapshots/darwin/source_card-light.png b/testdata/snapshots/darwin/source_card-light.png new file mode 100644 index 0000000..a397f52 Binary files /dev/null and b/testdata/snapshots/darwin/source_card-light.png differ diff --git a/testdata/snapshots/darwin/sources_panel-dark.png b/testdata/snapshots/darwin/sources_panel-dark.png new file mode 100644 index 0000000..29a75d2 Binary files /dev/null and b/testdata/snapshots/darwin/sources_panel-dark.png differ diff --git a/testdata/snapshots/darwin/sources_panel-light.png b/testdata/snapshots/darwin/sources_panel-light.png new file mode 100644 index 0000000..ac9753c Binary files /dev/null and b/testdata/snapshots/darwin/sources_panel-light.png differ diff --git a/testdata/snapshots/darwin/token_counter-dark.png b/testdata/snapshots/darwin/token_counter-dark.png new file mode 100644 index 0000000..58690c0 Binary files /dev/null and b/testdata/snapshots/darwin/token_counter-dark.png differ diff --git a/testdata/snapshots/darwin/token_counter-light.png b/testdata/snapshots/darwin/token_counter-light.png new file mode 100644 index 0000000..79f791f Binary files /dev/null and b/testdata/snapshots/darwin/token_counter-light.png differ diff --git a/testdata/snapshots/darwin/tool_toggle_menu-dark.png b/testdata/snapshots/darwin/tool_toggle_menu-dark.png new file mode 100644 index 0000000..8c301a1 Binary files /dev/null and b/testdata/snapshots/darwin/tool_toggle_menu-dark.png differ diff --git a/testdata/snapshots/darwin/tool_toggle_menu-light.png b/testdata/snapshots/darwin/tool_toggle_menu-light.png new file mode 100644 index 0000000..930e105 Binary files /dev/null and b/testdata/snapshots/darwin/tool_toggle_menu-light.png differ diff --git a/testdata/snapshots/darwin/voice_input_button-dark.png b/testdata/snapshots/darwin/voice_input_button-dark.png new file mode 100644 index 0000000..7b8bcc5 Binary files /dev/null and b/testdata/snapshots/darwin/voice_input_button-dark.png differ diff --git a/testdata/snapshots/darwin/voice_input_button-light.png b/testdata/snapshots/darwin/voice_input_button-light.png new file mode 100644 index 0000000..b77288f Binary files /dev/null and b/testdata/snapshots/darwin/voice_input_button-light.png differ diff --git a/testdata/snapshots/darwin/voice_waveform-dark.png b/testdata/snapshots/darwin/voice_waveform-dark.png new file mode 100644 index 0000000..01742ab Binary files /dev/null and b/testdata/snapshots/darwin/voice_waveform-dark.png differ diff --git a/testdata/snapshots/darwin/voice_waveform-light.png b/testdata/snapshots/darwin/voice_waveform-light.png new file mode 100644 index 0000000..b72974b Binary files /dev/null and b/testdata/snapshots/darwin/voice_waveform-light.png differ diff --git a/text_area.go b/text_area.go index b9e82b8..c5f0fff 100644 --- a/text_area.go +++ b/text_area.go @@ -27,6 +27,8 @@ type TextAreaOptions struct { ReadOnly bool // Error marks the value invalid and shows the message below. Error string + // keys sees keys before the editor, for PromptComposer's menus. + keys func(ui.InputEvent) bool } // TextArea is a multi-line field editing *value. @@ -67,6 +69,9 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { // Taken before the editor, which would make a plain Enter a newline. submit := ui.Local(frame, "submit", func() bool { return false }) in.HandleInput(func(ev ui.InputEvent) bool { + if opts.keys != nil && opts.keys(ev) { + return true + } if ev.Kind == ui.InputKeyDown && ev.Key == key && ev.Mods == mods { *submit = true return true diff --git a/token_counter.go b/token_counter.go new file mode 100644 index 0000000..d36043b --- /dev/null +++ b/token_counter.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// tokenWarnShare is the share of the limit from which the count warns. +const tokenWarnShare = 0.9 + +// TokenCounter shows the caller's token estimate against the limit: muted, warning past 90%, danger over it. +func TokenCounter(c *ui.Context, used, limit int) *ui.Element { + if used < 0 || limit <= 0 { + panic("mujica: TokenCounter needs used >= 0 and limit > 0") + } + k := Tokens(c) + u := Density(c).Unit() + label := fmt.Sprintf(msg(c, "token_counter.label"), tokenThousands(used), tokenThousands(limit)) + color, icon := k.TextMuted, "" + switch { + case used > limit: + color, icon = k.Danger, "circle-alert" + label += ", " + fmt.Sprintf(msg(c, "token_counter.over"), tokenThousands(used-limit)) + case float64(used) >= tokenWarnShare*float64(limit): + color, icon = k.Warning, "triangle-alert" + } + row := ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).TextColor(color).Role(ui.RoleStatus).Label(label) + row.Children(func() { + if icon != "" { + ui.Icon(c, icons.Must(icon)).Size(12, 12) + } + ui.Text(c, "≈ "+tokenThousands(used)+" / "+tokenThousands(limit)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() + }) + return row +} + +// tokenThousands writes n with comma thousands separators. +func tokenThousands(n int) string { + s := strconv.Itoa(n) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return s +} diff --git a/token_counter_test.go b/token_counter_test.go new file mode 100644 index 0000000..1e62f71 --- /dev/null +++ b/token_counter_test.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTokenCounterLevels(t *testing.T) { + for _, tc := range []struct { + used int + text string + label string + }{ + {1234, "≈ 1,234 / 8,000", "About 1,234 of 8,000 tokens"}, + {7500, "≈ 7,500 / 8,000", "About 7,500 of 8,000 tokens"}, + {9100, "≈ 9,100 / 8,000", "About 9,100 of 8,000 tokens, 1,100 over the limit"}, + } { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TokenCounter(c, tc.used, 8000) + }, 300, 60) + if !tt.HasText(tc.text) { + t.Errorf("%d: texts %q", tc.used, tt.Texts()) + } + if _, ok := tt.Find(tc.label); !ok { + t.Errorf("%d: no %q", tc.used, tc.label) + } + } + if tokenThousands(1234567) != "1,234,567" || tokenThousands(12) != "12" { + t.Fatal("thousands separators") + } +} + +func TestTokenCounterBadLimit(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a zero limit") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TokenCounter(c, 1, 0) + }, 100, 100) +} + +func TestTokenCounterSnapshot(t *testing.T) { + snapshot(t, "token_counter", 240, 110, func(c *ui.Context) { + ui.Column(c).Padding(12).Gap(8).Children(func() { + TokenCounter(c, 1234, 8000) + TokenCounter(c, 7500, 8000) + TokenCounter(c, 9100, 8000) + }) + }) +} diff --git a/tool_toggle_menu.go b/tool_toggle_menu.go new file mode 100644 index 0000000..74d9e27 --- /dev/null +++ b/tool_toggle_menu.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ToolItem is one tool a ToolToggleMenu turns on or off. +type ToolItem struct { + // ID and On are required. + ID string + Label string + Description string + On *bool + // NeedsAuth marks a tool the user must authorize before turning it on. + NeedsAuth, Authorized bool +} + +// ToolToggleResult is the menu's button and what the user did this frame. +type ToolToggleResult struct { + Element *ui.Element + changed, authorize string +} + +// Changed returns the ID of the tool turned on or off. +func (r ToolToggleResult) Changed() (string, bool) { return r.changed, r.changed != "" } + +// Authorize returns the ID of a tool whose authorization the user asked for. +func (r ToolToggleResult) Authorize() (string, bool) { return r.authorize, r.authorize != "" } + +// ToolToggleMenu is a button opening a panel of tool switches; tools needing authorization show a mark and an authorize action. +func ToolToggleMenu(c *ui.Context, tools []ToolItem) ToolToggleResult { + on := 0 + for _, t := range tools { + if t.ID == "" || t.On == nil { + panic("mujica: ToolItem needs ID and On") + } + if *t.On { + on++ + } + } + k := Tokens(c) + u := Density(c).Unit() + label := fmt.Sprintf(msg(c, "tool_toggle_menu.count"), on) + var r ToolToggleResult + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted) + r.Element = b + internal.Ring(b, k.Focus, theme.ControlRadius) + open := ui.Local(b, "open", func() bool { return false }) + switch { + case *open: + b.Background(k.Selection).TextColor(k.Text) + case b.Hovered(): + b.Background(k.SurfaceHover).TextColor(k.Text) + } + b.Children(func() { + ui.Icon(c, icons.Must("sliders-horizontal")).Size(16, 16) + if on > 0 { + ui.Text(c, fmt.Sprint(on)).FontFeatures("tnum").FontWeight(600).TextColor(k.AccentText).SingleLine() + } + }) + disabled := b.IsDisabled() + if b.Clicked() && !disabled { + *open = !*open + } + if disabled { + *open = false + } + Popover(c, b, open, PopoverOptions{Title: msg(c, "tool_toggle_menu.label"), Width: 320}, func() { + for _, t := range tools { + toolToggleRow(c, t, &r) + } + }) + if r.changed != "" || r.authorize != "" { + slog.Debug("mujica: tool_toggle_menu", "changed", r.changed, "authorize", r.authorize) + } + return r +} + +// toolToggleRow shows one tool: its switch, or its authorization mark and action. +func toolToggleRow(c *ui.Context, t ToolItem, r *ToolToggleResult) { + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + locked := t.NeedsAuth && !t.Authorized + ui.Row(c).Key(t.ID).Gap(u * 3).AlignItems(ui.Center).Children(func() { + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + ui.Row(c).Gap(u).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() { + if locked { + ui.Text(c, t.Label).FontWeight(600).SingleLine() + } else if Switch(c, t.On, t.Label, SwitchOptions{}).Changed() { + r.changed = t.ID + } + if t.NeedsAuth { + icon := "key-round" + if t.Authorized { + icon = "shield-check" + } + ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(k.Warning).Label(msg(c, "tool_toggle_menu.needs_auth")) + } + }) + if t.Description != "" { + ui.Text(c, t.Description).FontSize(small).TextColor(k.TextMuted) + } + if locked { + ui.Text(c, msg(c, "tool_toggle_menu.needs_auth")).FontSize(small).TextColor(k.Warning) + } + }) + if locked { + full := fmt.Sprintf(msg(c, "tool_toggle_menu.authorize"), t.Label) + if Button(c, msg(c, "tool_toggle_menu.authorize_short"), ButtonOptions{Variant: Outline, Label: full}).Clicked() { + r.authorize = t.ID + } + } + }) +} diff --git a/tool_toggle_menu_test.go b/tool_toggle_menu_test.go new file mode 100644 index 0000000..74e69be --- /dev/null +++ b/tool_toggle_menu_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +// toolMenuApp holds tools and what the menu reported. +type toolMenuApp struct { + web, code, mail bool + authorized bool + changed, auth []string + off bool +} + +func (a *toolMenuApp) view(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).AlignItems(ui.Start).Disabled(a.off).Children(func() { + r := ToolToggleMenu(c, []ToolItem{ + {ID: "web", Label: "Web search", Description: "Search the open web", On: &a.web}, + {ID: "code", Label: "Code execution", On: &a.code}, + {ID: "mail", Label: "Mail", Description: "Read your inbox", On: &a.mail, NeedsAuth: true, Authorized: a.authorized}, + }) + if id, ok := r.Changed(); ok { + a.changed = append(a.changed, id) + } + if id, ok := r.Authorize(); ok { + a.auth = append(a.auth, id) + } + }) +} + +func TestToolToggleMenuSwitchesAndAuthorization(t *testing.T) { + a := &toolMenuApp{web: true} + tt := ui.NewTester(a.view, 500, 500) + if err := tt.Click("Tools, 1 on"); err != nil { + t.Fatal(err) + } + _ = tt.Click("Code execution") + if !a.code || len(a.changed) != 1 || a.changed[0] != "code" { + t.Fatalf("code %v changed %v", a.code, a.changed) + } + if !tt.HasText("Needs authorization") { + t.Fatalf("auth mark missing: %q", tt.Texts()) + } + _ = tt.Click("Authorize Mail") + if a.mail || len(a.auth) != 1 { + t.Fatalf("mail %v auth %v", a.mail, a.auth) + } + a.authorized = true + tt.Frame() + _ = tt.Click("Mail") + if !a.mail { + t.Fatal("authorized tool did not turn on") + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Search the open web") { + t.Fatal("Escape did not close the panel") + } +} + +func TestToolToggleMenuClosesWhenDisabled(t *testing.T) { + a := &toolMenuApp{} + tt := ui.NewTester(a.view, 500, 500) + _ = tt.Click("Tools, 0 on") + a.off = true + tt.Frame() + if tt.HasText("Search the open web") { + t.Fatal("panel stayed open under a disabled control") + } +} + +func TestToolToggleMenuSnapshot(t *testing.T) { + snapshotAfter(t, "tool_toggle_menu", 400, 360, func(c *ui.Context) { + (*ui.Local(c.Root(), "a", func() *toolMenuApp { return &toolMenuApp{web: true} })).view(c) + }, func(tt *ui.Tester) { _ = tt.Click("Tools, 1 on") }) +} diff --git a/voice_input_button.go b/voice_input_button.go new file mode 100644 index 0000000..4eea543 --- /dev/null +++ b/voice_input_button.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// VoiceInputOptions describe the caller's recording. +type VoiceInputOptions struct { + // Recording shows the stop state and the time since Started. + Recording bool + Started time.Time + // Denied shows that microphone access failed; the button then retries. + Denied bool + Disabled bool +} + +// VoiceInputButton asks the caller to start or stop recording; while recording it shows the time. +// Clicked reports a press: start when idle or denied, stop when recording. +func VoiceInputButton(c *ui.Context, opts VoiceInputOptions) ChoiceResult { + if opts.Recording && (opts.Started.IsZero() || opts.Denied) { + panic("mujica: VoiceInputButton recording needs Started and no Denied") + } + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Disabled(opts.Disabled) + r.Element.Children(func() { + elapsed := "" + label := msg(c, "voice_input_button.start") + icon := "mic" + if opts.Recording { + d := max(c.Now().Sub(opts.Started), 0) + elapsed = audioClock(d.Truncate(time.Second)) + label, icon = fmt.Sprintf(msg(c, "voice_input_button.stop"), elapsed), "square" + c.After(time.Second - d%time.Second) + } + h := Density(c).ControlHeight() + b := ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(label).Transition(colorFade) + internal.Ring(b, k.Focus, h/2) + switch { + case opts.Recording: + b.Background(k.DangerFill).TextColor(k.OnDanger) + case b.Pressed(): + b.Background(k.Selection).TextColor(k.Text) + case b.Hovered(): + b.Background(k.SurfaceHover).TextColor(k.Text) + default: + b.TextColor(k.TextMuted) + } + b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) }) + if b.Clicked() && !b.IsDisabled() { + r.changed = true + slog.Debug("mujica: voice_input_button press", "recording", opts.Recording) + } + switch { + case opts.Recording: + ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(8, 8).Radius(4).Background(k.Danger) + ui.Text(c, elapsed).FontFeatures("tnum").TextColor(k.Danger).SingleLine() + }) + case opts.Denied: + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() { + ui.Icon(c, icons.Must("mic-off")).Size(14, 14) + ui.Text(c, msg(c, "voice_input_button.denied")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) + } + }) + return r +} diff --git a/voice_input_button_test.go b/voice_input_button_test.go new file mode 100644 index 0000000..bf94b0c --- /dev/null +++ b/voice_input_button_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestVoiceInputButtonStartStopAndTime(t *testing.T) { + var opts VoiceInputOptions + presses := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if VoiceInputButton(c, opts).Changed() { + presses++ + if opts.Recording { + opts = VoiceInputOptions{} + } else { + opts = VoiceInputOptions{Recording: true, Started: c.Now().Add(-65 * time.Second)} + } + } + }, 300, 100) + _ = tt.Click("Start voice input") + tt.Frame() + if !opts.Recording || !tt.HasText("1:05") { + t.Fatalf("recording %v texts %q", opts.Recording, tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if opts.Recording || presses != 2 { + t.Fatalf("stop: recording %v presses %d", opts.Recording, presses) + } +} + +func TestVoiceInputButtonDeniedAndDisabled(t *testing.T) { + opts := VoiceInputOptions{Denied: true} + presses := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if VoiceInputButton(c, opts).Changed() { + presses++ + } + }, 400, 100) + if !tt.HasText("Microphone access denied") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Start voice input") + opts.Disabled = true + _ = tt.Click("Start voice input") + if presses != 1 { + t.Fatalf("presses %d, want 1 (retry once, then disabled)", presses) + } +} + +func TestVoiceInputButtonRecordingNeedsStart(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for recording without Started") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + VoiceInputButton(c, VoiceInputOptions{Recording: true}) + }, 100, 100) +} + +func TestVoiceInputButtonSnapshot(t *testing.T) { + snapshot(t, "voice_input_button", 360, 170, func(c *ui.Context) { + ui.Column(c).Padding(12).Gap(8).Children(func() { + VoiceInputButton(c, VoiceInputOptions{}) + VoiceInputButton(c, VoiceInputOptions{Recording: true, Started: c.Now().Add(-12 * time.Second)}) + VoiceInputButton(c, VoiceInputOptions{Denied: true}) + }) + }) +} diff --git a/voice_waveform.go b/voice_waveform.go new file mode 100644 index 0000000..5526c60 --- /dev/null +++ b/voice_waveform.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// VoiceWaveformOptions configure a VoiceWaveform. +type VoiceWaveformOptions struct { + // Silence is the level below which input counts as silent; zero means 0.05. + Silence float32 + // SilentFor is how many trailing samples under Silence show the hint; zero means 20. + SilentFor int + // Width is the bar area width in DIPs; zero means 200. + Width float32 +} + +// VoiceWaveform draws recent input levels, 0 to 1, newest at the right, and hints at silence. +func VoiceWaveform(c *ui.Context, levels []float32, opts VoiceWaveformOptions) *ui.Element { + if opts.Silence < 0 || opts.Silence >= 1 || opts.SilentFor < 0 || opts.Width < 0 { + panic("mujica: VoiceWaveform needs 0 <= Silence < 1 and non-negative SilentFor and Width") + } + for _, v := range levels { + if !(v >= 0 && v <= 1) { + panic("mujica: VoiceWaveform levels must be from 0 to 1") + } + } + silence, need, w := opts.Silence, opts.SilentFor, opts.Width + if silence == 0 { + silence = 0.05 + } + if need == 0 { + need = 20 + } + if w == 0 { + w = 200 + } + k := Tokens(c) + u := Density(c).Unit() + silent := len(levels) >= need + for _, v := range levels[max(0, len(levels)-need):] { + silent = silent && v < silence + } + row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleMeter).Label(msg(c, "voice_waveform.label")) + row.Children(func() { + ui.Box(c).Size(w, 28).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) { + n := int((b.W + audioBarGap) / (audioBarWidth + audioBarGap)) + cy := b.Y + b.H/2 + for i := range n { + // The newest sample sits at the right edge. + j := len(levels) - n + i + v := float32(0) + if j >= 0 { + v = levels[j] + } + h := max(2, v*(b.H-4)) + col := k.AccentText + if v < silence { + col = k.TextMuted.Alpha(0.55) + } + x := b.X + float32(i)*(audioBarWidth+audioBarGap) + p.Fill(ui.Rect{X: x, Y: cy - h/2, W: audioBarWidth, H: h}, col, 1) + } + }) + if silent { + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Warning).Role(ui.RoleStatus).Children(func() { + ui.Icon(c, icons.Must("mic-off")).Size(14, 14) + ui.Text(c, msg(c, "voice_waveform.silent")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) + } + }) + return row +} diff --git a/voice_waveform_test.go b/voice_waveform_test.go new file mode 100644 index 0000000..3de0f8e --- /dev/null +++ b/voice_waveform_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func voiceLevels(n int, v float32) []float32 { + out := make([]float32, n) + for i := range out { + out[i] = v + } + return out +} + +func TestVoiceWaveformSilenceHint(t *testing.T) { + levels := append(voiceLevels(30, 0.6), voiceLevels(10, 0.01)...) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + VoiceWaveform(c, levels, VoiceWaveformOptions{SilentFor: 12}) + }, 400, 100) + if tt.HasText("No sound detected") { + t.Fatal("hint before 12 silent samples") + } + levels = append(levels, voiceLevels(2, 0.02)...) + tt.Frame() + if !tt.HasText("No sound detected") { + t.Fatal("no hint after 12 silent samples") + } + if _, ok := tt.Find("Input level"); !ok { + t.Fatal("meter not named") + } +} + +func TestVoiceWaveformBadLevels(t *testing.T) { + for _, l := range [][]float32{{-0.1}, {1.5}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v: no panic", l) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + VoiceWaveform(c, l, VoiceWaveformOptions{}) + }, 100, 100) + }() + } +} + +func TestVoiceWaveformSnapshot(t *testing.T) { + levels := make([]float32, 60) + for i := range levels { + levels[i] = float32((i*7)%10) / 10 + } + snapshot(t, "voice_waveform", 420, 110, func(c *ui.Context) { + ui.Column(c).Padding(12).Gap(12).Children(func() { + VoiceWaveform(c, levels, VoiceWaveformOptions{}) + VoiceWaveform(c, voiceLevels(40, 0), VoiceWaveformOptions{Width: 160}) + }) + }) +}