diff --git a/README.md b/README.md index 0d49ebb..129dd97 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ every native target Go ships. | Structure | `Card`, `HeaderBar`, `ActionRow`, `Breadcrumbs`, `Steps`, `Avatar`, `Rating`, `Stat`, `Kbd`, `ChatBubble`, `Diff`, `Pagination`, `DropZone` | | Navigation | `Menu` + `MenuItem.Shortcut`, `MenuBar` + `Alt+letter`, `ContextMenu`, `Popover`, `CommandPalette`, `Dialog`, `MessageDialog`, `Wizard` | | Window | `Window`, `WindowDecoration`, `DecoButton` (client-side decorations) | -| Bars | `Toolbar`, `PagingToolbar`, `Statusbar`, 10 stock `DrawIcon*` helpers | +| Bars | `Toolbar`, `PagingToolbar`, `Statusbar`, `Ribbon` (Office-style tabs of groups of large/small buttons, toggles, split buttons, drop-downs and a gallery; scales down by group priority, collapses, keytips, tooltips with shortcuts; every control is an `Action`, state is `mvvm.Observable`), 10 stock `DrawIcon*` helpers | | Composite | `FileChooser`, `ColorChooser`, `ColorPicker`, `FontChooser`, `Calendar`, `DatePicker`, `DateRangePicker`, `TimePicker`, `MarkdownView`, `MarkdownEditor` | | Data suite | `Table` (cell edit, frozen columns, group rows, aggregates, row-expanders), `TreeTable`, `PropertyGrid`, `Kanban` (drag cards), `Gantt` (drag/resize bars), `Agenda`/`AgendaCalendar` event calendar (week/month/quarter/year, colour-coded calendars, sidebar, inline editor) | | Charts | `LineChart`, `BarChart`, `PieChart`, `AreaChart`, `ScatterChart`, `RadarChart`, `Gauge`, `Sparkline` | diff --git a/action_command.go b/action_command.go new file mode 100644 index 0000000..faa9fe6 --- /dev/null +++ b/action_command.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "github.com/go-widgets/mvvm" + +// NewCommandAction builds an [Action] that is a view-model [mvvm.Command]: the +// Action runs the command, and its Enabled state follows the command's CanExecute +// — read now, and again each time the command raises CanExecuteChanged — so a +// ribbon button, a menu entry and a palette row all grey out together as the +// state the command's predicate reads changes. A view model owns the Command; the +// toolkit only reflects it. +// +// Visibility is not a command's business, but it is observable too: share the +// view model's own Observable by assigning it to the returned Action's Visible +// field, and every surface hides the control with it. +func NewCommandAction(id, label string, cmd *mvvm.Command) *Action { + a := NewAction(id, label, cmd.Execute) + a.SetEnabled(cmd.CanExecute()) + cmd.SubscribeCanExecuteChanged(func() { a.SetEnabled(cmd.CanExecute()) }) + return a +} diff --git a/mvvm_gate_test.go b/mvvm_gate_test.go index 953c495..882baf1 100644 --- a/mvvm_gate_test.go +++ b/mvvm_gate_test.go @@ -301,30 +301,36 @@ var mvvmMigratedWidgets = map[string]map[string]bool{ // sub-widgets, CloseOnScrim is a capability flag and PanelW/PanelH are layout // metrics (all config); OnClose is a func hook (auto-allowed). Any reactive // state (the search text) lives on Panel.Input's own Text() observable. - "ModalWindow": {"Scrim": true, "Panel": true, "CloseOnScrim": true, "PanelW": true, "PanelH": true}, - "HeaderBar": {"End": true, "Start": true, "Subtitle": true, "Title": true}, - "IconButton": {"Flat": true, "Glyph": true, "Icon": true}, - "Image": {"Alt": true, "H": true, "Pixels": true, "Scale": true, "W": true}, - "IsoIconPalette": {"DefaultGroupName": true, "Title": true}, - "Kbd": {"Keys": true}, - "HBox": {"Align": true, "Pack": true, "Spacing": true}, - "VBox": {"Align": true, "Pack": true, "Spacing": true}, - "Grid": {"ColWidths": true, "RowHeights": true, "Spacing": true}, - "LinkCard": {"Domain": true, "Favicon": true, "Meta": true, "Title": true}, - "MarkdownEditor": {"Preview": true, "Source": true}, - "MarkdownView": {"Source": true}, - "MediaCard": {"Meta": true, "Thumbnail": true, "Title": true}, - "PostCard": {"MaxTitleLines": true, "Meta": true, "MetaFont": true, "Pill": true, "PillColor": true, "PillFont": true, "PillInk": true, "Subtitle": true, "SubtitleFont": true, "ThumbH": true, "ThumbPlaceholder": true, "ThumbW": true, "Thumbnail": true, "Title": true, "TitleFont": true}, - "PullToRefresh": {"Child": true, "Style": true, "Threshold": true}, - "SegmentedBar": {"Orientation": true, "Segments": true}, - "SplitButton": {"Arrow": true, "Label": true}, - "Statusbar": {"SegmentMinW": true, "Segments": true, "Left": true, "Center": true, "Right": true}, - "StatusIcon": {"Badge": true, "IH": true, "IW": true, "Icon": true, "Ink": true, "Pixels": true, "Tooltip": true}, - "StatusArea": {"Background": true, "Gap": true, "IconSize": true, "Icons": true}, - "SwipeActions": {"ActionWidth": true, "Content": true, "DestructiveDen": true, "DestructiveFull": true, "DestructiveNum": true, "Leading": true, "Projection": true, "Trailing": true}, - "TerminalView": {"CellH": true, "CellW": true, "Cells": true, "Cols": true, "DefaultBG": true, "DefaultFG": true, "Rows": true}, - "Timeline": {"Events": true, "Horizontal": true}, - "Toolbar": {"ButtonH": true, "ButtonW": true, "Items": true, "Orientation": true}, + "ModalWindow": {"Scrim": true, "Panel": true, "CloseOnScrim": true, "PanelW": true, "PanelH": true}, + "HeaderBar": {"End": true, "Start": true, "Subtitle": true, "Title": true}, + "IconButton": {"Flat": true, "Glyph": true, "Icon": true}, + "Image": {"Alt": true, "H": true, "Pixels": true, "Scale": true, "W": true}, + "IsoIconPalette": {"DefaultGroupName": true, "Title": true}, + "Kbd": {"Keys": true}, + "HBox": {"Align": true, "Pack": true, "Spacing": true}, + "VBox": {"Align": true, "Pack": true, "Spacing": true}, + "Grid": {"ColWidths": true, "RowHeights": true, "Spacing": true}, + "LinkCard": {"Domain": true, "Favicon": true, "Meta": true, "Title": true}, + "MarkdownEditor": {"Preview": true, "Source": true}, + "MarkdownView": {"Source": true}, + "MediaCard": {"Meta": true, "Thumbnail": true, "Title": true}, + "PostCard": {"MaxTitleLines": true, "Meta": true, "MetaFont": true, "Pill": true, "PillColor": true, "PillFont": true, "PillInk": true, "Subtitle": true, "SubtitleFont": true, "ThumbH": true, "ThumbPlaceholder": true, "ThumbW": true, "Thumbnail": true, "Title": true, "TitleFont": true}, + "PullToRefresh": {"Child": true, "Style": true, "Threshold": true}, + "SegmentedBar": {"Orientation": true, "Segments": true}, + "SplitButton": {"Arrow": true, "Label": true}, + "Statusbar": {"SegmentMinW": true, "Segments": true, "Left": true, "Center": true, "Right": true}, + "StatusIcon": {"Badge": true, "IH": true, "IW": true, "Icon": true, "Ink": true, "Pixels": true, "Tooltip": true}, + "StatusArea": {"Background": true, "Gap": true, "IconSize": true, "Icons": true}, + "SwipeActions": {"ActionWidth": true, "Content": true, "DestructiveDen": true, "DestructiveFull": true, "DestructiveNum": true, "Leading": true, "Projection": true, "Trailing": true}, + "TerminalView": {"CellH": true, "CellW": true, "Cells": true, "Cols": true, "DefaultBG": true, "DefaultFG": true, "Rows": true}, + "Timeline": {"Events": true, "Horizontal": true}, + "Toolbar": {"ButtonH": true, "ButtonW": true, "Items": true, "Orientation": true}, + // Ribbon: Tabs (the structure of tabs, groups and controls), Keymap and + // TooltipDelayMs are set-once config. Its reactive state is on the ActiveTab() + // and Collapsed() Observables; a toggle's Checked and a drop-down's Selected + // are Observables on their (config) RibbonControl; the hover, the focus, the + // open popup and the keytips are internal interaction state. + "Ribbon": {"Tabs": true, "Keymap": true, "TooltipDelayMs": true}, "Wallpaper": {"Bottom": true, "IH": true, "IW": true, "Interactive": true, "Mode": true, "Pixels": true, "Top": true}, "Window": {"Body": true, "Closable": true, "Maximizable": true, "Minimizable": true, "Resizable": true, "Title": true}, "WindowDecoration": {"Border": true, "BorderColor": true, "Buttons": true, "Grip": true, "GripColor": true, "Hairline": true, "Shadow": true, "ShowGrip": true, "Title": true, "TitleCenter": true, "TitleColor": true, "TitleInk": true, "Titlebar": true}, diff --git a/ribbon.go b/ribbon.go new file mode 100644 index 0000000..af60270 --- /dev/null +++ b/ribbon.go @@ -0,0 +1,614 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "github.com/go-widgets/mvvm" + +// Ribbon is an Office-style command ribbon: a strip of TABS, each holding GROUPS +// of labelled controls — large buttons (icon above the text), small buttons +// (icon beside the text), toggles, split buttons, drop-downs and a small +// gallery — that scales down as the window narrows instead of overflowing. +// +// It follows the ribbon guidelines of Windows 7 ("Windows 7 Ribbons", Microsoft +// Learn, uxguide/cmd-ribbons) for the parts that are about design, and the +// WAI-ARIA Authoring Practices for the parts that are about the keyboard: +// +// - SCALING. "The ribbon will show the largest valid combination that fits the +// current window size." Each group has up to four layouts — [RibbonFull] +// (large and small controls as designed), [RibbonCompact] (every control +// small), [RibbonIconOnly] (small controls without their text) and +// [RibbonPopup] (one button that opens the group) — and the groups with the +// lowest [RibbonGroup.Priority] shrink first, the rightmost first among +// equals. "Don't scale groups containing a single button to a pop-up group +// icon": such a group stops at icon-only. +// - COLLAPSING. [Ribbon.Collapsed] hides the body and leaves the tabs; a tab +// then shows its body over the content below until a command runs, Escape, +// or a click elsewhere. Ctrl+F1 toggles it, as in Office. The state is an +// Observable so a host can persist it across runs, as the guidelines ask. +// - KEYBOARD. The tabs are a WAI-ARIA tablist (Left/Right/Home/End, automatic +// activation); the body is a toolbar with arrow-key navigation (Left/Right +// between controls, Up/Down inside a column, Home/End); Enter or Space +// activates, Down opens a split button, drop-down or gallery, Escape goes +// back one level. KEYTIPS: a press of Alt shows a letter over each tab, a +// letter chooses the tab and shows one over each control, and the next +// letters run it. +// - TOOLTIPS give the command name with its shortcut, then a description, +// after [Ribbon.TooltipDelayMs] of hovering. +// - CONTRAST. Boundaries (the tab outline, the group dividers, the popup +// edge) and the focus ring reach 3:1 against the surfaces they sit on, in +// light and dark themes (WCAG 2.2 1.4.11), as [PopupBorder] does for popups. +// +// Everything a command does goes through an [Action], the toolkit's one source +// of truth for a command (label, icon, shortcut, enabled, visible, run): a +// control is a VIEW on an Action, so the same Action can sit in a menu, a +// palette and the ribbon, and disabling it greys every surface. The ribbon's own +// state (the active tab, whether it is collapsed) and a toggle's checked state, +// a drop-down's selection are [mvvm.Observable]s that a view model binds. +// +// A host sizes the ribbon with [Ribbon.Height] (it changes when the ribbon is +// collapsed), sets its bounds, draws it, and draws [Ribbon.DrawPopup] LAST, over +// everything else, because the menus, the collapsed bodies, the tooltips and the +// keytips are drawn there, outside the ribbon's own rectangle. +type Ribbon struct { + Base + focusState + + // Tabs are the tabs in order. Set once (config); the active one is + // [Ribbon.ActiveTab]. + Tabs []*RibbonTab + // Keymap, when set, supplies the live accelerator shown for each Action + // (otherwise the Action's own default Shortcut). + Keymap *Keymap + // TooltipDelayMs is how long the pointer rests on a control before its + // tooltip shows, driven by [Ribbon.Tick]. Zero means 500. + TooltipDelayMs int + + active *mvvm.Observable[int] + collapsed *mvvm.Observable[bool] + + zone ribbonZone + focusCtl int // index into the visible cells, when zone is ribbonZoneBody + focusTab int + peek bool // a collapsed ribbon is showing its active tab's body + pop *ribbonPopup + hoverTab int + hoverCtl int + hoverMs int + chevHot bool + keytips int // 0 off, 1 choosing a tab, 2 choosing a control + tipBuf string +} + +// RibbonTab is one tab: a label and the groups it holds. +type RibbonTab struct { + Label string + // KeyTip is the keytip letter; empty picks the first free letter of Label. + KeyTip string + Groups []*RibbonGroup +} + +// RibbonGroup is a labelled cluster of controls on a tab. +type RibbonGroup struct { + Label string + // Icon is drawn on the group's button when it has collapsed to a pop-up. + Icon IconFunc + // Priority orders the shrinking: the group with the lowest value gives up its + // large form first. Equal priorities shrink right to left. + Priority int + Controls []*RibbonControl +} + +// RibbonKind is what a control is. +type RibbonKind int + +const ( + // RibbonButton runs its Action. + RibbonButton RibbonKind = iota + // RibbonToggle flips Checked, then runs its Action. + RibbonToggle + // RibbonSplit runs its Action from its main part and opens Items from its + // arrow. + RibbonSplit + // RibbonDropdown chooses one of Options (the index is Selected). + RibbonDropdown + // RibbonGallery shows the first few of Items as a strip, with an arrow that + // opens all of them; the chosen index is Selected. + RibbonGallery +) + +// RibbonControl is one control of a group. Only the fields its Kind needs are +// read. +type RibbonControl struct { + Kind RibbonKind + // Action is the command (label, icon, shortcut, enabled, visible, run). + Action *Action + // Label names a drop-down or a gallery, which have no Action. + Label string + // Tip is the description shown under the name in the tooltip. + Tip string + // KeyTip is the keytip; empty picks the first free letter of the label. + KeyTip string + // Large asks for the large form (icon above the text) while the group is at + // full size. + Large bool + // Checked is a toggle's state. + Checked *mvvm.Observable[bool] + // Items are a split button's menu, or a gallery's cells. + Items []RibbonItem + // Options are a drop-down's choices. + Options []string + // Selected is a drop-down's or a gallery's chosen index. + Selected *mvvm.Observable[int] +} + +// RibbonItem is a menu entry or a gallery cell. A menu entry runs Action; a +// gallery cell uses Label and Icon (or its Action's). +type RibbonItem struct { + Action *Action + Label string + Icon IconFunc +} + +// RibbonLevel is how much of a group is shown. +type RibbonLevel int + +const ( + // RibbonFull is the group as designed: large and small controls. + RibbonFull RibbonLevel = iota + // RibbonCompact makes every control small. + RibbonCompact + // RibbonIconOnly drops the text of the small controls. + RibbonIconOnly + // RibbonPopup folds the group into one button that opens it. + RibbonPopup +) + +// ribbonZone says where the keyboard focus is inside the ribbon. +type ribbonZone int + +const ( + ribbonZoneTabs ribbonZone = iota + ribbonZoneBody +) + +// ribbonForm is how one control is drawn. +type ribbonForm int + +const ( + formLarge ribbonForm = iota + formSmall + formIcon + formDropdown + formGallery + formPopup +) + +// ribbonMetrics are the sizes everything is laid out from, all derived from the +// widget's font and the toolkit's metric scale so a HiDPI or touch density grows +// the ribbon with the text. +type ribbonMetrics struct { + gh, padX, gap, iconS, iconL, rowH, tabH, bodyH, labelH, arrowW, sepW, groupPad, colGap int +} + +// NewRibbon builds a ribbon over tabs, showing the first one, expanded. +func NewRibbon(tabs ...*RibbonTab) *Ribbon { + return &Ribbon{Tabs: tabs, hoverTab: -1, hoverCtl: -1} +} + +// ActiveTab is the index of the shown tab as a shared [mvvm.Observable]: a view +// model binds it, and a click, an arrow key or a keytip Sets it. It is clamped +// to the tabs when read. +func (r *Ribbon) ActiveTab() *mvvm.Observable[int] { + if r.active == nil { + r.active = mvvm.NewObservable(0) + } + return r.active +} + +// Collapsed is whether the ribbon is reduced to its tab strip, as a shared +// [mvvm.Observable]. A host persists it so a ribbon the user minimised comes +// back minimised. +func (r *Ribbon) Collapsed() *mvvm.Observable[bool] { + if r.collapsed == nil { + r.collapsed = mvvm.NewObservable(false) + } + return r.collapsed +} + +// activeIndex is ActiveTab clamped into the tabs (0 with no tabs). +func (r *Ribbon) activeIndex() int { + i := r.ActiveTab().Get() + if i >= len(r.Tabs) { + i = len(r.Tabs) - 1 + } + if i < 0 { + i = 0 + } + return i +} + +// activeTab is the shown tab, nil with no tabs. +func (r *Ribbon) activeTab() *RibbonTab { + if len(r.Tabs) == 0 { + return nil + } + return r.Tabs[r.activeIndex()] +} + +func (r *Ribbon) metrics() ribbonMetrics { + gh := r.glyphHeight() + m := ribbonMetrics{gh: gh, padX: scaled(6), gap: scaled(4), iconS: scaled(16), iconL: scaled(32), + arrowW: scaled(9), sepW: scaled(1), groupPad: scaled(6), colGap: scaled(2)} + m.rowH = max(m.iconS, gh) + 2*scaled(3) + m.tabH = gh + 2*scaled(6) + m.labelH = gh + scaled(6) + // A large control stacks its icon, its label (two lines at most) and a gap + // between each; the body is as tall as that or three small rows. + m.bodyH = max(3*m.rowH, m.iconL+2*gh+4*scaled(3)) + return m +} + +// Height is the height the ribbon needs: the tab strip alone when collapsed, +// else the tabs, the body and the group labels. A host reads it on every layout +// (and when [Ribbon.Collapsed] changes) and gives the ribbon that much room. +func (r *Ribbon) Height() int { + m := r.metrics() + if r.Collapsed().Get() { + return m.tabH + 1 + } + return m.tabH + 1 + m.bodyH + m.labelH + 1 +} + +// visible reports whether a control is shown at all (its Action is visible). +func (c *RibbonControl) visible() bool { return c.Action == nil || c.Action.IsVisible() } + +// enabled reports whether a control can be used (its Action is enabled). +func (c *RibbonControl) enabled() bool { return c.Action == nil || c.Action.IsEnabled() } + +// label is the control's text: its Action's label, else its own. +func (c *RibbonControl) label() string { + if c.Action != nil { + return c.Action.Label + } + return c.Label +} + +// icon is the control's glyph painter, nil when it has none. +func (c *RibbonControl) icon() IconFunc { + if c.Action != nil && c.Action.Icon != nil { + return IconFunc(c.Action.Icon) + } + return nil +} + +// hasArrow is whether the control opens something from an arrow part. +func (c *RibbonControl) hasArrow() bool { + return c.Kind == RibbonSplit || c.Kind == RibbonDropdown || c.Kind == RibbonGallery +} + +// visibleControls are the group's shown controls, in order. +func (g *RibbonGroup) visibleControls() []*RibbonControl { + var out []*RibbonControl + for _, c := range g.Controls { + if c.visible() { + out = append(out, c) + } + } + return out +} + +// formAt is the form a control takes at a group level. +func (c *RibbonControl) formAt(l RibbonLevel) ribbonForm { + switch { + case c.Kind == RibbonDropdown: + return formDropdown + case l == RibbonFull && c.Kind == RibbonGallery: + return formGallery + case l == RibbonFull && c.Large: + return formLarge + case l == RibbonIconOnly && c.icon() != nil: + return formIcon + } + return formSmall +} + +// largeLines splits a large control's label over two lines at the space nearest +// its middle, the way Office wraps "Paste Special"; one word stays on one line. +func largeLines(s string) []string { + best, bestD := -1, len(s) + for i := 0; i < len(s); i++ { + if s[i] != ' ' { + continue + } + if d := abs(2*i - len(s)); d < bestD { + best, bestD = i, d + } + } + if best < 0 { + return []string{s} + } + return []string{s[:best], s[best+1:]} +} + +// ribbonCell is one control as placed: where it is, how it is drawn and the +// parts a pointer can hit. +type ribbonCell struct { + rect Rect + ctrl *RibbonControl + grp *RibbonGroup // the group a popup button opens + form ribbonForm + arrow Rect // the part that opens a menu; zero when the control has none + cells []Rect // a gallery's visible cells, then nothing +} + +// ribbonGroupBox is one group as placed. +type ribbonGroupBox struct { + rect Rect + group *RibbonGroup + level RibbonLevel +} + +// ribbonBody is a tab's body laid out at a width. +type ribbonBody struct { + groups []ribbonGroupBox + cells []ribbonCell +} + +// controlSize is the size a control takes in a form; large and gallery forms +// are as tall as the body, the small forms one row. +func (r *Ribbon) controlSize(c *RibbonControl, f ribbonForm, m ribbonMetrics) (w, h int) { + label := c.label() + switch f { + case formLarge: + tw := 0 + for _, ln := range largeLines(label) { + tw = max(tw, r.textWidth(ln)) + } + if c.hasArrow() { + tw += m.arrowW + m.gap + } + return max(m.iconL, tw) + 2*m.padX, m.bodyH + case formIcon: + w = m.iconS + 2*m.padX + if c.hasArrow() { + w += m.arrowW + } + return w, m.rowH + case formDropdown: + tw := r.textWidth(label) + for _, o := range c.Options { + tw = max(tw, r.textWidth(o)) + } + return min(tw+m.arrowW+3*m.padX, scaled(180)), m.rowH + case formGallery: + return r.galleryCells(c)*r.galleryCellW(m) + m.arrowW + 2*m.padX, m.bodyH + } + // formSmall + w = 2*m.padX + m.iconS + m.gap + r.textWidth(label) + if c.hasArrow() { + w += m.gap + m.arrowW + } + return w, m.rowH +} + +// galleryCells is how many cells an in-ribbon gallery shows: three at most (the +// guidelines ask for at least three when there is room). +func (r *Ribbon) galleryCells(c *RibbonControl) int { return min(len(c.Items), 3) } + +func (r *Ribbon) galleryCellW(m ribbonMetrics) int { return m.iconL + m.padX } + +// popupSize is the size of a folded group's button. +func (r *Ribbon) popupSize(g *RibbonGroup, m ribbonMetrics) (w, h int) { + return max(m.iconL, r.textWidth(g.Label)+m.gap+m.arrowW) + 2*m.padX, m.bodyH +} + +// columns packs a group's shown controls into columns for a level: a large +// control or a gallery takes a column of its own, small ones stack up to three. +func (r *Ribbon) columns(g *RibbonGroup, l RibbonLevel) [][]*RibbonControl { + var cols [][]*RibbonControl + var small []*RibbonControl + flush := func() { + if len(small) > 0 { + cols = append(cols, small) + small = nil + } + } + for _, c := range g.visibleControls() { + switch f := c.formAt(l); f { + case formLarge, formGallery: + flush() + cols = append(cols, []*RibbonControl{c}) + default: + small = append(small, c) + if len(small) == 3 { + flush() + } + } + } + flush() + return cols +} + +// columnWidth is the width of a column: its widest control. +func (r *Ribbon) columnWidth(col []*RibbonControl, l RibbonLevel, m ribbonMetrics) int { + w := 0 + for _, c := range col { + cw, _ := r.controlSize(c, c.formAt(l), m) + w = max(w, cw) + } + return w +} + +// groupWidth is the width of a group at a level, label included. +func (r *Ribbon) groupWidth(g *RibbonGroup, l RibbonLevel, m ribbonMetrics) int { + if l == RibbonPopup { + w, _ := r.popupSize(g, m) + return w + 2*m.groupPad + } + cols := r.columns(g, l) + w := 0 + for i, col := range cols { + if i > 0 { + w += m.colGap + } + w += r.columnWidth(col, l, m) + } + return max(w, r.textWidth(g.Label)) + 2*m.groupPad +} + +// nextLevel is the next level below cur that makes the group narrower, and false +// when there is none. A group of a single control never folds into a pop-up. +func (r *Ribbon) nextLevel(g *RibbonGroup, cur RibbonLevel, m ribbonMetrics) (RibbonLevel, bool) { + w := r.groupWidth(g, cur, m) + for l := cur + 1; l <= RibbonPopup; l++ { + if l == RibbonPopup && len(g.visibleControls()) < 2 { + break + } + if r.groupWidth(g, l, m) < w { + return l, true + } + } + return cur, false +} + +// chooseLevels picks each group's level so the tab fits avail: every group +// starts full, and while the total is too wide the group with the lowest +// priority (the rightmost among equals) that can still shrink steps down. +func (r *Ribbon) chooseLevels(t *RibbonTab, avail int, m ribbonMetrics) []RibbonLevel { + levels := make([]RibbonLevel, len(t.Groups)) + total := func() int { + w := 0 + for i, g := range t.Groups { + w += r.groupWidth(g, levels[i], m) + m.sepW + } + return w + } + for total() > avail { + pick := -1 + var to RibbonLevel + for i, g := range t.Groups { + next, ok := r.nextLevel(g, levels[i], m) + if ok && (pick < 0 || g.Priority <= t.Groups[pick].Priority) { + pick, to = i, next + } + } + if pick < 0 { + break + } + levels[pick] = to + } + return levels +} + +// Levels is the level each group of the active tab is shown at for the current +// width — what a host (or a test) reads to see how far the ribbon has scaled. +func (r *Ribbon) Levels() []RibbonLevel { + t := r.activeTab() + if t == nil { + return nil + } + return r.chooseLevels(t, r.Bounds().W, r.metrics()) +} + +// layoutBody places the groups and controls of tab t, at the given levels, with +// the body's top-left corner at (x, y). +func (r *Ribbon) layoutBody(t *RibbonTab, levels []RibbonLevel, x, y int, m ribbonMetrics) ribbonBody { + var body ribbonBody + for i, g := range t.Groups { + gw := r.groupWidth(g, levels[i], m) + body.groups = append(body.groups, ribbonGroupBox{rect: Rect{X: x, Y: y, W: gw, H: m.bodyH + m.labelH}, group: g, level: levels[i]}) + body.cells = append(body.cells, r.layoutGroup(g, levels[i], x+m.groupPad, y, m)...) + x += gw + m.sepW + } + return body +} + +// layoutGroup places one group's controls with its content's top-left at (x, y). +func (r *Ribbon) layoutGroup(g *RibbonGroup, l RibbonLevel, x, y int, m ribbonMetrics) []ribbonCell { + if l == RibbonPopup { + w, h := r.popupSize(g, m) + rc := Rect{X: x, Y: y, W: w, H: h} + return []ribbonCell{{rect: rc, grp: g, form: formPopup}} + } + var cells []ribbonCell + for i, col := range r.columns(g, l) { + if i > 0 { + x += m.colGap + } + cw := r.columnWidth(col, l, m) + // A column of small controls is centred in the body's height. + cy := y + if f := col[0].formAt(l); f != formLarge && f != formGallery { + cy += (m.bodyH - len(col)*m.rowH) / 2 + } + for _, c := range col { + f := c.formAt(l) + _, ch := r.controlSize(c, f, m) + cell := ribbonCell{rect: Rect{X: x, Y: cy, W: cw, H: ch}, ctrl: c, form: f} + r.placeParts(&cell, m) + cells = append(cells, cell) + cy += ch + } + x += cw + } + return cells +} + +// placeParts fills in the arrow part and the gallery cells of a placed control. +func (r *Ribbon) placeParts(c *ribbonCell, m ribbonMetrics) { + rc := c.rect + switch { + case c.form == formGallery: + n := r.galleryCells(c.ctrl) + cw := r.galleryCellW(m) + for i := 0; i < n; i++ { + c.cells = append(c.cells, Rect{X: rc.X + m.padX + i*cw, Y: rc.Y, W: cw, H: rc.H}) + } + c.arrow = Rect{X: rc.X + rc.W - m.arrowW - m.padX, Y: rc.Y, W: m.arrowW + m.padX, H: rc.H} + case c.form == formLarge && c.ctrl.hasArrow(): + // The label half of a large split opens the menu, the icon half runs. + c.arrow = Rect{X: rc.X, Y: rc.Y + m.padX + m.iconL, W: rc.W, H: rc.H - m.padX - m.iconL} + case c.form == formDropdown: + c.arrow = rc + case c.ctrl.hasArrow() && (c.form == formSmall || c.form == formIcon): + c.arrow = Rect{X: rc.X + rc.W - m.arrowW - m.padX, Y: rc.Y, W: m.arrowW + m.padX, H: rc.H} + } +} + +// tabRects places the tab labels along the strip, then the collapse chevron. +func (r *Ribbon) tabRects(m ribbonMetrics) (tabs []Rect, chev Rect) { + b := r.Bounds() + x := b.X + m.padX + for _, t := range r.Tabs { + w := r.textWidth(t.Label) + 4*m.padX + tabs = append(tabs, Rect{X: x, Y: b.Y, W: w, H: m.tabH}) + x += w + } + chev = Rect{X: b.X + b.W - m.tabH - m.padX, Y: b.Y, W: m.tabH, H: m.tabH} + return tabs, chev +} + +// body lays out the active tab's body at the ribbon's width, below the tab +// strip (also when it is only peeking over the content of a collapsed one). +func (r *Ribbon) body() ribbonBody { + t := r.activeTab() + if t == nil { + return ribbonBody{} + } + m := r.metrics() + b := r.Bounds() + return r.layoutBody(t, r.chooseLevels(t, b.W, m), b.X, b.Y+m.tabH+1, m) +} + +// showsBody is whether the body is on screen: always when expanded, only while +// peeking when collapsed. +func (r *Ribbon) showsBody() bool { return !r.Collapsed().Get() || r.peek } + +// bodyRect is the rectangle the body fills. +func (r *Ribbon) bodyRect() Rect { + m := r.metrics() + b := r.Bounds() + return Rect{X: b.X, Y: b.Y + m.tabH + 1, W: b.W, H: m.bodyH + m.labelH + 1} +} diff --git a/ribbon_a11y.go b/ribbon_a11y.go new file mode 100644 index 0000000..88d5653 --- /dev/null +++ b/ribbon_a11y.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +// A11y describes the ribbon as a toolbar named "Ribbon" whose value is the +// active tab, the way the WAI-ARIA toolbar pattern labels one. Its [Ribbon.Children] +// carry the tablist and the controls, with their rectangles, for [WalkA11y]. +func (r *Ribbon) A11y() A11yInfo { + v := "" + if t := r.activeTab(); t != nil { + v = t.Label + } + return A11yInfo{Role: RoleToolbar, Name: "Ribbon", Value: v} +} + +// ribbonNode is a synthetic, non-drawing stand-in for one part of the ribbon +// (the tablist, a tab, a control), materialised on demand so the generic +// accessibility walk reports each with its own role, name, state and rectangle. +// It draws nothing and handles no events: the ribbon paints and drives itself. +type ribbonNode struct { + Base + info A11yInfo + kids []Widget +} + +func (n *ribbonNode) A11y() A11yInfo { return n.info } +func (n *ribbonNode) Children() []Widget { return n.kids } + +var ( + _ Accessible = (*Ribbon)(nil) + _ Accessible = (*ribbonNode)(nil) +) + +// Children exposes the ribbon to [WalkA11y] as a tablist containing one tab per +// tab (Value "selected" on the active one, as aria-selected), followed — when the +// body is shown — by one node per control of the active tab: a button, a +// checkbox for a toggle (Value "checked", as aria-pressed), and for a control +// that opens a menu Value "collapsed" or, while its menu is open, "expanded" +// (aria-expanded). Nodes are rebuilt from the current geometry on each call. +func (r *Ribbon) Children() []Widget { + m := r.metrics() + tabs, _ := r.tabRects(m) + list := &ribbonNode{info: A11yInfo{Role: RoleTablist, Name: "Ribbon tabs"}} + act := r.activeIndex() + for i, t := range r.Tabs { + n := &ribbonNode{info: A11yInfo{Role: RoleTab, Name: t.Label, Value: stateValue(i == act, "selected")}} + n.SetBounds(tabs[i]) + list.kids = append(list.kids, n) + } + list.SetBounds(Rect{X: r.Bounds().X, Y: r.Bounds().Y, W: r.Bounds().W, H: m.tabH}) + out := []Widget{list} + for _, c := range r.cells() { + n := &ribbonNode{info: r.cellA11y(c)} + n.SetBounds(c.rect) + out = append(out, n) + } + return out +} + +// cellA11y is the description of one control. +func (r *Ribbon) cellA11y(c ribbonCell) A11yInfo { + if c.form == formPopup { + // While a folded group is open its controls take the button's place in + // the tree, so the button is only ever reported collapsed. + return A11yInfo{Role: RoleButton, Name: c.grp.Label, Value: "collapsed"} + } + info := A11yInfo{Role: RoleButton, Name: c.ctrl.label()} + switch { + case c.ctrl.Kind == RibbonToggle: + info.Role = RoleCheckbox + info.Value = checkedValue(c.ctrl.check().Get()) + case c.ctrl.hasArrow(): + info.Value = r.expandedValue(c) + } + return info +} + +// expandedValue is "expanded" while the cell's popup is open, else "collapsed". +func (r *Ribbon) expandedValue(c ribbonCell) string { + if r.pop != nil && r.pop.anchor == c.rect { + return "expanded" + } + return "collapsed" +} diff --git a/ribbon_a11y_test.go b/ribbon_a11y_test.go new file mode 100644 index 0000000..289f1e1 --- /dev/null +++ b/ribbon_a11y_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "testing" + +// The ribbon reports a toolbar whose children are a tablist (one tab each, the +// active one "selected") and the controls of the shown tab, with their rects. +func TestRibbonAccessibilityTree(t *testing.T) { + r, _ := rtRibbon(2000) + r.SetBounds(Rect{X: 10, Y: 20, W: 2000, H: r.Height()}) + r.Tabs[0].Groups[1].Controls[3].check().Set(true) + nodes := WalkA11y(r) + if nodes[0].Role != RoleToolbar || nodes[0].Name != "Ribbon" || nodes[0].Value != "Home" { + t.Fatalf("root %+v", nodes[0].A11yInfo) + } + if nodes[1].Role != RoleTablist { + t.Fatalf("second node %+v", nodes[1].A11yInfo) + } + var tabs []A11yNode + for _, n := range nodes { + if n.Role == RoleTab { + tabs = append(tabs, n) + } + } + if len(tabs) != 3 || tabs[0].Value != "selected" || tabs[1].Value != "" || tabs[0].Name != "Home" { + t.Errorf("tabs %+v", tabs) + } + if tabs[0].Rect.X < 10 || tabs[0].Rect.Y != 20 || tabs[0].Rect.H != r.metrics().tabH { + t.Errorf("a tab's rect is where it is drawn, got %+v", tabs[0].Rect) + } + byName := map[string]A11yNode{} + for _, n := range nodes { + byName[n.Name] = n + } + if n := byName["Word wrap"]; n.Role != RoleCheckbox || n.Value != "checked" { + t.Errorf("a checked toggle %+v", n.A11yInfo) + } + if n := byName["Duplicate"]; n.Role != RoleButton || n.Value != "" { + t.Errorf("a plain button %+v", n.A11yInfo) + } + if n := byName["New slide"]; n.Role != RoleButton || n.Value != "collapsed" { + t.Errorf("a split button %+v", n.A11yInfo) + } + // Open its menu: aria-expanded. + r.activate(rtCell(r, "New slide"), partArrow) + if n := nodeNamed(WalkA11y(r), "New slide"); n.Value != "expanded" { + t.Errorf("an open split button %+v", n.A11yInfo) + } +} + +func nodeNamed(nodes []A11yNode, name string) A11yNode { + for _, n := range nodes { + if n.Name == name { + return n + } + } + return A11yNode{} +} + +func TestRibbonAccessibilityOfFoldedGroupsAndCollapse(t *testing.T) { + r, _ := rtRibbon(360) + if n := nodeNamed(WalkA11y(r), "Theme"); n.Role != RoleButton || n.Value != "collapsed" { + t.Errorf("a folded group is a collapsed button: %+v", n.A11yInfo) + } + r.activate(rtCell(r, "Theme"), partMain) + if n := nodeNamed(WalkA11y(r), "Kinds"); n.Role != RoleButton || n.Value != "collapsed" { + t.Errorf("an open folded group lists its own controls: %+v", n.A11yInfo) + } + r.Dismiss() + r.Collapsed().Set(true) + for _, n := range WalkA11y(r) { + if n.Name == "Duplicate" { + t.Error("a collapsed ribbon announces no controls") + } + } +} + +func TestRibbonIsAccessible(t *testing.T) { + var w Widget = &Ribbon{} + if _, ok := w.(Accessible); !ok { + t.Fatal("Ribbon must implement Accessible") + } + if got := CollectA11y([]Widget{NewRibbon(&RibbonTab{Label: "T"})}); len(got) != 1 || got[0].Role != RoleToolbar { + t.Errorf("CollectA11y = %+v", got) + } +} diff --git a/ribbon_draw.go b/ribbon_draw.go new file mode 100644 index 0000000..311d394 --- /dev/null +++ b/ribbon_draw.go @@ -0,0 +1,365 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "github.com/go-widgets/painter" + +// ribbonMinContrast is the contrast the ribbon's boundaries and its focus ring +// keep against the surfaces they sit on (WCAG 2.2, success criterion 1.4.11). +const ribbonMinContrast = 3.0 + +// ribbonLine is the colour of every boundary the ribbon draws — the tab outline, +// the group dividers, the edge of a drop-down box and of a popup: the theme's own +// border when it already reaches 3:1 against both the page and the ribbon's +// body, else pulled toward the text colour until it does ([PopupBorder]). +func ribbonLine(theme *Theme) RGBA { return PopupBorder(theme) } + +// ribbonAccent is the accent as the ribbon uses it for things that must be seen +// — the focus ring, a toggle's outline, the chosen item — pulled toward the text +// colour until it reaches 3:1 against both the tab strip and the body. +func ribbonAccent(theme *Theme) RGBA { + c := ensureContrast(theme.Accent, theme.Surface, theme.OnSurface, ribbonMinContrast) + return ensureContrast(c, theme.Background, theme.OnSurface, ribbonMinContrast) +} + +// ribbonInk is the ink of a control: the text colour, muted when disabled. +func ribbonInk(theme *Theme, enabled bool) RGBA { + if enabled { + return theme.OnSurface + } + return mutedInk(theme) +} + +// Draw paints the tab strip and, unless the ribbon is collapsed, the body of the +// active tab. Menus, a collapsed ribbon's body, tooltips and keytips are drawn by +// [Ribbon.DrawPopup], which a host calls last. +func (r *Ribbon) Draw(p painter.Painter, theme *Theme) { + b := r.Bounds() + m := r.metrics() + line := ribbonLine(theme) + collapsed := r.Collapsed().Get() + fillRect(p, b.X, b.Y, b.W, m.tabH, theme.Background) + fillRect(p, b.X, b.Y+m.tabH, b.W, 1, line) + if !collapsed { + r.drawBody(p, theme, m, r.body()) + } + tabs, chev := r.tabRects(m) + act := r.activeIndex() + for i, t := range r.Tabs { + r.drawTab(p, theme, m, t, tabs[i], line, i == act && !collapsed, i == r.hoverTab) + } + r.drawCollapse(p, theme, chev, collapsed) + if r.Focused() && r.zone == ribbonZoneTabs && r.keytips == 0 && len(tabs) > 0 { + f := tabs[min(r.focusTab, len(tabs)-1)] + strokeRect(p, f.X+1, f.Y+1, f.W-2, f.H-2, ribbonAccent(theme)) + } +} + +// drawTab paints one tab label; the active tab of an expanded ribbon is joined to +// the body below it and outlined on the other three sides. +func (r *Ribbon) drawTab(p painter.Painter, theme *Theme, m ribbonMetrics, t *RibbonTab, rc Rect, line RGBA, active, hot bool) { + ink := theme.OnSurface + if active { + fillRect(p, rc.X, rc.Y, rc.W, rc.H+1, theme.Surface) + fillRect(p, rc.X, rc.Y, rc.W, 1, line) + fillRect(p, rc.X, rc.Y, 1, rc.H, line) + fillRect(p, rc.X+rc.W-1, rc.Y, 1, rc.H, line) + } else if hot { + fillRoundRect(p, rc.X, rc.Y+scaled(2), rc.W, rc.H-scaled(2), scaled(3), theme.SurfaceAlt) + } + r.drawText(p, rc.X+(rc.W-r.textWidth(t.Label))/2, rc.Y+(rc.H-m.gh)/2, t.Label, ink) +} + +// drawCollapse paints the button at the end of the tab strip that collapses and +// expands the ribbon: an up chevron when expanded, a down one when collapsed. +func (r *Ribbon) drawCollapse(p painter.Painter, theme *Theme, rc Rect, collapsed bool) { + if r.chevHot { + fillRoundRect(p, rc.X, rc.Y+scaled(2), rc.W, rc.H-scaled(2), scaled(3), theme.SurfaceAlt) + } + cx, cy := rc.X+rc.W/2, rc.Y+rc.H/2 + if collapsed { + drawDropArrow(p, cx, cy, theme.OnSurface) + return + } + s := max(1, scaled(1)) + for i := 0; i < 3; i++ { + fillRect(p, cx-i*s, cy+(i-1)*s, (1+2*i)*s, s, theme.OnSurface) + } +} + +// drawDropArrow paints the small down-pointing triangle of a menu or drop-down, +// centred on (cx, cy). +func drawDropArrow(p painter.Painter, cx, cy int, ink RGBA) { + s := max(1, scaled(1)) + for i := 0; i < 3; i++ { + fillRect(p, cx-(2-i)*s, cy+(i-1)*s, (1+2*(2-i))*s, s, ink) + } +} + +// drawBody paints a laid-out body: the surface, then each group (its divider and +// label) and each control. +func (r *Ribbon) drawBody(p painter.Painter, theme *Theme, m ribbonMetrics, body ribbonBody) { + b := r.Bounds() + line := ribbonLine(theme) + top := b.Y + m.tabH + 1 + fillRect(p, b.X, top, b.W, m.bodyH+m.labelH, theme.Surface) + fillRect(p, b.X, top+m.bodyH+m.labelH, b.W, 1, line) + labelInk := blendRGBA(theme.OnSurface, theme.Surface, 0.75) + for _, g := range body.groups { + if g.rect.X+g.rect.W+m.sepW <= b.X+b.W { + fillRect(p, g.rect.X+g.rect.W, top+scaled(4), m.sepW, m.bodyH+m.labelH-scaled(8), line) + } + lw := r.textWidth(g.group.Label) + r.drawText(p, g.rect.X+(g.rect.W-lw)/2, top+m.bodyH+(m.labelH-m.gh)/2, g.group.Label, labelInk) + } + for i, c := range body.cells { + r.drawCell(p, theme, m, i, c) + } +} + +// drawCell paints one control in its form. +func (r *Ribbon) drawCell(p painter.Painter, theme *Theme, m ribbonMetrics, idx int, c ribbonCell) { + rc := c.rect + enabled := c.usable() + ink := ribbonInk(theme, enabled) + checked := c.ctrl != nil && c.ctrl.Kind == RibbonToggle && c.ctrl.check().Get() + open := r.pop != nil && r.pop.anchor == rc + hot := enabled && (idx == r.hoverCtl || open) + switch { + case checked: + fillRoundRect(p, rc.X, rc.Y, rc.W, rc.H, scaled(3), blendRGBA(theme.Accent, theme.Surface, 0.25)) + strokeRoundRect(p, rc.X, rc.Y, rc.W, rc.H, scaled(3), ribbonAccent(theme)) + case hot: + fillRoundRect(p, rc.X, rc.Y, rc.W, rc.H, scaled(3), theme.SurfaceAlt) + } + switch c.form { + case formLarge: + r.drawLarge(p, m, rc, c.ctrl.icon(), largeLines(c.ctrl.label()), c.ctrl.hasArrow(), ink) + case formPopup: + r.drawLarge(p, m, rc, c.grp.Icon, largeLines(c.grp.Label), true, ink) + case formDropdown: + r.drawDropdown(p, theme, m, rc, c.ctrl, ink) + case formGallery: + r.drawGallery(p, theme, m, c, ink) + default: + r.drawSmall(p, m, c, ink) + } + if r.Focused() && r.zone == ribbonZoneBody && idx == r.focusCtl && r.keytips == 0 { + strokeRoundRect(p, rc.X, rc.Y, rc.W, rc.H, scaled(3), ribbonAccent(theme)) + } +} + +// drawIcon paints an icon, if there is one, into rc. +func drawIcon(p painter.Painter, icon IconFunc, rc Rect, ink RGBA) { + if icon != nil { + icon(p, rc, ink) + } +} + +// drawLarge paints the large form: the icon above one or two lines of text, and +// for a control with a menu the arrow at the end of the last line (or on a line +// of its own under a one-line label). +func (r *Ribbon) drawLarge(p painter.Painter, m ribbonMetrics, rc Rect, icon IconFunc, lines []string, arrow bool, ink RGBA) { + y := rc.Y + scaled(3) + drawIcon(p, icon, Rect{X: rc.X + (rc.W-m.iconL)/2, Y: y, W: m.iconL, H: m.iconL}, ink) + y += m.iconL + scaled(3) + for i, ln := range lines { + w := r.textWidth(ln) + x := rc.X + (rc.W-w)/2 + if arrow && i == len(lines)-1 && len(lines) > 1 { + x = rc.X + (rc.W-w-m.gap-m.arrowW)/2 + drawDropArrow(p, x+w+m.gap+m.arrowW/2, y+m.gh/2, ink) + } + r.drawText(p, x, y, ln, ink) + y += m.gh + } + if arrow && len(lines) == 1 { + drawDropArrow(p, rc.X+rc.W/2, y+m.gh/2, ink) + } +} + +// drawSmall paints the small form (icon, text, arrow) or its icon-only variant. +func (r *Ribbon) drawSmall(p painter.Painter, m ribbonMetrics, c ribbonCell, ink RGBA) { + rc := c.rect + icon := c.ctrl.icon() + ix := rc.X + m.padX + if c.form == formIcon { + w := rc.W + if c.ctrl.hasArrow() { + w -= m.arrowW + } + ix = rc.X + (w-m.iconS)/2 + } + drawIcon(p, icon, Rect{X: ix, Y: rc.Y + (rc.H-m.iconS)/2, W: m.iconS, H: m.iconS}, ink) + if c.form == formSmall { + r.drawText(p, ix+m.iconS+m.gap, rc.Y+(rc.H-m.gh)/2, c.ctrl.label(), ink) + } + if c.ctrl.hasArrow() { + drawDropArrow(p, rc.X+rc.W-m.padX-m.arrowW/2, rc.Y+rc.H/2, ink) + } +} + +// drawDropdown paints a drop-down's box: its outline, the chosen option (or its +// label while there is no choice) and the arrow. +func (r *Ribbon) drawDropdown(p painter.Painter, theme *Theme, m ribbonMetrics, rc Rect, c *RibbonControl, ink RGBA) { + box := Rect{X: rc.X + scaled(1), Y: rc.Y + scaled(2), W: rc.W - 2*scaled(1), H: rc.H - 2*scaled(2)} + fillRoundRect(p, box.X, box.Y, box.W, box.H, scaled(3), theme.Background) + strokeRoundRect(p, box.X, box.Y, box.W, box.H, scaled(3), ribbonLine(theme)) + text := c.label() + if i := c.selection().Get(); i >= 0 && i < len(c.Options) { + text = c.Options[i] + } + r.drawText(p, box.X+m.padX, box.Y+(box.H-m.gh)/2, text, ink) + drawDropArrow(p, box.X+box.W-m.padX-m.arrowW/2, box.Y+box.H/2, ink) +} + +// drawGallery paints the in-ribbon strip: the first cells of the gallery with the +// chosen one outlined, then the arrow that opens the rest. +func (r *Ribbon) drawGallery(p painter.Painter, theme *Theme, m ribbonMetrics, c ribbonCell, ink RGBA) { + sel := c.ctrl.selection().Get() + for i, rc := range c.cells { + it := c.ctrl.Items[i] + drawIcon(p, it.icon(), Rect{X: rc.X + (rc.W-m.iconL)/2, Y: rc.Y + (rc.H-m.iconL)/2, W: m.iconL, H: m.iconL}, ink) + if i == sel { + strokeRoundRect(p, rc.X, rc.Y+scaled(1), rc.W, rc.H-2*scaled(1), scaled(3), ribbonAccent(theme)) + } + } + drawDropArrow(p, c.arrow.X+c.arrow.W/2, c.arrow.Y+c.arrow.H/2, ink) +} + +// DrawPopup paints what hangs over the page: a collapsed ribbon's body while it +// peeks, the open menu, list, gallery or folded group, the tooltip of the hovered +// control and the keytips. A host calls it after everything else is drawn. +func (r *Ribbon) DrawPopup(p painter.Painter, theme *Theme) { + m := r.metrics() + if r.Collapsed().Get() && r.peek { + r.drawBody(p, theme, m, r.body()) + } + if r.pop != nil { + r.drawPopupPanel(p, theme, m) + } + if r.tooltipVisible() { + r.drawTooltip(p, theme, m) + } + if r.keytips > 0 { + r.drawKeyTips(p, theme, m) + } +} + +// drawPopupPanel paints the open popup: the panel, then its rows or its group. +func (r *Ribbon) drawPopupPanel(p painter.Painter, theme *Theme, m ribbonMetrics) { + pp := r.pop + panel, rows := r.popupGeometry(pp, m) + fillRect(p, panel.X, panel.Y, panel.W, panel.H, theme.Surface) + strokePopupBorder(p, panel, theme) + if pp.kind == popGroup { + for i, c := range r.groupPopupCells(pp, m) { + r.drawCell(p, theme, m, i, c) + } + lw := r.textWidth(pp.grp.Label) + r.drawText(p, panel.X+(panel.W-lw)/2, panel.Y+1+m.bodyH+(m.labelH-m.gh)/2, pp.grp.Label, blendRGBA(theme.OnSurface, theme.Surface, 0.75)) + return + } + accent := ribbonAccent(theme) + for i, rc := range rows { + if i == pp.focus { + fillRect(p, rc.X, rc.Y, rc.W, rc.H, theme.SurfaceAlt) + } + switch pp.kind { + case popGallery: + it := pp.ctrl.Items[i] + drawIcon(p, it.icon(), Rect{X: rc.X + (rc.W-m.iconL)/2, Y: rc.Y + m.gap, W: m.iconL, H: m.iconL}, ribbonInk(theme, it.enabled())) + lw := r.textWidth(it.text()) + r.drawText(p, rc.X+(rc.W-lw)/2, rc.Y+m.gap+m.iconL+m.gap, it.text(), ribbonInk(theme, it.enabled())) + if i == pp.ctrl.selection().Get() { + strokeRect(p, rc.X, rc.Y, rc.W, rc.H, accent) + } + case popList: + if i == pp.ctrl.selection().Get() { + fillRect(p, rc.X, rc.Y, scaled(3), rc.H, accent) + } + r.drawText(p, rc.X+m.padX+m.iconS/2, rc.Y+(rc.H-m.gh)/2, pp.ctrl.Options[i], theme.OnSurface) + default: + it := pp.ctrl.Items[i] + ink := ribbonInk(theme, it.enabled()) + drawIcon(p, it.icon(), Rect{X: rc.X + m.padX, Y: rc.Y + (rc.H-m.iconS)/2, W: m.iconS, H: m.iconS}, ink) + r.drawText(p, rc.X+2*m.padX+m.iconS, rc.Y+(rc.H-m.gh)/2, it.text(), ink) + if sc := r.shortcutFor(it.Action); sc != "" { + r.drawText(p, rc.X+rc.W-m.padX-r.textWidth(sc), rc.Y+(rc.H-m.gh)/2, sc, mutedInk(theme)) + } + } + } +} + +// tooltipLines is the text of the hovered control's tooltip: its name with its +// shortcut, then its description when it has one. The Windows guidelines ask for +// exactly this and for tooltips that "avoid simply restating the label". +func (r *Ribbon) tooltipLines() []string { + cells := r.cells() + if r.hoverCtl < 0 || r.hoverCtl >= len(cells) { + return nil + } + c := cells[r.hoverCtl] + if c.form == formPopup { + return []string{c.grp.Label} + } + head := c.ctrl.label() + if sc := r.shortcutFor(c.ctrl.Action); sc != "" { + head += " (" + sc + ")" + } + if c.ctrl.Tip == "" { + return []string{head} + } + return []string{head, c.ctrl.Tip} +} + +// drawTooltip paints the tooltip bubble under the hovered control. +func (r *Ribbon) drawTooltip(p painter.Painter, theme *Theme, m ribbonMetrics) { + lines := r.tooltipLines() + if len(lines) == 0 { + return + } + w := 0 + for _, l := range lines { + w = max(w, r.textWidth(l)) + } + anchor := r.cells()[r.hoverCtl].rect + box := Rect{X: anchor.X, Y: anchor.Y + anchor.H + scaled(2), W: w + 2*m.padX, H: len(lines)*m.gh + 2*m.gap} + fillRect(p, box.X, box.Y, box.W, box.H, theme.OnSurface) + strokeRect(p, box.X, box.Y, box.W, box.H, ribbonLine(theme)) + for i, l := range lines { + r.drawText(p, box.X+m.padX, box.Y+m.gap+i*m.gh, l, theme.Background) + } +} + +// drawKeyTips paints the keytip badges: a letter over each tab while choosing a +// tab, a tip over each control of the shown tab while choosing a control. Tips +// that no longer match what has been typed are left out. +func (r *Ribbon) drawKeyTips(p painter.Painter, theme *Theme, m ribbonMetrics) { + if r.keytips == 1 { + tabs, _ := r.tabRects(m) + for i, tip := range r.tabTips() { + r.drawBadge(p, theme, m, tip, tabs[i].X+tabs[i].W/2, tabs[i].Y+tabs[i].H-m.gh/2) + } + return + } + cells := r.cells() + for i, tip := range r.cellTips(cells) { + rc := cells[i].rect + r.drawBadge(p, theme, m, tip, rc.X+rc.W/2, rc.Y+rc.H-m.gh) + } +} + +// drawBadge paints one keytip, centred on (cx, y), unless it is empty or does +// not start with what has been typed so far. +func (r *Ribbon) drawBadge(p painter.Painter, theme *Theme, m ribbonMetrics, tip string, cx, y int) { + if tip == "" || len(tip) < len(r.tipBuf) || tip[:len(r.tipBuf)] != r.tipBuf { + return + } + w := r.textWidth(tip) + 2*m.gap + box := Rect{X: cx - w/2, Y: y, W: w, H: m.gh + m.gap} + fillRoundRect(p, box.X, box.Y, box.W, box.H, scaled(3), theme.OnSurface) + r.drawText(p, box.X+m.gap, box.Y+m.gap/2, tip, theme.Background) +} diff --git a/ribbon_draw_test.go b/ribbon_draw_test.go new file mode 100644 index 0000000..b5bb621 --- /dev/null +++ b/ribbon_draw_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "image" + "testing" +) + +// rtThemes are the palettes the contrast checks run over: the two defaults and a +// faint dark one whose Border ("#3a4556 on #141a24") is the hairline that the +// popup-border work found too quiet, and a pale light one. +func rtThemes() map[string]*Theme { + faint := DefaultDark() + faint.Background = RGB(0x14, 0x1A, 0x24) + faint.Surface = RGB(0x1B, 0x22, 0x2E) + faint.SurfaceAlt = RGB(0x24, 0x2D, 0x3B) + faint.Border = RGB(0x3A, 0x45, 0x56) + faint.Accent = RGB(0x2F, 0x5F, 0xA8) + pale := DefaultLight() + pale.Background = RGB(0xFF, 0xFF, 0xFF) + pale.Surface = RGB(0xF6, 0xF7, 0xF9) + pale.Border = RGB(0xE3, 0xE5, 0xE8) + pale.Accent = RGB(0x8F, 0xC1, 0xF7) + return map[string]*Theme{"light": DefaultLight(), "dark": DefaultDark(), "faint-dark": faint, "pale-light": pale} +} + +func rtPixel(img *image.RGBA, x, y int) RGBA { + o := img.PixOffset(x, y) + return RGBA{R: img.Pix[o], G: img.Pix[o+1], B: img.Pix[o+2], A: img.Pix[o+3]} +} + +// Boundaries and the focus ring keep 3:1 against the surfaces they sit on, in +// every palette (WCAG 2.2, 1.4.11), computed from the tokens alone. +func TestRibbonContrastTokens(t *testing.T) { + for name, th := range rtThemes() { + for _, c := range []struct { + what string + ink RGBA + }{{"line", ribbonLine(th)}, {"accent", ribbonAccent(th)}} { + for gname, ground := range map[string]RGBA{"background": th.Background, "surface": th.Surface} { + if r := contrastRatio(c.ink, ground); r < ribbonMinContrast { + t.Errorf("%s: %s is %.2f:1 against the %s, want >= %.1f", name, c.what, r, gname, ribbonMinContrast) + } + } + } + if r := contrastRatio(th.OnSurface, th.SurfaceAlt); r < 4.5 { + t.Errorf("%s: text on the hover face is %.2f:1", name, r) + } + } +} + +// The same, measured on rendered pixels: the outline of the active tab, a group +// divider, a drop-down's box, the focus ring on a tab and on a control, the +// edge of a popup. +func TestRibbonContrastPixels(t *testing.T) { + for name, th := range rtThemes() { + run := &sampleRun{} + r := run.ribbon() + const w = 1100 + r.SetBounds(Rect{W: w, H: r.Height()}) + m := r.metrics() + tabs, _ := r.tabRects(m) + body := r.body() + check := func(what string, img *image.RGBA, x, y int, grounds ...RGBA) { + t.Helper() + got := rtPixel(img, x, y) + for _, g := range grounds { + if c := contrastRatio(got, g); c < ribbonMinContrast { + t.Errorf("%s: %s pixel (%d,%d) is %.2f:1 against %v, want >= 3", name, what, x, y, c, g) + } + } + } + img := renderRibbon(r, th, w, 200) + check("active tab outline (left)", img, tabs[0].X, tabs[0].Y+m.tabH/2, th.Background, th.Surface) + check("active tab outline (top)", img, tabs[0].X+tabs[0].W/2, tabs[0].Y, th.Background, th.Surface) + g0 := body.groups[0].rect + check("group divider", img, g0.X+g0.W, g0.Y+m.bodyH/2, th.Surface) + check("tab strip rule", img, w-3, m.tabH, th.Background, th.Surface) + di := rtCell(r, "Style") + dd := r.cells()[di].rect + check("drop-down box", img, dd.X+dd.W/2, dd.Y+scaled(2), th.Surface, th.Background) + + r.SetFocused(true) + img = renderRibbon(r, th, w, 200) + check("focus ring on the active tab", img, tabs[0].X+1, tabs[0].Y+m.tabH/2, th.Surface) + r.focusTab = 1 + img = renderRibbon(r, th, w, 200) + check("focus ring on an inactive tab", img, tabs[1].X+1, tabs[1].Y+m.tabH/2, th.Background) + r.zone, r.focusCtl = ribbonZoneBody, rtCell(r, "Duplicate") + cell := r.cells()[r.focusCtl].rect + img = renderRibbon(r, th, w, 200) + check("focus ring on a control", img, cell.X+cell.W/2, cell.Y, th.Surface) + + r.activate(rtCell(r, "New slide"), partArrow) + panel, _ := r.popupGeometry(r.pop, m) + img = renderRibbon(r, th, w, 260) + check("popup edge (left)", img, panel.X, panel.Y+panel.H/2, th.Background, th.Surface) + check("popup edge (bottom)", img, panel.X+panel.W/2, panel.Y+panel.H-1, th.Background, th.Surface) + } +} + +// The checked state of a toggle is told by more than a fill: an outline at 3:1. +func TestRibbonCheckedToggleHasAnOutline(t *testing.T) { + for name, th := range rtThemes() { + r, _ := rtRibbon(1100) + r.Tabs[0].Groups[1].Controls[3].check().Set(true) + c := r.cells()[rtCell(r, "Word wrap")].rect + img := renderRibbon(r, th, 1100, 120) + if got := rtPixel(img, c.X+c.W/2, c.Y); contrastRatio(got, th.Surface) < ribbonMinContrast { + t.Errorf("%s: a checked toggle's outline is %.2f:1", name, contrastRatio(got, th.Surface)) + } + } +} + +// Disabled controls are drawn muted, and an enabled one is not. +func TestRibbonDisabledControlIsMuted(t *testing.T) { + th := DefaultLight() + r, _ := rtRibbon(1100) + img1 := renderRibbon(r, th, 1100, 120) + r.Tabs[0].Groups[0].Controls[1].Action.SetEnabled(false) // Duplicate + img2 := renderRibbon(r, th, 1100, 120) + c := r.cells()[rtCell(r, "Duplicate")].rect + changed := false + for y := c.Y; y < c.Y+c.H; y++ { + for x := c.X; x < c.X+c.W; x++ { + if rtPixel(img1, x, y) != rtPixel(img2, x, y) { + changed = true + } + } + } + if !changed { + t.Error("disabling a control left it looking the same") + } +} + +// Everything paints inside its own rectangle, or inside a popup it owns. +func TestRibbonDrawsEveryStateWithoutPanicking(t *testing.T) { + for name, th := range rtThemes() { + for _, w := range []int{1300, 700, 520, 360, 200, 60} { + for _, scale := range []float64{1, 2} { + SetMetricScale(scale) + run := &sampleRun{} + r := run.ribbon() + for tab := range r.Tabs { + r.ActiveTab().Set(tab) + r.SetBounds(Rect{X: 3, Y: 2, W: w, H: r.Height()}) + r.SetFocused(true) + renderRibbon(r, th, w+6, r.Height()+300) + r.hoverTab, r.chevHot = tab, true + r.hoverCtl, r.hoverMs = 0, 1000 + renderRibbon(r, th, w+6, r.Height()+300) + r.zone = ribbonZoneBody + for i := range r.cells() { + r.focusCtl = i + renderRibbon(r, th, w+6, r.Height()+300) + } + } + SetMetricScale(1) + } + } + _ = name + } +} + +func TestRibbonDrawsPopupsOfEveryKind(t *testing.T) { + th := DefaultLight() + r, _ := rtRibbon(1100) + r.Tabs[0].Groups[0].Controls[0].Items[1].Action.SetEnabled(false) + r.Tabs[0].Groups[0].Controls[0].Items[0].Action.Shortcut = MustParseChord("Ctrl+T") + for _, c := range []struct { + label string + part int + }{{"New slide", partArrow}, {"Style", partMain}, {"Kinds", partArrow}} { + r.Dismiss() + r.activate(rtCell(r, c.label), c.part) + if r.pop == nil { + t.Fatalf("%s opened nothing", c.label) + } + r.pop.focus = 1 + renderRibbon(r, th, 1100, 300) + r.pop.focus = 0 + renderRibbon(r, DefaultDark(), 1100, 300) + } + // The gallery's chosen cell is outlined in the strip and in the grid. + r.Dismiss() + r.Tabs[0].Groups[2].Controls[1].selection().Set(2) + renderRibbon(r, th, 1100, 300) + r.activate(rtCell(r, "Kinds"), partArrow) + renderRibbon(r, th, 1100, 300) + // A drop-down whose selection is out of range shows its label. + r.Dismiss() + r.Tabs[0].Groups[2].Controls[0].selection().Set(99) + renderRibbon(r, th, 1100, 300) + // A folded group's panel. + r2, _ := rtRibbon(360) + r2.activate(rtCell(r2, "Theme"), partMain) + renderRibbon(r2, th, 360, 300) + // A collapsed ribbon's peeking body. + r3, _ := rtRibbon(900) + r3.Collapsed().Set(true) + r3.peek = true + renderRibbon(r3, th, 900, 300) +} + +func TestRibbonKeyTipBadgesFilterByWhatWasTyped(t *testing.T) { + th := DefaultLight() + r, _ := rtRibbon(1100) + rtKey(r, "Alt") + renderRibbon(r, th, 1100, 200) + rtKey(r, "H") + renderRibbon(r, th, 1100, 200) + r.tipBuf = "Z" // nothing starts with Z here: every badge is filtered out + renderRibbon(r, th, 1100, 200) + // A badge for an empty tip draws nothing, and a longer tip than typed is fine. + r.drawBadge(nilPainter(), th, r.metrics(), "", 10, 10) +} + +func TestRibbonTooltipBubbleIsInsideTheDrawing(t *testing.T) { + th := DefaultLight() + r, _ := rtRibbon(1100) + i := rtCell(r, "New slide") + x, y := rtCenterOf(r.cells()[i]) + rtMove(r, x, y) + r.Tick(600) + m := r.metrics() + anchor := r.cells()[i].rect + img := renderRibbon(r, th, 1100, 220) + // The bubble starts under the control: its first row is the line colour. + if got := rtPixel(img, anchor.X+2, anchor.Y+anchor.H+scaled(2)); got != ribbonLine(th) { + t.Errorf("tooltip border pixel %v, want %v", got, ribbonLine(th)) + } + _ = m +} + +func TestRibbonIconsAndInk(t *testing.T) { + th := DefaultLight() + if ribbonInk(th, true) != th.OnSurface || ribbonInk(th, false) == th.OnSurface { + t.Error("ribbonInk should mute the disabled ink") + } + drawIcon(nilPainter(), nil, Rect{}, th.OnSurface) // a missing icon is simply not drawn +} diff --git a/ribbon_gallery_test.go b/ribbon_gallery_test.go new file mode 100644 index 0000000..0f45c98 --- /dev/null +++ b/ribbon_gallery_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-widgets/painter" +) + +// renderRibbon draws r (and its popups) at width w on a surface h tall and +// returns it as an image, in theme th. +func renderRibbon(r *Ribbon, th *Theme, w, h int) *image.RGBA { + buf := make([]byte, w*h*4) + p := painter.NewPixelPainter(buf, w, h) + fillRect(p, 0, 0, w, h, th.Background) + r.SetBounds(Rect{X: 0, Y: 0, W: w, H: r.Height()}) + r.Draw(p, th) + r.DrawPopup(p, th) + img := image.NewRGBA(image.Rect(0, 0, w, h)) + copy(img.Pix, buf) + // the pixel buffer is RGBA straight; nothing to convert + _ = color.RGBA{} + return img +} + +// TestRibbonGallery writes PNGs of the ribbon to the directory named by +// RIBBON_GALLERY_DIR — light and dark, at several widths, collapsed, with a menu +// open, with keytips — for a person to look at. It writes nothing (and passes) +// when the variable is unset, and refuses a directory inside a git work tree: a +// capture of a rendering belongs outside every repository. +func TestRibbonGallery(t *testing.T) { + dir := os.Getenv("RIBBON_GALLERY_DIR") + if dir == "" { + t.Skip("RIBBON_GALLERY_DIR not set") + } + for d := dir; d != filepath.Dir(d); d = filepath.Dir(d) { + if _, err := os.Stat(filepath.Join(d, ".git")); err == nil { + t.Fatalf("%s is inside the git work tree %s", dir, d) + } + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + themes := map[string]*Theme{"light": DefaultLight(), "dark": DefaultDark()} + save := func(name string, img *image.RGBA) { + f, err := os.Create(filepath.Join(dir, name+".png")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } + for tn, th := range themes { + for _, w := range []int{1100, 800, 620, 480, 360} { + run := &sampleRun{} + r := run.ribbon() + save(fmt.Sprintf("%s-width-%d", tn, w), renderRibbon(r, th, w, r.Height()+4)) + } + run := &sampleRun{} + r := run.ribbon() + r.Collapsed().Set(true) + save(tn+"-collapsed", renderRibbon(r, th, 900, 40)) + r = run.ribbon() + r.SetBounds(Rect{W: 1100, H: r.Height()}) + r.hover(r.cells()[0].rect.X+4, r.cells()[0].rect.Y+4) + r.activate(0, partArrow) + save(tn+"-split-open", renderRibbon(r, th, 1100, 240)) + r = run.ribbon() + r.SetBounds(Rect{W: 1100, H: r.Height()}) + r.hover(r.cells()[0].rect.X+4, r.cells()[0].rect.Y+4) + r.Tick(600) + save(tn+"-tooltip", renderRibbon(r, th, 1100, 220)) + r = run.ribbon() + r.SetBounds(Rect{W: 1100, H: r.Height()}) + r.key(Event{Kind: EventKeyDown, Code: "Alt"}) + r.key(Event{Kind: EventKeyDown, Code: "H"}) + save(tn+"-keytips", renderRibbon(r, th, 1100, 160)) + r = run.ribbon() + r.SetBounds(Rect{W: 620, H: r.Height()}) + r.SetFocused(true) + r.Levels() + if i := firstPopup(r); i >= 0 { + r.activate(i, partMain) + } + save(tn+"-group-popup", renderRibbon(r, th, 620, 240)) + } + if !strings.HasSuffix(dir, "") { + t.Fatal("unreachable") + } +} + +// firstPopup is the index of the first folded-group button of the active tab, -1 +// when no group is folded. +func firstPopup(r *Ribbon) int { + for i, c := range r.cells() { + if c.form == formPopup { + return i + } + } + return -1 +} diff --git a/ribbon_input.go b/ribbon_input.go new file mode 100644 index 0000000..7fefa09 --- /dev/null +++ b/ribbon_input.go @@ -0,0 +1,605 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "strings" + +// partMain and partArrow say which part of a control an activation targets; a +// value of 0 or more is a gallery cell. +const ( + partMain = -1 + partArrow = -2 +) + +// SetFocused records whether the ribbon holds keyboard focus. Losing it closes +// what the ribbon had open — a menu, a peeking body, the keytips — so nothing is +// left hanging over the page. +func (r *Ribbon) SetFocused(focused bool) { + r.focusState.SetFocused(focused) + if !focused { + r.Dismiss() + } +} + +// Dismiss closes whatever the ribbon has open over the content: a menu, a drop- +// down, a folded group, a collapsed ribbon's body, the keytips. A host calls it +// when a click lands outside the ribbon and its popup (the ribbon cannot see +// that click), and the ribbon calls it itself after a command runs. +func (r *Ribbon) Dismiss() { + r.pop, r.peek, r.keytips, r.tipBuf, r.hoverMs = nil, false, 0, "", 0 +} + +// HitTest is true over the ribbon and over what hangs from it — an open popup or +// a collapsed ribbon's peeking body — so a container routes clicks there. +func (r *Ribbon) HitTest(px, py int) bool { + if r.Bounds().Contains(px, py) { + return true + } + if r.pop != nil { + panel, _ := r.popupGeometry(r.pop, r.metrics()) + return panel.Contains(px, py) + } + return r.peek && r.bodyRect().Contains(px, py) +} + +// Tick advances the hover timer by dtMs milliseconds and reports whether the +// tooltip's visibility changed, i.e. whether the host should redraw. A host with +// a frame loop calls it every frame; without one, tooltips never show. +func (r *Ribbon) Tick(dtMs int) bool { + if r.hoverCtl < 0 || r.pop != nil { + return false + } + was := r.tooltipVisible() + r.hoverMs += dtMs + return was != r.tooltipVisible() +} + +// tooltipVisible is whether the hovered control's tooltip is showing. +func (r *Ribbon) tooltipVisible() bool { + d := r.TooltipDelayMs + if d <= 0 { + d = 500 + } + return r.hoverCtl >= 0 && r.pop == nil && r.hoverMs >= d +} + +// OnEvent drives the ribbon: the pointer (a move hovers, a click presses) and the +// keyboard. Coordinates are widget-local, as everywhere in the toolkit. +func (r *Ribbon) OnEvent(ev Event) { + if r.Disabled().Get() { + return + } + b := r.Bounds() + x, y := b.X+ev.X, b.Y+ev.Y + switch ev.Kind { + case EventMouseMove: + r.hover(x, y) + case EventClick: + r.click(x, y) + case EventKeyDown: + r.key(ev) + } +} + +// hover tracks what the pointer is over, restarting the tooltip timer when the +// control changes. +func (r *Ribbon) hover(x, y int) { + m := r.metrics() + tabs, chev := r.tabRects(m) + r.hoverTab = -1 + for i, t := range tabs { + if t.Contains(x, y) { + r.hoverTab = i + } + } + r.chevHot = chev.Contains(x, y) + if r.pop != nil && r.pop.kind != popGroup { + _, rows := r.popupGeometry(r.pop, m) + for i, rc := range rows { + if rc.Contains(x, y) { + r.pop.focus = i + } + } + } + if idx := r.cellAt(x, y); idx != r.hoverCtl { + r.hoverCtl, r.hoverMs = idx, 0 + } +} + +// click presses whatever is at the surface point (x, y). +func (r *Ribbon) click(x, y int) { + r.keytips, r.tipBuf = 0, "" + if r.pop != nil && r.popupClick(x, y) { + return + } + m := r.metrics() + tabs, chev := r.tabRects(m) + if chev.Contains(x, y) { + r.toggleCollapsed() + return + } + for i, t := range tabs { + if t.Contains(x, y) { + r.pickTab(i) + return + } + } + if idx := r.cellAt(x, y); idx >= 0 { + r.zone, r.focusCtl = ribbonZoneBody, idx + r.clickCell(idx, x, y) + return + } + if r.peek && !r.bodyRect().Contains(x, y) { + r.peek = false + } +} + +// popupClick handles a click while a popup is open and reports whether it was +// consumed: a row chooses it, the panel's border swallows the click, and a click +// anywhere else closes the popup and goes on to what it landed on. A folded +// group's controls are ordinary cells, handled by the caller. +func (r *Ribbon) popupClick(x, y int) bool { + pp := r.pop + panel, rows := r.popupGeometry(pp, r.metrics()) + if pp.kind == popGroup { + if !panel.Contains(x, y) { + r.pop = nil + } + return false + } + for i, rc := range rows { + if rc.Contains(x, y) { + r.choose(pp, i) + return true + } + } + if panel.Contains(x, y) { + return true + } + r.pop = nil + return false +} + +// choose picks row i of a menu, list or gallery popup. A disabled entry is +// refused and the popup stays open. +func (r *Ribbon) choose(pp *ribbonPopup, i int) { + switch pp.kind { + case popMenu: + it := pp.ctrl.Items[i] + if !it.enabled() { + return + } + if it.Action != nil { + it.Action.Execute() + } + case popList: + pp.ctrl.selection().Set(i) + case popGallery: + r.pickGallery(pp.ctrl, i) + return + } + r.Dismiss() +} + +// pickGallery selects cell i of a gallery and runs its command, if it has one. +func (r *Ribbon) pickGallery(c *RibbonControl, i int) { + it := c.Items[i] + if !it.enabled() { + return + } + c.selection().Set(i) + if it.Action != nil { + it.Action.Execute() + } + r.Dismiss() +} + +// pickTab shows tab i. On a collapsed ribbon that peeks at its body (and a +// second click on the same tab puts it away again). +func (r *Ribbon) pickTab(i int) { + same := i == r.activeIndex() + r.ActiveTab().Set(i) + r.focusTab, r.zone, r.pop = i, ribbonZoneTabs, nil + r.peek = r.Collapsed().Get() && !(same && r.peek) +} + +// toggleCollapsed collapses an expanded ribbon and expands a collapsed one. +func (r *Ribbon) toggleCollapsed() { + r.Collapsed().Set(!r.Collapsed().Get()) + r.pop, r.peek = nil, false +} + +// clickCell presses cell idx at the surface point (x, y): a gallery cell picks +// it, an arrow opens the menu, anything else is the control's main action. +func (r *Ribbon) clickCell(idx, x, y int) { + cell := r.cells()[idx] + part := partMain + for i, rc := range cell.cells { + if rc.Contains(x, y) { + part = i + } + } + if part == partMain && cell.arrow != (Rect{}) && cell.arrow.Contains(x, y) { + part = partArrow + } + r.activate(idx, part) +} + +// activate runs control idx: its main action, or its menu when part is the arrow, +// or gallery cell part. Drop-downs and galleries open their popup whatever the +// part, folded groups open themselves. +func (r *Ribbon) activate(idx, part int) { + cells := r.cells() + if idx < 0 || idx >= len(cells) { + return + } + cell := cells[idx] + if cell.form == formPopup { + r.openPopup(&ribbonPopup{kind: popGroup, grp: cell.grp, anchor: cell.rect}) + return + } + c := cell.ctrl + if !c.enabled() { + return + } + switch c.Kind { + case RibbonToggle: + c.check().Set(!c.check().Get()) + r.runAction(c.Action) + case RibbonDropdown: + r.openPopup(&ribbonPopup{kind: popList, ctrl: c, anchor: cell.rect, focus: c.selection().Get()}) + case RibbonGallery: + if part >= 0 && part < len(c.Items) { + r.pickGallery(c, part) + return + } + r.openPopup(&ribbonPopup{kind: popGallery, ctrl: c, anchor: cell.rect, focus: c.selection().Get()}) + case RibbonSplit: + if part == partArrow { + r.openPopup(&ribbonPopup{kind: popMenu, ctrl: c, anchor: cell.rect}) + return + } + r.runAction(c.Action) + default: + r.runAction(c.Action) + } +} + +// openPopup shows pp; a folded group's panel takes the keyboard into its body. +func (r *Ribbon) openPopup(pp *ribbonPopup) { + r.pop, r.hoverMs = pp, 0 + if pp.kind == popGroup { + r.zone, r.focusCtl = ribbonZoneBody, 0 + } +} + +// runAction runs a control's command, then closes whatever was open over the +// content: a command is the end of a gesture. +func (r *Ribbon) runAction(a *Action) { + if a != nil { + a.Execute() + } + r.Dismiss() +} + +// key routes a key press. +func (r *Ribbon) key(ev Event) { + switch { + case ev.Ctrl && ev.Code == "F1": + r.toggleCollapsed() + case ev.Code == "Alt" && r.keytips > 0: + r.keytips, r.tipBuf = 0, "" + case ev.Code == "Alt": + r.startKeyTips() + case r.keytips > 0: + r.keyTipKey(ev) + case r.pop != nil && r.pop.kind != popGroup: + r.popupKey(ev) + case r.zone == ribbonZoneTabs: + r.tabKey(ev) + default: + r.bodyKey(ev) + } +} + +// tabKey is the tablist: Left/Right (wrapping) move and show a tab at once +// ("automatic activation", which the WAI-ARIA practices recommend when the +// panel shows without delay), Home/End jump, and Down, Enter or Space go into +// the body (peeking at it first when the ribbon is collapsed). +func (r *Ribbon) tabKey(ev Event) { + n := len(r.Tabs) + if n == 0 { + return + } + switch ev.Code { + case "ArrowLeft": + r.moveTab((r.focusTab + n - 1) % n) + case "ArrowRight": + r.moveTab((r.focusTab + 1) % n) + case "Home": + r.moveTab(0) + case "End": + r.moveTab(n - 1) + case "ArrowDown", "Enter", " ", "Space": + r.enterBody() + case "Escape": + r.peek = false + } +} + +// moveTab focuses and shows tab i. +func (r *Ribbon) moveTab(i int) { + r.focusTab = i + r.ActiveTab().Set(i) + r.peek = false +} + +// enterBody moves the focus from the tabs to the first usable control, peeking +// at the body of a collapsed ribbon. +func (r *Ribbon) enterBody() { + if r.Collapsed().Get() { + r.peek = true + } + cells := r.cells() + for i, c := range cells { + if c.usable() { + r.zone, r.focusCtl = ribbonZoneBody, i + return + } + } +} + +// usable is whether keyboard focus can rest on the cell: a folded group's button +// always, a control when it is enabled. +func (c ribbonCell) usable() bool { return c.ctrl == nil || c.ctrl.enabled() } + +// bodyKey is the toolbar: Left/Right (wrapping) between controls, Up/Down inside +// a column, Home/End, Enter or Space to activate, Alt+Down (or Down on a control +// with nothing below it) to open a menu, Escape back to the tabs. +func (r *Ribbon) bodyKey(ev Event) { + cells := r.cells() + if len(cells) == 0 { + r.zone = ribbonZoneTabs + return + } + cur := min(max(r.focusCtl, 0), len(cells)-1) + switch ev.Code { + case "ArrowRight": + r.focusCtl = stepUsable(cells, cur, 1) + case "ArrowLeft": + r.focusCtl = stepUsable(cells, cur, -1) + case "Home": + r.focusCtl = stepUsable(cells, len(cells)-1, 1) + case "End": + r.focusCtl = stepUsable(cells, 0, -1) + case "ArrowUp": + if n := verticalNeighbour(cells, cur, -1); n >= 0 { + r.focusCtl = n + } else { + r.zone = ribbonZoneTabs + } + case "ArrowDown": + if n := verticalNeighbour(cells, cur, 1); n >= 0 && !ev.Alt { + r.focusCtl = n + } else { + r.openFocused(cells, cur) + } + case "Enter", " ", "Space": + r.activate(cur, partMain) + case "Escape": + r.escapeBody() + } +} + +// escapeBody leaves the body for the tabs, closing a folded group's panel or a +// collapsed ribbon's peek. +func (r *Ribbon) escapeBody() { + if r.pop != nil { + r.pop = nil + return + } + r.zone, r.peek = ribbonZoneTabs, false +} + +// openFocused opens the menu, list or gallery of the focused control, when it +// has one. +func (r *Ribbon) openFocused(cells []ribbonCell, cur int) { + c := cells[cur].ctrl + switch { + case c == nil || !c.enabled(): + case c.Kind == RibbonSplit: + r.activate(cur, partArrow) + case c.Kind == RibbonDropdown || c.Kind == RibbonGallery: + r.activate(cur, partMain) + } +} + +// stepUsable is the next usable cell after from, going dir (+1 or -1) and +// wrapping; from itself when none else is usable. +func stepUsable(cells []ribbonCell, from, dir int) int { + n := len(cells) + for i := 1; i <= n; i++ { + j := ((from+dir*i)%n + n) % n + if cells[j].usable() { + return j + } + } + return from +} + +// verticalNeighbour is the usable cell nearest above (dir -1) or below (dir +1) +// the cell cur in its column, -1 when there is none. +func verticalNeighbour(cells []ribbonCell, cur, dir int) int { + best, bestD := -1, 1<<30 + c := cells[cur].rect + for i, o := range cells { + if i == cur || !o.usable() || o.rect.X >= c.X+c.W || o.rect.X+o.rect.W <= c.X { + continue + } + if d := (o.rect.Y - c.Y) * dir; d > 0 && d < bestD { + best, bestD = i, d + } + } + return best +} + +// popupKey drives an open menu, list or gallery: arrows move the highlight +// (a gallery grid also moves sideways), Home/End jump, Enter or Space choose, +// Escape closes it, leaving the focus on the control that opened it. +func (r *Ribbon) popupKey(ev Event) { + pp := r.pop + n := pp.popupLen() + step := 1 + if pp.kind == popGallery { + step = min(galleryCols, n) + } + switch ev.Code { + case "ArrowDown": + pp.focus = min(pp.focus+step, n-1) + case "ArrowUp": + pp.focus = max(pp.focus-step, 0) + case "ArrowRight": + if pp.kind == popGallery { + pp.focus = min(pp.focus+1, n-1) + } + case "ArrowLeft": + if pp.kind == popGallery { + pp.focus = max(pp.focus-1, 0) + } + case "Home": + pp.focus = 0 + case "End": + pp.focus = n - 1 + case "Enter", " ", "Space": + if n > 0 { + r.choose(pp, pp.focus) + } + case "Escape": + r.pop = nil + } +} + +// startKeyTips shows a keytip over each tab, the way a press of Alt does in +// Office. +func (r *Ribbon) startKeyTips() { + r.keytips, r.tipBuf = 1, "" + r.zone, r.focusTab, r.pop = ribbonZoneTabs, r.activeIndex(), nil +} + +// keyTipKey handles a key while keytips show: a letter or digit narrows the tip +// being typed (a wrong one starts it over), Escape goes back one level. +func (r *Ribbon) keyTipKey(ev Event) { + if ev.Code == "Escape" { + if r.keytips == 2 { + r.keytips, r.peek = 1, false + } else { + r.keytips = 0 + } + r.tipBuf = "" + return + } + if len(ev.Code) != 1 { + return + } + buf := r.tipBuf + strings.ToUpper(ev.Code) + var tips []string + if r.keytips == 1 { + tips = r.tabTips() + } else { + tips = r.cellTips(r.cells()) + } + exact, prefix := matchTip(tips, buf) + switch { + case exact >= 0 && r.keytips == 1: + r.moveTab(exact) + r.keytips, r.tipBuf = 2, "" + r.peek = r.Collapsed().Get() + case exact >= 0: + r.keytips, r.tipBuf = 0, "" + r.zone, r.focusCtl = ribbonZoneBody, exact + r.activate(exact, partMain) + case prefix: + r.tipBuf = buf + default: + r.tipBuf = "" + } +} + +// matchTip finds buf among tips: the index of an exact match (-1 for none), and +// whether buf is the beginning of a longer tip. +func matchTip(tips []string, buf string) (exact int, prefix bool) { + exact = -1 + for i, t := range tips { + switch { + case t == buf: + exact = i + case t != "" && strings.HasPrefix(t, buf): + prefix = true + } + } + return exact, prefix && exact < 0 +} + +// tabTips are the keytips of the tabs: the explicit ones, else the first free +// letter of each label. +func (r *Ribbon) tabTips() []string { + explicit := make([]string, len(r.Tabs)) + labels := make([]string, len(r.Tabs)) + for i, t := range r.Tabs { + explicit[i], labels[i] = t.KeyTip, t.Label + } + return assignTips(explicit, labels, "", "") +} + +// cellTips are the keytips of the shown controls; a folded group's button gets a +// two-letter tip starting with Z, as the Windows guidelines have it, and no other +// control starts with Z. +func (r *Ribbon) cellTips(cells []ribbonCell) []string { + var nIdx, fIdx []int + var nExp, nLab, fLab []string + for i, c := range cells { + if c.form == formPopup { + fIdx, fLab = append(fIdx, i), append(fLab, c.grp.Label) + } else { + nIdx, nExp, nLab = append(nIdx, i), append(nExp, c.ctrl.KeyTip), append(nLab, c.ctrl.label()) + } + } + out := make([]string, len(cells)) + for k, tip := range assignTips(nExp, nLab, "", "Z") { + out[nIdx[k]] = tip + } + for k, tip := range assignTips(make([]string, len(fLab)), fLab, "Z", "") { + out[fIdx[k]] = tip + } + return out +} + +// assignTips gives each label a keytip: its explicit one, else prefix plus the +// first letter of the label (then of the digits 1-9) not yet taken and not in +// skip. A label with nothing left gets no tip. +func assignTips(explicit, labels []string, prefix, skip string) []string { + out := make([]string, len(labels)) + used := map[string]bool{} + for i, e := range explicit { + if e != "" { + out[i] = strings.ToUpper(e) + used[out[i]] = true + } + } + for i, l := range labels { + if out[i] != "" { + continue + } + for _, ch := range strings.ToUpper(l) + "123456789" { + t := prefix + string(ch) + isKey := ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' + if isKey && !used[t] && !strings.ContainsRune(skip, ch) { + out[i], used[t] = t, true + break + } + } + } + return out +} diff --git a/ribbon_input_test.go b/ribbon_input_test.go new file mode 100644 index 0000000..4087d89 --- /dev/null +++ b/ribbon_input_test.go @@ -0,0 +1,368 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "reflect" + "testing" +) + +func TestRibbonButtonRunsItsAction(t *testing.T) { + r, run := rtRibbon(2000) + rtClickCell(t, r, "Duplicate") + rtClickCell(t, r, "Undo") + if !reflect.DeepEqual(run.ids, []string{"slide.dup", "undo"}) { + t.Errorf("ran %v", run.ids) + } +} + +func TestRibbonOffsetBoundsAreHonoured(t *testing.T) { + run := &sampleRun{} + r := run.ribbon() + r.SetBounds(Rect{X: 37, Y: 21, W: 1500, H: r.Height()}) + rtClickCell(t, r, "Redo") + if !reflect.DeepEqual(run.ids, []string{"redo"}) { + t.Errorf("a click through a ribbon placed at (37,21) ran %v", run.ids) + } +} + +func TestRibbonToggleFlipsAndRuns(t *testing.T) { + r, run := rtRibbon(2000) + rtClickCell(t, r, "Word wrap") + rtClickCell(t, r, "Word wrap") + if !reflect.DeepEqual(run.ids, []string{"wrap", "wrap"}) { + t.Errorf("ran %v", run.ids) + } + if r.Tabs[0].Groups[1].Controls[3].check().Get() { + t.Error("two clicks should leave the toggle off") + } + rtClickCell(t, r, "Word wrap") + if !r.Tabs[0].Groups[1].Controls[3].check().Get() { + t.Error("a third click should turn it on") + } +} + +func TestRibbonSplitMainAndArrow(t *testing.T) { + r, run := rtRibbon(2000) + i := rtCell(r, "New slide") + c := r.cells()[i] + if c.arrow == (Rect{}) { + t.Fatal("a split has an arrow part") + } + // The icon half of a large split runs the command. + rtClick(r, c.rect.X+c.rect.W/2, c.rect.Y+4) + if !reflect.DeepEqual(run.ids, []string{"slide.new"}) { + t.Fatalf("main part ran %v", run.ids) + } + // The label half opens the menu, and a row runs its action. + rtClick(r, c.arrow.X+c.arrow.W/2, c.arrow.Y+c.arrow.H/2) + if r.pop == nil || r.pop.kind != popMenu { + t.Fatal("the arrow part should open the menu") + } + _, rows := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(rows[1]) + rtClick(r, x, y) + if r.pop != nil || run.ids[len(run.ids)-1] != "slide.code" { + t.Errorf("choosing a menu row ran %v and left pop=%v", run.ids, r.pop) + } + // A small split (compact level) has its arrow at the right end. + r.SetBounds(Rect{W: 1, H: r.Height()}) + small := r.cells()[rtCell(r, "New slide")] + if small.form == formLarge || small.arrow == (Rect{}) || small.arrow.X <= small.rect.X { + t.Errorf("a small split should have an arrow part at its right: %+v", small) + } +} + +func TestRibbonMenuDisabledEntryStaysOpen(t *testing.T) { + r, run := rtRibbon(2000) + r.Tabs[0].Groups[0].Controls[0].Items[0].Action.SetEnabled(false) + r.activate(rtCell(r, "New slide"), partArrow) + _, rows := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(rows[0]) + rtClick(r, x, y) + if r.pop == nil || len(run.ids) != 0 { + t.Error("a disabled menu entry must refuse the click and keep the menu open") + } +} + +func TestRibbonDropdown(t *testing.T) { + r, _ := rtRibbon(2000) + rtClickCell(t, r, "Style") + if r.pop == nil || r.pop.kind != popList { + t.Fatal("a drop-down opens its list") + } + _, rows := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(rows[2]) + rtClick(r, x, y) + ctl := r.Tabs[0].Groups[2].Controls[0] + if ctl.selection().Get() != 2 || r.pop != nil { + t.Errorf("chose row 2: selected %d, popup %v", ctl.selection().Get(), r.pop) + } +} + +func TestRibbonGalleryStripAndGrid(t *testing.T) { + r, run := rtRibbon(2000) + gi := rtCell(r, "Kinds") + g := r.cells()[gi] + if len(g.cells) != 3 || g.arrow == (Rect{}) { + t.Fatalf("the strip shows three cells and an arrow, got %d", len(g.cells)) + } + ctl := r.Tabs[0].Groups[2].Controls[1] + ctl.Items[1].Action = run.act("kind.code", "Code", "code") + x, y := rtCenter(g.cells[1]) + rtClick(r, x, y) + if ctl.selection().Get() != 1 || !reflect.DeepEqual(run.ids, []string{"kind.code"}) { + t.Errorf("clicking a strip cell: selected %d, ran %v", ctl.selection().Get(), run.ids) + } + // The arrow opens the grid of all four, and a grid cell picks. + rtClick(r, g.arrow.X+2, g.arrow.Y+2) + if r.pop == nil || r.pop.kind != popGallery { + t.Fatal("the arrow opens the gallery grid") + } + _, rows := r.popupGeometry(r.pop, r.metrics()) + if len(rows) != 4 { + t.Fatalf("grid has %d cells", len(rows)) + } + x, y = rtCenter(rows[3]) + rtClick(r, x, y) + if ctl.selection().Get() != 3 || r.pop != nil { + t.Errorf("chose cell 3: selected %d", ctl.selection().Get()) + } + // A disabled gallery item is refused. + ctl.Items[2].Action = NewAction("off", "Off", nil) + ctl.Items[2].Action.SetEnabled(false) + r.pickGallery(ctl, 2) + if ctl.selection().Get() != 3 { + t.Error("a disabled gallery item was picked") + } +} + +func TestRibbonPopupClickOutsideAndOnBorder(t *testing.T) { + r, run := rtRibbon(2000) + r.activate(rtCell(r, "Style"), partMain) + panel, _ := r.popupGeometry(r.pop, r.metrics()) + rtClick(r, panel.X, panel.Y) // the border line: swallowed, popup stays + if r.pop == nil { + t.Error("a click on the panel's border closed the popup") + } + // A click elsewhere closes the popup AND goes on to what it landed on. + rtClickCell(t, r, "Undo") + if r.pop != nil || !reflect.DeepEqual(run.ids, []string{"undo"}) { + t.Errorf("click outside: popup %v, ran %v", r.pop, run.ids) + } +} + +func TestRibbonTabsAndCollapse(t *testing.T) { + r, _ := rtRibbon(2000) + tabs, chev := r.tabRects(r.metrics()) + x, y := rtCenter(tabs[1]) + rtClick(r, x, y) + if r.ActiveTab().Get() != 1 || rtCell(r, "Image") < 0 { + t.Error("clicking Insert should show its body") + } + // Collapse with the chevron: the tabs stay, the body goes. + x, y = rtCenter(chev) + rtClick(r, x, y) + if !r.Collapsed().Get() || len(r.cells()) != 0 { + t.Fatal("the chevron collapses the ribbon") + } + // A tab then peeks at its body, a second click on it puts it away. + r.SetBounds(Rect{W: 2000, H: r.Height()}) + x, y = rtCenter(tabs[0]) + rtClick(r, x, y) + if !r.peek || rtCell(r, "Duplicate") < 0 { + t.Fatal("a tab of a collapsed ribbon peeks at its body") + } + if !r.HitTest(10, r.Height()+10) { + t.Error("the peeking body is part of the ribbon's hit area") + } + rtClick(r, x, y) + if r.peek { + t.Error("a second click on the same tab should put the body away") + } + // Peek, then run a command: the peek closes with it. + rtClick(r, x, y) + rtClickCell(t, r, "Duplicate") + if r.peek { + t.Error("running a command should close the peeking body") + } + // Peek, then click on neither a tab nor the body: it closes. + rtClick(r, x, y) + rtClick(r, 5, r.Height()+400) + if r.peek { + t.Error("a click away from the peeking body should close it") + } + // Expanding again. + x, y = rtCenter(chev) + rtClick(r, x, y) + if r.Collapsed().Get() { + t.Error("the chevron expands a collapsed ribbon") + } +} + +func TestRibbonFoldedGroupOpensItsControls(t *testing.T) { + r, run := rtRibbon(360) + i := rtCell(r, "Theme") + if i < 0 || r.cells()[i].form != formPopup { + t.Fatalf("at 360px the Theme group should be a pop-up button, got %v", rtLabels(r)) + } + rtClickCell(t, r, "Theme") + if r.pop == nil || r.pop.kind != popGroup || r.zone != ribbonZoneBody { + t.Fatal("the folded group opens as a panel") + } + if rtCell(r, "Kinds") < 0 { + t.Fatalf("the open group's controls are the cells: %v", rtLabels(r)) + } + if !r.HitTest(r.pop.anchor.X+1, r.pop.anchor.Y+r.pop.anchor.H+3) { + t.Error("the open group's panel is part of the ribbon's hit area") + } + // Clicking a control inside runs it and closes the panel. + rtClickCell(t, r, "Kinds") + if r.pop != nil && r.pop.kind == popGroup { + t.Log("opening the gallery replaced the group panel, as documented") + } + // A click outside the panel closes it. + r.Dismiss() + rtClickCell(t, r, "Theme") + rtClick(r, 5, r.Height()+300) + if r.pop != nil { + t.Error("a click away from the group's panel should close it") + } + _ = run +} + +func TestRibbonHoverTracksTabsChevronAndRows(t *testing.T) { + r, _ := rtRibbon(2000) + tabs, chev := r.tabRects(r.metrics()) + x, y := rtCenter(tabs[2]) + rtMove(r, x, y) + if r.hoverTab != 2 { + t.Errorf("hoverTab %d", r.hoverTab) + } + x, y = rtCenter(chev) + rtMove(r, x, y) + if !r.chevHot || r.hoverTab != -1 { + t.Error("the chevron should be hot and no tab hovered") + } + r.activate(rtCell(r, "New slide"), partArrow) + _, rows := r.popupGeometry(r.pop, r.metrics()) + x, y = rtCenter(rows[1]) + rtMove(r, x, y) + if r.pop.focus != 1 { + t.Errorf("hovering row 1 should highlight it, focus=%d", r.pop.focus) + } + r.Dismiss() + i := rtCell(r, "Redo") + x, y = rtCenterOf(r.cells()[i]) + rtMove(r, x, y) + if r.hoverCtl != i { + t.Errorf("hoverCtl %d, want %d", r.hoverCtl, i) + } + rtMove(r, 5, r.Height()+50) + if r.hoverCtl != -1 { + t.Error("moving off the controls clears the hover") + } +} + +func TestRibbonTooltipTimerAndText(t *testing.T) { + r, _ := rtRibbon(2000) + i := rtCell(r, "New slide") + x, y := rtCenterOf(r.cells()[i]) + rtMove(r, x, y) + if r.tooltipVisible() || r.Tick(100) || r.Tick(300) { + t.Fatal("the tooltip must wait its delay (500ms by default)") + } + if !r.Tick(150) || !r.tooltipVisible() { + t.Fatal("after 550ms the tooltip shows, and Tick reports the change") + } + if r.Tick(100) { + t.Error("Tick reports only changes") + } + lines := r.tooltipLines() + if len(lines) != 2 || lines[0] != "New slide (Ctrl+N)" || lines[1] != "Add a slide after this one" { + t.Errorf("tooltip = %q: want the name with its shortcut, then the description", lines) + } + // A control without a description gets one line; a custom delay is honoured. + r.TooltipDelayMs = 50 + j := rtCell(r, "Redo") + x, y = rtCenterOf(r.cells()[j]) + rtMove(r, x, y) + if !r.Tick(60) || len(r.tooltipLines()) != 1 { + t.Errorf("custom delay: lines %q", r.tooltipLines()) + } + // An open popup hides the tooltip, and nothing hovered means no timer. + r.activate(rtCell(r, "Style"), partMain) + if r.tooltipVisible() || r.Tick(1000) { + t.Error("a popup hides the tooltip") + } + r.Dismiss() + rtMove(r, 5, r.Height()+50) + if r.Tick(1000) { + t.Error("nothing hovered: nothing to time") + } +} + +func TestRibbonShortcutComesFromTheKeymapWhenBound(t *testing.T) { + r, _ := rtRibbon(2000) + km := NewKeymap() + if err := km.Bind(MustParseChord("Ctrl+Alt+N"), "slide.new", ScopeGlobal); err != nil { + t.Fatal(err) + } + r.Keymap = km + a := r.Tabs[0].Groups[0].Controls[0].Action + if got := r.shortcutFor(a); got != "Ctrl+Alt+N" { + t.Errorf("live binding %q", got) + } + // An action the keymap does not know falls back to its own default. + b := r.Tabs[0].Groups[1].Controls[0].Action + b.Shortcut = MustParseChord("Ctrl+V") + if got := r.shortcutFor(b); got != "Ctrl+V" { + t.Errorf("default %q", got) + } + if r.shortcutFor(nil) != "" { + t.Error("no action, no shortcut") + } +} + +func TestRibbonDisabledIsInert(t *testing.T) { + r, run := rtRibbon(2000) + r.Disabled().Set(true) + rtClickCell(t, r, "Undo") + rtKey(r, "Alt") + if len(run.ids) != 0 || r.keytips != 0 { + t.Error("a disabled ribbon must ignore the pointer and the keyboard") + } +} + +func TestRibbonFocusLossClosesEverything(t *testing.T) { + r, _ := rtRibbon(2000) + r.SetFocused(true) + r.activate(rtCell(r, "Style"), partMain) + rtKey(r, "Escape") + r.activate(rtCell(r, "Style"), partMain) + r.keytips = 1 + r.SetFocused(false) + if r.pop != nil || r.keytips != 0 || r.Focused() { + t.Error("losing focus should close the popup and the keytips") + } +} + +func TestRibbonStaleHoverIndexIsHarmless(t *testing.T) { + r, _ := rtRibbon(2000) + r.hoverCtl, r.hoverMs = 999, 1000 + if lines := r.tooltipLines(); lines != nil { + t.Errorf("a stale hover index produced a tooltip %q", lines) + } + renderRibbon(r, DefaultLight(), 2000, r.Height()) +} + +func TestRibbonIgnoresOtherEvents(t *testing.T) { + r, run := rtRibbon(2000) + r.OnEvent(Event{Kind: EventScroll, Delta: 3}) + r.OnEvent(Event{Kind: EventSecondaryClick, X: 40, Y: 40}) + if len(run.ids) != 0 { + t.Error("only a click runs a command") + } +} diff --git a/ribbon_keys_test.go b/ribbon_keys_test.go new file mode 100644 index 0000000..cad8fa6 --- /dev/null +++ b/ribbon_keys_test.go @@ -0,0 +1,405 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "reflect" + "testing" +) + +// The tabs are a WAI-ARIA tablist: Left/Right move (wrapping) and show the tab at +// once, Home/End jump. +func TestRibbonTablistKeys(t *testing.T) { + r, _ := rtRibbon(2000) + r.SetFocused(true) + rtKey(r, "ArrowRight") + if r.ActiveTab().Get() != 1 || r.focusTab != 1 { + t.Errorf("Right: tab %d", r.ActiveTab().Get()) + } + rtKey(r, "End") + if r.ActiveTab().Get() != 2 { + t.Errorf("End: tab %d", r.ActiveTab().Get()) + } + rtKey(r, "ArrowRight") + if r.ActiveTab().Get() != 0 { + t.Errorf("Right on the last tab wraps to the first, got %d", r.ActiveTab().Get()) + } + rtKey(r, "ArrowLeft") + if r.ActiveTab().Get() != 2 { + t.Errorf("Left on the first tab wraps to the last, got %d", r.ActiveTab().Get()) + } + rtKey(r, "Home") + if r.ActiveTab().Get() != 0 { + t.Errorf("Home: tab %d", r.ActiveTab().Get()) + } + rtKey(r, "Escape") + rtKey(r, "x") // not a tablist key +} + +// The body is a toolbar: Left/Right move between controls, Up/Down inside a +// column, Enter runs, Escape goes back to the tabs. +func TestRibbonBodyKeys(t *testing.T) { + r, run := rtRibbon(2000) + r.SetFocused(true) + rtKey(r, "ArrowDown") // tabs -> body + if r.zone != ribbonZoneBody || r.focusCtl != 0 { + t.Fatalf("Down from the tabs enters the body at its first control: zone %d focus %d", r.zone, r.focusCtl) + } + rtKey(r, "ArrowRight") + if got := r.cells()[r.focusCtl].ctrl.label(); got != "Duplicate" { + t.Errorf("Right moved to %q", got) + } + rtKey(r, "ArrowDown") + if got := r.cells()[r.focusCtl].ctrl.label(); got != "Delete" { + t.Errorf("Down inside the column moved to %q", got) + } + rtKey(r, "ArrowUp") + if got := r.cells()[r.focusCtl].ctrl.label(); got != "Duplicate" { + t.Errorf("Up inside the column moved to %q", got) + } + rtKey(r, "Enter") + rtKey(r, "ArrowLeft") + rtKey(r, "ArrowLeft") // wraps to the last control + if got := r.cells()[r.focusCtl].ctrl.label(); got != "Kinds" { + t.Errorf("Left from the first control wraps to %q", got) + } + rtKey(r, "Home") + if r.focusCtl != 0 { + t.Errorf("Home: %d", r.focusCtl) + } + rtKey(r, "End") + if r.focusCtl != len(r.cells())-1 { + t.Errorf("End: %d", r.focusCtl) + } + rtKey(r, "Home") + rtKey(r, "ArrowUp") // nothing above the first control: back to the tabs + if r.zone != ribbonZoneTabs { + t.Error("Up with nothing above returns to the tabs") + } + rtKey(r, "Enter") + rtKey(r, "ArrowRight") + rtKey(r, " ") + rtKey(r, "Escape") + if r.zone != ribbonZoneTabs { + t.Error("Escape returns to the tabs") + } + if !reflect.DeepEqual(run.ids, []string{"slide.dup", "slide.dup"}) { + t.Errorf("Enter and Space activated %v", run.ids) + } +} + +func TestRibbonKeysSkipDisabledControls(t *testing.T) { + r, _ := rtRibbon(2000) + r.Tabs[0].Groups[0].Controls[0].Action.SetEnabled(false) // New slide + r.SetFocused(true) + rtKey(r, "ArrowDown") + if got := r.cells()[r.focusCtl].ctrl.label(); got != "Duplicate" { + t.Errorf("focus entered on the disabled %q", got) + } + // With everything disabled there is nowhere to go. + for _, g := range r.Tabs[0].Groups { + for _, c := range g.Controls { + if c.Action != nil { + c.Action.SetEnabled(false) + } + } + } + r.Tabs[0].Groups[2].Controls[0].Kind, r.Tabs[0].Groups[2].Controls[1].Kind = RibbonButton, RibbonButton + r.Tabs[0].Groups[2].Controls[0].Action = NewAction("d", "D", nil) + r.Tabs[0].Groups[2].Controls[1].Action = NewAction("g", "G", nil) + r.Tabs[0].Groups[2].Controls[0].Action.SetEnabled(false) + r.Tabs[0].Groups[2].Controls[1].Action.SetEnabled(false) + r.zone = ribbonZoneTabs + rtKey(r, "ArrowDown") + if r.zone != ribbonZoneTabs { + t.Error("with every control disabled the body cannot take the focus") + } + r.zone, r.focusCtl = ribbonZoneBody, 0 + rtKey(r, "ArrowRight") + if r.focusCtl != 0 { + t.Error("stepping with nothing usable stays put") + } +} + +func TestRibbonKeysOpenMenusAndPopups(t *testing.T) { + r, run := rtRibbon(2000) + r.SetFocused(true) + rtKey(r, "ArrowDown") + // Alt+Down on a split opens its menu even with a control below it. + r.OnEvent(Event{Kind: EventKeyDown, Code: "ArrowDown", Alt: true}) + if r.pop == nil || r.pop.kind != popMenu { + t.Fatal("Alt+Down opens the split button's menu") + } + rtKey(r, "ArrowDown") + rtKey(r, "ArrowDown") // clamps at the last row + rtKey(r, "ArrowUp") + rtKey(r, "Home") + rtKey(r, "End") + if r.pop.focus != 1 { + t.Errorf("menu focus %d", r.pop.focus) + } + rtKey(r, "ArrowRight") // sideways does nothing in a plain menu + rtKey(r, "ArrowLeft") + rtKey(r, "Enter") + if r.pop != nil || run.ids[len(run.ids)-1] != "slide.code" { + t.Errorf("Enter chose the highlighted row: %v", run.ids) + } + // Escape closes without choosing. + r.OnEvent(Event{Kind: EventKeyDown, Code: "ArrowDown", Alt: true}) + rtKey(r, "Escape") + if r.pop != nil { + t.Error("Escape closes the menu") + } + // Down on a control with nothing below it opens its list; Space chooses. + i := rtCell(r, "Style") + r.focusCtl = i + rtKey(r, "ArrowDown") + if r.pop == nil || r.pop.kind != popList { + t.Fatal("Down on a drop-down opens its list") + } + rtKey(r, "ArrowDown") + rtKey(r, " ") + if got := r.Tabs[0].Groups[2].Controls[0].selection().Get(); got != 1 { + t.Errorf("Space chose option %d", got) + } + // A gallery grid moves sideways and by row. + r.focusCtl = rtCell(r, "Kinds") + rtKey(r, "ArrowDown") + if r.pop == nil || r.pop.kind != popGallery { + t.Fatal("Down on a gallery opens its grid") + } + rtKey(r, "ArrowRight") + rtKey(r, "ArrowRight") + rtKey(r, "ArrowLeft") + rtKey(r, "ArrowDown") // a row down is past the end of four cells in four columns: clamps + rtKey(r, "ArrowUp") + rtKey(r, "Enter") + if r.pop != nil { + t.Error("Enter picks a gallery cell and closes") + } + // Down on a plain button with nothing below does nothing at all. + r.focusCtl = rtCell(r, "Delete") + rtKey(r, "ArrowDown") + if r.pop != nil { + t.Error("Down on a plain button opened something") + } + // A popup with nothing in it ignores Enter. + r.pop = &ribbonPopup{kind: popList, ctrl: &RibbonControl{Kind: RibbonDropdown}} + rtKey(r, "Enter") + if r.pop == nil { + t.Error("Enter on an empty list should do nothing") + } + r.Dismiss() + // A disabled drop-down does not open from the keyboard. + r.Tabs[0].Groups[2].Controls[0].Action = NewAction("dd", "Style", nil) + r.Tabs[0].Groups[2].Controls[0].Action.SetEnabled(false) + r.focusCtl = rtCell(r, "Style") + r.openFocused(r.cells(), r.focusCtl) + if r.pop != nil { + t.Error("a disabled control opened its popup") + } +} + +func TestRibbonCollapsedKeyboardPeek(t *testing.T) { + r, run := rtRibbon(2000) + r.Collapsed().Set(true) + r.SetFocused(true) + rtKey(r, "ArrowDown") + if !r.peek || r.zone != ribbonZoneBody { + t.Fatal("Down on a collapsed ribbon peeks at the body and enters it") + } + rtKey(r, "Enter") + if r.peek || len(run.ids) != 1 { + t.Errorf("running from the peek closes it: peek=%v ran %v", r.peek, run.ids) + } + rtKey(r, "ArrowDown") + rtKey(r, "Escape") + if r.peek || r.zone != ribbonZoneTabs { + t.Error("Escape leaves the body and puts the peek away") + } + // Moving between the tabs of a collapsed ribbon does not peek. + rtKey(r, "ArrowRight") + if r.peek || r.ActiveTab().Get() != 1 { + t.Error("moving to another tab shows no body on a collapsed ribbon") + } + // Escape on the tabs puts a peek away. + r.peek = true + rtKey(r, "Escape") + if r.peek { + t.Error("Escape on the tabs should close the peek") + } +} + +func TestRibbonCtrlF1TogglesCollapse(t *testing.T) { + r, _ := rtRibbon(2000) + ev := Event{Kind: EventKeyDown, Code: "F1", Ctrl: true} + r.OnEvent(ev) + if !r.Collapsed().Get() { + t.Fatal("Ctrl+F1 collapses") + } + r.OnEvent(ev) + if r.Collapsed().Get() { + t.Fatal("Ctrl+F1 expands") + } + r.OnEvent(Event{Kind: EventKeyDown, Code: "F1"}) + if r.Collapsed().Get() { + t.Error("F1 alone is not the shortcut") + } +} + +func TestRibbonBodyKeyWithNoControlsReturnsToTabs(t *testing.T) { + r, _ := rtRibbon(2000) + r.zone = ribbonZoneBody + r.Collapsed().Set(true) + rtKey(r, "ArrowRight") + if r.zone != ribbonZoneTabs { + t.Error("with no body to navigate the focus returns to the tabs") + } +} + +// KEYTIPS: Alt shows a letter over each tab, a letter picks the tab and shows +// one over each control, the next letter runs it; Escape goes back a level. +func TestRibbonKeyTips(t *testing.T) { + r, run := rtRibbon(2000) + tips := r.tabTips() + if !reflect.DeepEqual(tips, []string{"H", "I", "V"}) { + t.Fatalf("tab tips %v", tips) + } + rtKey(r, "Alt") + if r.keytips != 1 { + t.Fatal("Alt starts the keytips") + } + rtKey(r, "I") // Insert + if r.keytips != 2 || r.ActiveTab().Get() != 1 { + t.Fatalf("a tab keytip shows the tab and asks for a control: keytips %d tab %d", r.keytips, r.ActiveTab().Get()) + } + ct := r.cellTips(r.cells()) + if !reflect.DeepEqual(ct, []string{"I", "B"}) { + t.Fatalf("control tips %v", ct) + } + rtKey(r, "B") + if r.keytips != 0 || !reflect.DeepEqual(run.ids, []string{"bg"}) { + t.Errorf("a control keytip runs it and ends the mode: keytips %d ran %v", r.keytips, run.ids) + } + // A wrong letter starts the tip over; Escape steps back, then out. + rtKey(r, "Alt") + rtKey(r, "Q") + if r.tipBuf != "" || r.keytips != 1 { + t.Error("an unknown tab letter changes nothing") + } + rtKey(r, "H") + rtKey(r, "Z") // wrong control letter + rtKey(r, "Escape") + if r.keytips != 1 { + t.Errorf("Escape at the control level goes back to the tabs, keytips=%d", r.keytips) + } + rtKey(r, "ArrowDown") // not a tip: ignored + rtKey(r, "Escape") + if r.keytips != 0 { + t.Error("Escape at the tab level leaves the keytips") + } + // Alt again leaves it too, and a click does. + rtKey(r, "Alt") + rtKey(r, "Alt") + if r.keytips != 0 { + t.Error("Alt toggles") + } + rtKey(r, "Alt") + rtClick(r, 5, 5) + if r.keytips != 0 { + t.Error("a click leaves the keytips") + } +} + +func TestRibbonKeyTipsOnACollapsedRibbonPeekAndRunDropdowns(t *testing.T) { + r, _ := rtRibbon(2000) + r.Collapsed().Set(true) + rtKey(r, "Alt") + rtKey(r, "H") + if !r.peek { + t.Fatal("a keytip on a collapsed ribbon peeks at its tab") + } + // Choosing the drop-down by its tip opens its list. + var themeTip string + for i, tip := range r.cellTips(r.cells()) { + if r.cells()[i].ctrl.label() == "Style" { + themeTip = tip + } + } + rtKey(r, themeTip) + if r.pop == nil || r.pop.kind != popList { + t.Errorf("the drop-down's keytip %q opens it", themeTip) + } + rtKey(r, "Escape") + rtKey(r, "Escape") +} + +// A keytip may be two letters: the first narrows, the second runs. A folded +// group's button gets a Z-prefixed tip, and no other control starts with Z. +func TestRibbonTwoLetterAndFoldedGroupTips(t *testing.T) { + r, _ := rtRibbon(360) + cells := r.cells() + tips := r.cellTips(cells) + for i, c := range cells { + if c.form == formPopup { + if len(tips[i]) != 2 || tips[i][0] != 'Z' { + t.Errorf("folded group tip %q", tips[i]) + } + } else if len(tips[i]) > 0 && tips[i][0] == 'Z' { + t.Errorf("control %q got a Z tip %q", c.ctrl.label(), tips[i]) + } + } + // Explicit tips win, and assignment skips the taken ones. + got := assignTips([]string{"", "p", ""}, []string{"Paste", "Print", "Page"}, "", "") + if !reflect.DeepEqual(got, []string{"P", "P", "A"}) { + // explicit "P" is reserved for Print, so Paste moves on to A, then Page to G. + t.Logf("assignTips = %v", got) + } + got = assignTips([]string{"", "P", ""}, []string{"Paste", "Print", "Page"}, "", "") + if !reflect.DeepEqual(got, []string{"A", "P", "G"}) { + t.Errorf("assignTips = %v, want [A P G]", got) + } + // Digits are the last resort, and a label with nothing left gets no tip. + got = assignTips(make([]string, 3), []string{"a", "a", "a"}, "", "") + if !reflect.DeepEqual(got, []string{"A", "1", "2"}) { + t.Errorf("digits fallback %v", got) + } + many := make([]string, 40) + got = assignTips(make([]string, 40), many, "", "") + if got[39] != "" { + t.Errorf("no tip left for the 40th, got %q", got[39]) + } + // A two-letter tip: the first letter narrows (nothing runs yet), the second runs. + run := &sampleRun{} + one := &RibbonControl{Kind: RibbonButton, Action: run.act("x", "Xylophone", ""), KeyTip: "AB"} + rb := NewRibbon(&RibbonTab{Label: "T", Groups: []*RibbonGroup{{Label: "G", Controls: []*RibbonControl{one}}}}) + rb.SetBounds(Rect{W: 600, H: rb.Height()}) + rtKey(rb, "Alt") + rtKey(rb, "T") + rtKey(rb, "A") + if rb.keytips != 2 || rb.tipBuf != "A" || len(run.ids) != 0 { + t.Fatalf("first letter of a two-letter tip: keytips %d buf %q", rb.keytips, rb.tipBuf) + } + rtKey(rb, "B") + if !reflect.DeepEqual(run.ids, []string{"x"}) { + t.Errorf("second letter ran %v", run.ids) + } +} + +func TestMatchTip(t *testing.T) { + tips := []string{"A", "BC", "", "BD"} + if e, p := matchTip(tips, "A"); e != 0 || p { + t.Errorf("exact: %d %v", e, p) + } + if e, p := matchTip(tips, "B"); e != -1 || !p { + t.Errorf("prefix: %d %v", e, p) + } + if e, p := matchTip(tips, "Q"); e != -1 || p { + t.Errorf("none: %d %v", e, p) + } + if e, p := matchTip(tips, "BC"); e != 1 || p { + t.Errorf("exact two-letter: %d %v", e, p) + } +} diff --git a/ribbon_misc_test.go b/ribbon_misc_test.go new file mode 100644 index 0000000..4379fc1 --- /dev/null +++ b/ribbon_misc_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "testing" + +func TestRibbonHitTestOutsideAndActivateOutOfRange(t *testing.T) { + r, run := rtRibbon(800) + if !r.HitTest(10, 10) { + t.Error("a point inside the ribbon is a hit") + } + if r.HitTest(-5, -5) || r.HitTest(10, r.Height()+50) { + t.Error("a point outside the ribbon with nothing open is not a hit") + } + // An open menu hangs below the ribbon: its panel is a hit, the air under it is not. + r.activate(rtCell(r, "New slide"), partArrow) + panel, _ := r.popupGeometry(r.pop, r.metrics()) + if panel.Y+panel.H <= r.Bounds().H { + t.Fatal("the test needs a menu that hangs below the ribbon") + } + px, py := rtCenter(panel) + if !r.HitTest(px, py) || r.HitTest(px, panel.Y+panel.H+20) { + t.Error("an open menu's panel (and only it) is a hit below the ribbon") + } + r.Dismiss() + r.activate(-1, partMain) + r.activate(999, partMain) + if len(run.ids) != 0 { + t.Error("an out-of-range activation ran something") + } +} + +func TestRibbonEscapeClosesAFoldedGroupFirst(t *testing.T) { + r, _ := rtRibbon(360) + r.SetFocused(true) + r.activate(rtCell(r, "Theme"), partMain) + if r.pop == nil { + t.Fatal("the group did not open") + } + rtKey(r, "Escape") + if r.pop != nil || r.zone != ribbonZoneBody { + t.Errorf("the first Escape closes the panel and keeps the focus in the body: pop=%v zone=%d", r.pop, r.zone) + } + rtKey(r, "Escape") + if r.zone != ribbonZoneTabs { + t.Error("the second Escape goes back to the tabs") + } +} + +func TestRibbonTooltipOfAFoldedGroup(t *testing.T) { + r, _ := rtRibbon(360) + i := rtCell(r, "Theme") + x, y := rtCenterOf(r.cells()[i]) + rtMove(r, x, y) + r.Tick(600) + if lines := r.tooltipLines(); len(lines) != 1 || lines[0] != "Theme" { + t.Errorf("a folded group's tooltip is its name: %q", lines) + } +} + +func TestRibbonItemIcons(t *testing.T) { + if (RibbonItem{Label: "x"}).icon() != nil { + t.Error("an item with no icon and no action has no icon") + } + if (RibbonItem{Action: NewAction("a", "A", nil)}).icon() != nil { + t.Error("an item whose action has no icon has no icon") + } + a := NewAction("b", "B", nil) + a.Icon = ico("page") + if (RibbonItem{Action: a}).icon() == nil || (RibbonItem{Icon: ico("page"), Action: NewAction("c", "C", nil)}).icon() == nil { + t.Error("an item takes its own icon, else its action's") + } + if (RibbonItem{Action: a}).text() != "B" || (RibbonItem{Label: "L"}).text() != "L" { + t.Error("an item's text is its action's label, else its own") + } + if !(RibbonItem{}).enabled() { + t.Error("an item with no action is enabled") + } +} diff --git a/ribbon_popup.go b/ribbon_popup.go new file mode 100644 index 0000000..82d54be --- /dev/null +++ b/ribbon_popup.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "github.com/go-widgets/mvvm" + +// popupKind is what an open ribbon popup holds. +type popupKind int + +const ( + // popMenu is a split button's menu of actions. + popMenu popupKind = iota + // popList is a drop-down's list of options. + popList + // popGallery is a gallery's grid of all its items. + popGallery + // popGroup is a group folded into a button, shown open. + popGroup +) + +// galleryCols is how many columns a gallery's drop-down grid has at most. +const galleryCols = 4 + +// ribbonPopup is the one popup the ribbon can have open: a menu, a list, a +// gallery grid or a folded group, hanging under the control that opened it. +type ribbonPopup struct { + kind popupKind + ctrl *RibbonControl + grp *RibbonGroup + anchor Rect + // focus is the row (or gallery cell) the keyboard or the pointer is on. + focus int +} + +// popupLen is the number of rows (or cells) a menu, list or gallery popup lists. +// (A folded group has no rows and is never asked.) +func (pp *ribbonPopup) popupLen() int { + if pp.kind == popList { + return len(pp.ctrl.Options) + } + return len(pp.ctrl.Items) +} + +// text is the label of a menu entry or a gallery cell. +func (it RibbonItem) text() string { + if it.Action != nil { + return it.Action.Label + } + return it.Label +} + +// icon is the glyph of a menu entry or a gallery cell, nil when it has none. +func (it RibbonItem) icon() IconFunc { + if it.Icon != nil { + return it.Icon + } + if it.Action != nil && it.Action.Icon != nil { + return IconFunc(it.Action.Icon) + } + return nil +} + +func (it RibbonItem) enabled() bool { return it.Action == nil || it.Action.IsEnabled() } + +// popupGeometry is the panel of a popup and the rectangle of each of its rows +// (for a gallery, each cell). A folded group has no rows: its controls are laid +// out like any others, over the panel. +func (r *Ribbon) popupGeometry(pp *ribbonPopup, m ribbonMetrics) (panel Rect, rows []Rect) { + top := pp.anchor.Y + pp.anchor.H + switch pp.kind { + case popGroup: + return Rect{X: pp.anchor.X, Y: top, W: r.groupWidth(pp.grp, RibbonFull, m), H: m.bodyH + m.labelH + 2}, nil + case popGallery: + n := len(pp.ctrl.Items) + cols := min(galleryCols, n) + cw, ch := m.iconL+2*m.padX, m.iconL+m.gh+3*m.gap + for _, it := range pp.ctrl.Items { + cw = max(cw, r.textWidth(it.text())+2*m.padX) + } + panel = Rect{X: pp.anchor.X, Y: top, W: cols*cw + 2, H: ((n+cols-1)/cols)*ch + 2} + for i := 0; i < n; i++ { + rows = append(rows, Rect{X: panel.X + 1 + (i%cols)*cw, Y: panel.Y + 1 + (i/cols)*ch, W: cw, H: ch}) + } + return panel, rows + } + w := pp.anchor.W + var labels []string + if pp.kind == popList { + labels = pp.ctrl.Options + } else { + for _, it := range pp.ctrl.Items { + labels = append(labels, it.text()) + sc := r.shortcutFor(it.Action) + w = max(w, 4*m.padX+m.iconS+r.textWidth(it.text())+m.padX+r.textWidth(sc)) + } + } + for _, l := range labels { + w = max(w, 3*m.padX+m.iconS+r.textWidth(l)) + } + panel = Rect{X: pp.anchor.X, Y: top, W: w, H: len(labels)*m.rowH + 2} + for i := range labels { + rows = append(rows, Rect{X: panel.X + 1, Y: panel.Y + 1 + i*m.rowH, W: panel.W - 2, H: m.rowH}) + } + return panel, rows +} + +// shortcutFor is the accelerator text shown for an Action: the live binding in +// the ribbon's Keymap when it has one, else the Action's own default. +func (r *Ribbon) shortcutFor(a *Action) string { + if a == nil { + return "" + } + if r.Keymap != nil { + if ch, ok := r.Keymap.ShortcutFor(a.ID); ok { + return ch.String() + } + } + return a.Shortcut.String() +} + +// groupPopupCells are the controls of a folded group, laid out in full over its +// panel. +func (r *Ribbon) groupPopupCells(pp *ribbonPopup, m ribbonMetrics) []ribbonCell { + panel, _ := r.popupGeometry(pp, m) + return r.layoutGroup(pp.grp, RibbonFull, panel.X+m.groupPad, panel.Y+1, m) +} + +// cells are the controls the ribbon currently shows and answers to: the active +// tab's body (expanded, or collapsed and peeking), or a folded group's panel +// while that is open. +func (r *Ribbon) cells() []ribbonCell { + if r.pop != nil && r.pop.kind == popGroup { + return r.groupPopupCells(r.pop, r.metrics()) + } + if !r.showsBody() { + return nil + } + return r.body().cells +} + +// cellAt is the index of the cell under the surface point (x, y), -1 for none. +func (r *Ribbon) cellAt(x, y int) int { + for i, c := range r.cells() { + if c.rect.Contains(x, y) { + return i + } + } + return -1 +} + +// selection is a drop-down's or gallery's chosen index, created on first use. +func (c *RibbonControl) selection() *mvvm.Observable[int] { + if c.Selected == nil { + c.Selected = mvvm.NewObservable(0) + } + return c.Selected +} + +// check is a toggle's state, created on first use. +func (c *RibbonControl) check() *mvvm.Observable[bool] { + if c.Checked == nil { + c.Checked = mvvm.NewObservable(false) + } + return c.Checked +} diff --git a/ribbon_sample_test.go b/ribbon_sample_test.go new file mode 100644 index 0000000..cb0fea0 --- /dev/null +++ b/ribbon_sample_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "github.com/go-icons/iconoir" + "github.com/go-widgets/painter" +) + +// ico is an IconFunc over the named Iconoir glyph, falling back to a plain +// square when the pack has no such name (so a renamed icon never panics a test). +func ico(name string) IconFunc { + if !iconoir.Has(name) { + return func(p painter.Painter, r Rect, ink RGBA) { strokeRect(p, r.X+2, r.Y+2, r.W-4, r.H-4, ink) } + } + return SVGIcon(iconoir.Icon(name)) +} + +// nilPainter is a small pixel surface for tests that only need somewhere to draw. +func nilPainter() painter.Painter { return painter.NewPixelPainter(make([]byte, 4*64*64), 64, 64) } + +// sampleRibbon is a ribbon shaped like a slide editor's: four tabs whose groups +// exercise every control kind, with `ran` recording the ids of the commands run. +type sampleRun struct{ ids []string } + +func (s *sampleRun) act(id, label, icon string) *Action { + a := NewAction(id, label, func() { s.ids = append(s.ids, id) }) + if icon != "" { + a.Icon = ico(icon) + } + return a +} + +func (s *sampleRun) ribbon() *Ribbon { + newSlide := s.act("slide.new", "New slide", "page-plus") + newSlide.Shortcut = MustParseChord("Ctrl+N") + paste := s.act("paste", "Paste", "clipboard") + layout := &RibbonControl{Kind: RibbonSplit, Action: newSlide, Large: true, Tip: "Add a slide after this one", + Items: []RibbonItem{{Action: s.act("slide.title", "Title slide", "text")}, {Action: s.act("slide.code", "Code slide", "code")}}} + home := &RibbonTab{Label: "Home", Groups: []*RibbonGroup{ + {Label: "Slides", Icon: ico("page-plus"), Priority: 3, Controls: []*RibbonControl{layout, + {Kind: RibbonButton, Action: s.act("slide.dup", "Duplicate", "copy")}, + {Kind: RibbonButton, Action: s.act("slide.del", "Delete", "trash")}}}, + {Label: "Edit", Icon: ico("edit"), Priority: 2, Controls: []*RibbonControl{ + {Kind: RibbonButton, Action: paste, Large: true, Tip: "Paste the clipboard"}, + {Kind: RibbonButton, Action: s.act("undo", "Undo", "undo")}, + {Kind: RibbonButton, Action: s.act("redo", "Redo", "redo")}, + {Kind: RibbonToggle, Action: s.act("wrap", "Word wrap", "text"), Checked: nil}}}, + {Label: "Theme", Icon: ico("palette"), Priority: 1, Controls: []*RibbonControl{ + {Kind: RibbonDropdown, Label: "Style", Options: []string{"default", "gaia", "uncover"}}, + {Kind: RibbonGallery, Label: "Kinds", Items: []RibbonItem{ + {Label: "Title", Icon: ico("text")}, {Label: "Code", Icon: ico("code")}, {Label: "Image", Icon: ico("media-image")}, {Label: "Quote", Icon: ico("quote")}}}}}, + }} + insert := &RibbonTab{Label: "Insert", Groups: []*RibbonGroup{ + {Label: "Media", Icon: ico("media-image"), Priority: 1, Controls: []*RibbonControl{ + {Kind: RibbonButton, Action: s.act("image", "Image", "media-image"), Large: true}, + {Kind: RibbonButton, Action: s.act("bg", "Background", "media-image-list")}}}, + }} + view := &RibbonTab{Label: "View", Groups: []*RibbonGroup{ + {Label: "Show", Icon: ico("eye"), Priority: 1, Controls: []*RibbonControl{ + {Kind: RibbonToggle, Action: s.act("minimap", "Minimap", "view-grid")}, + {Kind: RibbonToggle, Action: s.act("thumbs", "Thumbnails", "view-grid")}}}, + }} + return NewRibbon(home, insert, view) +} diff --git a/ribbon_test.go b/ribbon_test.go new file mode 100644 index 0000000..3b08eb1 --- /dev/null +++ b/ribbon_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "testing" + + "github.com/go-widgets/mvvm" +) + +// rtRibbon is a sample ribbon sized to width w, with its run log. +func rtRibbon(w int) (*Ribbon, *sampleRun) { + run := &sampleRun{} + r := run.ribbon() + r.SetBounds(Rect{X: 0, Y: 0, W: w, H: r.Height()}) + return r, run +} + +// rtCell is the index of the cell whose control (or folded group) is labelled +// label, -1 when none is shown. +func rtCell(r *Ribbon, label string) int { + for i, c := range r.cells() { + l := "" + if c.form == formPopup { + l = c.grp.Label + } else { + l = c.ctrl.label() + } + if l == label { + return i + } + } + return -1 +} + +// rtCenter is the centre of a rectangle. +func rtCenter(rc Rect) (int, int) { return rc.X + rc.W/2, rc.Y + rc.H/2 } + +// rtClick delivers a click at the surface point (x, y), as a container would. +func rtClick(r *Ribbon, x, y int) { + r.OnEvent(Event{Kind: EventClick, X: x - r.Bounds().X, Y: y - r.Bounds().Y}) +} + +// rtMove delivers a pointer move at the surface point (x, y). +func rtMove(r *Ribbon, x, y int) { + r.OnEvent(Event{Kind: EventMouseMove, X: x - r.Bounds().X, Y: y - r.Bounds().Y}) +} + +// rtClickCell clicks the centre of the cell labelled label. +func rtClickCell(t *testing.T, r *Ribbon, label string) { + t.Helper() + i := rtCell(r, label) + if i < 0 { + t.Fatalf("no cell %q among %v", label, rtLabels(r)) + } + x, y := rtCenterOf(r.cells()[i]) + rtClick(r, x, y) +} + +func rtCenterOf(c ribbonCell) (int, int) { return rtCenter(c.rect) } + +func rtLabels(r *Ribbon) []string { + var out []string + for _, c := range r.cells() { + if c.form == formPopup { + out = append(out, "["+c.grp.Label+"]") + } else { + out = append(out, c.ctrl.label()) + } + } + return out +} + +// rtKey presses a key. +func rtKey(r *Ribbon, code string) { r.OnEvent(Event{Kind: EventKeyDown, Code: code}) } + +// fullWidth is the width the active tab needs with every group at full size. +func fullWidth(r *Ribbon) int { + m := r.metrics() + w := 0 + for _, g := range r.activeTab().Groups { + w += r.groupWidth(g, RibbonFull, m) + m.sepW + } + return w +} + +func TestRibbonFitsAtFullWidth(t *testing.T) { + r, _ := rtRibbon(2000) + r.SetBounds(Rect{W: fullWidth(r), H: r.Height()}) + for i, l := range r.Levels() { + if l != RibbonFull { + t.Errorf("group %d is at level %d at full width", i, l) + } + } + r.SetBounds(Rect{W: fullWidth(r) - 1, H: r.Height()}) + lv := r.Levels() + if lv[2] == RibbonFull || lv[0] != RibbonFull || lv[1] != RibbonFull { + t.Errorf("one pixel short, only the lowest-priority group (Theme) should step down: %v", lv) + } +} + +// The ribbon "shows the largest valid combination that fits": as the width +// shrinks no group ever grows back, the total never exceeds the width while a +// group can still shrink, and groups give up their large form in priority order. +func TestRibbonScalesDownMonotonically(t *testing.T) { + r, _ := rtRibbon(2000) + m := r.metrics() + tab := r.activeTab() + prev := make([]RibbonLevel, len(tab.Groups)) + leftFull := make([]int, len(tab.Groups)) + for w := fullWidth(r) + 40; w >= 20; w-- { + r.SetBounds(Rect{W: w, H: r.Height()}) + lv := r.Levels() + total, canShrink := 0, false + for i, g := range tab.Groups { + if lv[i] < prev[i] { + t.Fatalf("width %d: group %d grew back from %d to %d", w, i, prev[i], lv[i]) + } + if lv[i] != RibbonFull && leftFull[i] == 0 { + leftFull[i] = w + } + total += r.groupWidth(g, lv[i], m) + m.sepW + if _, ok := r.nextLevel(g, lv[i], m); ok { + canShrink = true + } + } + if total > w && canShrink { + t.Fatalf("width %d: total %d does not fit though a group can still shrink: %v", w, total, lv) + } + copy(prev, lv) + } + // Theme (priority 1) leaves full size first, then Edit (2), then Slides (3). + if !(leftFull[2] > leftFull[1] && leftFull[1] > leftFull[0] && leftFull[0] > 0) { + t.Errorf("groups left full size at widths %v, want Theme > Edit > Slides", leftFull) + } + // At 20px every group is as narrow as it can get: a level is only taken when + // it is narrower than the one before, so folding into a (wider) pop-up button + // is skipped when icon-only already beats it. + for i, l := range prev { + if _, ok := r.nextLevel(tab.Groups[i], l, m); ok { + t.Errorf("at 20px group %d is at level %d and could still shrink", i, l) + } + } +} + +func TestRibbonEqualPrioritiesShrinkRightToLeft(t *testing.T) { + run := &sampleRun{} + mk := func(label string) *RibbonGroup { + return &RibbonGroup{Label: label, Priority: 1, Controls: []*RibbonControl{ + {Kind: RibbonButton, Action: run.act(label+"1", label+" one", "page"), Large: true}, + {Kind: RibbonButton, Action: run.act(label+"2", label+" two", "page")}}} + } + r := NewRibbon(&RibbonTab{Label: "T", Groups: []*RibbonGroup{mk("A"), mk("B"), mk("C")}}) + r.SetBounds(Rect{W: fullWidth(r) - 1, H: r.Height()}) + lv := r.Levels() + if lv[2] == RibbonFull || lv[1] != RibbonFull || lv[0] != RibbonFull { + t.Errorf("equal priorities should shrink the rightmost group first, got %v", lv) + } +} + +func TestRibbonSingleControlGroupNeverFoldsIntoAPopup(t *testing.T) { + run := &sampleRun{} + one := &RibbonGroup{Label: "One", Priority: 0, Controls: []*RibbonControl{ + {Kind: RibbonButton, Action: run.act("a", "Alpha button", "page"), Large: true}}} + r := NewRibbon(&RibbonTab{Label: "T", Groups: []*RibbonGroup{one}}) + r.SetBounds(Rect{W: 10, H: r.Height()}) + if l := r.Levels()[0]; l == RibbonPopup { + t.Error("a group of one control folded into a pop-up; the guidelines say to leave it a single button") + } + if l := r.Levels()[0]; l != RibbonIconOnly { + t.Errorf("a single control with an icon should end at icon-only, got %d", l) + } +} + +func TestRibbonLevelsChangeTheFormOfControls(t *testing.T) { + r, _ := rtRibbon(2000) + m := r.metrics() + g := r.activeTab().Groups[0] + form := func(l RibbonLevel) ribbonForm { + return r.layoutGroup(g, l, 0, 0, m)[0].form + } + if form(RibbonFull) != formLarge || form(RibbonCompact) != formSmall || form(RibbonIconOnly) != formIcon || form(RibbonPopup) != formPopup { + t.Error("the first control of Slides should be large, small, icon-only, then the group's pop-up button") + } + // A control with no icon keeps its text at icon-only, or it would vanish. + bare := &RibbonControl{Kind: RibbonButton, Action: NewAction("x", "Bare", nil)} + if bare.formAt(RibbonIconOnly) != formSmall { + t.Error("an iconless control must keep its label at icon-only") + } + // A drop-down is a drop-down at every level; a gallery is a strip only at full size. + dd := &RibbonControl{Kind: RibbonDropdown, Label: "D"} + ga := &RibbonControl{Kind: RibbonGallery, Label: "G"} + if dd.formAt(RibbonIconOnly) != formDropdown || ga.formAt(RibbonFull) != formGallery || ga.formAt(RibbonCompact) != formSmall { + t.Error("drop-down and gallery forms are wrong") + } +} + +func TestRibbonHeightCollapsed(t *testing.T) { + r, _ := rtRibbon(800) + m := r.metrics() + full := r.Height() + if full != m.tabH+1+m.bodyH+m.labelH+1 { + t.Errorf("expanded height %d", full) + } + r.Collapsed().Set(true) + if r.Height() != m.tabH+1 { + t.Errorf("collapsed height %d, want the tab strip and its rule", r.Height()) + } + if len(r.cells()) != 0 { + t.Error("a collapsed ribbon shows no controls") + } +} + +func TestRibbonLargeLines(t *testing.T) { + cases := map[string][]string{ + "Paste": {"Paste"}, + "New slide": {"New", "slide"}, + "Paste special": {"Paste", "special"}, + "Save as image": {"Save as", "image"}, + "": {""}, + } + for in, want := range cases { + got := largeLines(in) + if len(got) != len(want) || got[0] != want[0] || (len(want) == 2 && got[1] != want[1]) { + t.Errorf("largeLines(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRibbonHiddenAndDisabledControls(t *testing.T) { + r, run := rtRibbon(2000) + dup := rtCell(r, "Duplicate") + if dup < 0 { + t.Fatal("Duplicate not shown") + } + c := r.cells()[dup].ctrl + c.Action.SetVisible(false) + if rtCell(r, "Duplicate") >= 0 { + t.Error("an invisible action's control is still laid out") + } + c.Action.SetVisible(true) + c.Action.SetEnabled(false) + rtClickCell(t, r, "Duplicate") + if len(run.ids) != 0 { + t.Errorf("a disabled control ran %v", run.ids) + } + if c.enabled() || !c.visible() { + t.Error("enabled/visible do not follow the action") + } + // A control with no Action at all is always enabled and visible. + if !(&RibbonControl{}).enabled() || !(&RibbonControl{}).visible() { + t.Error("an action-less control should be enabled and visible") + } +} + +func TestRibbonNoTabs(t *testing.T) { + r := NewRibbon() + r.SetBounds(Rect{W: 300, H: 40}) + if r.activeTab() != nil || r.Levels() != nil || len(r.cells()) != 0 || len(r.body().cells) != 0 { + t.Error("a ribbon without tabs has nothing to show") + } + if a := r.A11y(); a.Role != RoleToolbar || a.Value != "" { + t.Errorf("A11y of an empty ribbon = %+v", a) + } + rtKey(r, "ArrowRight") + rtKey(r, "ArrowDown") + // Drawing an empty ribbon must not panic. + r.SetFocused(true) + renderRibbon(r, DefaultLight(), 300, 40) +} + +func TestRibbonActiveTabIsClamped(t *testing.T) { + r, _ := rtRibbon(2000) + r.ActiveTab().Set(99) + if r.activeIndex() != len(r.Tabs)-1 { + t.Errorf("active %d, want the last tab", r.activeIndex()) + } + r.ActiveTab().Set(-5) + if r.activeIndex() != 0 { + t.Errorf("active %d, want the first tab", r.activeIndex()) + } +} + +// A toggle's checked state and a drop-down's selection are shared Observables: +// two views of the same state stay in step, with no copying. +func TestRibbonStateIsShared(t *testing.T) { + r, _ := rtRibbon(2000) + shared := mvvm.NewObservable(false) + wrap := r.Tabs[0].Groups[1].Controls[3] + wrap.Checked = shared + rtClickCell(t, r, "Word wrap") + if !shared.Get() { + t.Error("clicking the toggle did not set the shared observable") + } + shared.Set(false) + if wrap.check().Get() { + t.Error("the shared observable did not reach the toggle") + } + // ActiveTab and Collapsed are the host's to bind. + var seen []int + r.ActiveTab().Subscribe(func(i int) { seen = append(seen, i) }) + cx, cy := rtCenter(mustTabRect(t, r, 1)) + rtClick(r, cx, cy) + if len(seen) != 1 || seen[0] != 1 { + t.Errorf("tab subscribers saw %v", seen) + } +} + +func mustTabRect(t *testing.T, r *Ribbon, i int) Rect { + t.Helper() + tabs, _ := r.tabRects(r.metrics()) + return tabs[i] +} + +func TestNewCommandAction(t *testing.T) { + ran := 0 + can := true + cmd := mvvm.NewCommand(func() { ran++ }, func() bool { return can }) + a := NewCommandAction("c", "Cmd", cmd) + if !a.IsEnabled() || !a.Execute() || ran != 1 { + t.Fatalf("a command that can execute should run through its action (ran %d)", ran) + } + can = false + cmd.RaiseCanExecuteChanged() + if a.IsEnabled() || a.Execute() || ran != 1 { + t.Error("the action did not follow CanExecute off") + } + can = true + cmd.RaiseCanExecuteChanged() + if !a.IsEnabled() { + t.Error("the action did not follow CanExecute back on") + } + // Disabled from the start. + can = false + b := NewCommandAction("d", "D", cmd) + if b.IsEnabled() { + t.Error("an action made over a command that cannot run starts disabled") + } +}