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
9 changes: 6 additions & 3 deletions ribbon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions ribbon_draw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion ribbon_draw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions ribbon_gallery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()})
Expand Down
68 changes: 59 additions & 9 deletions ribbon_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 101 additions & 11 deletions ribbon_input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion ribbon_misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions ribbon_sample_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
Loading