From 08cc562ecfe81cca3d412c9471f129d1748a1371 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 23 Oct 2026 20:57:34 +0200 Subject: [PATCH] Ribbon: RibbonGrid, a size picker (Table: columns by rows) A control kind whose popup is a GridCols x GridRows grid of cells (8 x 6 by default, at most 16 x 16): the pointer, or the arrow keys, light the cells from the corner to the one they are on, a caption under them says "3 x 2", and choosing a cell (click, Enter) calls OnGrid(cols, rows) and closes the popup. Home/End jump to the first and the last cell, Escape closes. Drawn in both themes (accent fill and edge on the lit cells), reported to assistive technology as a button that is expanded while the grid is open, disabled with its Action. The popup machinery is the ribbon's own (popupGeometry, popupClick, popupKey), so a grid is positioned, hovered, clicked and keyed like a gallery. 100% statement coverage; README updated. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- ribbon.go | 24 +++++- ribbon_draw.go | 30 ++++++++ ribbon_grid_test.go | 179 ++++++++++++++++++++++++++++++++++++++++++++ ribbon_input.go | 32 +++++--- ribbon_popup.go | 45 ++++++++++- 6 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 ribbon_grid_test.go diff --git a/README.md b/README.md index 289d153..2e6e5b3 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`, `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 | +| Bars | `Toolbar`, `PagingToolbar`, `Statusbar`, `Ribbon` (Office-style tabs of groups of large/small buttons, toggles, split buttons, drop-downs, a gallery and a size grid (`RibbonGrid`, the Table picker: the pointer or the arrow keys light the cells from the corner, a caption says "3 × 2", `OnGrid(cols, rows)` runs); 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`, `FileTree` (an Explorer over paths: rename in place, new file/folder, delete with confirmation, drag to move, drop from outside, context menu, APG treeview keys, type-ahead), `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/ribbon.go b/ribbon.go index 9bffcc2..ce952c6 100644 --- a/ribbon.go +++ b/ribbon.go @@ -125,6 +125,11 @@ const ( // RibbonGallery shows the first few of Items as a strip, with an arrow that // opens all of them; the chosen index is Selected. RibbonGallery + // RibbonGrid opens a grid of GridCols by GridRows cells under it, as the Table + // button of a word processor does: the pointer (or the arrow keys) lights the + // cells from the corner up to the one it is on, a caption says "3 × 2", and + // choosing calls OnGrid with that many columns and rows. + RibbonGrid ) // RibbonControl is one control of a group. Only the fields its Kind needs are @@ -150,6 +155,23 @@ type RibbonControl struct { Options []string // Selected is a drop-down's or a gallery's chosen index. Selected *mvvm.Observable[int] + // GridCols and GridRows are the size of a grid's popup, in cells (8 by 6 when + // zero, at most 16 by 16); OnGrid is called with the columns and rows chosen. + GridCols, GridRows int + OnGrid func(cols, rows int) +} + +// gridSize is the size of a grid control's popup: its GridCols by GridRows, 8 by 6 +// by default, kept between 1 and 16 each. +func (c *RibbonControl) gridSize() (cols, rows int) { + cols, rows = c.GridCols, c.GridRows + if cols <= 0 { + cols = 8 + } + if rows <= 0 { + rows = 6 + } + return min(cols, 16), min(rows, 16) } // RibbonItem is a menu entry or a gallery cell. A menu entry runs Action; a @@ -294,7 +316,7 @@ func (c *RibbonControl) icon() IconFunc { // 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 + return c.Kind == RibbonSplit || c.Kind == RibbonDropdown || c.Kind == RibbonGallery || c.Kind == RibbonGrid } // visibleControls are the group's shown controls, in order. diff --git a/ribbon_draw.go b/ribbon_draw.go index bdf9fa1..69dccff 100644 --- a/ribbon_draw.go +++ b/ribbon_draw.go @@ -264,6 +264,10 @@ func (r *Ribbon) drawPopupPanel(p painter.Painter, theme *Theme, m ribbonMetrics return } accent := ribbonAccent(theme) + if pp.kind == popGrid { + r.drawGridPanel(p, theme, m, pp, panel, rows) + return + } for i, rc := range rows { if i == pp.focus { fillRect(p, rc.X, rc.Y, rc.W, rc.H, theme.SurfaceAlt) @@ -294,6 +298,32 @@ func (r *Ribbon) drawPopupPanel(p painter.Painter, theme *Theme, m ribbonMetrics } } +// drawGridPanel paints a grid popup: its cells, those from the corner to the one +// the highlight is on filled with the accent, a border on each, and the caption +// ("3 × 2") under them. +func (r *Ribbon) drawGridPanel(p painter.Painter, theme *Theme, m ribbonMetrics, pp *ribbonPopup, panel Rect, cells []Rect) { + cols, _ := pp.ctrl.gridSize() + fc, fr := -1, -1 + if pp.focus >= 0 { + fc, fr = pp.focus%cols, pp.focus/cols + } + accent := ribbonAccent(theme) + for i, rc := range cells { + in := i%cols <= fc && i/cols <= fr + if in { + fillRect(p, rc.X, rc.Y, rc.W, rc.H, blendRGBA(theme.Surface, accent, 0.35)) + } + edge := ribbonLine(theme) + if in { + edge = accent + } + strokeRect(p, rc.X+m.gap/2, rc.Y+m.gap/2, rc.W-m.gap, rc.H-m.gap, edge) + } + last := cells[len(cells)-1] + cap := pp.gridCaption() + r.drawText(p, panel.X+(panel.W-r.textWidth(cap))/2, last.Y+last.H+(m.rowH-m.gh)/2, cap, theme.OnSurface) +} + // 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". diff --git a/ribbon_grid_test.go b/ribbon_grid_test.go new file mode 100644 index 0000000..f39dd9d --- /dev/null +++ b/ribbon_grid_test.go @@ -0,0 +1,179 @@ +// 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/painter" +) + +// gridRibbon is a ribbon with one grid control, "Table", whose OnGrid records the +// size it was called with. +func gridRibbon(cols, rows int) (*Ribbon, *RibbonControl, *[][2]int) { + var got [][2]int + c := &RibbonControl{Kind: RibbonGrid, Large: true, Action: NewAction("table", "Table", nil), Tip: "Insert a table.", + GridCols: cols, GridRows: rows, OnGrid: func(c, r int) { got = append(got, [2]int{c, r}) }} + r := NewRibbon(&RibbonTab{Label: "Insert", Groups: []*RibbonGroup{{Label: "Tables", Priority: 1, Controls: []*RibbonControl{c}}}}) + r.SetBounds(Rect{W: 800, H: r.Height()}) + return r, c, &got +} + +func TestRibbonGridOpensAGridOfCells(t *testing.T) { + r, c, got := gridRibbon(0, 0) + if cols, rows := c.gridSize(); cols != 8 || rows != 6 { + t.Fatalf("the default grid is 8 by 6: %d by %d", cols, rows) + } + i := rtCell(r, "Table") + cell := r.cells()[i] + if !c.hasArrow() || cell.arrow == (Rect{}) { + t.Fatal("a grid control has an arrow") + } + // the icon half and the label half both open it + rtClick(r, cell.rect.X+cell.rect.W/2, cell.rect.Y+4) + if r.pop == nil || r.pop.kind != popGrid || r.pop.focus != -1 { + t.Fatalf("a click should open the grid with no cell lit: %+v", r.pop) + } + panel, cells := r.popupGeometry(r.pop, r.metrics()) + if len(cells) != 48 || r.pop.popupLen() != 48 || r.pop.popupCols() != 8 { + t.Fatalf("%d cells", len(cells)) + } + if cells[9].X != cells[1].X || cells[9].Y != cells[1].Y+cells[1].H { + t.Fatalf("cells run in rows of 8: %+v %+v", cells[1], cells[9]) + } + if panel.W < cells[7].X+cells[7].W-panel.X || panel.Y != cell.rect.Y+cell.rect.H { + t.Fatalf("the panel hangs under the control and holds every cell: %+v", panel) + } + if r.pop.gridCaption() != "Table" { + t.Errorf("before a cell is lit the caption is the control's name: %q", r.pop.gridCaption()) + } + // the pointer lights the cells from the corner to the one it is on + x, y := rtCenter(cells[2*8+3]) // 4 columns, 3 rows + r.hover(x, y) + if r.pop.focus != 2*8+3 || r.pop.gridCaption() != "4 × 3" { + t.Fatalf("hover: focus %d caption %q", r.pop.focus, r.pop.gridCaption()) + } + rtClick(r, x, y) + if len(*got) != 1 || (*got)[0] != [2]int{4, 3} || r.pop != nil { + t.Fatalf("choosing a cell calls OnGrid(4, 3) and closes the grid: %v pop=%v", *got, r.pop) + } +} + +func TestRibbonGridSizeIsKept(t *testing.T) { + r, c, got := gridRibbon(99, 3) + if cols, rows := c.gridSize(); cols != 16 || rows != 3 { + t.Fatalf("a grid is at most 16 wide: %d by %d", cols, rows) + } + r.activate(rtCell(r, "Table"), partMain) + if r.pop.popupLen() != 48 { + t.Fatalf("16 by 3: %d cells", r.pop.popupLen()) + } + _, cells := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(cells[47]) + rtClick(r, x, y) + if (*got)[0] != [2]int{16, 3} { + t.Fatalf("last cell: %v", *got) + } +} + +func TestRibbonGridKeyboard(t *testing.T) { + r, _, got := gridRibbon(4, 3) + r.byKey = true + r.activate(rtCell(r, "Table"), partMain) + r.byKey = false + if r.pop == nil || r.pop.focus != 0 { + t.Fatalf("opened by the keyboard the corner cell is lit: %+v", r.pop) + } + for _, k := range []string{"ArrowLeft", "ArrowUp", "ArrowRight", "ArrowRight", "ArrowDown"} { + r.key(Event{Kind: EventKeyDown, Code: k}) + } + if r.pop.focus != 6 || r.pop.gridCaption() != "3 × 2" { // column 3, row 2 of 4 columns + t.Fatalf("arrows move over the grid: focus %d %q", r.pop.focus, r.pop.gridCaption()) + } + r.key(Event{Kind: EventKeyDown, Code: "End"}) + if r.pop.gridCaption() != "4 × 3" { + t.Fatalf("End: %q", r.pop.gridCaption()) + } + r.key(Event{Kind: EventKeyDown, Code: "ArrowDown"}) // already on the last row + r.key(Event{Kind: EventKeyDown, Code: "ArrowRight"}) + r.key(Event{Kind: EventKeyDown, Code: "Home"}) + if r.pop.focus != 0 { + t.Fatalf("Home: %d", r.pop.focus) + } + r.key(Event{Kind: EventKeyDown, Code: "ArrowDown"}) + r.key(Event{Kind: EventKeyDown, Code: "ArrowRight"}) + r.key(Event{Kind: EventKeyDown, Code: "Enter"}) + if len(*got) != 1 || (*got)[0] != [2]int{2, 2} || r.pop != nil { + t.Fatalf("Enter chooses the lit cell: %v", *got) + } + // a grid opened with the pointer and then driven by keys starts at the corner + r.activate(rtCell(r, "Table"), partMain) + r.key(Event{Kind: EventKeyDown, Code: "ArrowDown"}) + if r.pop.focus < 0 { + t.Fatalf("an arrow lights a cell: %d", r.pop.focus) + } + r.key(Event{Kind: EventKeyDown, Code: "Escape"}) + if r.pop != nil { + t.Error("Escape closes the grid") + } +} + +func TestRibbonGridDisabledAndWithoutACallback(t *testing.T) { + r, c, _ := gridRibbon(3, 3) + c.Action.SetEnabled(false) + r.activate(rtCell(r, "Table"), partMain) + if r.pop != nil { + t.Error("a disabled grid does not open") + } + c.Action.SetEnabled(true) + c.OnGrid = nil + r.activate(rtCell(r, "Table"), partMain) + _, cells := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(cells[4]) + rtClick(r, x, y) + if r.pop != nil { + t.Error("choosing a cell closes the grid, callback or not") + } + // the open grid is "expanded" for assistive technology + r.activate(rtCell(r, "Table"), partMain) + var found bool + for _, n := range r.Children() { + if info := n.(Accessible).A11y(); info.Name == "Table" { + found = info.Role == RoleButton && info.Value == "expanded" + } + } + if !found { + t.Error("an open grid reports expanded") + } + // a caption for a highlight outside the grid falls back to the name + r.pop.focus = 999 + if r.pop.gridCaption() != "Table" { + t.Errorf("caption %q", r.pop.gridCaption()) + } +} + +func TestRibbonGridIsDrawnInBothThemes(t *testing.T) { + for _, th := range []*Theme{DefaultLight(), DefaultDark()} { + r, _, _ := gridRibbon(5, 4) + r.activate(rtCell(r, "Table"), partMain) + w, h := 800, 300 + plain := make([]byte, w*h*4) + r.DrawPopover(painter.NewPixelPainter(plain, w, h), th) + _, cells := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(cells[1*5+2]) + r.hover(x, y) + lit := make([]byte, w*h*4) + r.DrawPopover(painter.NewPixelPainter(lit, w, h), th) + if string(plain) == string(lit) { + t.Error("lighting cells changes the picture") + } + // the lit cells use the accent, the others the line colour + in := cells[0] + px := ((in.Y+in.H/2)*w + in.X + in.W/2) * 4 + if lit[px] == plain[px] && lit[px+1] == plain[px+1] && lit[px+2] == plain[px+2] { + t.Error("a lit cell is filled") + } + } +} diff --git a/ribbon_input.go b/ribbon_input.go index 4bd3a91..24dda3d 100644 --- a/ribbon_input.go +++ b/ribbon_input.go @@ -234,6 +234,14 @@ func (r *Ribbon) choose(pp *ribbonPopup, i int) { case popGallery: r.pickGallery(pp.ctrl, i) return + case popGrid: + cols, _ := pp.ctrl.gridSize() + c := pp.ctrl + r.Dismiss() + if c.OnGrid != nil { + c.OnGrid(i%cols+1, i/cols+1) + } + return } r.Dismiss() } @@ -311,6 +319,14 @@ func (r *Ribbon) activate(idx, part int) { return } r.openPopup(&ribbonPopup{kind: popGallery, ctrl: c, anchor: cell.rect, focus: c.selection().Get()}) + case RibbonGrid: + // opened with the keyboard the corner cell is lit; with the pointer, none + // is until it moves over one + focus := -1 + if r.byKey { + focus = 0 + } + r.openPopup(&ribbonPopup{kind: popGrid, ctrl: c, anchor: cell.rect, focus: focus}) case RibbonSplit: if part == partArrow { r.openPopup(&ribbonPopup{kind: popMenu, ctrl: c, anchor: cell.rect}) @@ -491,7 +507,7 @@ func (r *Ribbon) openFocused(cells []ribbonCell, cur int) { case c == nil || !c.enabled(): case c.Kind == RibbonSplit: r.activate(cur, partArrow) - case c.Kind == RibbonDropdown || c.Kind == RibbonGallery: + case c.Kind == RibbonDropdown || c.Kind == RibbonGallery || c.Kind == RibbonGrid: r.activate(cur, partMain) } } @@ -531,21 +547,19 @@ func verticalNeighbour(cells []ribbonCell, cur, dir int) int { func (r *Ribbon) popupKey(ev Event) { pp := r.pop n := pp.popupLen() - step := 1 - if pp.kind == popGallery { - step = min(galleryCols, n) - } + step := pp.popupCols() + sideways := pp.kind == popGallery || pp.kind == popGrid switch ev.Code { case "ArrowDown": - pp.focus = min(pp.focus+step, n-1) + pp.focus = max(min(pp.focus+step, n-1), 0) case "ArrowUp": pp.focus = max(pp.focus-step, 0) case "ArrowRight": - if pp.kind == popGallery { - pp.focus = min(pp.focus+1, n-1) + if sideways { + pp.focus = max(min(pp.focus+1, n-1), 0) } case "ArrowLeft": - if pp.kind == popGallery { + if sideways { pp.focus = max(pp.focus-1, 0) } case "Home": diff --git a/ribbon_popup.go b/ribbon_popup.go index 82d54be..1f94d8c 100644 --- a/ribbon_popup.go +++ b/ribbon_popup.go @@ -4,7 +4,11 @@ package toolkit -import "github.com/go-widgets/mvvm" +import ( + "strconv" + + "github.com/go-widgets/mvvm" +) // popupKind is what an open ribbon popup holds. type popupKind int @@ -18,6 +22,8 @@ const ( popGallery // popGroup is a group folded into a button, shown open. popGroup + // popGrid is a grid control's cells, with a caption under them. + popGrid ) // galleryCols is how many columns a gallery's drop-down grid has at most. @@ -37,12 +43,39 @@ type ribbonPopup struct { // 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 { + switch pp.kind { + case popList: return len(pp.ctrl.Options) + case popGrid: + cols, rows := pp.ctrl.gridSize() + return cols * rows } return len(pp.ctrl.Items) } +// popupCols is how many cells make a row of a popup: a gallery's or a grid's +// columns, else 1. +func (pp *ribbonPopup) popupCols() int { + switch pp.kind { + case popGallery: + return min(galleryCols, len(pp.ctrl.Items)) + case popGrid: + cols, _ := pp.ctrl.gridSize() + return cols + } + return 1 +} + +// gridCaption is what a grid popup says under its cells: "3 × 2" for the cell the +// highlight is on, or the control's label before anything is. +func (pp *ribbonPopup) gridCaption() string { + cols, _ := pp.ctrl.gridSize() + if pp.focus < 0 || pp.focus >= pp.popupLen() { + return pp.ctrl.label() + } + return strconv.Itoa(pp.focus%cols+1) + " × " + strconv.Itoa(pp.focus/cols+1) +} + // text is the label of a menu entry or a gallery cell. func (it RibbonItem) text() string { if it.Action != nil { @@ -84,6 +117,14 @@ func (r *Ribbon) popupGeometry(pp *ribbonPopup, m ribbonMetrics) (panel Rect, ro 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 + case popGrid: + cols, nrows := pp.ctrl.gridSize() + cs := m.rowH + panel = Rect{X: pp.anchor.X, Y: top, W: max(cols*cs+2, r.textWidth(pp.ctrl.label())+2*m.padX), H: nrows*cs + m.rowH + 2} + for i := 0; i < cols*nrows; i++ { + rows = append(rows, Rect{X: panel.X + 1 + (i%cols)*cs, Y: panel.Y + 1 + (i/cols)*cs, W: cs, H: cs}) + } + return panel, rows } w := pp.anchor.W var labels []string