io/key: [API] implement key event propagation

Before this change, every Event would be passed to the focused InputOp
tag, making it impossible to implement, say, program-wide shortcuts.
This change implements key.Event routing similar to how pointer.Events
are routed: every InputOp describes the set of keys it can handle, and
the router use that information to deliver an Event to the matching
handler.

This is an API change, because every InputOp must now include a filter
matching the keys it wants to handle.

Fixes: https://todo.sr.ht/~eliasnaur/gio/395
Signed-off-by: Elias Naur <mail@eliasnaur.com>
This commit is contained in:
Elias Naur
2022-04-14 12:18:00 +02:00
parent bec0283e54
commit 380f96b3fc
13 changed files with 266 additions and 46 deletions
+2 -2
View File
@@ -423,9 +423,9 @@ func (t OpType) Size() int {
func (t OpType) NumRefs() int {
switch t {
case TypeKeyInput, TypeKeyFocus, TypePointerInput, TypeProfile, TypeCall, TypeClipboardRead, TypeClipboardWrite, TypeSemanticLabel, TypeSemanticDesc, TypeSelection:
case TypeKeyFocus, TypePointerInput, TypeProfile, TypeCall, TypeClipboardRead, TypeClipboardWrite, TypeSemanticLabel, TypeSemanticDesc, TypeSelection:
return 1
case TypeImage, TypeSource, TypeTarget, TypeSnippet:
case TypeKeyInput, TypeImage, TypeSource, TypeTarget, TypeSnippet:
return 2
case TypeOffer:
return 3
+1 -1
View File
@@ -25,7 +25,7 @@ The following example declares a handler ready for key input:
ops := new(op.Ops)
var h *Handler = ...
key.InputOp{Tag: h}.Add(ops)
key.InputOp{Tag: h, Filter: ...}.Add(ops)
*/
package event
+110 -2
View File
@@ -25,10 +25,30 @@ import (
// Key events are in general only delivered to the
// focused key handler.
type InputOp struct {
Tag event.Tag
Tag event.Tag
// Hint describes the type of text expected by Tag.
Hint InputHint
// Keys is the set of keys Tag can handle. That is, Tag will only
// receive an Event if its key and modifiers are accepted by Keys.Contains.
Keys Set
}
// Set is an expression that describes a set of key combinations, in the form
// "<modifiers>-<keyset>|...". Modifiers are separated by dashes, optional
// modifiers are enclosed by parentheses. A key set is either a literal key
// name or a list of key names separated by commas and enclosed in brackets.
//
// The "Short" modifier matches the shortcut modifier (ModShortcut) and
// "ShortAlt" matches the alternative modifier (ModShortcutAlt).
//
// Examples:
//
// - A|B matches the A and B keys
// - [A,B] also matches the A and B keys
// - Shift-A matches A key if shift is pressed, and no other modifier.
// - Shift-(Ctrl)-A matches A if shift is pressed, and optionally ctrl.
type Set string
// SoftKeyboardOp shows or hide the on-screen keyboard, if available.
// It replaces any previous SoftKeyboardOp.
type SoftKeyboardOp struct {
@@ -207,11 +227,99 @@ func (m Modifiers) Contain(m2 Modifiers) bool {
return m&m2 == m2
}
func (k Set) Contains(name string, mods Modifiers) bool {
ks := string(k)
for len(ks) > 0 {
// Cut next key expression.
chord, rest, _ := cut(ks, "|")
ks = rest
// Separate key set and modifier set.
var modSet, keySet string
sep := strings.LastIndex(chord, "-")
if sep != -1 {
modSet, keySet = chord[:sep], chord[sep+1:]
} else {
modSet, keySet = "", chord
}
if !keySetContains(keySet, name) {
continue
}
if modSetContains(modSet, mods) {
return true
}
}
return false
}
func keySetContains(keySet, name string) bool {
// Check for single key match.
if keySet == name {
return true
}
// Check for set match.
if len(keySet) < 2 || keySet[0] != '[' || keySet[len(keySet)-1] != ']' {
return false
}
keySet = keySet[1 : len(keySet)-1]
for len(keySet) > 0 {
key, rest, _ := cut(keySet, ",")
keySet = rest
if key == name {
return true
}
}
return false
}
func modSetContains(modSet string, mods Modifiers) bool {
var smods Modifiers
for len(modSet) > 0 {
mod, rest, _ := cut(modSet, "-")
modSet = rest
if len(mod) >= 2 && mod[0] == '(' && mod[len(mod)-1] == ')' {
mods &^= modFor(mod[1 : len(mod)-1])
} else {
smods |= modFor(mod)
}
}
return mods == smods
}
// cut is a copy of the standard library strings.Cut.
// TODO: remove when Go 1.18 is our minimum.
func cut(s, sep string) (before, after string, found bool) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
func modFor(name string) Modifiers {
switch name {
case NameCtrl:
return ModCtrl
case NameShift:
return ModShift
case NameAlt:
return ModAlt
case NameSuper:
return ModSuper
case NameCommand:
return ModCommand
case "Short":
return ModShortcut
case "ShortAlt":
return ModShortcutAlt
}
return 0
}
func (h InputOp) Add(o *op.Ops) {
if h.Tag == nil {
panic("Tag must be non-nil")
}
data := ops.Write1(&o.Internal, ops.TypeKeyInputLen, h.Tag)
filter := h.Keys
data := ops.Write2(&o.Internal, ops.TypeKeyInputLen, h.Tag, &filter)
data[0] = byte(ops.TypeKeyInput)
data[1] = byte(h.Hint)
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: Unlicense OR MIT
package key
import (
"testing"
)
func TestKeySet(t *testing.T) {
const allMods = ModAlt | ModShift | ModSuper | ModCtrl | ModCommand
tests := []struct {
Set Set
Matches []Event
Mismatches []Event
}{
{"A", []Event{{Name: "A"}}, []Event{{Name: "A", Modifiers: ModShift}}},
{"[A,B,C]", []Event{{Name: "A"}, {Name: "B"}}, []Event{}},
{"Short-A", []Event{{Name: "A", Modifiers: ModShortcut}}, []Event{{Name: "A", Modifiers: ModShift}}},
{"(Ctrl)-A", []Event{{Name: "A", Modifiers: ModCtrl}, {Name: "A"}}, []Event{{Name: "A", Modifiers: ModShift}}},
{"Shift-[A,B,C]", []Event{{Name: "A", Modifiers: ModShift}}, []Event{{Name: "B", Modifiers: ModShift | ModCtrl}}},
{Set(allMods.String() + "-A"), []Event{{Name: "A", Modifiers: allMods}}, []Event{}},
}
for _, tst := range tests {
for _, e := range tst.Matches {
if !tst.Set.Contains(e.Name, e.Modifiers) {
t.Errorf("key set %q didn't contain %+v", tst.Set, e)
}
}
for _, e := range tst.Mismatches {
if tst.Set.Contains(e.Name, e.Modifiers) {
t.Errorf("key set %q contains %+v", tst.Set, e)
}
}
}
}
+6 -2
View File
@@ -5,6 +5,10 @@
package key
// ModShortcut is the platform's shortcut modifier, usually the Ctrl
// key. On Apple platforms it is the Cmd key.
// ModShortcut is the platform's shortcut modifier, usually the ctrl
// modifier. On Apple platforms it is the cmd key.
const ModShortcut = ModCtrl
// ModShortcutAlt is the platform's alternative shortcut modifier,
// usually the ctrl modifier. On Apple platforms it is the alt modifier.
const ModShortcutAlt = ModCtrl
+6 -2
View File
@@ -2,6 +2,10 @@
package key
// ModShortcut is the platform's shortcut modifier, usually the Ctrl
// key. On Apple platforms it is the Cmd key.
// ModShortcut is the platform's shortcut modifier, usually the ctrl
// modifier. On Apple platforms it is the cmd key.
const ModShortcut = ModCommand
// ModShortcut is the platform's alternative shortcut modifier,
// usually the ctrl modifier. On Apple platforms it is the alt modifier.
const ModShortcutAlt = ModAlt
+6 -6
View File
@@ -41,6 +41,7 @@ type keyHandler struct {
hint key.InputHint
order int
dirOrder int
filter key.Set
}
// keyCollector tracks state required to update a keyQueue
@@ -254,12 +255,6 @@ func (q *keyQueue) MoveFocus(dir FocusDirection, events *handlerEvents) bool {
return false
}
func (q *keyQueue) Push(e event.Event, events *handlerEvents) {
if q.focus != nil {
events.Add(q.focus, e)
}
}
func (q *keyQueue) BoundsFor(t event.Tag) image.Rectangle {
order := q.handlers[t].dirOrder
return q.dirOrder[order].bounds
@@ -270,6 +265,10 @@ func (q *keyQueue) AreaFor(t event.Tag) int {
return q.dirOrder[order].area
}
func (q *keyQueue) Accepts(t event.Tag, e key.Event) bool {
return q.handlers[t].filter.Contains(e.Name, e.Modifiers)
}
func (q *keyQueue) setFocus(focus event.Tag, events *handlerEvents) {
if focus != nil {
if _, exists := q.handlers[focus]; !exists {
@@ -323,6 +322,7 @@ func (k *keyCollector) inputOp(op key.InputOp, area int, bounds image.Rectangle)
h := k.handlerFor(op.Tag, area, bounds)
h.visible = true
h.hint = op.Hint
h.filter = op.Keys
}
func (k *keyCollector) selectionOp(t f32.Affine2D, op key.SelectionOp) {
+28 -3
View File
@@ -103,12 +103,12 @@ func TestKeyRemoveFocus(t *testing.T) {
r := new(Router)
// New InputOp with Focus and Keyboard:
key.InputOp{Tag: &handlers[0]}.Add(ops)
key.InputOp{Tag: &handlers[0], Keys: "Short-Tab"}.Add(ops)
key.FocusOp{Tag: &handlers[0]}.Add(ops)
key.SoftKeyboardOp{Show: true}.Add(ops)
// New InputOp without any focus:
key.InputOp{Tag: &handlers[1]}.Add(ops)
key.InputOp{Tag: &handlers[1], Keys: "Short-Tab"}.Add(ops)
r.Frame(ops)
@@ -320,6 +320,31 @@ func TestNoFocus(t *testing.T) {
r.MoveFocus(FocusForward)
}
func TestKeyRouting(t *testing.T) {
handlers := make([]int, 3)
ops := new(op.Ops)
r := new(Router)
rect := clip.Rect{Max: image.Pt(10, 10)}
key.InputOp{Tag: &handlers[0], Keys: "A"}.Add(ops)
cl1 := rect.Push(ops)
key.InputOp{Tag: &handlers[1], Keys: "B"}.Add(ops)
key.InputOp{Tag: &handlers[2], Keys: "A"}.Add(ops)
cl1.Pop()
key.FocusOp{Tag: &handlers[2]}.Add(ops)
r.Frame(ops)
A, B := key.Event{Name: "A"}, key.Event{Name: "B"}
r.Queue(A, B)
assertKeyEvent(t, r.Events(&handlers[2]), true, A)
assertKeyEvent(t, r.Events(&handlers[1]), false, B)
assertKeyEvent(t, r.Events(&handlers[0]), false)
}
func assertKeyEvent(t *testing.T, events []event.Event, expected bool, expectedInputs ...event.Event) {
t.Helper()
var evtFocus int
@@ -333,7 +358,7 @@ func assertKeyEvent(t *testing.T, events []event.Event, expected bool, expectedI
evtFocus++
case key.Event, key.EditEvent:
if len(expectedInputs) <= evtKeyPress {
t.Errorf("unexpected key events")
t.Fatalf("unexpected key events")
}
if !reflect.DeepEqual(ev, expectedInputs[evtKeyPress]) {
t.Errorf("expected %v events, got %v", expectedInputs[evtKeyPress], ev)
+24
View File
@@ -9,6 +9,7 @@ import (
"gioui.org/f32"
"gioui.org/internal/ops"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/semantic"
"gioui.org/io/transfer"
@@ -40,6 +41,7 @@ type hitNode struct {
// For handler nodes.
tag event.Tag
ktag event.Tag
pass bool
}
@@ -258,6 +260,15 @@ func (c *pointerCollector) newHandler(tag event.Tag, events *handlerEvents) *poi
return h
}
func (c *pointerCollector) keyInputOp(op key.InputOp) {
areaID := c.currentArea()
c.addHitNode(hitNode{
area: areaID,
ktag: op.Tag,
pass: true,
})
}
func (c *pointerCollector) inputOp(op pointer.InputOp, events *handlerEvents) {
areaID := c.currentArea()
area := &c.q.areas[areaID]
@@ -636,6 +647,19 @@ func (q *pointerQueue) Deliver(areaIdx int, e pointer.Event, events *handlerEven
}
}
// SemanticArea returns the sematic content for area, and its parent area.
func (q *pointerQueue) SemanticArea(areaIdx int) (semanticContent, int) {
for areaIdx != -1 {
a := &q.areas[areaIdx]
areaIdx = a.parent
if !a.semantic.valid {
continue
}
return a.semantic.content, areaIdx
}
return semanticContent{}, -1
}
func (q *pointerQueue) Push(e pointer.Event, events *handlerEvents) {
if e.Type == pointer.Cancel {
q.pointers = q.pointers[:0]
+39 -2
View File
@@ -140,8 +140,42 @@ func (q *Router) Queue(events ...event.Event) bool {
q.profile = e
case pointer.Event:
q.pointer.queue.Push(e, &q.handlers)
case key.EditEvent, key.Event, key.FocusEvent, key.SnippetEvent, key.SelectionEvent:
q.key.queue.Push(e, &q.handlers)
case key.Event:
f := q.key.queue.focus
if f == nil {
break
}
kq := &q.key.queue
if kq.Accepts(f, e) {
q.handlers.Add(f, e)
break
}
a := kq.AreaFor(f)
pq := &q.pointer.queue
idx := len(pq.hitTree) - 1
// Locate first potential receiver.
for idx != -1 {
n := &pq.hitTree[idx]
if n.area == a {
break
}
idx--
}
for idx != -1 {
n := &pq.hitTree[idx]
idx = n.next
if n.ktag == nil {
continue
}
if n.ktag != nil && kq.Accepts(n.ktag, e) {
q.handlers.Add(n.ktag, e)
break
}
}
case key.EditEvent, key.FocusEvent, key.SnippetEvent, key.SelectionEvent:
if f := q.key.queue.focus; f != nil {
q.handlers.Add(f, e)
}
case clipboard.Event:
q.cqueue.Push(e, &q.handlers)
}
@@ -398,12 +432,15 @@ func (q *Router) collect() {
}
kc.softKeyboard(op.Show)
case ops.TypeKeyInput:
filter := encOp.Refs[1].(*key.Set)
op := key.InputOp{
Tag: encOp.Refs[0].(event.Tag),
Hint: key.InputHint(encOp.Data[1]),
Keys: *filter,
}
a := pc.currentArea()
b := pc.currentAreaBounds()
pc.keyInputOp(op)
kc.inputOp(op, a, b)
case ops.TypeSnippet:
op := key.SnippetOp{
+1 -1
View File
@@ -111,7 +111,7 @@ func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimension
semantic.DisabledOp(disabled).Add(gtx.Ops)
b.click.Add(gtx.Ops)
if !disabled {
key.InputOp{Tag: &b.keyTag}.Add(gtx.Ops)
key.InputOp{Tag: &b.keyTag, Keys: "⏎|Space"}.Add(gtx.Ops)
} else {
b.focused = false
}
+7 -24
View File
@@ -8,7 +8,6 @@ import (
"image"
"io"
"math"
"runtime"
"sort"
"strings"
"time"
@@ -364,10 +363,9 @@ func (e *Editor) processKey(gtx layout.Context) {
continue
}
}
if e.command(gtx, ke) {
e.caret.scroll = true
e.scroller.Stop()
}
e.command(gtx, ke)
e.caret.scroll = true
e.scroller.Stop()
case key.SnippetEvent:
e.updateSnippet(gtx, ke.Start, ke.End)
case key.EditEvent:
@@ -403,16 +401,12 @@ func (e *Editor) moveLines(distance int, selAct selectionAction) {
e.updateSelection(selAct)
}
func (e *Editor) command(gtx layout.Context, k key.Event) bool {
func (e *Editor) command(gtx layout.Context, k key.Event) {
direction := 1
if e.locale.Direction.Progression() == system.TowardOrigin {
direction = -1
}
modSkip := key.ModCtrl
if runtime.GOOS == "darwin" {
modSkip = key.ModAlt
}
moveByWord := k.Modifiers.Contain(modSkip)
moveByWord := k.Modifiers.Contain(key.ModShortcutAlt)
selAct := selectionClear
if k.Modifiers.Contain(key.ModShift) {
selAct = selectionExtend
@@ -465,15 +459,9 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool {
// Initiate a paste operation, by requesting the clipboard contents; other
// half is in Editor.processKey() under clipboard.Event.
case "V":
if k.Modifiers != key.ModShortcut {
return false
}
clipboard.ReadOp{Tag: &e.eventKey}.Add(gtx.Ops)
// Copy or Cut selection -- ignored if nothing selected.
case "C", "X":
if k.Modifiers != key.ModShortcut {
return false
}
if text := e.SelectedText(); text != "" {
clipboard.WriteOp{Text: text}.Add(gtx.Ops)
if k.Name == "X" {
@@ -482,15 +470,9 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool {
}
// Select all
case "A":
if k.Modifiers != key.ModShortcut {
return false
}
e.caret.end = 0
e.caret.start = e.Len()
default:
return false
}
return true
}
// Focus requests the input focus for the Editor.
@@ -625,7 +607,8 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens
defer clip.Rect(image.Rectangle{Max: e.viewSize}).Push(gtx.Ops).Pop()
pointer.CursorText.Add(gtx.Ops)
key.InputOp{Tag: &e.eventKey, Hint: e.InputHint}.Add(gtx.Ops)
const keyFilter = "(Short)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(Short)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]"
key.InputOp{Tag: &e.eventKey, Hint: e.InputHint, Keys: keyFilter}.Add(gtx.Ops)
if e.requestFocus {
key.FocusOp{Tag: &e.eventKey}.Add(gtx.Ops)
key.SoftKeyboardOp{Show: true}.Add(gtx.Ops)
+1 -1
View File
@@ -120,7 +120,7 @@ func (e *Enum) Layout(gtx layout.Context, k string, content layout.Widget) layou
clk.Add(gtx.Ops)
disabled := gtx.Queue == nil
if !disabled {
key.InputOp{Tag: &state.tag}.Add(gtx.Ops)
key.InputOp{Tag: &state.tag, Keys: "⏎|Space"}.Add(gtx.Ops)
} else if e.focus == k {
e.focused = false
}