Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions dnd.go
Original file line number Diff line number Diff line change
@@ -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)
}
61 changes: 61 additions & 0 deletions dnd_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
44 changes: 27 additions & 17 deletions dropzone.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,26 @@ 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
Hover bool
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
Expand Down Expand Up @@ -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})
}
}
}
72 changes: 46 additions & 26 deletions dropzone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading