https://bugs.kde.org/show_bug.cgi?id=525896

            Bug ID: 525896
           Summary: KWin re-claims the PRIMARY selection with stale
                    content when a client clears it, breaking mouse text
                    selection in GTK3 applications
    Classification: Plasma
           Product: kwin
      Version First 6.7.5
       Reported In:
          Platform: Arch Linux
                OS: Linux
            Status: REPORTED
          Severity: normal
          Priority: NOR
         Component: wayland-generic
          Assignee: [email protected]
          Reporter: [email protected]
  Target Milestone: ---

Created attachment 196321
  --> https://bugs.kde.org/attachment.cgi?id=196321&action=edit
video demo

# KWin re-claims the PRIMARY selection with stale content when a client clears
it, breaking mouse text selection in GTK3 applications

## Summary

On a Plasma Wayland session, a GTK3 application cannot start a **new** mouse
selection
while a selection already exists. Pressing the mouse button somewhere else and
dragging
clears the old selection but never creates a new one. Clicking once to clear
the
selection first, and only then dragging, works.

The text is not moved, so this is not drag-and-drop of the selected text: the
press
happens outside the existing selection.

The cause is that KWin claims the `PRIMARY` selection, offering the *previous*
selection content plus the marker type `application/x-kde-onlyReplaceEmpty`, at
the
exact moment a client clears `PRIMARY` -- which GTK3 does in the middle of
every
selection gesture. GTK3 then drops its selection, because losing `PRIMARY`
means
"someone else now holds the selection".

This affects every GTK3 text widget, not one application. It reproduces in a
~130-line
GTK3 program with no dependencies beyond `gtk+-3.0`.

## Steps to reproduce

1. Open any GTK3 application with a text area (Mousepad, gedit, or the
reproducer below).
2. Drag-select some text on the first line.
3. Without clicking anywhere first, press the mouse button on the second line
and drag
   along it, as you would to select that line.

**Expected:** the first selection is dropped and the second line gets selected.

**Actual:** the first selection is dropped and nothing gets selected. The
second line
is only selectable after an extra single click that clears the selection.

## Environment

- Arch Linux, Plasma 6.x, Wayland session
- kwin 6.7.5, gtk3 1:3.24.52-1
- Reproduces in Mousepad 0.7.0 and in the minimal GTK3 reproducer below

Does **not** reproduce with the same GTK version on:

- the same machine under X11
- Wayland + sway (wlroots)
- Wayland + Mutter (GNOME), tested on identical Arch packages

## What happens, step by step

GTK3 ties the visible text selection to ownership of the `PRIMARY` selection.

`gtk/gtktextbuffer.c`, `update_selection_clipboards()`:

```c
if (has_selection)
  {
    /* Even if we already have the selection, we need to update our timestamp.
*/
    gtk_clipboard_set_with_owner (clipboard, ..., clipboard_get_selection_cb,
                                  clipboard_clear_selection_cb, G_OBJECT
(buffer));
  }
else if (gtk_clipboard_get_owner (clipboard) == G_OBJECT (buffer))
  gtk_clipboard_clear (clipboard);
```

and `gtk/gtktextbuffer.c:3187`:

```c
/* Called when we lose the primary selection. */
static void
clipboard_clear_selection_cb (GtkClipboard *clipboard, gpointer data)
{
  /* Move selection_bound to the insertion point */
  ...
  if (!gtk_text_iter_equal (&insert, &selection_bound))
    gtk_text_buffer_move_mark (buffer,
                               gtk_text_buffer_get_selection_bound (buffer),
                               &insert);
}
```

So:

1. The button press starts a new selection drag, which first collapses the old
   selection (`GtkTextView` places the cursor at the press position).
2. The buffer now has no selection, so GTK releases `PRIMARY`
   (`gtk_clipboard_clear()`).
3. **KWin immediately claims `PRIMARY`**, offering the previously selected text
plus
   `application/x-kde-onlyReplaceEmpty`.
4. From then on, every motion event of the drag extends the selection, which
calls
   `update_selection_clipboards()`; the application no longer owns `PRIMARY`,
   `clipboard_clear_selection_cb()` fires and moves `selection_bound` back onto
   `insert`. The selection is collapsed as fast as it is created, so the drag
produces
   nothing.

Starting a drag when no selection exists works because the application does not
own
`PRIMARY` at press time, nothing is released mid-gesture, and the drag acquires
ownership cleanly.

## Evidence that it is KWin

`application/x-kde-onlyReplaceEmpty` appears in exactly one file on the system,
and the
string sits right next to the `zwp_primary_selection_*` protocol strings:

```
$ grep -rl "onlyReplaceEmpty" /usr/lib /usr/bin /usr/share
/usr/lib/libkwin.so.6.7.5
```

It is not in `libklipper.so`, the clipboard applet, or `plasmashell`.
Consistently with
that, the Klipper options that look related make no difference, including after
restarting `plasma-plasmashell`:

- **Text selection: Only when explicitly copied** (`IgnoreSelection=true`)
- **Selection and Clipboard: Always save in history** unchecked
(`SyncClipboards=false`)

