diff --git a/internal/ops/ops.go b/internal/ops/ops.go index fc845e58..fc181282 100644 --- a/internal/ops/ops.go +++ b/internal/ops/ops.go @@ -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 diff --git a/io/event/event.go b/io/event/event.go index 08bfbd66..502275e2 100644 --- a/io/event/event.go +++ b/io/event/event.go @@ -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 diff --git a/io/key/key.go b/io/key/key.go index ceaea426..6c9c8e91 100644 --- a/io/key/key.go +++ b/io/key/key.go @@ -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 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) } diff --git a/io/key/key_test.go b/io/key/key_test.go new file mode 100644 index 00000000..aa01c40f --- /dev/null +++ b/io/key/key_test.go @@ -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) + } + } + } +} diff --git a/io/key/mod.go b/io/key/mod.go index 4b23d32c..3cf7c2d3 100644 --- a/io/key/mod.go +++ b/io/key/mod.go @@ -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 diff --git a/io/key/mod_darwin.go b/io/key/mod_darwin.go index c0f14377..929b3928 100644 --- a/io/key/mod_darwin.go +++ b/io/key/mod_darwin.go @@ -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 diff --git a/io/router/key.go b/io/router/key.go index 18f6bf27..add86f81 100644 --- a/io/router/key.go +++ b/io/router/key.go @@ -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) { diff --git a/io/router/key_test.go b/io/router/key_test.go index c99040df..d9d4b39d 100644 --- a/io/router/key_test.go +++ b/io/router/key_test.go @@ -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) diff --git a/io/router/pointer.go b/io/router/pointer.go index e5123ee4..71e0a48c 100644 --- a/io/router/pointer.go +++ b/io/router/pointer.go @@ -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] diff --git a/io/router/router.go b/io/router/router.go index 3e01ff0d..a4072f16 100644 --- a/io/router/router.go +++ b/io/router/router.go @@ -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{ diff --git a/widget/button.go b/widget/button.go index ac4ff9db..0980b137 100644 --- a/widget/button.go +++ b/widget/button.go @@ -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 } diff --git a/widget/editor.go b/widget/editor.go index ff3b1d19..8fda117b 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -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) diff --git a/widget/enum.go b/widget/enum.go index e1e09cc9..2a1343e8 100644 --- a/widget/enum.go +++ b/widget/enum.go @@ -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 }