]> git.ipfire.org Git - thirdparty/vim.git/commitdiff
patch 9.2.0518: GTK4: input method cannot compose text v9.2.0518
authorYasuhiro Matsumoto <mattn.jp@gmail.com>
Sat, 23 May 2026 18:25:16 +0000 (18:25 +0000)
committerChristian Brabandt <cb@256bit.org>
Sat, 23 May 2026 18:25:16 +0000 (18:25 +0000)
Problem:  GTK4: input method cannot compose text
          (lilydjwg, after v9.2.0501)
Solution: Render the over-the-spot preedit with a GtkPopover instead of
          a separate toplevel, so the compositor keeps
          keyboard focus on the drawing area and does not disable
          text-input-v3; attach the key controller to gui.drawarea and
          filter key events manually with gtk_im_context_filter_keypress()
          (Yasuhiro Matsumoto)

fixes:  #20257
closes: #20266

Co-Authored-by: lilydjwg <lilydjwg@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Yasuhiro Matsumoto <mattn.jp@gmail.com>
Signed-off-by: Christian Brabandt <cb@256bit.org>
src/gui_gtk4.c
src/gui_xim.c
src/version.c

index 508a33d587e12479c8ff532604b503339ffec90b..bc66ba9e7f6499316d35249e853643f21668f6c5 100644 (file)
@@ -528,7 +528,10 @@ gui_mch_init(void)
                         G_CALLBACK(key_press_event), NULL);
        g_signal_connect(key_ctrl, "key-released",
                         G_CALLBACK(key_release_event), NULL);
-       gtk_widget_add_controller(gui.mainwin, key_ctrl);
+       gtk_widget_add_controller(gui.drawarea, key_ctrl);
+#ifdef FEAT_XIM
+       xim_init();
+#endif
     }
 
     {
@@ -1103,6 +1106,10 @@ gui_mch_init_font(char_u *font_name, int fontset UNUSED)
     get_styled_font_variants();
     ascii_glyph_table_init();
 
+    // im window position depends on cursor size which depends on font metrics
+    // update the position after we've initialized font
+    im_set_position(gui.row, gui.col);
+
     return OK;
 }
 
@@ -1859,15 +1866,14 @@ drawarea_realize_cb(GtkWidget *widget UNUSED, gpointer data UNUSED)
     gui.surface = create_backing_surface(w, h);
 
     gui_mch_new_colors();
-
-#ifdef FEAT_XIM
-    xim_init();
-#endif
 }
 
     static void
 drawarea_unrealize_cb(GtkWidget *widget UNUSED, gpointer data UNUSED)
 {
+#ifdef FEAT_XIM
+    im_shutdown();
+#endif
     if (gui.surface != NULL)
     {
        cairo_surface_destroy(gui.surface);
index acc6351936075557c033a57abf2107d4ad129be9..ec4620f38ba8f3a5cdddf5b982fba2dacd57e9a1 100644 (file)
@@ -198,6 +198,15 @@ static unsigned int  im_activatekey_state  = 0;
 
 static GtkWidget *preedit_window = NULL;
 static GtkWidget *preedit_label = NULL;
+#  ifdef USE_GTK4
+static int preedit_label_width = 0;    // last measured popover natural width
+static int preedit_popover_height = 0; // last measured popover natural height
+// CSS provider that styles the preedit popover frame; rebuilt whenever
+// gui.back_pixel changes so the popover background matches the editor's
+// background colour and fully covers the cursor cell.
+static GtkCssProvider *preedit_css_provider = NULL;
+static guicolor_T preedit_css_cached_bg = INVALCOLOR;
+#  endif
 
 static void im_preedit_window_set_position(void);
 
@@ -235,9 +244,28 @@ im_set_position(int row, int col)
 
     area.x = FILL_X(col);
     area.y = FILL_Y(row);
-    area.width  = gui.char_width * (mb_lefthalve(row, col) ? 2 : 1);
+    // The screen buffer may not be allocated yet when this is called from
+    // xim_init() during gui_mch_init() to seed the IM's initial cursor
+    // location, so guard mb_lefthalve() against a NULL LineOffset[].
+    area.width  = gui.char_width
+                   * ((ScreenLines != NULL && mb_lefthalve(row, col)) ? 2 : 1);
     area.height = gui.char_height;
 
+#  ifdef USE_GTK4
+    // Stretch the cursor rect height to cover the preedit popover so the
+    // IM's candidate window is placed below the popover rather than on
+    // top of it.  Coordinates stay in client-widget (drawarea) space;
+    // GTK4's IM module translates to the surface internally.
+    if (p_imst == IM_OVER_THE_SPOT)
+    {
+       int popover_h = preedit_popover_height;
+       if (popover_h <= 0)
+           popover_h = gui.char_height;
+       if (popover_h > area.height)
+           area.height = popover_h;
+    }
+#  endif
+
     gtk_im_context_set_cursor_location(xic, &area);
 
     if (p_imst == IM_OVER_THE_SPOT)
@@ -274,25 +302,46 @@ im_add_to_input(char_u *str, int len)
     static void
 im_preedit_window_set_position(void)
 {
-    int x, y, width, height;
-    int screen_x, screen_y, screen_width, screen_height;
-
     if (preedit_window == NULL)
        return;
 
 #  ifdef USE_GTK4
-    // GTK4: positioning popup windows is limited.
-    // Use a simpler approach - just place near the cursor.
-    x = FILL_X(gui.col);
-    y = FILL_Y(gui.row) + gui.char_height;
-    width = 0;
-    height = 0;
-    screen_x = 0;
-    screen_y = 0;
-    screen_width = 0;
-    screen_height = 0;
-    // GTK4 doesn't have gtk_window_move; preedit is shown in-place.
+    // GTK4: The popover is parented to gui.mainwin (the toplevel) rather
+    // than gui.drawarea, because GtkDrawingArea is a leaf widget that does
+    // not support children and causes snapshot/redraw artifacts when used
+    // as a popover parent.  Translate the cursor cell coordinates from
+    // drawarea space to mainwin space, and use the cached label width as
+    // the anchor rect width so the popover's left edge aligns with the
+    // cursor cell:
+    //   popup_origin.x = anchor.x + anchor.w/2 - popup_w/2
+    // With anchor.w == label_w and popup_w ≈ label_w (after CSS zeroing
+    // popover frame padding), popup_origin.x ≈ anchor.x.
+    GdkRectangle rect;
+    graphene_point_t pt_in, pt_out;
+
+    pt_in.x = FILL_X(gui.col);
+    pt_in.y = FILL_Y(gui.row);
+    if (!gtk_widget_compute_point(gui.drawarea, gui.mainwin,
+                                               &pt_in, &pt_out))
+    {
+       pt_out.x = pt_in.x;
+       pt_out.y = pt_in.y;
+    }
+    rect.x = (int)pt_out.x;
+    rect.y = (int)pt_out.y;
+    rect.width = preedit_label_width;
+    rect.height = 0;
+    // make up for the difference between GVim's line height and GtkLabel's line height
+    int offset = gui.char_height - preedit_popover_height;
+    gtk_popover_set_offset(GTK_POPOVER(preedit_window), 0, offset);
+    // make the arrow pointer at start so that it only starts sliding
+    // horizontally when screen border is reached
+    gtk_widget_set_halign(preedit_window, GTK_ALIGN_START);
+    gtk_popover_set_pointing_to(GTK_POPOVER(preedit_window), &rect);
 #  else
+    int x, y, width, height;
+    int screen_x, screen_y, screen_width, screen_height;
+
     gui_gtk_get_screen_geom_of_win(gui.drawarea, 0, 0,
                          &screen_x, &screen_y, &screen_width, &screen_height);
     gdk_window_get_origin(gtk_widget_get_window(gui.drawarea), &x, &y);
@@ -328,27 +377,99 @@ im_preedit_window_open(void)
     if (preedit_window == NULL)
     {
 #  ifdef USE_GTK4
-       preedit_window = gtk_window_new();
+       // GTK4: use GtkPopover (xdg_popup) so the compositor keeps keyboard
+       // focus on the drawing area and does not disable text-input-v3.
+       // See issue #20257.  Parent the popover to gui.mainwin (a real
+       // container) rather than gui.drawarea: GtkDrawingArea is a leaf
+       // widget and using it as a popover parent causes snapshot artifacts
+       // (white-out) on the drawing area.
+       preedit_window = gtk_popover_new();
+       gtk_popover_set_autohide(GTK_POPOVER(preedit_window), FALSE);
+       gtk_popover_set_has_arrow(GTK_POPOVER(preedit_window), FALSE);
+       // Pin the popover below the anchor.  Together with anchor.h == 0 in
+       // im_preedit_window_set_position() this lands the popover's top edge
+       // exactly on FILL_Y(row), so the popover covers the cursor cell rather
+       // than landing above it (the default GTK_POS_TOP would do that and
+       // only flip to bottom when there is no room above, which is flaky).
+       gtk_popover_set_position(GTK_POPOVER(preedit_window), GTK_POS_BOTTOM);
+       gtk_widget_set_can_focus(preedit_window, FALSE);
+       gtk_widget_set_focusable(preedit_window, FALSE);
+       gtk_widget_set_can_target(preedit_window, FALSE);
+       gtk_widget_set_parent(preedit_window, gui.mainwin);
 #  else
        preedit_window = gtk_window_new(GTK_WINDOW_POPUP);
-#  endif
        gtk_window_set_transient_for(GTK_WINDOW(preedit_window),
                                                     GTK_WINDOW(gui.mainwin));
+#  endif
        preedit_label = gtk_label_new("");
        gtk_widget_set_name(preedit_label, "vim-gui-preedit-area");
 #  ifdef USE_GTK4
-       gtk_window_set_child(GTK_WINDOW(preedit_window), preedit_label);
+       gtk_label_set_xalign(GTK_LABEL(preedit_label), 0.0);
+       gtk_widget_set_halign(preedit_label, GTK_ALIGN_START);
+       gtk_widget_set_valign(preedit_label, GTK_ALIGN_START);
+       // Tag the popover with a CSS class so the minimal frame CSS below
+       // (no border, no rounded corners, no shadow, no padding) applies
+       // only to our preedit popover and not other popovers in the app.
+       gtk_widget_add_css_class(preedit_window, "vim-preedit-popover");
+       gtk_popover_set_child(GTK_POPOVER(preedit_window), preedit_label);
 #  else
        gtk_container_add(GTK_CONTAINER(preedit_window), preedit_label);
 #  endif
     }
 
-#  if GTK_CHECK_VERSION(3,16,0)
+#  ifdef USE_GTK4
+    // Build (or rebuild on background-colour change) the CSS that flattens
+    // the popover frame and paints its background with gui.back_pixel.
+    // Using a solid background instead of `background: transparent` makes
+    // the popover surface fully cover the cursor cell even when the popover
+    // surface is allocated a pixel or two larger than the glyph bounding
+    // box (e.g. due to residual theme insets) -- otherwise the blinking
+    // text cursor at the cell origin shows through the popover frame.
+    if (preedit_css_provider == NULL || preedit_css_cached_bg != gui.back_pixel)
+    {
+       GdkDisplay *display = gdk_display_get_default();
+       gchar *css;
+
+       if (preedit_css_provider != NULL)
+       {
+           gtk_style_context_remove_provider_for_display(display,
+                                   GTK_STYLE_PROVIDER(preedit_css_provider));
+           g_object_unref(preedit_css_provider);
+       }
+       preedit_css_provider = gtk_css_provider_new();
+       css = g_strdup_printf(
+               "popover.vim-preedit-popover,\n"
+               "popover.vim-preedit-popover > contents,\n"
+               "popover.vim-preedit-popover label {\n"
+               "  border: none;\n"
+               "  border-radius: 0;\n"
+               "  box-shadow: none;\n"
+               "  padding: 0;\n"
+               "  margin: 0;\n"
+               "  min-width: 0;\n"
+               "  min-height: 0;\n"
+               "  background-color: #%02lx%02lx%02lx;\n"
+               "}\n"
+               "popover.vim-preedit-popover > arrow {\n"
+               "  background: transparent;\n"
+               "  border: none;\n"
+               "}\n",
+               (unsigned long)((gui.back_pixel >> 16) & 0xff),
+               (unsigned long)((gui.back_pixel >> 8) & 0xff),
+               (unsigned long)(gui.back_pixel & 0xff));
+       gtk_css_provider_load_from_string(preedit_css_provider, css);
+       g_free(css);
+       gtk_style_context_add_provider_for_display(display,
+                           GTK_STYLE_PROVIDER(preedit_css_provider), G_MAXUINT);
+       preedit_css_cached_bg = gui.back_pixel;
+    }
+#  endif
+
+#  ifndef USE_GTK4
+#   if GTK_CHECK_VERSION(3,16,0)
     {
-#   ifndef USE_GTK4
        GtkStyleContext * const context
                                  = gtk_widget_get_style_context(preedit_label);
-#   endif
        GtkCssProvider * const provider = gtk_css_provider_new();
        gchar              *css = NULL;
        const char * const fontname
@@ -361,15 +482,10 @@ im_preedit_window_open(void)
        {
            // fontsize was given in points.  Convert it into that in pixels
            // to use with CSS.
-#   ifdef USE_GTK4
-           // GTK4: assume 96 DPI as default
-           fontsize = 96 * fontsize / 72;
-#   else
            GdkScreen * const screen
                  = gdk_window_get_screen(gtk_widget_get_window(gui.mainwin));
            const gdouble dpi = gdk_screen_get_resolution(screen);
            fontsize = dpi * fontsize / 72;
-#   endif
        }
        if (fontsize > 0)
            fontsize_propval = g_strdup_printf("%dpx", fontsize);
@@ -392,22 +508,15 @@ im_preedit_window_open(void)
                (gui.back_pixel >> 8) & 0xff,
                gui.back_pixel & 0xff);
 
-#   ifdef USE_GTK4
-       gtk_css_provider_load_from_string(provider, css);
-       gtk_style_context_add_provider_for_display(
-               gdk_display_get_default(),
-               GTK_STYLE_PROVIDER(provider), G_MAXUINT);
-#   else
        gtk_css_provider_load_from_data(provider, css, -1, NULL);
        gtk_style_context_add_provider(context,
                                     GTK_STYLE_PROVIDER(provider), G_MAXUINT);
-#   endif
 
        g_free(css);
        g_free(fontsize_propval);
        g_object_unref(provider);
     }
-#  elif GTK_CHECK_VERSION(3,0,0)
+#   elif GTK_CHECK_VERSION(3,0,0)
     gtk_widget_override_font(preedit_label, gui.norm_font);
 
     vim_snprintf(buf, sizeof(buf), "#%06X", gui.norm_pixel);
@@ -418,7 +527,7 @@ im_preedit_window_open(void)
     gdk_rgba_parse(&color, buf);
     gtk_widget_override_background_color(preedit_label, GTK_STATE_FLAG_NORMAL,
                                                                      &color);
-#  else
+#   else
     gtk_widget_modify_font(preedit_label, gui.norm_font);
 
     vim_snprintf(buf, sizeof(buf), "#%06X", (unsigned)gui.norm_pixel);
@@ -428,12 +537,49 @@ im_preedit_window_open(void)
     vim_snprintf(buf, sizeof(buf), "#%06X", (unsigned)gui.back_pixel);
     gdk_color_parse(buf, &color);
     gtk_widget_modify_bg(preedit_window, GTK_STATE_NORMAL, &color);
-#  endif
+#   endif
+#  endif // !USE_GTK4
 
     gtk_im_context_get_preedit_string(xic, &preedit_string, &attr_list, NULL);
 
     if (preedit_string[0] != NUL)
     {
+#  ifdef USE_GTK4
+       // GTK4: drive all styling (font + foreground + background) via
+       // PangoAttributes on the label rather than CSS.  This pins the
+       // preedit text to gui.norm_font (family + weight + style + size)
+       // and the editor colors without going through CSS DPI conversion
+       // or selector matching.  These attributes are merged with whatever
+       // the IM gave us (e.g. underline for composing range).
+       PangoAttribute *pa;
+
+       if (attr_list == NULL)
+           attr_list = pango_attr_list_new();
+
+       if (gui.norm_font != NULL)
+       {
+           pa = pango_attr_font_desc_new(gui.norm_font);
+           pa->start_index = 0;
+           pa->end_index = G_MAXUINT;
+           pango_attr_list_insert(attr_list, pa);
+       }
+
+       pa = pango_attr_foreground_new(
+               ((gui.norm_pixel >> 16) & 0xff) * 257,
+               ((gui.norm_pixel >> 8) & 0xff) * 257,
+               (gui.norm_pixel & 0xff) * 257);
+       pa->start_index = 0;
+       pa->end_index = G_MAXUINT;
+       pango_attr_list_insert(attr_list, pa);
+
+       pa = pango_attr_background_new(
+               ((gui.back_pixel >> 16) & 0xff) * 257,
+               ((gui.back_pixel >> 8) & 0xff) * 257,
+               (gui.back_pixel & 0xff) * 257);
+       pa->start_index = 0;
+       pa->end_index = G_MAXUINT;
+       pango_attr_list_insert(attr_list, pa);
+#  endif
        gtk_label_set_text(GTK_LABEL(preedit_label), preedit_string);
        gtk_label_set_attributes(GTK_LABEL(preedit_label), attr_list);
 
@@ -441,14 +587,47 @@ im_preedit_window_open(void)
        pango_layout_get_pixel_size(layout, &w, &h);
        h = MAX(h, gui.char_height);
 #  ifdef USE_GTK4
-       gtk_window_set_default_size(GTK_WINDOW(preedit_window), w, h);
-       gtk_widget_set_visible(preedit_window, TRUE);
+       // Cache label/popover size derived from the Pango layout, which is
+       // available without mapping the popover.  Using these for positioning
+       // avoids calling gtk_popover_set_pointing_to() after popup, which
+       // triggers a GDK "compositor doesn't support moving popups" warning
+       // on compositors without xdg_popup.reposition (e.g. Weston).
+       preedit_label_width = w;
+       preedit_popover_height = h;
+
+       // Report an enlarged cursor rectangle that covers both the cursor
+       // cell and the preedit popover.  This way the IM (e.g. fcitx5)
+       // places its candidate window below the popover instead of on top
+       // of it.  Coordinates are in client-widget (drawarea) space.
+       if (xic != NULL)
+       {
+           GdkRectangle area;
+
+           area.x = FILL_X(gui.col);
+           area.y = FILL_Y(gui.row);
+           area.width = preedit_label_width;
+           area.height = MAX(gui.char_height, preedit_popover_height);
+           gtk_im_context_set_cursor_location(xic, &area);
+       }
+
+       // Pop down before re-anchoring: on compositors that lack
+       // xdg_popup.reposition (e.g. Weston), calling set_pointing_to() on
+       // a mapped popover triggers GDK's remap fallback and a warning.
+       // Doing the popdown ourselves makes the next popup land at the new
+       // anchor cleanly without that warning.
+       if (gtk_widget_get_mapped(preedit_window))
+           gtk_popover_popdown(GTK_POPOVER(preedit_window));
+
+       im_preedit_window_set_position();
+
+       gtk_widget_queue_resize(preedit_window);
+       gtk_popover_popup(GTK_POPOVER(preedit_window));
 #  else
        gtk_window_resize(GTK_WINDOW(preedit_window), w, h);
        gtk_widget_show_all(preedit_window);
-#  endif
 
        im_preedit_window_set_position();
+#  endif
     }
 
     g_free(preedit_string);
@@ -458,11 +637,12 @@ im_preedit_window_open(void)
     static void
 im_preedit_window_close(void)
 {
-    if (preedit_window != NULL)
+    if (preedit_window == NULL)
+       return;
 #  ifdef USE_GTK4
-       gtk_widget_set_visible(preedit_window, FALSE);
+    gtk_popover_popdown(GTK_POPOVER(preedit_window));
 #  else
-       gtk_widget_hide(preedit_window);
+    gtk_widget_hide(preedit_window);
 #  endif
 }
 
@@ -931,7 +1111,6 @@ xim_init(void)
 #  endif
 
     xic = gtk_im_multicontext_new();
-    g_object_ref(xic);
 
     im_commit_handler_id = g_signal_connect(G_OBJECT(xic), "commit",
                                            G_CALLBACK(&im_commit_cb), NULL);
@@ -944,6 +1123,15 @@ xim_init(void)
 
 #  ifdef USE_GTK4
     gtk_im_context_set_client_widget(xic, gui.drawarea);
+
+    // Seed the cursor location immediately.  GTK4's Wayland IM module
+    // batches text-input-v3 state and sends it on the next commit cycle
+    // (typically triggered by focus_in).  Without this seed the very
+    // first commit would carry the protocol default (0, 0, 0, 0) for the
+    // cursor rectangle, and the IM's candidate window for the very first
+    // composition would appear at the top-left of the surface instead of
+    // next to the cursor.
+    im_set_position(gui.row, gui.col);
 #  else
     gtk_im_context_set_client_window(xic, gtk_widget_get_window(gui.drawarea));
 #  endif
@@ -962,6 +1150,25 @@ im_shutdown(void)
        g_object_unref(xic);
        xic = NULL;
     }
+#  ifdef USE_GTK4
+    // GTK4: a widget added via gtk_widget_set_parent() must be detached
+    // with gtk_widget_unparent() before its parent is finalized, otherwise
+    // GTK prints "Finalizing ..., but it still has children left".
+    if (preedit_window != NULL)
+    {
+       gtk_widget_unparent(preedit_window);
+       preedit_window = NULL;
+       preedit_label = NULL;
+    }
+    if (preedit_css_provider != NULL)
+    {
+       gtk_style_context_remove_provider_for_display(gdk_display_get_default(),
+                               GTK_STYLE_PROVIDER(preedit_css_provider));
+       g_object_unref(preedit_css_provider);
+       preedit_css_provider = NULL;
+       preedit_css_cached_bg = INVALCOLOR;
+    }
+#  endif
     im_is_active = FALSE;
     im_commit_handler_id = 0;
     if (p_imst == IM_ON_THE_SPOT)
index f3dd63e77549f7e02b015a93dc9529c65cd5ac60..caec10bb1ab35a6bb11dac16b3f7dee416cdd515 100644 (file)
@@ -729,6 +729,8 @@ static char *(features[]) =
 
 static int included_patches[] =
 {   /* Add new patch number below this line */
+/**/
+    518,
 /**/
     517,
 /**/