From 339b199ab4c26ae62ef5853c5cd62a67b97639af Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 22 Oct 2026 10:31:29 +0200 Subject: [PATCH] feat: Ribbon.OnLeave and Ribbon.Measure OnLeave tells the host the keyboard use of the ribbon is over - a command ran from the keyboard, Escape on the tabs with nothing open, the keytips ended - so it can give the keyboard back to the editor. Measure makes the ribbon a natural size for VBox.AddNatural: the width offered, the height it needs now (which shrinks when collapsed), with no host code to resize it. Co-Authored-By: Claude Sonnet 5.5 --- ribbon.go | 6 +++ ribbon_input.go | 35 +++++++++++++++- ribbon_leave_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 ribbon_leave_test.go diff --git a/ribbon.go b/ribbon.go index 20048af..3e2b38a 100644 --- a/ribbon.go +++ b/ribbon.go @@ -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] diff --git a/ribbon_input.go b/ribbon_input.go index 3c66799..4bd3a91 100644 --- a/ribbon_input.go +++ b/ribbon_input.go @@ -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]. @@ -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: @@ -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() + } } } @@ -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 diff --git a/ribbon_leave_test.go b/ribbon_leave_test.go new file mode 100644 index 0000000..24e92d3 --- /dev/null +++ b/ribbon_leave_test.go @@ -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()) + } +}