The content KWin offers is stale: in the log below it is
`"gtk-primary-selection-bug.c"`,
a string selected long before the test, not anything selected during it.

## Actual log

Output of the reproducer below on the affected machine. The failure is the pair
of
`owner-change` / `now offered by someone else` lines right after the second
press:

```
  [PRIMARY owner-change] we own it: NO (taken away), NO SELECTION
  [PRIMARY owner-change] we own it: NO (taken away), NO SELECTION
  [PRIMARY now offered by someone else] types: text/plain;charset=utf-8,
text/plain
  [PRIMARY now offered by someone else] types: text/plain;charset=utf-8,
text/plain
  [PRIMARY content] "gtk-primary-selection-bug.c"
  [PRIMARY content] "gtk-primary-selection-bug.c"

--- first drag on line 1: works ---
press#1 btn=1 at (151,13) event_window=text window (ok) had_selection=no
  selection_bound moved: NO SELECTION
  [PRIMARY owner-change] we own it: yes, selection 25..26 (1 chars)
  [PRIMARY owner-change] we own it: yes, selection 23..26 (3 chars)
  ... (one owner-change per motion event, selection growing) ...
  [PRIMARY owner-change] we own it: yes, selection 9..26 (17 chars)
  after release: selection 9..26 (17 chars)

--- second drag on line 2, without clearing the selection first: fails ---
press#1 btn=1 at (207,32) event_window=text window (ok) had_selection=yes
  selection_bound moved: NO SELECTION
  [PRIMARY owner-change] we own it: NO (taken away), NO SELECTION
  [PRIMARY now offered by someone else] types:
  [PRIMARY content] (none)
  [PRIMARY owner-change] we own it: NO (taken away), NO SELECTION
  [PRIMARY now offered by someone else] types:
application/x-kde-onlyReplaceEmpty, text/plain;charset=utf-8, text/plain
  [PRIMARY content] "gtk-primary-selection-bug.c"
  selection_bound moved: NO SELECTION
  selection_bound moved: NO SELECTION
  selection_bound moved: NO SELECTION
  selection_bound moved: NO SELECTION
  after release: NO SELECTION
  [PRIMARY now offered by someone else] types:
application/x-kde-onlyReplaceEmpty, text/plain;charset=utf-8, text/plain
  [PRIMARY content] "gtk-primary-selection-bug.c"
```

For comparison, the same reproducer and the same GTK version under sway, where
nothing
claims `PRIMARY`:

```
press#1 btn=1 at (10,8) event_window=text window (ok) had_selection=no
  selection_bound moved: NO SELECTION
  after release: selection 2..26 (24 chars)
press#1 btn=1 at (10,25) event_window=text window (ok) had_selection=yes
  selection_bound moved: NO SELECTION
  after release: selection 28..56 (28 chars)
```

No `owner-change` events, and the second drag selects normally.

## Impact

Selecting text with the mouse is broken in every GTK3 application on a Plasma
Wayland
session whenever a selection is already present. There is no known workaround:
the
Klipper settings do not affect it, and the behaviour comes from the compositor
itself.

## Suggested fix

An empty `PRIMARY` selection is a meaningful state -- "nothing is selected
right now" --
not an accident that needs repairing. Re-claiming `PRIMARY` with previous
content when
a client clears it tells every application on the desktop that its selection is
no
longer current, which is exactly what GTK3 (and Motif-style X11 selection
semantics in
general) reacts to by dropping the highlight.

Whatever "do not let the selection become empty" logic exists should either

- not apply to `PRIMARY` at all (only to `CLIPBOARD`, where persisting content
after an
  application exits is genuinely useful), or
- trigger only when the owning client actually disconnects, not when a live
client
  clears the selection.

For reference, GTK3 also contributes to the race by releasing `PRIMARY` for a
moment in
the middle of a selection gesture. But that behaviour is long-standing, correct
on X11,
and GTK3 is in maintenance mode, while no other tested compositor (sway,
Mutter) turns
it into a bug.

## Minimal reproducer

