Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
24 changes: 23 additions & 1 deletion ribbon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions ribbon_draw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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".
Expand Down
179 changes: 179 additions & 0 deletions ribbon_grid_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
32 changes: 23 additions & 9 deletions ribbon_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading