Skip to content

Commit b4e8d18

Browse files
tronicaluAtomicBoolean
authored andcommitted
dnd-kanban: move drop policy into the host using DropEvent
Replace the bespoke `has-plaintext`/`source-column-of`/`add-task`/ `move-task` callbacks with `can-drop` and `dropped`, both taking a `DropEvent`. `kanban.slint` no longer decides move-vs-copy; the host inspects `event.data` and dispatches on `event.proposed_action`.
1 parent 453777f commit b4e8d18

5 files changed

Lines changed: 173 additions & 195 deletions

File tree

examples/dnd-kanban/kanban.cpp

Lines changed: 40 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
#include <vector>
1010

1111
// What we attach to each `DataTransfer` via `set_user_data`. A copy of the
12-
// `TaskData` plus the row it came from, so `source-column-of` can answer the
13-
// .slint side and `move-task` knows what to remove on a move.
12+
// `TaskData` plus the row it came from, so `can-drop` recognizes our own
13+
// payloads and `dropped` knows what to remove on a move.
1414
struct DragPayload
1515
{
1616
TaskData task;
@@ -49,56 +49,54 @@ int main()
4949
return transfer;
5050
});
5151

52-
api.on_source_column_of([](slint::DataTransfer data) {
53-
auto user_data = data.user_data();
54-
auto *payload = std::any_cast<DragPayload>(&user_data);
55-
return payload ? payload->source_column : -1;
56-
});
57-
58-
api.on_has_plaintext([](slint::DataTransfer data) { return data.has_plaintext(); });
59-
60-
api.on_add_task([columns](slint::DataTransfer data, int target, int target_index) {
61-
if (target < 0 || target >= static_cast<int>(columns.size())) {
62-
return;
52+
api.on_can_drop([](slint::language::DropEvent event, int /*target*/,
53+
int /*target_index*/) -> slint::language::DragAction {
54+
auto user_data = event.data.user_data();
55+
if (std::any_cast<DragPayload>(&user_data)) {
56+
// Our own card: accept whatever modifier the user is holding.
57+
return event.proposed_action;
6358
}
64-
auto user_data = data.user_data();
65-
if (auto *payload = std::any_cast<DragPayload>(&user_data)) {
66-
columns[target]->insert(static_cast<size_t>(target_index), payload->task);
67-
return;
68-
}
69-
if (auto text = data.fetch_plaintext()) {
70-
columns[target]->insert(static_cast<size_t>(target_index), TaskData { *text });
59+
if (event.data.has_plaintext()) {
60+
// External plaintext drop: always treated as a copy.
61+
return slint::language::DragAction::Copy;
7162
}
63+
return slint::language::DragAction::None;
7264
});
7365

74-
api.on_move_task([columns](slint::DataTransfer data, int target, int target_index) {
75-
auto user_data = data.user_data();
76-
auto *payload = std::any_cast<DragPayload>(&user_data);
77-
if (!payload) {
78-
return;
79-
}
66+
api.on_dropped([columns](slint::language::DropEvent event, int target, int target_index) {
8067
if (target < 0 || target >= static_cast<int>(columns.size())) {
8168
return;
8269
}
83-
int source = payload->source_column;
84-
int source_index = payload->source_index;
8570

86-
if (source == target) {
87-
// Same-column reorder. Drops at the source slot or immediately
88-
// after it are no-ops; otherwise remove the source first and
89-
// adjust the target index for the shift that the removal causes.
90-
if (target_index == source_index || target_index == source_index + 1) {
71+
auto user_data = event.data.user_data();
72+
if (auto *payload = std::any_cast<DragPayload>(&user_data)) {
73+
if (event.proposed_action != slint::language::DragAction::Move) {
74+
// Anything that isn't an explicit move is treated as a copy.
75+
columns[target]->insert(static_cast<size_t>(target_index), payload->task);
9176
return;
9277
}
93-
TaskData task = payload->task;
94-
columns[source]->erase(source_index);
95-
int adjusted = target_index > source_index ? target_index - 1 : target_index;
96-
columns[target]->insert(static_cast<size_t>(adjusted), task);
97-
} else {
98-
// Cross-column move. Source and target are independent models, so
99-
// the order of operations doesn't affect index stability.
100-
columns[source]->erase(source_index);
101-
columns[target]->insert(static_cast<size_t>(target_index), payload->task);
78+
int source = payload->source_column;
79+
int source_index = payload->source_index;
80+
81+
if (source == target) {
82+
// Same-column reorder. Drops at the source slot or immediately
83+
// after it are no-ops; otherwise remove the source first and
84+
// adjust the target index for the shift that the removal causes.
85+
if (target_index == source_index || target_index == source_index + 1) {
86+
return;
87+
}
88+
TaskData task = payload->task;
89+
columns[source]->erase(source_index);
90+
int adjusted = target_index > source_index ? target_index - 1 : target_index;
91+
columns[target]->insert(static_cast<size_t>(adjusted), task);
92+
} else {
93+
// Cross-column move. Source and target are independent models,
94+
// so the order of operations doesn't affect index stability.
95+
columns[source]->erase(source_index);
96+
columns[target]->insert(static_cast<size_t>(target_index), payload->task);
97+
}
98+
} else if (auto text = event.data.fetch_plaintext()) {
99+
columns[target]->insert(static_cast<size_t>(target_index), TaskData { *text });
102100
}
103101
});
104102

examples/dnd-kanban/kanban.slint

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,8 @@ export struct TaskData {
1212
// The implementations live in the host language.
1313
export global Api {
1414
pure callback make-data(task: TaskData, source-column: int, source-index: int) -> data-transfer;
15-
// Returns the source column for a payload produced by `make-data`, or -1 for foreign payloads.
16-
pure callback source-column-of(data: data-transfer) -> int;
17-
// Whether the payload carries plaintext we can turn into a new task title.
18-
pure callback has-plaintext(data: data-transfer) -> bool;
19-
// Copy the payload's task into `target-column` at `target-index` (clamped to [0, len]).
20-
callback add-task(data: data-transfer, target-column: int, target-index: int);
21-
// Move the payload's task to `target-column` at `target-index`. Handles same-column
22-
// reorder atomically.
23-
callback move-task(data: data-transfer, target-column: int, target-index: int);
15+
pure callback can-drop(event: DropEvent, target-column: int, target-index: int) -> DragAction;
16+
callback dropped(event: DropEvent, target-column: int, target-index: int);
2417
}
2518

2619
// Card geometry, used by both `Card` itself and the column's insertion-index
@@ -139,20 +132,12 @@ component KanbanColumn inherits Rectangle {
139132
drop-area := DropArea {
140133
vertical-stretch: 1;
141134
can-drop(event) => {
142-
// Reject foreign payloads that aren't our own card and aren't plaintext.
143-
if Api.source-column-of(event.data) < 0 && !Api.has-plaintext(event.data) {
144-
return DragAction.none;
145-
}
146135
// Track the cursor so the drop indicator can follow it.
147136
root.hover-y = event.position.y;
148-
return event.proposed-action;
137+
return Api.can-drop(event, root.column-id, root.drop-index);
149138
}
150139
dropped(event) => {
151-
if event.proposed-action == DragAction.move {
152-
Api.move-task(event.data, root.column-id, root.drop-index);
153-
} else {
154-
Api.add-task(event.data, root.column-id, root.drop-index);
155-
}
140+
Api.dropped(event, root.column-id, root.drop-index);
156141
return event.proposed-action;
157142
}
158143

examples/dnd-kanban/main.js

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
// SPDX-License-Identifier: MIT
44

55
import * as slint from "slint-ui";
6+
const { DragAction } = slint.language;
67

78
const ui = slint.loadFile(new URL("kanban.slint", import.meta.url));
89
const appWindow = new ui.MainWindow();
910

1011
// What we attach to each `DataTransfer` via the `userData` property. A copy of
11-
// the `TaskData` plus the row it came from, so `source-column-of` can answer
12-
// the .slint side and `move-task` knows what to remove on a move.
12+
// the `TaskData` plus the row it came from, so `can-drop` recognizes our own
13+
// payloads and `dropped` knows what to remove on a move.
1314
class DragPayload {
1415
constructor(task, sourceColumn, sourceIndex) {
1516
this.task = task;
@@ -43,46 +44,48 @@ appWindow.Api.make_data = (task, sourceColumn, sourceIndex) => {
4344
return transfer;
4445
};
4546

46-
appWindow.Api.source_column_of = (data) => {
47-
const payload = data.userData;
48-
return payload instanceof DragPayload ? payload.sourceColumn : -1;
49-
};
50-
51-
appWindow.Api.has_plaintext = (data) => data.hasPlaintext;
52-
53-
appWindow.Api.add_task = (data, targetColumn, targetIndex) => {
54-
if (targetColumn < 0 || targetColumn >= columns.length) return;
55-
const payload = data.userData;
56-
if (payload instanceof DragPayload) {
57-
columns[targetColumn].insert(targetIndex, payload.task);
58-
return;
47+
appWindow.Api.can_drop = (event, _targetColumn, _targetIndex) => {
48+
if (event.data.userData instanceof DragPayload) {
49+
// Our own card: accept whatever modifier the user is holding.
50+
return event.proposed_action;
5951
}
60-
if (data.hasPlaintext) {
61-
columns[targetColumn].insert(targetIndex, { title: data.fetchPlaintext() });
52+
if (event.data.hasPlaintext) {
53+
// External plaintext drop: always treated as a copy.
54+
return DragAction.Copy;
6255
}
56+
return DragAction.None;
6357
};
6458

65-
appWindow.Api.move_task = (data, targetColumn, targetIndex) => {
66-
const payload = data.userData;
67-
if (!(payload instanceof DragPayload)) return;
59+
appWindow.Api.dropped = (event, targetColumn, targetIndex) => {
6860
if (targetColumn < 0 || targetColumn >= columns.length) return;
69-
const source = payload.sourceColumn;
70-
const sourceIndex = payload.sourceIndex;
61+
const payload = event.data.userData;
62+
63+
if (payload instanceof DragPayload) {
64+
if (event.proposed_action !== DragAction.Move) {
65+
// Anything that isn't an explicit move is treated as a copy.
66+
columns[targetColumn].insert(targetIndex, payload.task);
67+
return;
68+
}
69+
const source = payload.sourceColumn;
70+
const sourceIndex = payload.sourceIndex;
7171

72-
if (source === targetColumn) {
73-
// Same-column reorder. Drops at the source slot or immediately after
74-
// it are no-ops; otherwise remove the source first, adjusting the
75-
// target index for the shift that the removal causes.
76-
if (targetIndex === sourceIndex || targetIndex === sourceIndex + 1) return;
77-
const task = payload.task;
78-
columns[source].remove(sourceIndex, 1);
79-
const adjusted = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex;
80-
columns[targetColumn].insert(adjusted, task);
81-
} else {
82-
// Cross-column move. Source and target are independent models, so the
83-
// order of operations doesn't affect index stability.
84-
columns[source].remove(sourceIndex, 1);
85-
columns[targetColumn].insert(targetIndex, payload.task);
72+
if (source === targetColumn) {
73+
// Same-column reorder. Drops at the source slot or immediately
74+
// after it are no-ops; otherwise remove the source first, adjusting
75+
// the target index for the shift that the removal causes.
76+
if (targetIndex === sourceIndex || targetIndex === sourceIndex + 1) return;
77+
const task = payload.task;
78+
columns[source].remove(sourceIndex, 1);
79+
const adjusted = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex;
80+
columns[targetColumn].insert(adjusted, task);
81+
} else {
82+
// Cross-column move. Source and target are independent models, so
83+
// the order of operations doesn't affect index stability.
84+
columns[source].remove(sourceIndex, 1);
85+
columns[targetColumn].insert(targetIndex, payload.task);
86+
}
87+
} else if (event.data.hasPlaintext) {
88+
columns[targetColumn].insert(targetIndex, { title: event.data.fetchPlaintext() });
8689
}
8790
};
8891

examples/dnd-kanban/main.py

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55

66
import slint
77
from slint import DataTransfer, ListModel
8+
from slint.language import DragAction, DropEvent
89

910
TaskData = slint.loader.kanban.TaskData
1011

1112

1213
# What we attach to each `DataTransfer` via `set_user_data`. A copy of the
13-
# `TaskData` plus the row it came from, so `source-column-of` can answer the
14-
# .slint side and `move-task` knows what to remove on a move.
14+
# `TaskData` plus the row it came from, so `can-drop` recognizes our own
15+
# payloads and `dropped` knows what to remove on a move.
1516
@dataclass
1617
class DragPayload:
1718
task: TaskData
@@ -50,56 +51,53 @@ def make_data(
5051
transfer.user_data = DragPayload(task, source_column, source_index)
5152
return transfer
5253

53-
@slint.callback(global_name="Api", name="source-column-of")
54-
def source_column_of(self, data: DataTransfer) -> int:
55-
payload = data.user_data
56-
return payload.source_column if isinstance(payload, DragPayload) else -1
54+
@slint.callback(global_name="Api", name="can-drop")
55+
def can_drop(
56+
self, event: DropEvent, target_column: int, target_index: int
57+
) -> DragAction:
58+
if isinstance(event.data.user_data, DragPayload):
59+
# Our own card: accept whatever modifier the user is holding.
60+
return event.proposed_action
61+
if event.data.has_plaintext:
62+
# External plaintext drop: always treated as a copy.
63+
return DragAction.copy
64+
return DragAction.none
5765

58-
@slint.callback(global_name="Api", name="has-plaintext")
59-
def has_plaintext(self, data: DataTransfer) -> bool:
60-
return data.has_plaintext
61-
62-
@slint.callback(global_name="Api", name="add-task")
63-
def add_task(
64-
self, data: DataTransfer, target_column: int, target_index: int
65-
) -> None:
66+
@slint.callback(global_name="Api", name="dropped")
67+
def dropped(self, event: DropEvent, target_column: int, target_index: int) -> None:
6668
if not 0 <= target_column < len(self._columns):
6769
return
68-
payload = data.user_data
69-
if isinstance(payload, DragPayload):
70-
self._columns[target_column].insert(target_index, payload.task)
71-
return
72-
text = data.fetch_plaintext()
73-
if text is not None:
74-
self._columns[target_column].insert(target_index, TaskData(title=text))
70+
payload = event.data.user_data
7571

76-
@slint.callback(global_name="Api", name="move-task")
77-
def move_task(
78-
self, data: DataTransfer, target_column: int, target_index: int
79-
) -> None:
80-
payload = data.user_data
81-
if not isinstance(payload, DragPayload):
82-
return
83-
if not 0 <= target_column < len(self._columns):
84-
return
85-
source = payload.source_column
86-
source_index = payload.source_index
87-
88-
if source == target_column:
89-
# Same-column reorder. Drops at the source slot or immediately
90-
# after it are no-ops; otherwise remove the source first and
91-
# adjust the target index for the shift that the removal causes.
92-
if target_index == source_index or target_index == source_index + 1:
72+
if isinstance(payload, DragPayload):
73+
if event.proposed_action != DragAction.move:
74+
# Anything that isn't an explicit move is treated as a copy.
75+
self._columns[target_column].insert(target_index, payload.task)
9376
return
94-
task = payload.task
95-
del self._columns[source][source_index]
96-
adjusted = target_index - 1 if target_index > source_index else target_index
97-
self._columns[target_column].insert(adjusted, task)
77+
source = payload.source_column
78+
source_index = payload.source_index
79+
80+
if source == target_column:
81+
# Same-column reorder. Drops at the source slot or immediately
82+
# after it are no-ops; otherwise remove the source first and
83+
# adjust the target index for the shift that the removal causes.
84+
if target_index == source_index or target_index == source_index + 1:
85+
return
86+
task = payload.task
87+
del self._columns[source][source_index]
88+
adjusted = (
89+
target_index - 1 if target_index > source_index else target_index
90+
)
91+
self._columns[target_column].insert(adjusted, task)
92+
else:
93+
# Cross-column move. Source and target are independent models,
94+
# so the order of operations doesn't affect index stability.
95+
del self._columns[source][source_index]
96+
self._columns[target_column].insert(target_index, payload.task)
9897
else:
99-
# Cross-column move. Source and target are independent models, so
100-
# the order of operations doesn't affect index stability.
101-
del self._columns[source][source_index]
102-
self._columns[target_column].insert(target_index, payload.task)
98+
text = event.data.fetch_plaintext()
99+
if text is not None:
100+
self._columns[target_column].insert(target_index, TaskData(title=text))
103101

104102

105103
main_window = MainWindow()

0 commit comments

Comments
 (0)