Repository: rockorager/vaxis
Pull request #19: vxfw: separate out HandleEvent into an optional interface
Author: login: avidal
Created: 2025-05-06T21:20:16Z | Merged: 2025-05-08T13:54:32Z

Lots of custom widgets won't need to handle any events, so let's make it optional.

Since `vxfw.App` already handles capturing towards the focus and bubbling up from the focus, it's simple enough to treat `HandleEvent` the same as `CaptureEvent`, although in the former case it's via a `tryHandleEvent` since it's called much more frequently.


Diff:
diff --git a/vxfw/center/center.go b/vxfw/center/center.go
index 372120a..25e2c11 100644
--- a/vxfw/center/center.go
+++ b/vxfw/center/center.go
@@ -1,7 +1,6 @@
 package center
 
 import (
-	"git.sr.ht/~rockorager/vaxis"
 	"git.sr.ht/~rockorager/vaxis/vxfw"
 )
 
@@ -10,10 +9,6 @@ type Center struct {
 	Child vxfw.Widget
 }
 
-func (c *Center) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) {
-	return nil, nil
-}
-
 func (c *Center) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
 	if ctx.Max.HasUnboundedHeight() || ctx.Max.HasUnboundedWidth() {
 		panic("Center must have bounded constraints")
diff --git a/vxfw/richtext/richtext.go b/vxfw/richtext/richtext.go
index af18b20..c08e43b 100644
--- a/vxfw/richtext/richtext.go
+++ b/vxfw/richtext/richtext.go
@@ -24,11 +24,6 @@ func New(segments []vaxis.Segment) *RichText {
 	}
 }
 