```c
/*
 * Minimal reproducer: a new mouse selection cannot be started while a
 * selection already exists, if something else claims the PRIMARY selection.
 *
 * Build:  gcc gtk-primary-selection-bug.c -o gtk-primary-selection-bug
$(pkg-config --cflags --libs gtk+-3.0)
 * Run:    ./gtk-primary-selection-bug
 *
 * Steps: drag-select line 1 with the mouse. Then, WITHOUT clicking to clear
 * it, press the mouse on line 2 and drag. Expected: line 2 gets selected.
 */
#include <gtk/gtk.h>
#include <string.h>

static void
dump_selection (GtkTextBuffer *buf, const char *prefix)
{
  GtkTextIter s, e;

  if (gtk_text_buffer_get_selection_bounds (buf, &s, &e))
    g_print ("%sselection %d..%d (%d chars)\n", prefix,
             gtk_text_iter_get_offset (&s), gtk_text_iter_get_offset (&e),
             gtk_text_iter_get_offset (&e) - gtk_text_iter_get_offset (&s));
  else
    g_print ("%sNO SELECTION\n", prefix);
}

static void
targets_received (GtkClipboard *clipboard, GdkAtom *atoms, gint n_atoms,
gpointer data)
{
  GString *str = g_string_new ("  [PRIMARY now offered by someone else] types:
");
  gint i;

  for (i = 0; i < n_atoms; i++)
    {
      gchar *name = gdk_atom_name (atoms[i]);
      g_string_append_printf (str, "%s%s", i ? ", " : "", name);
      g_free (name);
    }
  if (n_atoms == 0)
    g_string_append (str, "(none)");

  g_print ("%s\n", str->str);
  g_string_free (str, TRUE);
}

static void
text_received (GtkClipboard *clipboard, const gchar *text, gpointer data)
{
  gchar *shown;

  if (text == NULL)
    {
      g_print ("  [PRIMARY content] (none)\n");
      return;
    }

  shown = g_strndup (text, 60);
  g_strdelimit (shown, "\n", ' ');
  g_print ("  [PRIMARY content] \"%s\"%s\n", shown, strlen (text) > 60 ? "..."
: "");
  g_free (shown);
}

static gboolean
inspect_primary (gpointer data)
{
  GtkClipboard *clipboard = gtk_widget_get_clipboard (GTK_WIDGET (data),
GDK_SELECTION_PRIMARY);

  gtk_clipboard_request_targets (clipboard, targets_received, NULL);
  gtk_clipboard_request_text (clipboard, text_received, NULL);

  return G_SOURCE_REMOVE;
}

static gboolean
on_press (GtkWidget *view, GdkEventButton *ev, gpointer data)
{
  GdkWindow *text_win = gtk_text_view_get_window (GTK_TEXT_VIEW (view),
GTK_TEXT_WINDOW_TEXT);
  GdkWindow *ev_win = gdk_event_get_window ((GdkEvent *) ev);
  GtkTextBuffer *buf = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));

  g_print ("press#%d btn=%d at (%.0f,%.0f) event_window=%s had_selection=%s\n",
           ev->type == GDK_BUTTON_PRESS ? 1 : (ev->type == GDK_2BUTTON_PRESS ?
2 : 3),
           ev->button, ev->x, ev->y,
           ev_win == text_win ? "text window (ok)" : "OTHER WINDOW",
           gtk_text_buffer_get_has_selection (buf) ? "yes" : "no");
  return FALSE;
}

static gboolean
on_release (GtkWidget *view, GdkEventButton *ev, gpointer data)
{
  GtkTextBuffer *buf = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));

  dump_selection (buf, "  after release: ");

  /* the drag produced nothing: report who is holding PRIMARY instead of us */
  if (!gtk_text_buffer_get_has_selection (buf))
    g_idle_add (inspect_primary, view);

  return FALSE;
}

static void
on_owner_change (GtkClipboard *clipboard, GdkEvent *event, gpointer data)
{
  GtkTextBuffer *buf = gtk_text_view_get_buffer (GTK_TEXT_VIEW (data));
  gboolean ours = (gtk_clipboard_get_owner (clipboard) != NULL);

  g_print ("  [PRIMARY owner-change] we own it: %s, ", ours ? "yes" : "NO
(taken away)");
  dump_selection (buf, "");

  /* someone else took it: find out what they are offering */
  if (!ours)
    g_idle_add (inspect_primary, data);
}

static void
on_mark_set (GtkTextBuffer *buf, const GtkTextIter *loc, GtkTextMark *mark,
gpointer d)
{
  if (mark == gtk_text_buffer_get_selection_bound (buf))
    dump_selection (buf, "  selection_bound moved: ");
}

int
main (int argc, char **argv)
{
  GtkWidget *win, *sw, *view;
  GtkTextBuffer *buf;

  gtk_init (&argc, &argv);

  win = gtk_window_new (GTK_WINDOW_TOPLEVEL);
  gtk_window_set_default_size (GTK_WINDOW (win), 640, 300);
  gtk_window_set_title (GTK_WINDOW (win), "PRIMARY selection bug");

  sw = gtk_scrolled_window_new (NULL, NULL);
  view = gtk_text_view_new ();
  buf = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
  gtk_text_buffer_set_text (buf,
                            "first line: drag-select me\n"
                            "second line: then drag here without clicking
first\n"
                            "third line\n"
                            "fourth line\n", -1);

  g_signal_connect (view, "button-press-event", G_CALLBACK (on_press), NULL);
  g_signal_connect (view, "button-release-event", G_CALLBACK (on_release),
NULL);
  g_signal_connect (buf, "mark-set", G_CALLBACK (on_mark_set), NULL);

  gtk_container_add (GTK_CONTAINER (sw), view);
  gtk_container_add (GTK_CONTAINER (win), sw);
  g_signal_connect (win, "destroy", G_CALLBACK (gtk_main_quit), NULL);
  gtk_widget_show_all (win);

  g_signal_connect (gtk_widget_get_clipboard (view, GDK_SELECTION_PRIMARY),
                    "owner-change", G_CALLBACK (on_owner_change), view);

  gtk_main ();
  return 0;
}
```

I also attach video where I reproduce bug.

-- 
You are receiving this mail because:
You are watching all bug changes.

Reply via email to