ewx: (geek)
[personal profile] ewx

Disobedience, DisOrder's GUI client, uses a GtkTreeView to display its current queue of tracks (and for various other purposes). It allows rearrangement of the queue by drag and drop. The high-level support for this in GtkTreeView is rather unsatisfactory for two reasons:

  1. You can only drag one track (row) at a time. It should be possible to drag the whole selection.
  2. You cannot drag between widgets. Playlist editing in particular is likely to make this important.

(Actually the model where it reports the effects of the drag and drop by inserted/deleted signals on the underlying model is also quite inconvenient for me: Disobedience has to translate it back into a move which it communicates to the server.)

Fixing this involves doing rather a lot of the drag-and-drop work yourself, and while the documentation does exist it is not always clear or even complete. There is existing code out there, and I referred to it while working some of this out; but describing it in English online seems worthwhile. In addition, while some of the code is inextricably mixed with Disobedience's internal workings, a couple of independent modules are presented, which should be usable in other C programs.

This articles assumes some familiarity with GTK+. I have tried as far as reasonably possible to remove DisOrder-specific issues from the discussion here, but note that this isn't always possible.

1. Low-level drag and drop

See queue-generic.c for the full code of this section in context.

1.1 Setup

First it's necessary to define possible types of data to drag using a target array.

static const GtkTargetEntry queuelike_targets[] = {
  {
    (char *)"text/x-disorder-queued-tracks",
    GTK_TARGET_SAME_WIDGET,
    0
  },
  {
    (char *)"text/x-disorder-playable-tracks",
    GTK_TARGET_SAME_APP|GTK_TARGET_OTHER_WIDGET,
    1
  },
};

The first field is a string drag type; if there's a standard way to choose these I've not yet discovered it, but MIME content types seem to be popular.

The second is a set of flags defining possible relationships between source and destination. For queued tracks, dragging is restricted to rearrangement within the same widget. For tracks to play, it is restricted to other widgets within the same application.

The final field is an integer index value used to identify the type. It will be supplied to some of the signal handlers. Note that it does not identify the particular entry in the targets array, so having two entries with the same type name but different indexes won't work very well!

The name queuelike_targets: a queuelike is a list of tracks: instances are the queue itself, the recently-played list, the recently-added list, and in the future playlists. They correspond to tabs in the UI. All of them will be updated by the server, but the queue can also be updated by the user, by adding, removing and rearranging tracks. The goal, of course, is for rearrangement to be possible using drag and drop within the queue and for adding to be possible by dragging from outside it.

It is necessary to mark the widget as both a drag source and destination. For queuelikes this is done as follows:

gtk_drag_source_set(ql->view,
                    GDK_BUTTON1_MASK,
                    queuelike_targets,
                    sizeof queuelike_targets / sizeof *queuelike_targets,
                    GDK_ACTION_MOVE);
gtk_drag_dest_set(ql->view,
                  GTK_DEST_DEFAULT_HIGHLIGHT|GTK_DEST_DEFAULT_DROP,
                  queuelike_targets,
                  sizeof queuelike_targets / sizeof *queuelike_targets,
                  GDK_ACTION_MOVE|GDK_ACTION_COPY);

(ql->view is just the GtkTreeView used to display whichever queuelike it is.)

GDK_ACTION_MOVE restricts the possible set of drag types (move, copy, etc). The queue requires arbitrary rearrangement, so it is capable of participating in moves both as a source and as a destination; and in files being dragged into it from outside, hence its ability to take part in copy-dragging as a destination (only).

GTK_DEST_DEFAULT_HIGHLIGHT arrange for whole-widget highlighting to be done automatically for destinations. I don't know why you wouldn't set this.

GTK_DEST_DEFAULT_DROP arranges for the "drag-drop" signal to be handled automatically by destinations. It will request the data from the source; the "drag-data-received" signal will be used to actually communicate the data to the destination. There is a caveat about this: if you ever request the drag data in any other way then you can't use this option as it won't be able to tell the difference between your drag data and its.

