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
6 changes: 6 additions & 0 deletions ribbon.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ type Ribbon struct {
// TooltipDelayMs is how long the pointer rests on a control before its
// tooltip shows, driven by [Ribbon.Tick]. Zero means 500.
TooltipDelayMs int
// OnLeave, when set, is called when the keyboard use of the ribbon is over:
// a command ran from the keyboard, Escape was pressed on the tabs with
// nothing open, or the keytips ended. A host gives the keyboard back to the
// editor (or whatever held it) there.
OnLeave func()

byKey bool
active *mvvm.Observable[int]
collapsed *mvvm.Observable[bool]

Expand Down
35 changes: 33 additions & 2 deletions ribbon_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ func (r *Ribbon) HitTest(px, py int) bool {
return r.peek && r.bodyRect().Contains(px, py)
}

// Measure is the size the ribbon asks of a box that sizes its children by what
// they ask for ([VBox.AddNatural]): the whole width offered and the height it
// needs now, which shrinks when it is collapsed.
func (r *Ribbon) Measure(availW, availH int) (w, h int) { return availW, r.Height() }

// Tick advances the hover timer by dt seconds: the toolkit's [Animator] contract,
// so [TickTree] drives it with the rest of the tree and a tooltip shows once the
// pointer has rested on a control for [Ribbon.TooltipDelayMs].
Expand Down Expand Up @@ -332,15 +337,34 @@ func (r *Ribbon) runAction(a *Action) {
a.Execute()
}
r.Dismiss()
if r.byKey {
r.leave()
}
}

// key routes a key press.
// key routes a key press. While it runs, a command that runs ends with the focus
// handed back to the host through OnLeave: keyboard use of the ribbon is a
// detour, and its end is wherever the user was typing.
func (r *Ribbon) key(ev Event) {
r.byKey = true
r.dispatchKey(ev)
r.byKey = false
}

// leave hands the keyboard back to the host (OnLeave), if it asked.
func (r *Ribbon) leave() {
if r.OnLeave != nil {
r.OnLeave()
}
}

func (r *Ribbon) dispatchKey(ev Event) {
switch {
case ev.Ctrl && ev.Code == "F1":
r.toggleCollapsed()
case ev.Code == "Alt" && r.keytips > 0:
r.keytips, r.tipBuf = 0, ""
r.leave()
case ev.Code == "Alt":
r.startKeyTips()
case r.keytips > 0:
Expand Down Expand Up @@ -375,7 +399,13 @@ func (r *Ribbon) tabKey(ev Event) {
case "ArrowDown", "Enter", " ", "Space":
r.enterBody()
case "Escape":
r.peek = false
// A peeking body is put away first; with nothing open the keyboard
// goes back to where it came from.
if r.peek {
r.peek = false
} else {
r.leave()
}
}
}

Expand Down Expand Up @@ -546,6 +576,7 @@ func (r *Ribbon) keyTipKey(ev Event) {
r.keytips, r.peek = 1, false
} else {
r.keytips = 0
r.leave()
}
r.tipBuf = ""
return
Expand Down
96 changes: 96 additions & 0 deletions ribbon_leave_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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"

// OnLeave tells the host the keyboard use of the ribbon is over, so it can give
// the keyboard back to the editor: a command run from the keyboard, Escape on
// the tabs with nothing open, the keytips ended. A click does not call it (the
// pointer already told the host where the focus went).
func TestRibbonOnLeave(t *testing.T) {
r, run := rtRibbon(2000)
left := 0
r.OnLeave = func() { left++ }
r.SetFocused(true)

// Escape on the tabs with nothing open leaves.
rtKey(r, "Escape")
if left != 1 {
t.Errorf("Escape on the tabs: left %d times", left)
}
// A peeking body is put away first, and that is not leaving.
r.Collapsed().Set(true)
r.peek = true
rtKey(r, "Escape")
if left != 1 || r.peek {
t.Errorf("Escape over a peeking body should only close it: left %d", left)
}
r.Collapsed().Set(false)
// A command run from the keyboard leaves.
rtKey(r, "ArrowDown")
rtKey(r, "ArrowRight")
rtKey(r, "Enter")
if left != 2 || len(run.ids) != 1 {
t.Errorf("Enter on a control: left %d, ran %v", left, run.ids)
}
// A command run by the pointer does not.
rtClickCell(t, r, "Undo")
if left != 2 {
t.Errorf("a click must not call OnLeave, left %d", left)
}
// The keytips: running a control leaves, Escape out of them leaves, Alt again leaves.
rtKey(r, "Alt")
rtKey(r, "H")
rtKey(r, "U")
if left != 3 {
t.Errorf("a keytip that ran a command: left %d", left)
}
rtKey(r, "Alt")
rtKey(r, "Escape")
if left != 4 {
t.Errorf("Escape out of the keytips: left %d", left)
}
rtKey(r, "Alt")
rtKey(r, "Alt")
if left != 5 {
t.Errorf("Alt toggling the keytips off: left %d", left)
}
// Going back from the control tips to the tab tips is not leaving.
rtKey(r, "Alt")
rtKey(r, "H")
rtKey(r, "Escape")
if left != 5 || r.keytips != 1 {
t.Errorf("Escape from the control tips to the tab tips: left %d keytips %d", left, r.keytips)
}
// A ribbon with no hook just carries on.
r.OnLeave = nil
rtKey(r, "Escape")
rtKey(r, "Escape")
}

func TestRibbonMeasure(t *testing.T) {
r, _ := rtRibbon(2000)
if w, h := r.Measure(777, 5); w != 777 || h != r.Height() {
t.Errorf("Measure = %d,%d", w, h)
}
r.Collapsed().Set(true)
if _, h := r.Measure(777, 5); h != r.metrics().tabH+1 {
t.Errorf("collapsed Measure height = %d", h)
}
// In a VBox it is what AddNatural reads: the box gives it exactly that height.
box := NewVBox()
box.AddNatural(r)
box.AddFlex(NewLabel("content"), 1)
box.SetBounds(Rect{W: 800, H: 400})
if got := r.Bounds().H; got != r.Height() {
t.Errorf("VBox gave the ribbon %d, want %d", got, r.Height())
}
r.Collapsed().Set(false)
box.SetBounds(Rect{W: 800, H: 400})
if got := r.Bounds().H; got != r.Height() {
t.Errorf("after expanding, VBox gave the ribbon %d, want %d", got, r.Height())
}
}
Loading