-// Noop for text
-func (t *RichText) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
-	return nil, nil
-}
-
 func (t *RichText) cells(ctx vxfw.DrawContext) []vaxis.Cell {
 	cells := []vaxis.Cell{}
 	for _, seg := range t.Content {
diff --git a/vxfw/text/text.go b/vxfw/text/text.go
index 61ce7ad..9fd4cd8 100644
--- a/vxfw/text/text.go
+++ b/vxfw/text/text.go
@@ -30,11 +30,6 @@ func New(content string) *Text {
 	}
 }
 
-// Noop for text
-func (t *Text) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
-	return nil, nil
-}
-
 func (t *Text) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
 	if t.Softwrap {
 		return t.drawSoftwrap(ctx)
diff --git a/vxfw/vxfw.go b/vxfw/vxfw.go
index ecd5a07..0dd9b49 100644
--- a/vxfw/vxfw.go
+++ b/vxfw/vxfw.go
@@ -11,7 +11,6 @@ import (
 )
 
 type Widget interface {
-	HandleEvent(vaxis.Event, EventPhase) (Command, error)
 	Draw(DrawContext) (Surface, error)
 }
 
@@ -22,6 +21,12 @@ type EventCapturer interface {
 	CaptureEvent(vaxis.Event) (Command, error)
 }
 
+// EventHandler is a Widget which can handle events. It's a separate interface to simplify creating
+// custom [Widget]s that do not require event handling.
+type EventHandler interface {
+	HandleEvent(vaxis.Event, EventPhase) (Command, error)
+}
+
 type Event interface{}
 
 type (
@@ -275,7 +280,7 @@ func (f *focusHandler) handleEvent(app *App, ev vaxis.Event) error {
 	}
 
 	// Target phase
-	cmd, err := f.focused.HandleEvent(ev, TargetPhase)
+	cmd, err := tryHandleEvent(f.focused, ev, TargetPhase)
 	if err != nil {
 		return err
 	}
@@ -285,11 +290,11 @@ func (f *focusHandler) handleEvent(app *App, ev vaxis.Event) error {
 		return nil
 	}
 
-	// Bubble phase. We don't bubble to the focused widget (which is the
-	// last one in the list). Hence, - 2
+	// Bubble phase. We don't bubble to the focused widget (which is the last one in the list).
+	// Hence, - 2
 	for i := len(f.path) - 2; i >= 0; i -= 1 {
 		w := f.path[i]
-		cmd, err := w.HandleEvent(ev, BubblePhase)
+		cmd, err = tryHandleEvent(w, ev, BubblePhase)
 		if err != nil {
 			return err
 		}
@@ -351,16 +356,17 @@ func (f *focusHandler) focusWidget(app *App, w Widget) error {
 		return nil
 	}
 
-	cmd, err := f.focused.HandleEvent(vaxis.FocusOut{}, TargetPhase)
+	cmd, err := tryHandleEvent(f.focused, vaxis.FocusOut{}, TargetPhase)
 	if err != nil {
 		return err
 	}
 	app.handleCommand(cmd)
+
 	// Change the focused widget before we send the focus in event. If the
 	// newly focused widget changes focus again, we need to set this before
 	// the handleCommand call
 	f.focused = w
-	cmd, err = w.HandleEvent(vaxis.FocusIn{}, TargetPhase)
+	cmd, err = tryHandleEvent(w, vaxis.FocusIn{}, TargetPhase)
 	if err != nil {
 		return err
 	}
@@ -369,6 +375,18 @@ func (f *focusHandler) focusWidget(app *App, w Widget) error {
 	return nil
 }
 
+// tryHandleEvent calls HandleEvent on w, if w is an [EventHandler]
+// If w is not an EventHandler, tryHandleEvent returns nil, nil.
+// Otherwise, tryHandleEvent returns the [Command] and error from HandleEvent.
+func tryHandleEvent(w Widget, event vaxis.Event, phase EventPhase) (Command, error) {
+	eh, ok := w.(EventHandler)
+	if !ok {
+		return nil, nil
+	}
+
+	return eh.HandleEvent(event, phase)
+}
+
 type App struct {
 	vx *vaxis.Vaxis
 
@@ -446,7 +464,7 @@ func (a *App) Run(w Widget) error {
 					return err
 				}
 			case vaxis.FocusIn:
-				cmd, err := w.HandleEvent(MouseEnter{}, TargetPhase)
+				cmd, err := tryHandleEvent(w, MouseEnter{}, TargetPhase)
 				if err != nil {
 					return err
 				}
@@ -652,7 +670,7 @@ func (m *mouseHandler) handleEvent(app *App, ev vaxis.Mouse) error {
 	target := m.lastHits[len(m.lastHits)-1]
 
 	// Target phase
-	cmd, err := target.w.HandleEvent(ev, TargetPhase)
+	cmd, err := tryHandleEvent(target.w, ev, TargetPhase)
 	if err != nil {
 		return err
 	}
@@ -666,7 +684,7 @@ func (m *mouseHandler) handleEvent(app *App, ev vaxis.Mouse) error {
 	// last one in the list). Hence, - 2
 	for i := len(m.lastHits) - 2; i >= 0; i -= 1 {
 		h := m.lastHits[i]
-		cmd, err := h.w.HandleEvent(ev, BubblePhase)
+		cmd, err := tryHandleEvent(h.w, ev, BubblePhase)
 		if err != nil {
 			return err
 		}
@@ -707,7 +725,7 @@ outer_exit:
 		}
 		// h1 was not found in the new hitlist send it a mouse leave
 		// event
-		cmd, err := h1.w.HandleEvent(MouseLeave{}, TargetPhase)
+		cmd, err := tryHandleEvent(h1.w, MouseLeave{}, TargetPhase)
 		if err != nil {
 			return err
 		}
@@ -725,7 +743,7 @@ outer_enter:
 		}
 		// h1 was not found in the old hitlist send it a mouse enter
 		// event
-		cmd, err := h1.w.HandleEvent(MouseEnter{}, TargetPhase)
+		cmd, err := tryHandleEvent(h1.w, MouseEnter{}, TargetPhase)
 		if err != nil {
 			return err
 		}
@@ -741,7 +759,7 @@ outer_enter:
 // mouseExit send a mouseLeave event to each widget in the last hit list
 func (m *mouseHandler) mouseExit(app *App) error {
 	for _, h := range m.lastHits {
-		cmd, err := h.w.HandleEvent(MouseLeave{}, TargetPhase)
+		cmd, err := tryHandleEvent(h.w, MouseLeave{}, TargetPhase)
 		if err != nil {
 			return err
 		}


Reviews:
user: login: rockorager
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-07T16:53:03Z
user: login: avidal
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T17:15:32Z
user: login: avidal
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T17:28:49Z

Review comments:
diff_hunk: @@ -369,6 +375,19 @@ func (f *focusHandler) focusWidget(app *App, w Widget) error {
 	return nil
 }
 
+// tryHandleEvent calls HandleEvent on w, if w is an EventHandler
+// It returns true if w is an EventHandler, along with the command and error resulting from
+// HandleEvent, if any.
+func tryHandleEvent(w Widget, event vaxis.Event, phase EventPhase) (bool, Command, error) {
path: vxfw/vxfw.go
user: login: rockorager
body: It doesn't look like the `bool` returned is used anywhere, do you have plans for it or could we remove it from the return signature?
created_at: 2025-05-07T16:53:02Z
author_association: OWNER
diff_hunk: @@ -369,6 +375,19 @@ func (f *focusHandler) focusWidget(app *App, w Widget) error {
 	return nil
 }
 
+// tryHandleEvent calls HandleEvent on w, if w is an EventHandler
+// It returns true if w is an EventHandler, along with the command and error resulting from
+// HandleEvent, if any.
+func tryHandleEvent(w Widget, event vaxis.Event, phase EventPhase) (bool, Command, error) {
path: vxfw/vxfw.go
user: login: avidal
body: I originally put it in just in case app.handleCommand or the code around capturing needed to disambiguate between whether or not the widget handled the event. After I submitted the patch though I realized that the default implementation of HandleEvent just returns nil, nil and everything already worked....so I suppose we could drop the bool return.
created_at: 2025-05-07T17:15:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -369,6 +375,19 @@ func (f *focusHandler) focusWidget(app *App, w Widget) error {
 	return nil
 }
 
+// tryHandleEvent calls HandleEvent on w, if w is an EventHandler
+// It returns true if w is an EventHandler, along with the command and error resulting from
+// HandleEvent, if any.
+func tryHandleEvent(w Widget, event vaxis.Event, phase EventPhase) (bool, Command, error) {
path: vxfw/vxfw.go
user: login: avidal
body: I'm going to take out the boolean return value and force push. brb.
created_at: 2025-05-07T17:28:49Z
author_association: CONTRIBUTOR

PR comments:
user: login: sewnie
created_at: 2025-05-07T17:30:39Z
body: Are there any performance implications to this?
author_association: CONTRIBUTOR
user: login: rockorager
created_at: 2025-05-07T17:39:04Z
body: > Are there any performance implications to this?

Technically, yes because we have one additional interface type check. In practice, this will be so small compared to the render function that it will never show up in a perf report.
author_association: OWNER
user: login: avidal
created_at: 2025-05-07T17:40:06Z
body: > Are there any performance implications to this?

Not of any significance. See [this benchmark](https://gist.github.com/ww9/7e6f9fb8d3917114f3088abfe2681683) for comparisons.
author_association: CONTRIBUTOR
user: login: avidal
created_at: 2025-05-07T17:53:32Z
body: fwiw, i did a follow up benchmark that's specific to this change.

The code is at the bottom of this post. The benchmark results are:

```
goos: linux
goarch: amd64
pkg: typeassert
cpu: AMD Ryzen 7 7840U w/ Radeon  780M Graphics     
BenchmarkHandlingWidget-16        	1000000000	         0.4335 ns/op
BenchmarkTryHandleNoHandler-16    	536917526	         2.123 ns/op
BenchmarkTryHandleHandler-16      	753533648	         1.586 ns/op
PASS
ok  	typeassert	3.216s
```

The original implementation is analogous to BenchmarkHandlingWidget and the new version is a mix of the other two (depending on if the widget implements `EventHandler`). So while it's technically 4x slower at worst this is still 500 million operations in 3s.

```go
package main

import "testing"

type Widget interface {
	Draw() error
}

type EventHandler interface {
	HandleEvent() error
}

type HandlingWidget interface {
	Widget
	EventHandler
}

type W1 struct{}

func (w *W1) Draw() error        { return nil }
func (w *W1) HandleEvent() error { return nil }

type W2 struct{}

func (w *W2) Draw() error { return nil }

func BenchmarkHandlingWidget(b *testing.B) {
	w := &W1{}

	handleWidget(w, b.N)
}

func BenchmarkTryHandleNoHandler(b *testing.B) {
	w := &W2{}

	tryHandleWidget(w, b.N)
}

func BenchmarkTryHandleHandler(b *testing.B) {
	w := &W1{}
	tryHandleWidget(w, b.N)
}

func handleWidget(w HandlingWidget, n int) {
	for range n {
		w.HandleEvent()
	}
}

func tryHandleWidget(w Widget, n int) {
	for range n {
		if eh, ok := w.(EventHandler); ok {
			eh.HandleEvent()
		}
	}
}
```
author_association: CONTRIBUTOR
user: login: rockorager
created_at: 2025-05-08T13:54:37Z
body: Thanks!
author_association: OWNER