A variety of signals are then handled. For rearrangeable queues, we handle "drag-motion", "drag-leave", "drag-data-get" and "drag-data-received". These are discussed below.

For non-modifable queuelikes the code is a subset of the above: the gtk_drag_source_set() call (but with GDK_ACTION_COPY), and the "drag-data-get" signal handler. The widget is not marked as a drag destination and the other signals are not handled.

1.2 "drag-motion" handler

This signal applies to the destination and is emitted whenever the pointer moves over it during a drag. It has two main jobs: decide whether a drop at the current location is appropriate, and provide visual feedback about this.

These two operations are fairly separate in Disobedience though in other programs might well be more entangled. For the first half, the logic used by GtkTreeView is replicated, although in a simpler fashion, taking advantage of the small set of actions that Disobedience uses:

if(dc->suggested_action) {
  if(dc->suggested_action & (GDK_ACTION_MOVE|GDK_ACTION_COPY))
    action = dc->suggested_action;
} else if(dc->actions & GDK_ACTION_MOVE)
  action = GDK_ACTION_MOVE;
else if(dc->actions & GDK_ACTION_COPY)
  action = GDK_ACTION_COPY;

The full version can be found in gtk_drag_dest_motion() in gtkdnd.c - it only runs if GTK_DEST_DEFAULT_MOTION is set, which is why Disobedience needs to reimplement it.

Having done that it is checked whether this widget is a suitable destination. This is where the GtkTargetEntry array defined above comes into play.

if(action) {
  if(gtk_drag_dest_find_target(w, dc, NULL) == GDK_NONE)
    action = 0;
}

This much is enough to call gdk_drag_status() to indicate whether the drag will succeed and if so what action to use. If this depended upon the content of the drag then a more sophisticated approach would be required: the "drag-motion" handler would have to use gtk_drag_get_data() and field the response with the "drag-data-received" handler. This in turn would mean supplying a "drag-drop" handler which reimplemented the logic of GTK_DEST_DEFAULT_DROP but was also capable of distinguishing the intermediate checking drop from a final drop.

gdk_drag_status(dc, action, time);

The second half is implemented by calling gtk_tree_view_get_dest_row_at_pos() to find the target row. In Disobedience's case there is a bit of extra complexity. Firstly, you can't drop onto a row, only between rows, so GTK_TREE_VIEW_DROP_INTO_OR_BEFORE is replaced with GTK_TREE_VIEW_DROP_BEFORE and similarly for the _AFTER variants.

gtk_tree_view_convert_widget_to_tree_coords(GTK_TREE_VIEW(w),
                                            wx, wy, &tx, &ty);
  if(gtk_tree_view_get_dest_row_at_pos(GTK_TREE_VIEW(w),
                                       wx, wy,
                                       &path,
                                       &pos)) {
  switch(pos) {
  case GTK_TREE_VIEW_DROP_INTO_OR_BEFORE:
    pos = GTK_TREE_VIEW_DROP_BEFORE;
    break;
  case GTK_TREE_VIEW_DROP_INTO_OR_AFTER:
    pos = GTK_TREE_VIEW_DROP_AFTER;
    break;
  default: break;
  }

Secondly, if the drop is above the first row or below the last row gtk_tree_view_get_dest_row_at_pos returns no path, so the result has to be adjusted accordingly. Note that to distinguish between the two cases it's necessary to use tree coordinates, not the widget coordinates that the callback is supplied.

} else if(gtk_tree_model_get_iter_first(model, iter)) {
  if(ty >= 0) {
    do {
      *last = *iter;
    } while(gtk_tree_model_iter_next(model, iter));
    pos = GTK_TREE_VIEW_DROP_AFTER;
    *iter = *last;
  } else {
    pos = GTK_TREE_VIEW_DROP_BEFORE;
  }
  path = gtk_tree_model_get_path(model, iter);
}

model is the underlying GtkTreeModel; in this case a GtkListStore. Remember that paths must be freed after use.

It's worth pointing out that gtk_tree_view_convert_widget_to_tree_coords() only appears in GTK+ 2.12. So this code won't work on versions older than a couple of years.

