diff --git a/ribbon.go b/ribbon.go index af60270..20048af 100644 --- a/ribbon.go +++ b/ribbon.go @@ -48,9 +48,12 @@ import "github.com/go-widgets/mvvm" // 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. +// collapsed) and sets its bounds. The menus, a collapsed ribbon's body, the +// tooltips and the keytips are drawn outside the ribbon's own rectangle, over +// everything else, so the ribbon is a [PopoverOwner]: under a [PopoverHost] (the +// root of an application) it needs nothing more; a host without one calls +// [Ribbon.DrawPopover] last and offers [Ribbon.PopoverClick] the clicks first. +// It is an [Animator] too: [TickTree] drives its tooltip delay. type Ribbon struct { Base focusState diff --git a/ribbon_draw.go b/ribbon_draw.go index 311d394..bdf9fa1 100644 --- a/ribbon_draw.go +++ b/ribbon_draw.go @@ -34,7 +34,7 @@ func ribbonInk(theme *Theme, enabled bool) RGBA { // 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. +// [Ribbon.DrawPopover], which a [PopoverHost] calls after the tree. func (r *Ribbon) Draw(p painter.Painter, theme *Theme) { b := r.Bounds() m := r.metrics() @@ -229,10 +229,11 @@ func (r *Ribbon) drawGallery(p painter.Painter, theme *Theme, m ribbonMetrics, c 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 +// DrawPopover 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) { +// control and the keytips. A [PopoverHost] calls it after the whole tree is drawn +// (the ribbon is a [PopoverOwner]); a host without one calls it last itself. +func (r *Ribbon) DrawPopover(p painter.Painter, theme *Theme) { m := r.metrics() if r.Collapsed().Get() && r.peek { r.drawBody(p, theme, m, r.body()) diff --git a/ribbon_draw_test.go b/ribbon_draw_test.go index b5bb621..2a14ed3 100644 --- a/ribbon_draw_test.go +++ b/ribbon_draw_test.go @@ -224,7 +224,7 @@ func TestRibbonTooltipBubbleIsInsideTheDrawing(t *testing.T) { i := rtCell(r, "New slide") x, y := rtCenterOf(r.cells()[i]) rtMove(r, x, y) - r.Tick(600) + r.Tick(0.6) m := r.metrics() anchor := r.cells()[i].rect img := renderRibbon(r, th, 1100, 220) diff --git a/ribbon_gallery_test.go b/ribbon_gallery_test.go index 0f45c98..0b35ecc 100644 --- a/ribbon_gallery_test.go +++ b/ribbon_gallery_test.go @@ -25,7 +25,7 @@ func renderRibbon(r *Ribbon, th *Theme, w, h int) *image.RGBA { 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) + r.DrawPopover(p, th) img := image.NewRGBA(image.Rect(0, 0, w, h)) copy(img.Pix, buf) // the pixel buffer is RGBA straight; nothing to convert @@ -80,7 +80,7 @@ func TestRibbonGallery(t *testing.T) { 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) + r.Tick(0.6) save(tn+"-tooltip", renderRibbon(r, th, 1100, 220)) r = run.ribbon() r.SetBounds(Rect{W: 1100, H: r.Height()}) diff --git a/ribbon_input.go b/ribbon_input.go index 7fefa09..3c66799 100644 --- a/ribbon_input.go +++ b/ribbon_input.go @@ -44,18 +44,68 @@ func (r *Ribbon) HitTest(px, py int) bool { 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 +// 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]. +func (r *Ribbon) Tick(dt float64) { + if r.hoverCtl >= 0 && r.pop == nil { + r.hoverMs += int(dt * 1000) + } +} + +// Animating reports whether a tooltip is waiting for its delay, so a host that +// repaints only while something animates keeps painting until it shows. +func (r *Ribbon) Animating() bool { + return r.hoverCtl >= 0 && r.pop == nil && !r.tooltipVisible() +} + +// PopoverOpen is whether the ribbon has something to draw over the page: a menu, +// a drop-down, a folded group, a collapsed ribbon's body, a tooltip or keytips. +// With [PopoverOwner] the ribbon works under a [PopoverHost] like a [DropDown]: +// the host draws the floating part after the tree and offers it the clicks. +func (r *Ribbon) PopoverOpen() bool { + return r.pop != nil || r.peek || r.keytips > 0 || r.tooltipVisible() +} + +// PopoverBounds is where the floating part is: the open panel, the peeking body, +// else the ribbon itself (a tooltip or keytips hang off it). +func (r *Ribbon) PopoverBounds() Rect { + switch { + case r.pop != nil: + panel, _ := r.popupGeometry(r.pop, r.metrics()) + return panel + case r.peek: + return r.bodyRect() + } + return r.Bounds() +} + +// PopoverClick offers the ribbon a click that may land on its floating part, +// which lies outside its bounds where no container would route it. A click on the +// open panel or the peeking body is handled here and consumed; a click anywhere +// else outside the ribbon closes what is open and goes on to what it landed on. +func (r *Ribbon) PopoverClick(x, y int) bool { + switch { + case r.pop != nil: + if panel, _ := r.popupGeometry(r.pop, r.metrics()); panel.Contains(x, y) { + r.click(x, y) + return true + } + case r.peek: + if r.bodyRect().Contains(x, y) { + r.click(x, y) + return true + } + } + if !r.Bounds().Contains(x, y) { + r.Dismiss() } - was := r.tooltipVisible() - r.hoverMs += dtMs - return was != r.tooltipVisible() + return false } +var _ PopoverOwner = (*Ribbon)(nil) +var _ Animator = (*Ribbon)(nil) + // tooltipVisible is whether the hovered control's tooltip is showing. func (r *Ribbon) tooltipVisible() bool { d := r.TooltipDelayMs diff --git a/ribbon_input_test.go b/ribbon_input_test.go index 4087d89..779d2f7 100644 --- a/ribbon_input_test.go +++ b/ribbon_input_test.go @@ -271,14 +271,17 @@ func TestRibbonTooltipTimerAndText(t *testing.T) { 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.Animating() { + t.Fatal("a hovered control with its tooltip pending keeps the host painting") } - if !r.Tick(150) || !r.tooltipVisible() { - t.Fatal("after 550ms the tooltip shows, and Tick reports the change") + r.Tick(0.1) + r.Tick(0.3) + if r.tooltipVisible() { + t.Fatal("the tooltip must wait its delay (500ms by default)") } - if r.Tick(100) { - t.Error("Tick reports only changes") + r.Tick(0.15) + if !r.tooltipVisible() || r.Animating() { + t.Fatal("after 550ms the tooltip shows, and nothing is left animating") } lines := r.tooltipLines() if len(lines) != 2 || lines[0] != "New slide (Ctrl+N)" || lines[1] != "Add a slide after this one" { @@ -289,21 +292,108 @@ func TestRibbonTooltipTimerAndText(t *testing.T) { j := rtCell(r, "Redo") x, y = rtCenterOf(r.cells()[j]) rtMove(r, x, y) - if !r.Tick(60) || len(r.tooltipLines()) != 1 { + r.Tick(0.06) + if !r.tooltipVisible() || len(r.tooltipLines()) != 1 { t.Errorf("custom delay: lines %q", r.tooltipLines()) } - // An open popup hides the tooltip, and nothing hovered means no timer. + // An open popup hides the tooltip and stops the timer, and nothing hovered + // means nothing to time. r.activate(rtCell(r, "Style"), partMain) - if r.tooltipVisible() || r.Tick(1000) { - t.Error("a popup hides the tooltip") + ms := r.hoverMs + r.Tick(1) + if r.tooltipVisible() || r.Animating() || r.hoverMs != ms { + t.Error("a popup hides the tooltip and stops its timer") } r.Dismiss() rtMove(r, 5, r.Height()+50) - if r.Tick(1000) { + r.Tick(1) + if r.Animating() || r.hoverMs != 0 { t.Error("nothing hovered: nothing to time") } } +// The ribbon is a PopoverOwner: under a PopoverHost its menus, peeking body, +// tooltips and keytips are drawn after the tree and its clicks are routed to them, +// with no host code. +func TestRibbonUnderAPopoverHost(t *testing.T) { + run := &sampleRun{} + r := run.ribbon() + body := NewVBox() + body.AddFixed(r, r.Height()) + body.AddFlex(NewLabel("content"), 1) + host := NewPopoverHost(body) + host.SetBounds(Rect{W: 1100, H: 400}) + body.SetBounds(Rect{W: 1100, H: 400}) + r.SetBounds(Rect{W: 1100, H: r.Height()}) + if PopoverOwners(host) != nil { + t.Fatal("nothing is open yet") + } + // Open the split menu: the host now sees an open popover over the content. + i := rtCell(r, "New slide") + c := r.cells()[i] + host.OnEvent(Event{Kind: EventClick, X: c.arrow.X + c.arrow.W/2, Y: c.arrow.Y + c.arrow.H/2}) + owners := PopoverOwners(host) + if len(owners) != 1 || owners[0] != PopoverOwner(r) || !r.PopoverOpen() { + t.Fatalf("an open menu should be a popover, got %d owners", len(owners)) + } + panel := r.PopoverBounds() + if panel.Y+panel.H <= r.Bounds().H { + t.Fatal("the menu should hang below the ribbon") + } + // The host draws it over the content: a pixel inside the panel is the panel's. + th := DefaultLight() + buf := make([]byte, 1100*400*4) + p := painterOver(buf, 1100, 400) + host.Draw(p, th) + if got := rtPixelAt(buf, 1100, panel.X+panel.W/2, panel.Y+panel.H-2); got != th.Surface { + t.Errorf("the host did not draw the menu over the content: %v", got) + } + // A click on a row (below the ribbon, where no container would route it) runs it. + _, rows := r.popupGeometry(r.pop, r.metrics()) + x, y := rtCenter(rows[1]) + host.OnEvent(Event{Kind: EventClick, X: x, Y: y}) + if r.PopoverOpen() || run.ids[len(run.ids)-1] != "slide.code" { + t.Errorf("clicking a menu row through the host ran %v", run.ids) + } + // A click far away closes a popup and is not swallowed. + r.activate(rtCell(r, "Style"), partMain) + if r.PopoverClick(900, 380) || r.PopoverOpen() { + t.Error("a click outside closes the popup and passes through") + } + // A click on the panel's border is consumed, a click on the ribbon itself is not. + r.activate(rtCell(r, "Style"), partMain) + pb := r.PopoverBounds() + if !r.PopoverClick(pb.X, pb.Y) || !r.PopoverOpen() { + t.Error("the panel's border swallows the click") + } + if r.PopoverClick(5, 5) || !r.PopoverOpen() { + t.Error("a click on the ribbon itself is left to OnEvent, which closes the popup there") + } + r.Dismiss() + // A collapsed ribbon's peeking body is a popover too, and clickable. + r.Collapsed().Set(true) + r.SetBounds(Rect{W: 1100, H: r.Height()}) + tabs, _ := r.tabRects(r.metrics()) + x, y = rtCenter(tabs[0]) + host.OnEvent(Event{Kind: EventClick, X: x, Y: y}) + if !r.PopoverOpen() || r.PopoverBounds() != r.bodyRect() { + t.Fatal("a collapsed ribbon's tab should open its body as a popover") + } + bx, by := rtCenterOf(r.cells()[rtCell(r, "Duplicate")]) + host.OnEvent(Event{Kind: EventClick, X: bx, Y: by}) + if run.ids[len(run.ids)-1] != "slide.dup" || r.PopoverOpen() { + t.Errorf("a click on the peeking body ran %v", run.ids) + } + // Keytips and the tooltip are popovers as well; a click away ends the keytips. + rtKey(r, "Alt") + if !r.PopoverOpen() || r.PopoverBounds() != r.Bounds() { + t.Error("keytips are a popover anchored on the ribbon") + } + if r.PopoverClick(5, 380) || r.keytips != 0 { + t.Error("a click away from the ribbon ends the keytips") + } +} + func TestRibbonShortcutComesFromTheKeymapWhenBound(t *testing.T) { r, _ := rtRibbon(2000) km := NewKeymap() diff --git a/ribbon_misc_test.go b/ribbon_misc_test.go index 4379fc1..3d99cef 100644 --- a/ribbon_misc_test.go +++ b/ribbon_misc_test.go @@ -54,7 +54,7 @@ func TestRibbonTooltipOfAFoldedGroup(t *testing.T) { i := rtCell(r, "Theme") x, y := rtCenterOf(r.cells()[i]) rtMove(r, x, y) - r.Tick(600) + r.Tick(0.6) if lines := r.tooltipLines(); len(lines) != 1 || lines[0] != "Theme" { t.Errorf("a folded group's tooltip is its name: %q", lines) } diff --git a/ribbon_sample_test.go b/ribbon_sample_test.go index cb0fea0..7460b8d 100644 --- a/ribbon_sample_test.go +++ b/ribbon_sample_test.go @@ -18,6 +18,15 @@ func ico(name string) IconFunc { return SVGIcon(iconoir.Icon(name)) } +// painterOver is a pixel painter over buf (w by h, RGBA). +func painterOver(buf []byte, w, h int) painter.Painter { return painter.NewPixelPainter(buf, w, h) } + +// rtPixelAt reads the colour at (x, y) of an RGBA buffer w pixels wide. +func rtPixelAt(buf []byte, w, x, y int) RGBA { + o := (y*w + x) * 4 + return RGBA{R: buf[o], G: buf[o+1], B: buf[o+2], A: buf[o+3]} +} + // 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) }