forked from joejulian/gio
io: [API] introduce event filters; convert pointer input to use them
Instead of having to supply the predicates for event filtering at the
time of layout, the new Filter type allows widgets to filter at the time
of calling Source.Events. There is then only the need for a single input
op type, in package event.
Filters most importantly allow the use of one tag for several event types,
and we can define that a widget w has &w as its primary tag, by convention.
This allows the replacement of per-widget Focus methods with direct uses
of FocusCmd{&w}, and the later addition of Source.Focused(&w) queries.
Note that the TestCursor test needed restructuring to avoid its use of
InputOps.
Signed-off-by: Elias Naur <mail@eliasnaur.com>
This commit is contained in:
+20
-34
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/internal/fling"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
@@ -37,15 +38,12 @@ type Hover struct {
|
||||
|
||||
// Add the gesture to detect hovering over the current pointer area.
|
||||
func (h *Hover) Add(ops *op.Ops) {
|
||||
pointer.InputOp{
|
||||
Tag: h,
|
||||
Kinds: pointer.Enter | pointer.Leave,
|
||||
}.Add(ops)
|
||||
event.InputOp(ops, h)
|
||||
}
|
||||
|
||||
// Update state and report whether a pointer is inside the area.
|
||||
func (h *Hover) Update(q input.Source) bool {
|
||||
for _, ev := range q.Events(h) {
|
||||
for _, ev := range q.Events(h, pointer.Filter{Kinds: pointer.Enter | pointer.Leave}) {
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -114,7 +112,6 @@ type Drag struct {
|
||||
// movements as well as drag and fling touch gestures.
|
||||
type Scroll struct {
|
||||
dragging bool
|
||||
axis Axis
|
||||
estimator fling.Extrapolation
|
||||
flinger fling.Animation
|
||||
pid pointer.ID
|
||||
@@ -159,10 +156,7 @@ const touchSlop = unit.Dp(3)
|
||||
|
||||
// Add the handler to the operation list to receive click events.
|
||||
func (c *Click) Add(ops *op.Ops) {
|
||||
pointer.InputOp{
|
||||
Tag: c,
|
||||
Kinds: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave,
|
||||
}.Add(ops)
|
||||
event.InputOp(ops, c)
|
||||
}
|
||||
|
||||
// Hovered returns whether a pointer is inside the area.
|
||||
@@ -178,7 +172,7 @@ func (c *Click) Pressed() bool {
|
||||
// Update state and return the click events.
|
||||
func (c *Click) Update(q input.Source) []ClickEvent {
|
||||
var events []ClickEvent
|
||||
for _, evt := range q.Events(c) {
|
||||
for _, evt := range q.Events(c, pointer.Filter{Kinds: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave}) {
|
||||
e, ok := evt.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -248,13 +242,8 @@ func (ClickEvent) ImplementsEvent() {}
|
||||
// Add the handler to the operation list to receive scroll events.
|
||||
// The bounds variable refers to the scrolling boundaries
|
||||
// as defined in io/pointer.InputOp.
|
||||
func (s *Scroll) Add(ops *op.Ops, bounds image.Rectangle) {
|
||||
oph := pointer.InputOp{
|
||||
Tag: s,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Scroll,
|
||||
ScrollBounds: bounds,
|
||||
}
|
||||
oph.Add(ops)
|
||||
func (s *Scroll) Add(ops *op.Ops) {
|
||||
event.InputOp(ops, s)
|
||||
if s.flinger.Active() {
|
||||
op.InvalidateOp{}.Add(ops)
|
||||
}
|
||||
@@ -266,13 +255,13 @@ func (s *Scroll) Stop() {
|
||||
}
|
||||
|
||||
// Update state and report the scroll distance along axis.
|
||||
func (s *Scroll) Update(cfg unit.Metric, q input.Source, t time.Time, axis Axis) int {
|
||||
if s.axis != axis {
|
||||
s.axis = axis
|
||||
return 0
|
||||
}
|
||||
func (s *Scroll) Update(cfg unit.Metric, q input.Source, t time.Time, axis Axis, bounds image.Rectangle) int {
|
||||
total := 0
|
||||
for _, evt := range q.Events(s) {
|
||||
f := pointer.Filter{
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Scroll,
|
||||
ScrollBounds: bounds,
|
||||
}
|
||||
for _, evt := range q.Events(s, f) {
|
||||
e, ok := evt.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -289,7 +278,7 @@ func (s *Scroll) Update(cfg unit.Metric, q input.Source, t time.Time, axis Axis)
|
||||
}
|
||||
s.Stop()
|
||||
s.estimator = fling.Extrapolation{}
|
||||
v := s.val(e.Position)
|
||||
v := s.val(axis, e.Position)
|
||||
s.last = int(math.Round(float64(v)))
|
||||
s.estimator.Sample(e.Time, v)
|
||||
s.dragging = true
|
||||
@@ -306,7 +295,7 @@ func (s *Scroll) Update(cfg unit.Metric, q input.Source, t time.Time, axis Axis)
|
||||
case pointer.Cancel:
|
||||
s.dragging = false
|
||||
case pointer.Scroll:
|
||||
switch s.axis {
|
||||
switch axis {
|
||||
case Horizontal:
|
||||
s.scroll += e.Scroll.X
|
||||
case Vertical:
|
||||
@@ -319,7 +308,7 @@ func (s *Scroll) Update(cfg unit.Metric, q input.Source, t time.Time, axis Axis)
|
||||
if !s.dragging || s.pid != e.PointerID {
|
||||
continue
|
||||
}
|
||||
val := s.val(e.Position)
|
||||
val := s.val(axis, e.Position)
|
||||
s.estimator.Sample(e.Time, val)
|
||||
v := int(math.Round(float64(val)))
|
||||
dist := s.last - v
|
||||
@@ -338,8 +327,8 @@ func (s *Scroll) Update(cfg unit.Metric, q input.Source, t time.Time, axis Axis)
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *Scroll) val(p f32.Point) float32 {
|
||||
if s.axis == Horizontal {
|
||||
func (s *Scroll) val(axis Axis, p f32.Point) float32 {
|
||||
if axis == Horizontal {
|
||||
return p.X
|
||||
} else {
|
||||
return p.Y
|
||||
@@ -360,16 +349,13 @@ func (s *Scroll) State() ScrollState {
|
||||
|
||||
// Add the handler to the operation list to receive drag events.
|
||||
func (d *Drag) Add(ops *op.Ops) {
|
||||
pointer.InputOp{
|
||||
Tag: d,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release,
|
||||
}.Add(ops)
|
||||
event.InputOp(ops, d)
|
||||
}
|
||||
|
||||
// Update state and return the drag events.
|
||||
func (d *Drag) Update(cfg unit.Metric, q input.Source, axis Axis) []pointer.Event {
|
||||
var events []pointer.Event
|
||||
for _, e := range q.Events(d) {
|
||||
for _, e := range q.Events(d, pointer.Filter{Kinds: pointer.Press | pointer.Drag | pointer.Release}) {
|
||||
e, ok := e.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestHover(t *testing.T) {
|
||||
h.Add(ops)
|
||||
stack.Pop()
|
||||
r := new(input.Router)
|
||||
h.Update(r.Source())
|
||||
r.Frame(ops)
|
||||
|
||||
r.Queue(
|
||||
@@ -72,6 +73,7 @@ func TestMouseClicks(t *testing.T) {
|
||||
click.Add(&ops)
|
||||
|
||||
var r input.Router
|
||||
click.Update(r.Source())
|
||||
r.Frame(&ops)
|
||||
r.Queue(tc.events...)
|
||||
|
||||
|
||||
+5
-15
@@ -62,9 +62,7 @@ const (
|
||||
TypeLinearGradient
|
||||
TypePass
|
||||
TypePopPass
|
||||
TypePointerInput
|
||||
TypeSource
|
||||
TypeTarget
|
||||
TypeInput
|
||||
TypeKeyInput
|
||||
TypeSave
|
||||
TypeLoad
|
||||
@@ -140,9 +138,7 @@ const (
|
||||
TypeLinearGradientLen = 1 + 8*2 + 4*2
|
||||
TypePassLen = 1
|
||||
TypePopPassLen = 1
|
||||
TypePointerInputLen = 1 + 1*2 + 2*4 + 2*4
|
||||
TypeSourceLen = 1
|
||||
TypeTargetLen = 1
|
||||
TypeInputLen = 1
|
||||
TypeKeyInputLen = 1 + 1
|
||||
TypeSaveLen = 1 + 4
|
||||
TypeLoadLen = 1 + 4
|
||||
@@ -416,9 +412,7 @@ var opProps = [0x100]opProp{
|
||||
TypeLinearGradient: {Size: TypeLinearGradientLen, NumRefs: 0},
|
||||
TypePass: {Size: TypePassLen, NumRefs: 0},
|
||||
TypePopPass: {Size: TypePopPassLen, NumRefs: 0},
|
||||
TypePointerInput: {Size: TypePointerInputLen, NumRefs: 1},
|
||||
TypeSource: {Size: TypeSourceLen, NumRefs: 2},
|
||||
TypeTarget: {Size: TypeTargetLen, NumRefs: 2},
|
||||
TypeInput: {Size: TypeInputLen, NumRefs: 1},
|
||||
TypeKeyInput: {Size: TypeKeyInputLen, NumRefs: 2},
|
||||
TypeSave: {Size: TypeSaveLen, NumRefs: 0},
|
||||
TypeLoad: {Size: TypeLoadLen, NumRefs: 0},
|
||||
@@ -479,12 +473,8 @@ func (t OpType) String() string {
|
||||
return "Pass"
|
||||
case TypePopPass:
|
||||
return "PopPass"
|
||||
case TypePointerInput:
|
||||
return "PointerInput"
|
||||
case TypeSource:
|
||||
return "Source"
|
||||
case TypeTarget:
|
||||
return "Target"
|
||||
case TypeInput:
|
||||
return "Input"
|
||||
case TypeKeyInput:
|
||||
return "KeyInput"
|
||||
case TypeSave:
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// Package event contains types for event handling.
|
||||
package event
|
||||
|
||||
import (
|
||||
"gioui.org/internal/ops"
|
||||
"gioui.org/op"
|
||||
)
|
||||
|
||||
// Tag is the stable identifier for an event handler.
|
||||
// For a handler h, the tag is typically &h.
|
||||
type Tag interface{}
|
||||
@@ -11,3 +16,18 @@ type Tag interface{}
|
||||
type Event interface {
|
||||
ImplementsEvent()
|
||||
}
|
||||
|
||||
// Filter represents a filter for [Event] types.
|
||||
type Filter interface {
|
||||
ImplementsFilter()
|
||||
}
|
||||
|
||||
// InputOp declares a tag for input routing at the current transformation
|
||||
// and clip area hierarchy. It panics if tag is nil.
|
||||
func InputOp(o *op.Ops, tag Tag) {
|
||||
if tag == nil {
|
||||
panic("Tag must be non-nil")
|
||||
}
|
||||
data := ops.Write1(&o.Internal, ops.TypeInputLen, tag)
|
||||
data[0] = byte(ops.TypeInput)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestClipboardDuplicateEvent(t *testing.T) {
|
||||
}
|
||||
router.Queue(event)
|
||||
assertClipboardReadCmd(t, router, 0)
|
||||
assertClipboardEvent(t, router.Events(&handler[0]), true)
|
||||
assertClipboardEvent(t, router.Events(&handler[1]), true)
|
||||
assertClipboardEvent(t, router.Events(&handler[0], transfer.TargetFilter{Type: "application/text"}), true)
|
||||
assertClipboardEvent(t, router.Events(&handler[1], transfer.TargetFilter{Type: "application/text"}), true)
|
||||
ops.Reset()
|
||||
|
||||
// No ReadCmd
|
||||
@@ -81,7 +81,7 @@ func TestQueueProcessReadClipboard(t *testing.T) {
|
||||
}
|
||||
router.Queue(event)
|
||||
assertClipboardReadCmd(t, router, 0)
|
||||
assertClipboardEvent(t, router.Events(&handler[0]), true)
|
||||
assertClipboardEvent(t, router.Events(&handler[0], transfer.TargetFilter{Type: "application/text"}), true)
|
||||
ops.Reset()
|
||||
|
||||
// No ReadCmd
|
||||
|
||||
+15
-14
@@ -269,25 +269,24 @@ func TestFocusScroll(t *testing.T) {
|
||||
r := new(Router)
|
||||
h := new(int)
|
||||
|
||||
f := pointer.Filter{
|
||||
Kinds: pointer.Scroll,
|
||||
ScrollBounds: image.Rect(-100, -100, 100, 100),
|
||||
}
|
||||
r.Events(h, f)
|
||||
parent := clip.Rect(image.Rect(1, 1, 14, 39)).Push(ops)
|
||||
cl := clip.Rect(image.Rect(10, -20, 20, 30)).Push(ops)
|
||||
key.InputOp{Tag: h}.Add(ops)
|
||||
pointer.InputOp{
|
||||
Tag: h,
|
||||
Kinds: pointer.Scroll,
|
||||
ScrollBounds: image.Rect(-100, -100, 100, 100),
|
||||
}.Add(ops)
|
||||
event.InputOp(ops, h)
|
||||
// Test that h is scrolled even if behind another handler.
|
||||
pointer.InputOp{
|
||||
Tag: new(int),
|
||||
}.Add(ops)
|
||||
event.InputOp(ops, new(int))
|
||||
cl.Pop()
|
||||
parent.Pop()
|
||||
r.Frame(ops)
|
||||
|
||||
r.MoveFocus(key.FocusLeft)
|
||||
r.RevealFocus(image.Rect(0, 0, 15, 40))
|
||||
evts := r.Events(h)
|
||||
evts := r.Events(h, f)
|
||||
assertScrollEvent(t, evts[len(evts)-1], f32.Pt(6, -9))
|
||||
}
|
||||
|
||||
@@ -296,18 +295,20 @@ func TestFocusClick(t *testing.T) {
|
||||
r := new(Router)
|
||||
h := new(int)
|
||||
|
||||
f := pointer.Filter{
|
||||
Kinds: pointer.Press | pointer.Release,
|
||||
}
|
||||
assertEventPointerTypeSequence(t, r.Events(h, f), pointer.Cancel)
|
||||
cl := clip.Rect(image.Rect(0, 0, 10, 10)).Push(ops)
|
||||
key.InputOp{Tag: h}.Add(ops)
|
||||
pointer.InputOp{
|
||||
Tag: h,
|
||||
Kinds: pointer.Press | pointer.Release,
|
||||
}.Add(ops)
|
||||
event.InputOp(ops, h)
|
||||
cl.Pop()
|
||||
r.Frame(ops)
|
||||
|
||||
r.MoveFocus(key.FocusLeft)
|
||||
r.ClickFocus()
|
||||
assertEventPointerTypeSequence(t, r.Events(h), pointer.Cancel, pointer.Press, pointer.Release)
|
||||
|
||||
assertEventPointerTypeSequence(t, r.Events(h, f), pointer.Press, pointer.Release)
|
||||
}
|
||||
|
||||
func TestNoFocus(t *testing.T) {
|
||||
|
||||
+37
-26
@@ -244,11 +244,20 @@ func (q *pointerQueue) handlerFor(tag event.Tag, events *handlerEvents) *pointer
|
||||
h = &pointerHandler{
|
||||
area: -1,
|
||||
}
|
||||
if q.handlers == nil {
|
||||
q.handlers = make(map[event.Tag]*pointerHandler)
|
||||
}
|
||||
q.handlers[tag] = h
|
||||
// Cancel handlers on (each) first appearance, but don't
|
||||
// trigger redraw.
|
||||
events.AddNoRedraw(tag, pointer.Event{Kind: pointer.Cancel})
|
||||
}
|
||||
if !h.active {
|
||||
h.types = 0
|
||||
h.scrollRange = image.Rectangle{}
|
||||
h.sourceMimes = h.sourceMimes[:0]
|
||||
h.targetMimes = h.targetMimes[:0]
|
||||
}
|
||||
h.active = true
|
||||
return h
|
||||
}
|
||||
@@ -288,20 +297,17 @@ func (q *pointerQueue) grab(req pointer.GrabCmd, events *handlerEvents) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pointerCollector) inputOp(op pointer.InputOp, events *handlerEvents) {
|
||||
func (c *pointerCollector) inputOp(tag event.Tag, events *handlerEvents) {
|
||||
areaID := c.currentArea()
|
||||
area := &c.q.areas[areaID]
|
||||
area.semantic.content.tag = op.Tag
|
||||
if op.Kinds&(pointer.Press|pointer.Release) != 0 {
|
||||
area.semantic.content.gestures |= ClickGesture
|
||||
}
|
||||
if op.Kinds&pointer.Scroll != 0 {
|
||||
area.semantic.content.gestures |= ScrollGesture
|
||||
}
|
||||
area.semantic.valid = area.semantic.content.gestures != 0
|
||||
h := c.newHandler(op.Tag, events)
|
||||
h.types = h.types | op.Kinds
|
||||
h.scrollRange = op.ScrollBounds
|
||||
area.semantic.content.tag = tag
|
||||
c.newHandler(tag, events)
|
||||
}
|
||||
|
||||
func (q *pointerQueue) filterTag(tag event.Tag, f pointer.Filter, events *handlerEvents) {
|
||||
h := q.handlerFor(tag, events)
|
||||
h.types = h.types | f.Kinds
|
||||
h.scrollRange = h.scrollRange.Union(f.ScrollBounds)
|
||||
}
|
||||
|
||||
func (c *pointerCollector) semanticLabel(lbl string) {
|
||||
@@ -345,14 +351,14 @@ func (c *pointerCollector) cursor(cursor pointer.Cursor) {
|
||||
area.cursor = cursor
|
||||
}
|
||||
|
||||
func (c *pointerCollector) sourceOp(op transfer.SourceOp, events *handlerEvents) {
|
||||
h := c.newHandler(op.Tag, events)
|
||||
h.sourceMimes = append(h.sourceMimes, op.Type)
|
||||
func (q *pointerQueue) sourceFilter(tag event.Tag, f transfer.SourceFilter, events *handlerEvents) {
|
||||
h := q.handlerFor(tag, events)
|
||||
h.sourceMimes = append(h.sourceMimes, f.Type)
|
||||
}
|
||||
|
||||
func (c *pointerCollector) targetOp(op transfer.TargetOp, events *handlerEvents) {
|
||||
h := c.newHandler(op.Tag, events)
|
||||
h.targetMimes = append(h.targetMimes, op.Type)
|
||||
func (q *pointerQueue) targetFilter(tag event.Tag, f transfer.TargetFilter, events *handlerEvents) {
|
||||
h := q.handlerFor(tag, events)
|
||||
h.targetMimes = append(h.targetMimes, f.Type)
|
||||
}
|
||||
|
||||
func (q *pointerQueue) offerData(req transfer.OfferCmd, events *handlerEvents) {
|
||||
@@ -571,16 +577,9 @@ func (q *pointerQueue) hit(areaIdx int, p f32.Point) (bool, pointer.Cursor) {
|
||||
}
|
||||
|
||||
func (q *pointerQueue) reset() {
|
||||
if q.handlers == nil {
|
||||
q.handlers = make(map[event.Tag]*pointerHandler)
|
||||
}
|
||||
for _, h := range q.handlers {
|
||||
// Reset handler.
|
||||
h.active = false
|
||||
h.area = -1
|
||||
h.types = 0
|
||||
h.sourceMimes = h.sourceMimes[:0]
|
||||
h.targetMimes = h.targetMimes[:0]
|
||||
}
|
||||
q.hitTree = q.hitTree[:0]
|
||||
q.areas = q.areas[:0]
|
||||
@@ -612,6 +611,18 @@ func (q *pointerQueue) Frame(events *handlerEvents) {
|
||||
if !h.active {
|
||||
q.dropHandler(nil, k)
|
||||
delete(q.handlers, k)
|
||||
continue
|
||||
}
|
||||
h.active = false
|
||||
if h.area != -1 {
|
||||
area := &q.areas[h.area]
|
||||
if h.types&(pointer.Press|pointer.Release) != 0 {
|
||||
area.semantic.content.gestures |= ClickGesture
|
||||
}
|
||||
if h.types&pointer.Scroll != 0 {
|
||||
area.semantic.content.gestures |= ScrollGesture
|
||||
}
|
||||
area.semantic.valid = area.semantic.content.gestures != 0
|
||||
}
|
||||
}
|
||||
for i := range q.pointers {
|
||||
@@ -669,7 +680,7 @@ func (q *pointerQueue) Deliver(areaIdx int, e pointer.Event, events *handlerEven
|
||||
continue
|
||||
}
|
||||
h := q.handlers[n.tag]
|
||||
if e.Kind&h.types == 0 {
|
||||
if h == nil || e.Kind&h.types == 0 {
|
||||
continue
|
||||
}
|
||||
e := e
|
||||
|
||||
+245
-260
File diff suppressed because it is too large
Load Diff
+67
-38
@@ -119,16 +119,27 @@ func (s Source) Enabled() bool {
|
||||
return s.r != nil
|
||||
}
|
||||
|
||||
// Events returns the available events for the handler tag.
|
||||
func (s Source) Events(k event.Tag) []event.Event {
|
||||
// Events returns the events for the handler tag that matches one
|
||||
// or more of filters.
|
||||
func (s Source) Events(k event.Tag, filters ...event.Filter) []event.Event {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return s.r.Events(k)
|
||||
return s.r.Events(k, filters...)
|
||||
}
|
||||
|
||||
func (q *Router) Events(k event.Tag) []event.Event {
|
||||
events := q.handlers.Events(k)
|
||||
func (q *Router) Events(k event.Tag, filters ...event.Filter) []event.Event {
|
||||
for _, f := range filters {
|
||||
switch f := f.(type) {
|
||||
case pointer.Filter:
|
||||
q.pointer.queue.filterTag(k, f, &q.handlers)
|
||||
case transfer.SourceFilter:
|
||||
q.pointer.queue.sourceFilter(k, f, &q.handlers)
|
||||
case transfer.TargetFilter:
|
||||
q.pointer.queue.targetFilter(k, f, &q.handlers)
|
||||
}
|
||||
}
|
||||
events := q.handlers.Events(k, filters...)
|
||||
return events
|
||||
}
|
||||
|
||||
@@ -424,7 +435,6 @@ func (q *Router) collect() {
|
||||
kq := &q.key.queue
|
||||
q.key.queue.Reset()
|
||||
var t f32.Affine2D
|
||||
bo := binary.LittleEndian
|
||||
for encOp, ok := q.reader.Decode(); ok; encOp, ok = q.reader.Decode() {
|
||||
switch ops.OpType(encOp.Data[0]) {
|
||||
case ops.TypeInvalidate:
|
||||
@@ -464,42 +474,18 @@ func (q *Router) collect() {
|
||||
q.transStack = q.transStack[:n-1]
|
||||
pc.setTrans(t)
|
||||
|
||||
case ops.TypeInput:
|
||||
tag := encOp.Refs[0].(event.Tag)
|
||||
pc.inputOp(tag, &q.handlers)
|
||||
|
||||
// Pointer ops.
|
||||
case ops.TypePass:
|
||||
pc.pass()
|
||||
case ops.TypePopPass:
|
||||
pc.popPass()
|
||||
case ops.TypePointerInput:
|
||||
op := pointer.InputOp{
|
||||
Tag: encOp.Refs[0].(event.Tag),
|
||||
Kinds: pointer.Kind(bo.Uint16(encOp.Data[1:])),
|
||||
ScrollBounds: image.Rectangle{
|
||||
Min: image.Point{
|
||||
X: int(int32(bo.Uint32(encOp.Data[3:]))),
|
||||
Y: int(int32(bo.Uint32(encOp.Data[7:]))),
|
||||
},
|
||||
Max: image.Point{
|
||||
X: int(int32(bo.Uint32(encOp.Data[11:]))),
|
||||
Y: int(int32(bo.Uint32(encOp.Data[15:]))),
|
||||
},
|
||||
},
|
||||
}
|
||||
pc.inputOp(op, &q.handlers)
|
||||
case ops.TypeCursor:
|
||||
name := pointer.Cursor(encOp.Data[1])
|
||||
pc.cursor(name)
|
||||
case ops.TypeSource:
|
||||
op := transfer.SourceOp{
|
||||
Tag: encOp.Refs[0].(event.Tag),
|
||||
Type: encOp.Refs[1].(string),
|
||||
}
|
||||
pc.sourceOp(op, &q.handlers)
|
||||
case ops.TypeTarget:
|
||||
op := transfer.TargetOp{
|
||||
Tag: encOp.Refs[0].(event.Tag),
|
||||
Type: encOp.Refs[1].(string),
|
||||
}
|
||||
pc.targetOp(op, &q.handlers)
|
||||
case ops.TypeActionInput:
|
||||
act := system.Action(encOp.Data[1])
|
||||
pc.actionInputOp(act)
|
||||
@@ -570,12 +556,55 @@ func (h *handlerEvents) HadEvents() bool {
|
||||
return u
|
||||
}
|
||||
|
||||
func (h *handlerEvents) Events(k event.Tag) []event.Event {
|
||||
func (h *handlerEvents) Events(k event.Tag, filters ...event.Filter) []event.Event {
|
||||
var filtered []event.Event
|
||||
if events, ok := h.handlers[k]; ok {
|
||||
h.handlers[k] = h.handlers[k][:0]
|
||||
return events
|
||||
i := 0
|
||||
for i < len(events) {
|
||||
e := events[i]
|
||||
if filtersMatches(filters, e) {
|
||||
filtered = append(filtered, e)
|
||||
events = append(events[:i], events[i+1:]...)
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
h.handlers[k] = events
|
||||
}
|
||||
return nil
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filtersMatches(filters []event.Filter, e event.Event) bool {
|
||||
switch e := e.(type) {
|
||||
case pointer.Event:
|
||||
for _, f := range filters {
|
||||
if f, ok := f.(pointer.Filter); ok && f.Kinds&e.Kind == e.Kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case transfer.CancelEvent, transfer.InitiateEvent:
|
||||
for _, f := range filters {
|
||||
switch f.(type) {
|
||||
case transfer.SourceFilter, transfer.TargetFilter:
|
||||
return true
|
||||
}
|
||||
}
|
||||
case transfer.RequestEvent:
|
||||
for _, f := range filters {
|
||||
if f, ok := f.(transfer.SourceFilter); ok && f.Type == e.Type {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case transfer.DataEvent:
|
||||
for _, f := range filters {
|
||||
if f, ok := f.(transfer.TargetFilter); ok && f.Type == e.Type {
|
||||
return true
|
||||
}
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *handlerEvents) Clear() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gioui.org/io/pointer"
|
||||
)
|
||||
|
||||
func TestNoFilterAllocs(t *testing.T) {
|
||||
b := testing.Benchmark(func(b *testing.B) {
|
||||
var r Router
|
||||
s := r.Source()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Events(nil, pointer.Filter{})
|
||||
}
|
||||
})
|
||||
if allocs := b.AllocsPerOp(); allocs != 0 {
|
||||
t.Fatalf("expected 0 AllocsPerOp, got %d", allocs)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/op"
|
||||
@@ -74,13 +75,18 @@ func TestSemanticTree(t *testing.T) {
|
||||
|
||||
func TestSemanticDescription(t *testing.T) {
|
||||
var ops op.Ops
|
||||
pointer.InputOp{Tag: new(int), Kinds: pointer.Press | pointer.Release}.Add(&ops)
|
||||
|
||||
h := new(int)
|
||||
event.InputOp(&ops, h)
|
||||
semantic.DescriptionOp("description").Add(&ops)
|
||||
semantic.LabelOp("label").Add(&ops)
|
||||
semantic.Button.Add(&ops)
|
||||
semantic.EnabledOp(false).Add(&ops)
|
||||
semantic.SelectedOp(true).Add(&ops)
|
||||
var r Router
|
||||
r.Events(h, pointer.Filter{
|
||||
Kinds: pointer.Press | pointer.Release,
|
||||
})
|
||||
r.Frame(&ops)
|
||||
tree := r.AppendSemantics(nil)
|
||||
got := tree[0].Desc
|
||||
|
||||
+5
-28
@@ -3,8 +3,6 @@
|
||||
package pointer
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -56,11 +54,9 @@ type PassStack struct {
|
||||
macroID uint32
|
||||
}
|
||||
|
||||
// InputOp declares an input handler ready for pointer
|
||||
// events.
|
||||
type InputOp struct {
|
||||
Tag event.Tag
|
||||
// Kinds is a bitwise-or of event types to receive.
|
||||
// Filter matches [Event]s.
|
||||
type Filter struct {
|
||||
// Kinds is a bitwise-or of event types to match.
|
||||
Kinds Kind
|
||||
// ScrollBounds describe the maximum scrollable distances in both
|
||||
// axes. Specifically, any Event e delivered to Tag will satisfy
|
||||
@@ -240,27 +236,6 @@ func (op Cursor) Add(o *op.Ops) {
|
||||
data[1] = byte(op)
|
||||
}
|
||||
|
||||
// Add panics if the scroll range does not contain zero.
|
||||
func (op InputOp) Add(o *op.Ops) {
|
||||
if op.Tag == nil {
|
||||
panic("Tag must be non-nil")
|
||||
}
|
||||
if b := op.ScrollBounds; b.Min.X > 0 || b.Max.X < 0 || b.Min.Y > 0 || b.Max.Y < 0 {
|
||||
panic(fmt.Errorf("invalid scroll range value %v", b))
|
||||
}
|
||||
if op.Kinds>>16 > 0 {
|
||||
panic(fmt.Errorf("value in Types overflows uint16"))
|
||||
}
|
||||
data := ops.Write1(&o.Internal, ops.TypePointerInputLen, op.Tag)
|
||||
data[0] = byte(ops.TypePointerInput)
|
||||
bo := binary.LittleEndian
|
||||
bo.PutUint16(data[1:], uint16(op.Kinds))
|
||||
bo.PutUint32(data[3:], uint32(op.ScrollBounds.Min.X))
|
||||
bo.PutUint32(data[7:], uint32(op.ScrollBounds.Min.Y))
|
||||
bo.PutUint32(data[11:], uint32(op.ScrollBounds.Max.X))
|
||||
bo.PutUint32(data[15:], uint32(op.ScrollBounds.Max.Y))
|
||||
}
|
||||
|
||||
func (t Kind) String() string {
|
||||
if t == Cancel {
|
||||
return "Cancel"
|
||||
@@ -406,3 +381,5 @@ func (c Cursor) String() string {
|
||||
func (Event) ImplementsEvent() {}
|
||||
|
||||
func (GrabCmd) ImplementsCommand() {}
|
||||
|
||||
func (Filter) ImplementsFilter() {}
|
||||
|
||||
+15
-25
@@ -2,10 +2,10 @@
|
||||
//
|
||||
// The transfer protocol is as follows:
|
||||
//
|
||||
// - Data sources use [SourceFilter] to receive [InitiateEvent]s when a drag
|
||||
// is initiated, and [RequestEvent]s for each initiation of a data transfer.
|
||||
// Sources respond to requests with [OfferCommand].
|
||||
// - Data targets use [TargetFilter] to receive [DataEvent]s for receiving data.
|
||||
// - Data sources use [SourceFilter] to receive an [InitiateEvent] when a drag
|
||||
// is initiated, and an [RequestEvent] for each initiation of a data transfer.
|
||||
// Sources respond to requests with [OfferCmd].
|
||||
// - Data targets use [TargetFilter] to receive an [DataEvent] for receiving data.
|
||||
// The target must close the data event after use.
|
||||
//
|
||||
// When a user initiates a pointer-guided drag and drop transfer, the
|
||||
@@ -20,9 +20,7 @@ package transfer
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gioui.org/internal/ops"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/op"
|
||||
)
|
||||
|
||||
// OfferCmd is used by data sources as a response to a RequestEvent.
|
||||
@@ -34,38 +32,27 @@ type OfferCmd struct {
|
||||
// Data contains the offered data. It is closed when the
|
||||
// transfer is complete or cancelled.
|
||||
// Data must be kept valid until closed, and it may be used from
|
||||
// a goroutine separate from the one processing the frame..
|
||||
// a goroutine separate from the one processing the frame.
|
||||
Data io.ReadCloser
|
||||
}
|
||||
|
||||
func (OfferCmd) ImplementsCommand() {}
|
||||
|
||||
// SourceOp registers a tag as a data source for a MIME type.
|
||||
// Use multiple SourceOps if a tag supports multiple types.
|
||||
type SourceOp struct {
|
||||
Tag event.Tag
|
||||
// SourceFilter filters for any [RequestEvent] that match a MIME type
|
||||
// as well as [InitiateEvent] and [CancelEvent].
|
||||
// Use multiple filters to offer multiple types.
|
||||
type SourceFilter struct {
|
||||
// Type is the MIME type supported by this source.
|
||||
Type string
|
||||
}
|
||||
|
||||
// TargetOp registers a tag as a data target.
|
||||
// Use multiple TargetOps if a tag supports multiple types.
|
||||
type TargetOp struct {
|
||||
Tag event.Tag
|
||||
// TargetFilter filters for any [DataEvent] whose type matches a MIME type
|
||||
// as well as [CancelEvent]. Use multiple filters to accept multiple types.
|
||||
type TargetFilter struct {
|
||||
// Type is the MIME type accepted by this target.
|
||||
Type string
|
||||
}
|
||||
|
||||
func (op SourceOp) Add(o *op.Ops) {
|
||||
data := ops.Write2(&o.Internal, ops.TypeSourceLen, op.Tag, op.Type)
|
||||
data[0] = byte(ops.TypeSource)
|
||||
}
|
||||
|
||||
func (op TargetOp) Add(o *op.Ops) {
|
||||
data := ops.Write2(&o.Internal, ops.TypeTargetLen, op.Tag, op.Type)
|
||||
data[0] = byte(ops.TypeTarget)
|
||||
}
|
||||
|
||||
// RequestEvent requests data from a data source. The source must
|
||||
// respond with an OfferCmd.
|
||||
type RequestEvent struct {
|
||||
@@ -99,3 +86,6 @@ type DataEvent struct {
|
||||
}
|
||||
|
||||
func (DataEvent) ImplementsEvent() {}
|
||||
|
||||
func (SourceFilter) ImplementsFilter() {}
|
||||
func (TargetFilter) ImplementsFilter() {}
|
||||
|
||||
+20
-20
@@ -144,7 +144,25 @@ func (l *List) Dragging() bool {
|
||||
}
|
||||
|
||||
func (l *List) update(gtx Context) {
|
||||
d := l.scroll.Update(gtx.Metric, gtx.Source, gtx.Now, gesture.Axis(l.Axis))
|
||||
min, max := int(-inf), int(inf)
|
||||
if l.Position.First == 0 {
|
||||
// Use the size of the invisible part as scroll boundary.
|
||||
min = -l.Position.Offset
|
||||
if min > 0 {
|
||||
min = 0
|
||||
}
|
||||
}
|
||||
if l.Position.First+l.Position.Count == l.len {
|
||||
max = -l.Position.OffsetLast
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
}
|
||||
scrollRange := image.Rectangle{
|
||||
Min: l.Axis.Convert(image.Pt(min, 0)),
|
||||
Max: l.Axis.Convert(image.Pt(max, 0)),
|
||||
}
|
||||
d := l.scroll.Update(gtx.Metric, gtx.Source, gtx.Now, gesture.Axis(l.Axis), scrollRange)
|
||||
l.scrollDelta = d
|
||||
l.Position.Offset += d
|
||||
}
|
||||
@@ -332,25 +350,7 @@ func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
|
||||
call := macro.Stop()
|
||||
defer clip.Rect(image.Rectangle{Max: dims}).Push(ops).Pop()
|
||||
|
||||
min, max := int(-inf), int(inf)
|
||||
if l.Position.First == 0 {
|
||||
// Use the size of the invisible part as scroll boundary.
|
||||
min = -l.Position.Offset
|
||||
if min > 0 {
|
||||
min = 0
|
||||
}
|
||||
}
|
||||
if l.Position.First+l.Position.Count == l.len {
|
||||
max = -l.Position.OffsetLast
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
}
|
||||
scrollRange := image.Rectangle{
|
||||
Min: l.Axis.Convert(image.Pt(min, 0)),
|
||||
Max: l.Axis.Convert(image.Pt(max, 0)),
|
||||
}
|
||||
l.scroll.Add(ops, scrollRange)
|
||||
l.scroll.Add(ops)
|
||||
|
||||
call.Add(ops)
|
||||
return Dimensions{Size: dims}
|
||||
|
||||
+3
-5
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/layout"
|
||||
@@ -31,10 +32,7 @@ func (d *Draggable) Layout(gtx layout.Context, w, drag layout.Widget) layout.Dim
|
||||
|
||||
stack := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
|
||||
d.drag.Add(gtx.Ops)
|
||||
transfer.SourceOp{
|
||||
Tag: &d.handle,
|
||||
Type: d.Type,
|
||||
}.Add(gtx.Ops)
|
||||
event.InputOp(gtx.Ops, &d.handle)
|
||||
stack.Pop()
|
||||
|
||||
if drag != nil && d.drag.Pressed() {
|
||||
@@ -67,7 +65,7 @@ func (d *Draggable) Update(gtx layout.Context) (mime string, requested bool) {
|
||||
}
|
||||
d.pos = pos
|
||||
|
||||
for _, ev := range gtx.Source.Events(&d.handle) {
|
||||
for _, ev := range gtx.Events(&d.handle, transfer.SourceFilter{Type: d.Type}) {
|
||||
if e, ok := ev.(transfer.RequestEvent); ok {
|
||||
return e.Type, true
|
||||
}
|
||||
|
||||
+7
-5
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/transfer"
|
||||
@@ -29,12 +30,11 @@ func TestDraggable(t *testing.T) {
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
}, nil)
|
||||
stack := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
|
||||
transfer.TargetOp{
|
||||
Tag: drag,
|
||||
Type: drag.Type,
|
||||
}.Add(gtx.Ops)
|
||||
event.InputOp(gtx.Ops, drag)
|
||||
stack.Pop()
|
||||
|
||||
drag.Update(gtx)
|
||||
r.Events(drag, transfer.TargetFilter{Type: drag.Type})
|
||||
r.Frame(gtx.Ops)
|
||||
r.Queue(
|
||||
pointer.Event{
|
||||
@@ -52,9 +52,11 @@ func TestDraggable(t *testing.T) {
|
||||
)
|
||||
ofr := &offer{data: "hello"}
|
||||
drag.Offer(gtx, "file", ofr)
|
||||
drag.Update(gtx)
|
||||
r.Events(drag, transfer.TargetFilter{Type: drag.Type})
|
||||
r.Frame(gtx.Ops)
|
||||
|
||||
evs := r.Events(drag)
|
||||
evs := r.Events(drag, transfer.TargetFilter{Type: drag.Type})
|
||||
if len(evs) != 2 {
|
||||
t.Fatalf("expected 2 event, got %d", len(evs))
|
||||
}
|
||||
|
||||
+15
-14
@@ -225,7 +225,19 @@ func (e *Editor) processPointer(gtx layout.Context) {
|
||||
axis = gesture.Vertical
|
||||
smin, smax = sbounds.Min.Y, sbounds.Max.Y
|
||||
}
|
||||
sdist := e.scroller.Update(gtx.Metric, gtx.Source, gtx.Now, axis)
|
||||
var scrollRange image.Rectangle
|
||||
textDims := e.text.FullDimensions()
|
||||
visibleDims := e.text.Dimensions()
|
||||
if e.SingleLine {
|
||||
scrollOffX := e.text.ScrollOff().X
|
||||
scrollRange.Min.X = min(-scrollOffX, 0)
|
||||
scrollRange.Max.X = max(0, textDims.Size.X-(scrollOffX+visibleDims.Size.X))
|
||||
} else {
|
||||
scrollOffY := e.text.ScrollOff().Y
|
||||
scrollRange.Min.Y = -scrollOffY
|
||||
scrollRange.Max.Y = max(0, textDims.Size.Y-(scrollOffY+visibleDims.Size.Y))
|
||||
}
|
||||
sdist := e.scroller.Update(gtx.Metric, gtx.Source, gtx.Now, axis, scrollRange)
|
||||
var soff int
|
||||
if e.SingleLine {
|
||||
e.text.ScrollRel(sdist, 0)
|
||||
@@ -320,7 +332,7 @@ func (e *Editor) processKey(gtx layout.Context) {
|
||||
}
|
||||
// adjust keeps track of runes dropped because of MaxLen.
|
||||
var adjust int
|
||||
for _, ke := range gtx.Events(&e.eventKey) {
|
||||
for _, ke := range gtx.Events(&e.eventKey, transfer.TargetFilter{Type: "application/text"}) {
|
||||
e.blinkStart = gtx.Now
|
||||
switch ke := ke.(type) {
|
||||
case key.FocusEvent:
|
||||
@@ -609,7 +621,6 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call
|
||||
e.scrollCaret = false
|
||||
e.text.ScrollToCaret()
|
||||
}
|
||||
textDims := e.text.FullDimensions()
|
||||
visibleDims := e.text.Dimensions()
|
||||
|
||||
defer clip.Rect(image.Rectangle{Max: visibleDims.Size}).Push(gtx.Ops).Pop()
|
||||
@@ -642,17 +653,7 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call
|
||||
}
|
||||
key.InputOp{Tag: &e.eventKey, Hint: e.InputHint, Keys: keys}.Add(gtx.Ops)
|
||||
|
||||
var scrollRange image.Rectangle
|
||||
if e.SingleLine {
|
||||
scrollOffX := e.text.ScrollOff().X
|
||||
scrollRange.Min.X = min(-scrollOffX, 0)
|
||||
scrollRange.Max.X = max(0, textDims.Size.X-(scrollOffX+visibleDims.Size.X))
|
||||
} else {
|
||||
scrollOffY := e.text.ScrollOff().Y
|
||||
scrollRange.Min.Y = -scrollOffY
|
||||
scrollRange.Max.Y = max(0, textDims.Size.Y-(scrollOffY+visibleDims.Size.Y))
|
||||
}
|
||||
e.scroller.Add(gtx.Ops, scrollRange)
|
||||
e.scroller.Add(gtx.Ops)
|
||||
|
||||
e.clicker.Add(gtx.Ops)
|
||||
e.dragger.Add(gtx.Ops)
|
||||
|
||||
@@ -900,7 +900,6 @@ g 2 4 6 8 g
|
||||
var tim time.Duration
|
||||
selected := func(start, end int) string {
|
||||
// Layout once with no events; populate e.lines.
|
||||
gtx = gtx.Disabled()
|
||||
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
|
||||
e.Events() // throw away any events from this layout
|
||||
|
||||
|
||||
+11
-15
@@ -5,8 +5,11 @@ package widget_test
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/transfer"
|
||||
@@ -79,7 +82,7 @@ func ExampleDraggable_Layout() {
|
||||
}
|
||||
// mime is the type used to match drag and drop operations.
|
||||
// It could be left empty in this example.
|
||||
mime := "MyMime"
|
||||
const mime = "MyMime"
|
||||
drag := &widget.Draggable{Type: mime}
|
||||
var drop int
|
||||
// widget lays out the drag and drop handlers and processes
|
||||
@@ -94,7 +97,7 @@ func ExampleDraggable_Layout() {
|
||||
// drag must respond with an Offer event when requested.
|
||||
// Use the drag method for this.
|
||||
if m, ok := drag.Update(gtx); ok {
|
||||
drag.Offer(gtx, m, offer{Data: "hello world"})
|
||||
drag.Offer(gtx, m, io.NopCloser(strings.NewReader("hello world")))
|
||||
}
|
||||
|
||||
// Setup the area for drops.
|
||||
@@ -102,17 +105,17 @@ func ExampleDraggable_Layout() {
|
||||
Min: image.Pt(20, 20),
|
||||
Max: image.Pt(40, 40),
|
||||
}.Push(gtx.Ops)
|
||||
transfer.TargetOp{
|
||||
Tag: &drop,
|
||||
Type: mime, // this must match the drag Type for the drop to succeed
|
||||
}.Add(gtx.Ops)
|
||||
event.InputOp(gtx.Ops, &drop)
|
||||
ds.Pop()
|
||||
|
||||
// Check for the received data.
|
||||
for _, ev := range gtx.Events(&drop) {
|
||||
for _, ev := range gtx.Events(&drop, transfer.TargetFilter{Type: mime}) {
|
||||
switch e := ev.(type) {
|
||||
case transfer.DataEvent:
|
||||
data := e.Open()
|
||||
fmt.Println(data.(offer).Data)
|
||||
defer data.Close()
|
||||
content, _ := io.ReadAll(data)
|
||||
fmt.Println(string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,10 +148,3 @@ func ExampleDraggable_Layout() {
|
||||
// Output:
|
||||
// hello world
|
||||
}
|
||||
|
||||
type offer struct {
|
||||
Data string
|
||||
}
|
||||
|
||||
func (offer) Read([]byte) (int, error) { return 0, nil }
|
||||
func (offer) Close() error { return nil }
|
||||
|
||||
Reference in New Issue
Block a user