1.3 "drag-leave" handler

This signal applies to the destination and is very simple: the visual feedback showing where data will be dropped is removed.

gtk_tree_view_set_drag_dest_row(GTK_TREE_VIEW(w), NULL, 0);

1.4 "drag-data-get" handler

This signal applies to the source. Its job is to put together the data to be dropped. In Disobedience, this is always because the data is about to be dropped but in the general case it could be because some candidate destination needs to be able to do fine-grained rejection of possible drops. See the discussion of GTK_DEST_DEFAULT_DROP above.

In Disobedience's case, the data will be a string consisting of track IDs and names. Once it has been constructed it is communicated back to GTK+, which will subsequently make it available to the destination via the "drag-data-received" signal.

gtk_selection_data_set(data,
                       GDK_TARGET_STRING,
                       8, (guchar *)result->vec, result->nvec);

GDK_TARGET_STRING indicates that the data has string type and 8 is the number of bits per unit. This bit seems to be particularly opaquely documented, I got there with a fair bit of trial and error.

1.5 "drag-data-received" handler

This signal is called on the destination when droppable data has been received. (As discussed earlier) it can in principle happen any time during a drag and drop operation, but in Disobedience's case it only happens when the final drop occurs. The data is retrieved as follows:

result = (char *)gtk_selection_data_get_text(data);

To figure out where to drop, the same code as used by the "drag-motion" handler is called; it's important that these match as otherwise the visual feedback and the actual action will not be consistent.

2. Multiple-row drag and drop

GtkTreeView's built-in drag and drop support only handles one row at a time, which is not really adequate, and is one of the reasons for going to so much effort reinventing the wheel. The code above will cope with multiple rows just fine but there is one problem: as soon as the user presses a button to start a drag, the selection will be reduced to just one row!

The answer is found in Quodlibet's qltk/views.py: intercept the button press and release events and suppress the change to the selection unless they're in the same place.

My C implementation of this concept can be found in multidrag.c. The code is very liberally licensed.

The "button-press-event" handler does the following things:

  1. Discard any cached click location and unblock selection.
  2. If the click isn't button 1, or is modified (shifted etc), return.
  3. If the click doesn't identify some path in the tree, return.
  4. If the path is not selected, return.
  5. Otherwise, the click must be an unmodified left click on an identifitable, selected path in the tree. Attempts to modify the selection are blocked, and the pointer position is saved.

Blocking the selection is done via gtk_tree_selection_set_select_function(). The pointer position is stashed using g_object_set_data() and g_object_get_data().

The "button-release-event" handler does the following:

  1. If no click is remembered, return. In this case it's assumed that the selection can't be blocked, too.
  2. Unblock selection attempts.
  3. If the click is in the same place, find the path at that location and point the tree view cursor at it. This mimics the effect that the original click would have had.

This source file also handles the "drag-begin" signal and uses it to construct a drag icon out of the rows to be dragged. This isn't discussed in detail here.

3. Automatic scrolling

There is one more part of the jigsaw: when the pointer is near the top or bottom of a destination window, it should scroll up or down respectively, to allow drops in locations outside the current viewport. GtkTreeView only does this natively if you use the high-level interface and the code that implements it isn't exposed to callers.

I solved this by bringing this code out into autoscroll.c. The code is derived directly from the GTK+ source code (although modified to work without direct access to private data structures) and therefore retains the LGPL2+ license.

There are two entry points: autoscroll_add() enables automatic scrolling when the pointer is near the top or bottom of the window and autoscroll_remove() disables it.

autoscroll_add() is called at the end of the "drag-motion" handler, and autoscroll_remove() in the "drag-leave" handler. The GTK+ equivalents are called in a few other contexts but in fact "drag-leave" is raised when a drop completes as well as when the pointer leaves the destination, so this turns out to be sufficient.

References

February 2025

S M T W T F S
      1
2345678
9101112131415
16171819202122
232425262728 

Most Popular Tags

Expand Cut Tags

No cut tags