From c4ebfcd2200435338e9a5e2b0c01a28c584b6dbc Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 10 Jul 2026 14:35:55 +0200 Subject: [PATCH] toolkit: add first-class drag-and-drop event kinds Formalises the drag lifecycle the widget model was missing: EventDragStart / EventDragMove / EventDragLeave / EventDrop (appended to the event enum), plus a DragSource / DropTarget interface pair and SplitDropPayload / JoinDropPayload for multi-item payloads carried in Event.Code. DropZone drops its acknowledged synthetic-EventChar seam and now drives its hover cue + OnDrop from the formal events as a DropTarget (accepting any non-empty payload, delivering all newline-separated items). 100% coverage. Co-Authored-By: Claude Opus 4.8 --- README.md | 11 ++++---- dnd.go | 66 ++++++++++++++++++++++++++++++++++++++++++++ dnd_test.go | 61 ++++++++++++++++++++++++++++++++++++++++ dropzone.go | 44 +++++++++++++++++------------ dropzone_test.go | 72 +++++++++++++++++++++++++++++++----------------- widget.go | 18 ++++++++++++ 6 files changed, 224 insertions(+), 48 deletions(-) create mode 100644 dnd.go create mode 100644 dnd_test.go diff --git a/README.md b/README.md index 79f15bc..927d10e 100644 --- a/README.md +++ b/README.md @@ -163,11 +163,12 @@ model, not the widget catalogue: so a caller can plug a larger bitmap or a hand-rasterised TrueType at boot. Unblocks `FontChooser` (long-standing deferral) + retina-size Label rendering. -- **First-class drag-and-drop event kinds** — `EventDragStart` / - `EventDragMove` / `EventDrop`, plus a `DragSource` / `DropTarget` - interface pair. DropZone (v0.9) currently uses a synthetic- - `EventChar` seam; formalising the events lets it and future - siblings share the same host contract. +- ~~**First-class drag-and-drop event kinds**~~ — **done (v0.16)**: + `EventDragStart` / `EventDragMove` / `EventDragLeave` / `EventDrop`, + plus a `DragSource` / `DropTarget` interface pair and + `SplitDropPayload` / `JoinDropPayload` for multi-item payloads. + DropZone dropped its synthetic-`EventChar` seam and now drives its + hover cue + `OnDrop` from the formal lifecycle as a `DropTarget`. - **Context menu helper** — one-line `ShowContextMenu(x, y, *Menu, *Popover)` that spawns a Menu at worker-relative coords + auto- dismisses on outside-click. diff --git a/dnd.go b/dnd.go new file mode 100644 index 0000000..f201cb3 --- /dev/null +++ b/dnd.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "strings" + +// Drag-and-drop host contract. +// +// The toolkit does not run its own pointer loop, so it cannot originate drags +// on its own — a host (the wasmbox compositor's native HTML5 drag listeners, a +// TTY mouse decoder, ...) tracks the pointer and translates the platform's drag +// gestures into the toolkit's EventDragStart / EventDragMove / EventDragLeave / +// EventDrop events, delivering them to the widget under the pointer. These two +// interfaces let a host discover which widgets participate: +// +// - a DragSource is asked for its payload when a drag begins on it; +// - a DropTarget is asked whether it accepts a payload (to drive hover cues) +// and receives the EventDrop that carries it. +// +// A widget can implement either or both. Payloads are plain strings so the +// contract stays back-end-neutral; multiple items travel newline-separated and +// a target recovers them with SplitDropPayload. + +// DragSource is a widget a drag can originate from. DragData returns the payload +// string the host should carry for the drag (e.g. a file path or a row id). +type DragSource interface { + Widget + DragData() string +} + +// DropTarget is a widget that can receive a drop. AcceptsDrop reports whether +// the given payload is droppable here — a host consults it on EventDragStart to +// decide whether to show an "accepted" cursor and whether to deliver the later +// EventDrop. +type DropTarget interface { + Widget + AcceptsDrop(payload string) bool +} + +// DropPayloadSep separates individual items within a multi-item drag payload. +const DropPayloadSep = "\n" + +// SplitDropPayload splits a drop payload (as carried in Event.Code) into its +// individual items, dropping empty entries so a trailing separator or an empty +// payload yields no phantom items. +func SplitDropPayload(code string) []string { + if code == "" { + return nil + } + var items []string + for _, s := range strings.Split(code, DropPayloadSep) { + if s != "" { + items = append(items, s) + } + } + return items +} + +// JoinDropPayload builds a multi-item payload string from items, the inverse of +// SplitDropPayload — a host uses it to package several dragged paths into one +// Event.Code. +func JoinDropPayload(items []string) string { + return strings.Join(items, DropPayloadSep) +} diff --git a/dnd_test.go b/dnd_test.go new file mode 100644 index 0000000..e31d185 --- /dev/null +++ b/dnd_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "testing" + +func TestSplitDropPayload(t *testing.T) { + // Empty payload yields no items (nil). + if got := SplitDropPayload(""); got != nil { + t.Errorf("SplitDropPayload(\"\") = %v, want nil", got) + } + // Single item. + if got := SplitDropPayload("/a"); len(got) != 1 || got[0] != "/a" { + t.Errorf("single = %v, want [/a]", got) + } + // Multiple items, with a trailing separator and a blank line that must be + // dropped rather than becoming a phantom "" item. + got := SplitDropPayload("/a\n/b\n\n/c\n") + want := []string{"/a", "/b", "/c"} + if len(got) != len(want) { + t.Fatalf("multi = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("multi[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestJoinDropPayloadRoundTrips(t *testing.T) { + items := []string{"/x", "/y", "/z"} + if got := SplitDropPayload(JoinDropPayload(items)); len(got) != 3 || + got[0] != "/x" || got[2] != "/z" { + t.Errorf("round-trip = %v, want %v", got, items) + } +} + +// dragThing is a minimal DragSource used only to prove the interface is +// implementable and its DragData is reachable through the interface. +type dragThing struct { + Base + data string +} + +func (d *dragThing) DragData() string { return d.data } + +var _ DragSource = (*dragThing)(nil) + +func TestDragSourceContract(t *testing.T) { + var src DragSource = &dragThing{data: "/payload"} + if src.DragData() != "/payload" { + t.Errorf("DragData() = %q, want /payload", src.DragData()) + } + // It is a Widget too (Base supplies Bounds), so a container can lay it out. + src.SetBounds(Rect{X: 1, Y: 2, W: 3, H: 4}) + if src.Bounds().W != 3 { + t.Errorf("DragSource is not a usable Widget: %+v", src.Bounds()) + } +} diff --git a/dropzone.go b/dropzone.go index dfa676a..f589e56 100644 --- a/dropzone.go +++ b/dropzone.go @@ -14,13 +14,12 @@ import "github.com/go-widgets/painter" // wasmbox compositor wires to the widget). // // The Hover flag toggles the dashed-border colour + surface fill so -// the user sees drag-over feedback before releasing the drop. The -// host is expected to flip Hover based on native dragenter / dragleave -// events; the widget itself does not synthesise its own drag+drop -// events. To keep the OnEvent surface testable, EventClick flips -// Hover in place (a lightweight simulator hook) and EventChar -// delivers a single path via ev.Code -- fired to OnDrop only when -// Hover is true, so the host can gate deliveries on the drag state. +// the user sees drag-over feedback before releasing the drop. DropZone +// is a DropTarget: it drives Hover from the formal drag lifecycle — +// EventDragStart / EventDragMove raise it, EventDragLeave clears it, +// and EventDrop delivers the payload (multiple paths newline-separated, +// recovered with SplitDropPayload) to OnDrop and clears Hover. As a +// convenience for demos and tests, EventClick also flips Hover in place. type DropZone struct { Base Prompt string @@ -28,6 +27,13 @@ type DropZone struct { OnDrop func(paths []string) } +// DropZone is a DropTarget. +var _ DropTarget = (*DropZone)(nil) + +// AcceptsDrop reports whether a payload is droppable here. A DropZone is a +// generic file target, so it accepts any non-empty payload. +func (d *DropZone) AcceptsDrop(payload string) bool { return payload != "" } + // DropZone sizing constants. PadX / PadY are the outer insets from // the bounds to where inner content (the prompt text) sits; DashLen // + DashGap describe the stripe pattern of the dashed border, and @@ -92,19 +98,23 @@ func (d *DropZone) Draw(p painter.Painter, theme *Theme) { DrawText(p, tx, ty, d.Prompt, theme.OnSurface) } -// OnEvent implements the two host-driven behaviours: EventClick flips -// Hover (the simulator hook: real hosts flip Hover directly on -// dragenter / dragleave), and EventChar with Hover = true fires -// OnDrop with a one-element slice containing ev.Code (the delivered -// path). All other event kinds are ignored so a keyboard event bound -// for a sibling widget does not accidentally trigger a drop. +// OnEvent implements the drag lifecycle: EventDragStart / EventDragMove raise +// Hover, EventDragLeave clears it, and EventDrop fires OnDrop with the payload's +// items (split from ev.Code) then clears Hover. EventClick flips Hover in place +// as a demo/test hook. All other event kinds are ignored so a keyboard event +// bound for a sibling widget does not accidentally trigger a drop. func (d *DropZone) OnEvent(ev Event) { switch ev.Kind { + case EventDragStart, EventDragMove: + d.Hover = true + case EventDragLeave: + d.Hover = false + case EventDrop: + d.Hover = false + if d.OnDrop != nil { + d.OnDrop(SplitDropPayload(ev.Code)) + } case EventClick: d.Hover = !d.Hover - case EventChar: - if d.Hover && d.OnDrop != nil { - d.OnDrop([]string{ev.Code}) - } } } diff --git a/dropzone_test.go b/dropzone_test.go index 4721056..f668318 100644 --- a/dropzone_test.go +++ b/dropzone_test.go @@ -158,47 +158,67 @@ func TestDropZoneClickTogglesHover(t *testing.T) { } } -// TestDropZoneCharDropsWhenHovered covers the EventChar + Hover=true -// + OnDrop non-nil branch: OnDrop must fire with a one-element slice -// carrying ev.Code. -func TestDropZoneCharDropsWhenHovered(t *testing.T) { +// TestDropZoneDragLifecycle covers the enter/move/leave arms: DragStart and +// DragMove raise Hover, DragLeave clears it. +func TestDropZoneDragLifecycle(t *testing.T) { + d := NewDropZone("x") + d.OnEvent(Event{Kind: EventDragStart, Code: "/a"}) + if !d.Hover { + t.Fatal("EventDragStart should raise Hover") + } + d.Hover = false + d.OnEvent(Event{Kind: EventDragMove, Code: "/a"}) + if !d.Hover { + t.Fatal("EventDragMove should raise Hover") + } + d.OnEvent(Event{Kind: EventDragLeave}) + if d.Hover { + t.Fatal("EventDragLeave should clear Hover") + } +} + +// TestDropZoneDropDeliversItems covers EventDrop with a non-nil callback: the +// payload's newline-separated items reach OnDrop and Hover is cleared. +func TestDropZoneDropDeliversItems(t *testing.T) { var got []string d := NewDropZone("x") d.OnDrop = func(paths []string) { got = paths } d.Hover = true - d.OnEvent(Event{Kind: EventChar, Code: "/tmp/foo.txt"}) - if len(got) != 1 || got[0] != "/tmp/foo.txt" { - t.Fatalf("OnDrop got %v, want [/tmp/foo.txt]", got) + d.OnEvent(Event{Kind: EventDrop, Code: "/tmp/a.txt\n/tmp/b.txt"}) + if len(got) != 2 || got[0] != "/tmp/a.txt" || got[1] != "/tmp/b.txt" { + t.Fatalf("OnDrop got %v, want two paths", got) + } + if d.Hover { + t.Fatal("EventDrop should clear Hover") } } -// TestDropZoneCharIgnoredWhenIdle covers the EventChar + Hover=false -// branch: OnDrop must NOT fire when the widget is not in the drag- -// over state, even if a callback is registered. -func TestDropZoneCharIgnoredWhenIdle(t *testing.T) { - fires := 0 +// TestDropZoneDropNilCallbackNoPanic covers EventDrop + OnDrop=nil: the widget +// must not panic when no callback is wired, and still clears Hover. +func TestDropZoneDropNilCallbackNoPanic(t *testing.T) { d := NewDropZone("x") - d.OnDrop = func(paths []string) { fires++ } - d.Hover = false - d.OnEvent(Event{Kind: EventChar, Code: "/tmp/foo.txt"}) - if fires != 0 { - t.Fatalf("OnDrop fired %d times while idle, want 0", fires) + d.Hover = true + d.OnEvent(Event{Kind: EventDrop, Code: "/tmp/foo.txt"}) // must not panic + if d.Hover { + t.Fatal("EventDrop should clear Hover even with nil callback") } } -// TestDropZoneCharNilCallbackNoPanic covers the EventChar + Hover=true -// + OnDrop=nil branch: the widget must not panic when no callback is -// wired (the parent may register OnDrop lazily). -func TestDropZoneCharNilCallbackNoPanic(t *testing.T) { +// TestDropZoneAcceptsDrop covers the DropTarget contract: any non-empty payload +// is droppable, an empty one is not. +func TestDropZoneAcceptsDrop(t *testing.T) { d := NewDropZone("x") - d.Hover = true - // Must not panic. - d.OnEvent(Event{Kind: EventChar, Code: "/tmp/foo.txt"}) + if !d.AcceptsDrop("/tmp/a") { + t.Fatal("non-empty payload should be accepted") + } + if d.AcceptsDrop("") { + t.Fatal("empty payload should be rejected") + } } // TestDropZoneIgnoresOtherKinds covers the default branch of the -// switch: EventKeyDown (and every non-Click / non-Char event) must be -// a no-op — no state change, no callback fired. +// switch: EventKeyDown (and every unrelated event) must be a no-op — +// no state change, no callback fired. func TestDropZoneIgnoresOtherKinds(t *testing.T) { fires := 0 d := NewDropZone("x") diff --git a/widget.go b/widget.go index 98db73d..08aefca 100644 --- a/widget.go +++ b/widget.go @@ -73,6 +73,24 @@ const ( // track drag state clear it here. X/Y carry the release // position (widget-local). EventMouseUp + // EventDragStart fires on a DropTarget when a drag first enters it + // (drag-enter). The target typically raises a hover cue. Code + // carries the drag payload the host is offering, so the target can + // decide via AcceptsDrop whether to signal acceptance. + EventDragStart + // EventDragMove fires as the drag pointer moves while still over the + // same DropTarget. X/Y carry the widget-local position (for an + // insertion indicator); Code still carries the payload. + EventDragMove + // EventDragLeave fires when the drag pointer exits a DropTarget + // without dropping. The target clears its hover cue. It completes + // the enter/move/leave/drop lifecycle so a target never stays stuck + // in the hover state. + EventDragLeave + // EventDrop fires when the drag is released over a DropTarget. Code + // carries the payload (multiple items newline-separated — see + // SplitDropPayload); the target consumes it and clears its hover cue. + EventDrop ) // Event is one input event delivered to a widget. The parent container