From 9547dd371190f39ae00dfc3a3af69575a5c7efab Mon Sep 17 00:00:00 2001 From: deepend-tildeclub Date: Mon, 19 Oct 2026 15:25:28 -0600 Subject: [PATCH] prioritize editing shortcuts --- packaging/macos/README.md | 46 ++++ src/fe-gtk/fe-gtk.c | 3 + src/fe-gtk/fkeys.c | 63 +++++ src/fe-gtk/fkeys.h | 3 + src/fe-gtk/macos-shortcuts.c | 391 ++++++++++++++++++++++++++ src/fe-gtk/macos-shortcuts.h | 17 ++ src/fe-gtk/menu.c | 55 +++- src/fe-gtk/meson.build | 3 +- src/fe-gtk/setup.c | 4 + src/fe-gtk/setup.h | 1 + src/fe-gtk/tests/test-xtext.c | 503 ++++++++++++++++++++++++++++++++++ 11 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 src/fe-gtk/macos-shortcuts.c create mode 100644 src/fe-gtk/macos-shortcuts.h diff --git a/packaging/macos/README.md b/packaging/macos/README.md index b3bf667f..742201ee 100644 --- a/packaging/macos/README.md +++ b/packaging/macos/README.md @@ -49,6 +49,52 @@ sources and build them in a controlled prefix, or maintain pinned Homebrew formu ## 2. Verify portability and behavior +### Keyboard shortcuts + +These defaults apply only to macOS; Linux and Windows shortcuts are unchanged. + +| Shortcut | Action | +| --- | --- | +| Command+C / X / V | Copy / cut / paste in text fields, including search and preferences | +| Command+A | Select all text in the focused field | +| Command+Left / Right | Move to the beginning / end of the line | +| Option+Left / Right | Move by words | +| Command+Up / Down | Move to the beginning / end of the text | +| Shift with the movement shortcuts above | Extend the selection | +| Option+Delete (Backspace) | Delete back to the beginning of the word | +| Command+Delete (Backspace) | Delete back to the beginning of the line | +| Command+, | Open Preferences | +| Command+M | Minimize the active window | +| Control+Tab | Completion in the opposite direction; Tab / Shift+Tab still complete normally | +| Command+Option+Left / Right | Move the current tab left / right | +| Command+Shift+R | Reset the chat marker; Command+Shift+M still moves to the marker | + +The inherited Control+C/X/V clipboard bindings are removed on macOS only. +Command+C/X/V in multiline editors use plain UTF-8 text directly, avoiding +GTK3 Quartz's incomplete rich-text negotiation and pasteboard types without a +preferred MIME name. Text, newlines, selection replacement and editability +checks are preserved; rich clipboard formatting is not imported by this action. +Command+C copies selected input text first; if no input text is selected, it +copies the selected chat text. Command+X cuts editable text, never messages +from chat history. Password protection and read-only fields retain GTK's +existing behavior. Undo/redo is not added: GTK3's entry widget needs a separate +editing-history implementation before Command+Z can work reliably. + +Exact old default assignments for Command+Tab, Option+Left/Right and Command+M +are remapped in memory when loading defaults or an existing configuration. +Other custom bindings are preserved. If a replacement key is already assigned, +both existing assignments are left alone; resolve that conflict in **Settings +→ Keyboard Shortcuts**. Saving there persists the updated assignments. +Command+Tab is left to macOS for switching applications. Plugins and custom +input bindings retain priority; the new native menu accelerators are omitted +when the same key is already assigned to an input action. + +Before distributing a build, test the shortcuts in message input, search, +preferences, a detached chat window and a multiline editor, including Unicode, +selection replacement, and read-only/password fields. The Meson widget tests +exercise the GTK actions; the screenshot smoke check does not send real keys +through the macOS event loop. + ```sh python3 -m unittest discover -s packaging/macos/tests -v python3 packaging/macos/bundle.py audit dist/ZoiteChat.app diff --git a/src/fe-gtk/fe-gtk.c b/src/fe-gtk/fe-gtk.c index 4a5d7fce..14495fdb 100644 --- a/src/fe-gtk/fe-gtk.c +++ b/src/fe-gtk/fe-gtk.c @@ -23,6 +23,7 @@ #include "fe-gtk.h" #ifdef __APPLE__ #include "../common/macos.h" +#include "macos-shortcuts.h" #endif #ifdef GDK_WINDOWING_WIN32 @@ -503,6 +504,8 @@ fe_args (int argc, char *argv[]) gtk_init (&argc, &argv); #ifdef __APPLE__ + macos_shortcuts_init (setup_open); + if (g_getenv ("ZOITECHAT_MACOS_SHARE")) { char *icons = g_build_filename (g_getenv ("ZOITECHAT_MACOS_SHARE"), "icons", NULL); diff --git a/src/fe-gtk/fkeys.c b/src/fe-gtk/fkeys.c index 2da31691..a5b5b888 100644 --- a/src/fe-gtk/fkeys.c +++ b/src/fe-gtk/fkeys.c @@ -55,6 +55,9 @@ #include "maingui.h" #include "textgui.h" #include "fkeys.h" +#ifdef __APPLE__ +#include "macos-shortcuts.h" +#endif #define ICON_FKEYS_NEW "document-new" #define ICON_FKEYS_DELETE "edit-delete" @@ -570,6 +573,12 @@ key_handle_key_press (GtkWidget *wid, GdkEventKey *evt, session *sess) list = g_slist_next (list); } +#ifdef __APPLE__ + /* Plugins and user-defined input shortcuts above retain priority. */ + if (macos_shortcuts_copy_chat (wid, evt, GTK_XTEXT (sess->gui->xtext))) + return TRUE; +#endif + switch (evt->keyval) { case GDK_KEY_space: @@ -1333,6 +1342,57 @@ key_save_kbs (void) #define KBSTATE_DT1 3 #define KBSTATE_DT2 4 +#ifdef __APPLE__ +static void +key_macos_adjust_defaults (GSList *bindings) +{ + GSList *item, *other; + struct key_binding *kb, *existing; + guint keyval; + GdkModifierType mod; + + for (item = bindings; item; item = item->next) + { + kb = item->data; + keyval = kb->keyval; + mod = kb->mod; + if (!macos_shortcuts_remap_default (&keyval, &mod, + key_actions[kb->action].name, + kb->data1, kb->data2)) + continue; + + /* Never let a migrated default shadow an existing user shortcut. + * In that case leave the old assignment for the user to resolve. */ + for (other = bindings; other; other = other->next) + { + existing = other->data; + if (existing != kb && + gdk_keyval_to_lower (existing->keyval) == gdk_keyval_to_lower (keyval) && + existing->mod == mod) + break; + } + if (!other) + { + kb->keyval = keyval; + kb->mod = mod; + } + } +} + +gboolean +key_is_bound (guint keyval, GdkModifierType mod) +{ + GSList *item; + for (item = keybind_list; item; item = item->next) + { + struct key_binding *kb = item->data; + if (kb->keyval == keyval && kb->mod == mod) + return TRUE; + } + return FALSE; +} +#endif + #define STRIP_WHITESPACE \ while (buf[0] == ' ' || buf[0] == '\t') \ buf++; \ @@ -1519,6 +1579,9 @@ key_load_kbs_from_buffer (char *ibuf, off_t size, GSList **out_list) } } g_free (ibuf); +#ifdef __APPLE__ + key_macos_adjust_defaults (list); +#endif *out_list = list; return 0; diff --git a/src/fe-gtk/fkeys.h b/src/fe-gtk/fkeys.h index 224b45dd..d15b47e9 100644 --- a/src/fe-gtk/fkeys.h +++ b/src/fe-gtk/fkeys.h @@ -36,5 +36,8 @@ int key_action_insert (GtkWidget * wid, GdkEventKey * evt, char *d1, char *d2, session *sess); void key_check_replace_on_change (GtkEditable *editable, gpointer data); gboolean key_get_menu_accel (const char *name, guint *keyval, GdkModifierType *mod); +#ifdef __APPLE__ +gboolean key_is_bound (guint keyval, GdkModifierType mod); +#endif #endif diff --git a/src/fe-gtk/macos-shortcuts.c b/src/fe-gtk/macos-shortcuts.c new file mode 100644 index 00000000..a282c06a --- /dev/null +++ b/src/fe-gtk/macos-shortcuts.c @@ -0,0 +1,391 @@ +/* macOS text editing shortcuts. GPL-2.0-or-later; see COPYING. */ +#include "macos-shortcuts.h" +#include "config.h" +#include "../common/zoitechat.h" +#include "xtext.h" +#include + +static void (*preferences_callback) (void); + +static void +copy_text (GtkTextView *view) +{ + GtkTextBuffer *buffer = gtk_text_view_get_buffer (view); + GtkTextIter start, end; + if (gtk_text_buffer_get_selection_bounds (buffer, &start, &end)) + { + gchar *text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE); + gtk_clipboard_set_text (gtk_widget_get_clipboard (GTK_WIDGET (view), + GDK_SELECTION_CLIPBOARD), text, -1); + g_free (text); + } +} + +static void +cut_text (GtkTextView *view) +{ + copy_text (view); + gtk_text_buffer_delete_selection (gtk_text_view_get_buffer (view), TRUE, + gtk_text_view_get_editable (view)); + gtk_text_view_reset_im_context (view); + gtk_text_view_scroll_mark_onscreen (view, + gtk_text_buffer_get_insert (gtk_text_view_get_buffer (view))); +} + +typedef struct +{ + GtkTextView *view; + GtkTextBuffer *buffer; +} TextPaste; + +static void +text_paste_received (GtkClipboard *clipboard, const gchar *text, gpointer userdata) +{ + TextPaste *paste = userdata; + GtkTextView *view = paste->view; + GtkTextBuffer *buffer = paste->buffer; + + /* Clipboard replies may arrive after a view was closed or switched to + * another buffer. Retain the request's objects, but never edit a new one. */ + if (text && gtk_widget_get_realized (GTK_WIDGET (view)) && + gtk_text_view_get_buffer (view) == buffer) + { + gboolean editable = gtk_text_view_get_editable (view); + gtk_text_buffer_begin_user_action (buffer); + gtk_text_buffer_delete_selection (buffer, TRUE, editable); + gtk_text_buffer_insert_interactive_at_cursor (buffer, text, -1, editable); + gtk_text_buffer_end_user_action (buffer); + gtk_text_view_reset_im_context (view); + gtk_text_view_scroll_mark_onscreen (view, gtk_text_buffer_get_insert (buffer)); + g_signal_emit_by_name (buffer, "paste-done", clipboard); + } + g_object_unref (buffer); + g_object_unref (view); + g_free (paste); +} + +static void +paste_text (GtkTextView *view) +{ + TextPaste *paste = g_new (TextPaste, 1); + paste->view = g_object_ref (view); + paste->buffer = g_object_ref (gtk_text_view_get_buffer (view)); + /* ZoiteChat's editors consume text. GTK3's Quartz rich-text fallback + * enumerates pasteboard UTIs without MIME names (a NULL atom assertion) + * and its rich-text request callback is unimplemented. Request UTF-8 + * directly, retaining interactive selection and read-only behavior. */ + gtk_clipboard_request_text (gtk_widget_get_clipboard (GTK_WIDGET (view), + GDK_SELECTION_CLIPBOARD), text_paste_received, paste); +} + +static void +open_preferences (GtkWindow *window) +{ + if (preferences_callback) + preferences_callback (); +} + +static void +minimize_window (GtkWindow *window) +{ + gtk_window_iconify (window); +} + +void +macos_shortcuts_minimize_active (GtkWindow *fallback) +{ + GList *windows = gtk_window_list_toplevels (); + GList *item; + GtkWindow *target = fallback; + + for (item = windows; item; item = item->next) + { + if (gtk_window_is_active (GTK_WINDOW (item->data))) + { + target = GTK_WINDOW (item->data); + break; + } + } + if (target) + minimize_window (target); + g_list_free (windows); +} + +static void +add_movement (GtkBindingSet *bindings, guint keyval, GdkModifierType mod, + GtkMovementStep step, gint count) +{ + gtk_binding_entry_add_signal (bindings, keyval, mod, "move-cursor", 3, + GTK_TYPE_MOVEMENT_STEP, step, + G_TYPE_INT, count, G_TYPE_BOOLEAN, FALSE); + gtk_binding_entry_add_signal (bindings, keyval, mod | GDK_SHIFT_MASK, + "move-cursor", 3, GTK_TYPE_MOVEMENT_STEP, step, + G_TYPE_INT, count, G_TYPE_BOOLEAN, TRUE); +} + +static void +delete_text_view_backwards (GtkTextView *view, gboolean word) +{ + GtkTextBuffer *buffer = gtk_text_view_get_buffer (view); + GtkTextIter start, end; + gboolean editable = gtk_text_view_get_editable (view); + + /* GTK3 leaves GTK_DELETE_DISPLAY_LINE_ENDS unimplemented in TextView. + * Preserve selection deletion and per-range editability instead of + * moving the cursor first or deleting protected text unconditionally. */ + if (gtk_text_buffer_get_selection_bounds (buffer, &start, &end)) + gtk_text_buffer_delete_selection (buffer, TRUE, editable); + else + { + gtk_text_buffer_get_iter_at_mark (buffer, &end, gtk_text_buffer_get_insert (buffer)); + start = end; + if (word) + gtk_text_iter_backward_word_start (&start); + else + gtk_text_view_backward_display_line_start (view, &start); + gtk_text_buffer_begin_user_action (buffer); + gtk_text_buffer_delete_interactive (buffer, &start, &end, editable); + gtk_text_buffer_end_user_action (buffer); + } + gtk_text_view_reset_im_context (view); + gtk_text_view_scroll_mark_onscreen (view, gtk_text_buffer_get_insert (buffer)); +} + +static void +delete_display_line_start (GtkTextView *view) +{ + delete_text_view_backwards (view, FALSE); +} + +static void +delete_word_start (GtkTextView *view) +{ + delete_text_view_backwards (view, TRUE); +} + +static void +install_text_bindings (GtkSettings *settings, GParamSpec *property, + GtkCssProvider *provider) +{ + GtkWidget *widgets[2]; + const char *selectors[] = {"entry", "textview"}; + const char *names[] = {"zoitechat-macos-entry", "zoitechat-macos-textview"}; + GdkScreen *screen = gdk_screen_get_default (); + GString *css = g_string_new (NULL); + guint i, j; + + /* CSS key themes take precedence over class bindings. Put our small + * sets first, retaining the theme's remaining bindings as fallbacks. + * Remove our previous provider before reading a changed key theme. */ + g_object_ref (provider); + gtk_style_context_remove_provider_for_screen (screen, GTK_STYLE_PROVIDER (provider)); + widgets[0] = gtk_entry_new (); + widgets[1] = gtk_text_view_new (); + for (i = 0; i < G_N_ELEMENTS (widgets); i++) + { + GPtrArray *fallbacks = NULL; + GtkStyleContext *context = gtk_widget_get_style_context (widgets[i]); + + g_object_ref_sink (widgets[i]); + gtk_style_context_get (context, gtk_style_context_get_state (context), + "-gtk-key-bindings", &fallbacks, NULL); + g_string_append_printf (css, "%s { -gtk-key-bindings: %s", selectors[i], names[i]); + for (j = 0; fallbacks && j < fallbacks->len; j++) + { + GtkBindingSet *binding = g_ptr_array_index (fallbacks, j); + g_string_append_printf (css, ", %s", binding->set_name); + } + g_string_append (css, "; }\n"); + if (fallbacks) + g_ptr_array_unref (fallbacks); + gtk_widget_destroy (widgets[i]); + g_object_unref (widgets[i]); + } + gtk_css_provider_load_from_data (provider, css->str, css->len, NULL); + gtk_style_context_add_provider_for_screen (screen, GTK_STYLE_PROVIDER (provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_string_free (css, TRUE); + g_object_unref (provider); +} + +void +macos_shortcuts_init (void (*callback) (void)) +{ + GType types[] = {GTK_TYPE_ENTRY, GTK_TYPE_TEXT_VIEW}; + const char *names[] = {"zoitechat-macos-entry", "zoitechat-macos-textview"}; + GtkCssProvider *provider; + gpointer window_class; + GtkBindingSet *window_bindings; + static gboolean initialized = FALSE; + guint i; + + preferences_callback = callback; + if (initialized) + return; + initialized = TRUE; + + /* Use Command for these three actions on macOS, preserving GTK's + * selection, password-field and read-only checks. Entry subclasses + * (including the spell-checking input and search fields) inherit these. */ + for (i = 0; i < G_N_ELEMENTS (types); i++) + { + gpointer klass = g_type_class_ref (types[i]); + GtkBindingSet *bindings = gtk_binding_set_new (names[i]); + + gtk_binding_entry_skip (bindings, GDK_KEY_c, GDK_CONTROL_MASK); + gtk_binding_entry_skip (bindings, GDK_KEY_x, GDK_CONTROL_MASK); + gtk_binding_entry_skip (bindings, GDK_KEY_v, GDK_CONTROL_MASK); + if (types[i] == GTK_TYPE_TEXT_VIEW) + { + g_signal_new_class_handler ("zoitechat-macos-copy-text", types[i], + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (copy_text), NULL, NULL, NULL, + G_TYPE_NONE, 0); + g_signal_new_class_handler ("zoitechat-macos-cut-text", types[i], + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (cut_text), NULL, NULL, NULL, + G_TYPE_NONE, 0); + g_signal_new_class_handler ("zoitechat-macos-paste-text", types[i], + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (paste_text), NULL, NULL, NULL, + G_TYPE_NONE, 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_v, GDK_META_MASK, + "zoitechat-macos-paste-text", 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_c, GDK_META_MASK, + "zoitechat-macos-copy-text", 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_x, GDK_META_MASK, + "zoitechat-macos-cut-text", 0); + } + else + { + gtk_binding_entry_add_signal (bindings, GDK_KEY_c, GDK_META_MASK, + "copy-clipboard", 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_x, GDK_META_MASK, + "cut-clipboard", 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_v, GDK_META_MASK, + "paste-clipboard", 0); + } + /* Two movements select the whole buffer without changing its text. */ + gtk_binding_entry_add_signal (bindings, GDK_KEY_a, GDK_META_MASK, + "move-cursor", 3, GTK_TYPE_MOVEMENT_STEP, + GTK_MOVEMENT_BUFFER_ENDS, G_TYPE_INT, -1, + G_TYPE_BOOLEAN, FALSE); + gtk_binding_entry_add_signal (bindings, GDK_KEY_a, GDK_META_MASK, + "move-cursor", 3, GTK_TYPE_MOVEMENT_STEP, + GTK_MOVEMENT_BUFFER_ENDS, G_TYPE_INT, 1, + G_TYPE_BOOLEAN, TRUE); + add_movement (bindings, GDK_KEY_Left, GDK_META_MASK, + GTK_MOVEMENT_DISPLAY_LINE_ENDS, -1); + add_movement (bindings, GDK_KEY_Right, GDK_META_MASK, + GTK_MOVEMENT_DISPLAY_LINE_ENDS, 1); + add_movement (bindings, GDK_KEY_Left, GDK_MOD1_MASK, + GTK_MOVEMENT_WORDS, -1); + add_movement (bindings, GDK_KEY_Right, GDK_MOD1_MASK, + GTK_MOVEMENT_WORDS, 1); + add_movement (bindings, GDK_KEY_Up, GDK_META_MASK, + GTK_MOVEMENT_BUFFER_ENDS, -1); + add_movement (bindings, GDK_KEY_Down, GDK_META_MASK, + GTK_MOVEMENT_BUFFER_ENDS, 1); + if (types[i] == GTK_TYPE_TEXT_VIEW) + { + g_signal_new_class_handler ("zoitechat-macos-delete-word-start", types[i], + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (delete_word_start), + NULL, NULL, NULL, G_TYPE_NONE, 0); + g_signal_new_class_handler ("zoitechat-macos-delete-line-start", types[i], + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (delete_display_line_start), + NULL, NULL, NULL, G_TYPE_NONE, 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_META_MASK, + "zoitechat-macos-delete-line-start", 0); + gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_MOD1_MASK, + "zoitechat-macos-delete-word-start", 0); + } + else + { + gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_MOD1_MASK, + "delete-from-cursor", 2, GTK_TYPE_DELETE_TYPE, + GTK_DELETE_WORD_ENDS, G_TYPE_INT, -1); + gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_META_MASK, + "delete-from-cursor", 2, GTK_TYPE_DELETE_TYPE, + GTK_DELETE_DISPLAY_LINE_ENDS, G_TYPE_INT, -1); + } + g_type_class_unref (klass); + } + provider = gtk_css_provider_new (); + install_text_bindings (gtk_settings_get_default (), NULL, provider); + g_signal_connect_object (gtk_settings_get_default (), "notify::gtk-key-theme-name", + G_CALLBACK (install_text_bindings), provider, 0); + g_signal_connect_object (gtk_settings_get_default (), "notify::gtk-theme-name", + G_CALLBACK (install_text_bindings), provider, 0); + g_object_unref (provider); + + /* Window-level bindings also work in dialogs without a main menu bar. + * The native menu uses the same actions when Quartz handles the key. */ + window_class = g_type_class_ref (GTK_TYPE_WINDOW); + window_bindings = gtk_binding_set_by_class (window_class); + g_signal_new_class_handler ("zoitechat-macos-minimize", GTK_TYPE_WINDOW, + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (minimize_window), NULL, NULL, NULL, + G_TYPE_NONE, 0); + g_signal_new_class_handler ("zoitechat-macos-preferences", GTK_TYPE_WINDOW, + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK (open_preferences), NULL, NULL, NULL, + G_TYPE_NONE, 0); + gtk_binding_entry_add_signal (window_bindings, GDK_KEY_m, GDK_META_MASK, + "zoitechat-macos-minimize", 0); + gtk_binding_entry_add_signal (window_bindings, GDK_KEY_comma, GDK_META_MASK, + "zoitechat-macos-preferences", 0); + g_type_class_unref (window_class); +} + +gboolean +macos_shortcuts_remap_default (guint *keyval, GdkModifierType *mod, + const char *action, const char *data1, + const char *data2) +{ + /* Only exact legacy defaults qualify. Custom actions, parameters and + * alternate shortcuts must survive loading a configuration unchanged. */ + if (data2 && *data2) + return FALSE; + if (*keyval == GDK_KEY_Tab && *mod == GDK_META_MASK && + !g_strcmp0 (action, "Complete nick/command") && !g_strcmp0 (data1, "Up")) + { + *mod = GDK_CONTROL_MASK; + return TRUE; + } + if ((!data1 || !*data1) && *mod == GDK_MOD1_MASK && + ((*keyval == GDK_KEY_Left && !g_strcmp0 (action, "Move front tab left")) || + (*keyval == GDK_KEY_Right && !g_strcmp0 (action, "Move front tab right")))) + { + *mod |= GDK_META_MASK; + return TRUE; + } + if (*keyval == GDK_KEY_m && *mod == GDK_META_MASK && + !g_strcmp0 (action, "Menu Shortcut") && !g_strcmp0 (data1, "reset-marker")) + { + *keyval = GDK_KEY_R; + *mod |= GDK_SHIFT_MASK; + return TRUE; + } + return FALSE; +} + +gboolean +macos_shortcuts_copy_chat (GtkWidget *entry, GdkEventKey *event, GtkXText *xtext) +{ + gint start, end; + GdkModifierType modifiers = event->state & + (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK | + GDK_META_MASK | GDK_SUPER_MASK | GDK_HYPER_MASK); + + /* XText leaves keyboard focus in the message input. Prefer an input + * selection; otherwise Command+C copies the selected chat text. Leave + * Command+Shift+C and all other shortcuts to their existing handlers. */ + if (gdk_keyval_to_lower (event->keyval) != GDK_KEY_c || modifiers != GDK_META_MASK || + gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), &start, &end)) + return FALSE; + + gtk_xtext_copy_selection (xtext); + return TRUE; +} diff --git a/src/fe-gtk/macos-shortcuts.h b/src/fe-gtk/macos-shortcuts.h new file mode 100644 index 00000000..e068aaec --- /dev/null +++ b/src/fe-gtk/macos-shortcuts.h @@ -0,0 +1,17 @@ +/* macOS text editing shortcuts. GPL-2.0-or-later; see COPYING. */ +#ifndef ZOITECHAT_MACOS_SHORTCUTS_H +#define ZOITECHAT_MACOS_SHORTCUTS_H + +#include + +typedef struct _GtkXText GtkXText; + +void macos_shortcuts_init (void (*open_preferences) (void)); +void macos_shortcuts_minimize_active (GtkWindow *fallback); +gboolean macos_shortcuts_remap_default (guint *keyval, GdkModifierType *mod, + const char *action, const char *data1, + const char *data2); +gboolean macos_shortcuts_copy_chat (GtkWidget *entry, GdkEventKey *event, + GtkXText *xtext); + +#endif diff --git a/src/fe-gtk/menu.c b/src/fe-gtk/menu.c index cda0619e..58245f52 100644 --- a/src/fe-gtk/menu.c +++ b/src/fe-gtk/menu.c @@ -51,6 +51,9 @@ #include "chanlist.h" #include "editlist.h" #include "fkeys.h" +#ifdef __APPLE__ +#include "macos-shortcuts.h" +#endif #include "gtkutil.h" #include "maingui.h" #include "notifygui.h" @@ -1322,6 +1325,17 @@ menu_settings (GtkWidget * wid, gpointer none) setup_open (); } +#ifdef __APPLE__ +static void +menu_macos_minimize (GtkWidget *widget, gpointer userdata) +{ + GtkWindow *fallback = NULL; + if (current_sess && current_sess->gui && current_sess->gui->window) + fallback = GTK_WINDOW (current_sess->gui->window); + macos_shortcuts_minimize_active (fallback); +} +#endif + static void menu_usermenu (void) { @@ -2142,6 +2156,20 @@ menu_add_keybinding_accel (GtkWidget *item, GtkAccelGroup *accel_group, const ch g_object_set_data (G_OBJECT (item), "zc-key-accel-mod", GUINT_TO_POINTER (mod)); } +#ifdef __APPLE__ +static void +menu_add_macos_accel (GtkWidget *item, GtkAccelGroup *accel_group, guint keyval) +{ + g_object_set_data (G_OBJECT (item), "zc-macos-default-key", GUINT_TO_POINTER (keyval)); + if (!accel_group || key_is_bound (keyval, GDK_META_MASK)) + return; + gtk_widget_add_accelerator (item, "activate", accel_group, keyval, + GDK_META_MASK, GTK_ACCEL_VISIBLE); + g_object_set_data (G_OBJECT (item), "zc-key-accel-key", GUINT_TO_POINTER (keyval)); + g_object_set_data (G_OBJECT (item), "zc-key-accel-mod", GUINT_TO_POINTER (GDK_META_MASK)); +} +#endif + static void menu_refresh_keybinding_accels (GtkWidget *widget, gpointer data) { @@ -2164,6 +2192,12 @@ menu_refresh_keybinding_accels (GtkWidget *widget, gpointer data) } name = g_object_get_data (G_OBJECT (widget), "zc-key-action"); +#ifdef __APPLE__ + keyval = GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (widget), "zc-macos-default-key")); + if (keyval) + menu_add_macos_accel (widget, accel_group, keyval); + else +#endif menu_add_keybinding_accel (widget, accel_group, name); submenu = gtk_menu_item_get_submenu (GTK_MENU_ITEM (widget)); @@ -2217,7 +2251,12 @@ menu_key_action (const char *name, guint keyval, GdkModifierType state) menu_close (NULL, NULL); else if (!strcmp (name, "quit")) { - if (!prefs.hex_gui_ctrlq_quit && keyval == GDK_KEY_q && (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK)) == STATE_CTRL) + if (!prefs.hex_gui_ctrlq_quit && keyval == GDK_KEY_q && + (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK +#ifdef __APPLE__ + | GDK_META_MASK +#endif + )) == STATE_CTRL) return FALSE; menu_quit (NULL, NULL); } @@ -2841,6 +2880,16 @@ menu_create_main (void *accel_group, int bar, int away, int toplevel, #endif gtk_menu_shell_append (GTK_MENU_SHELL (menu_bar), menu_item); gtk_widget_show (menu_item); +#ifdef __APPLE__ + if (bar && !strcmp (mymenu[i].text, "_Window")) + { + GtkWidget *minimize = gtk_menu_item_new_with_mnemonic (_("_Minimize")); + menu_add_macos_accel (minimize, accel_group, GDK_KEY_m); + g_signal_connect (minimize, "activate", G_CALLBACK (menu_macos_minimize), NULL); + gtk_menu_shell_append (GTK_MENU_SHELL (menu), minimize); + gtk_widget_show (minimize); + } +#endif break; case M_MENUPIX: @@ -2854,6 +2903,10 @@ menu_create_main (void *accel_group, int bar, int away, int toplevel, case M_MENUITEM: item = gtk_menu_item_new_with_mnemonic (_(mymenu[i].text)); normalitem: +#ifdef __APPLE__ + if (bar && mymenu[i].callback == menu_settings) + menu_add_macos_accel (item, accel_group, GDK_KEY_comma); +#endif g_object_set_data (G_OBJECT (item), "zc-key-action", (gpointer) menu_get_key_action_name (i)); menu_add_keybinding_accel (item, accel_group, menu_get_key_action_name (i)); if (mymenu[i].key != 0 && !(mymenu[i].id == MENU_ID_QUIT && !prefs.hex_gui_ctrlq_quit)) diff --git a/src/fe-gtk/meson.build b/src/fe-gtk/meson.build index c4c3b2e0..699d0ccc 100644 --- a/src/fe-gtk/meson.build +++ b/src/fe-gtk/meson.build @@ -109,6 +109,7 @@ if host_machine.system() == 'windows' elif host_machine.system() == 'darwin' add_languages('objc', required: true) + zoitechat_gtk_sources += 'macos-shortcuts.c' zoitechat_gtk_sources += 'notifications/notification-osx.m' zoitechat_gtk_deps += dependency('appleframeworks', modules: ['Cocoa', 'UserNotifications']) zoitechat_gtk_objcflags += '-fobjc-arc' @@ -296,7 +297,7 @@ test('Emoji Font Tests', emoji_font_tests, ) xtext_tests = executable('xtext_tests', - ['tests/test-xtext.c', '../common/hexcolor.c'], + ['tests/test-xtext.c', 'macos-shortcuts.c', '../common/hexcolor.c'], include_directories: [config_h_include], dependencies: [gtk_dep], ) diff --git a/src/fe-gtk/setup.c b/src/fe-gtk/setup.c index e00c845c..5b74013d 100644 --- a/src/fe-gtk/setup.c +++ b/src/fe-gtk/setup.c @@ -567,7 +567,11 @@ static const setting general_settings[] = {ST_TOGGLE, N_("Hide join and part messages"), P_OFFINTNL(hex_irc_conf_mode), N_("Hide channel join/part messages by default."), 0, 0}, {ST_TOGGLE, N_("Hide nick change messages"), P_OFFINTNL(hex_irc_hide_nickchange), 0, 0, 0}, {ST_TOGGLE, N_("Hide hostmasks in join and part messages"), P_OFFINTNL(hex_irc_hide_join_part_hostmask), 0, 0, 0}, +#ifdef __APPLE__ + {ST_TOGGLE, N_("Enable Command+Q to quit"), P_OFFINTNL(hex_gui_ctrlq_quit), 0, 0, 0}, +#else {ST_TOGGLE, N_("Enable Ctrl+Q to quit"), P_OFFINTNL(hex_gui_ctrlq_quit), 0, 0, 0}, +#endif {ST_END, 0, 0, 0, 0, 0} }; diff --git a/src/fe-gtk/setup.h b/src/fe-gtk/setup.h index c069fb61..af8b8466 100644 --- a/src/fe-gtk/setup.h +++ b/src/fe-gtk/setup.h @@ -23,5 +23,6 @@ #include "theme/theme-manager.h" void setup_apply_real (const ThemeChangedEvent *event); +void setup_open (void); #endif diff --git a/src/fe-gtk/tests/test-xtext.c b/src/fe-gtk/tests/test-xtext.c index 1ba8a3a2..b0adbfab 100644 --- a/src/fe-gtk/tests/test-xtext.c +++ b/src/fe-gtk/tests/test-xtext.c @@ -3,6 +3,7 @@ * adding test-only symbols to the widget's public API. Application services * are stubbed; GTK, Pango, Cairo and the widget itself are exercised normally. */ #include "../xtext.c" +#include "../macos-shortcuts.h" #include struct zoitechatprefs prefs; @@ -252,6 +253,470 @@ static void test_selection_and_clipboard (Fixture *f, gconstpointer data) g_assert_false (gtk_xtext_is_selecting (f->xtext)); } +static void assert_clipboard (GtkWidget *widget, const char *expected) +{ + char *text = gtk_clipboard_wait_for_text ( + gtk_widget_get_clipboard (widget, GDK_SELECTION_CLIPBOARD)); + g_assert_cmpstr (text, ==, expected); + g_free (text); +} + +static GtkWidget *shortcut_window (GtkWidget *widget) +{ + GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL); + /* Give mapped editor fixtures a stable allocation before the first draw. */ + gtk_window_set_default_size (GTK_WINDOW (window), 640, 240); + gtk_container_set_border_width (GTK_CONTAINER (window), 16); + if (GTK_IS_ENTRY (widget)) + { + gtk_widget_set_size_request (widget, 320, 40); + gtk_widget_set_valign (widget, GTK_ALIGN_CENTER); + } + gtk_container_add (GTK_CONTAINER (window), widget); + gtk_widget_show_all (window); + drain_events (); + return window; +} + +static void test_macos_entry_shortcuts (gconstpointer data) +{ + gboolean search = GPOINTER_TO_INT (data); + GtkWidget *entry = g_object_ref_sink (search ? gtk_search_entry_new () : gtk_entry_new ()); + GtkWidget *window; + GtkClipboard *clipboard; + gint64 deadline; + + if (search) + { + /* Inheritance and clipboard actions do not require drawing. Quartz + * SearchEntry icon rendering can emit an unrelated fatal warning. + * Keep this variant attached to a screen but unmapped; the ordinary + * Entry variant below still exercises a mapped, editable widget. */ + window = gtk_window_new (GTK_WINDOW_TOPLEVEL); + gtk_container_add (GTK_CONTAINER (window), entry); + g_assert_true (gtk_widget_has_screen (entry)); + g_assert_false (gtk_widget_get_mapped (entry)); + } + else + { + window = shortcut_window (entry); + g_assert_true (gtk_widget_get_mapped (entry)); + } + clipboard = gtk_widget_get_clipboard (entry, GDK_SELECTION_CLIPBOARD); + + gtk_entry_set_text (GTK_ENTRY (entry), "hello 猫"); + gtk_editable_select_region (GTK_EDITABLE (entry), 6, 7); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK)); + assert_clipboard (entry, "猫"); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫"); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK)); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello "); + assert_clipboard (entry, "猫"); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_META_MASK)); + deadline = g_get_monotonic_time () + G_TIME_SPAN_SECOND * 2; + while (strcmp (gtk_entry_get_text (GTK_ENTRY (entry)), "hello 猫") && + g_get_monotonic_time () < deadline) + { + drain_events (); + g_usleep (1000); + } + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫"); + + /* The old Control actions must no longer perform clipboard editing. */ + gtk_editable_select_region (GTK_EDITABLE (entry), 0, -1); + gtk_clipboard_set_text (clipboard, "sentinel", -1); + g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_CONTROL_MASK)); + g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_CONTROL_MASK)); + g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_CONTROL_MASK)); + assert_clipboard (entry, "sentinel"); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫"); + + /* Password fields are ordinary entries, not icon-bearing search fields. */ + if (!search) + { + gtk_entry_set_visibility (GTK_ENTRY (entry), FALSE); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK)); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK)); + assert_clipboard (entry, "sentinel"); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫"); + gtk_entry_set_visibility (GTK_ENTRY (entry), TRUE); + } + gtk_editable_set_editable (GTK_EDITABLE (entry), FALSE); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK)); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_META_MASK)); + drain_events (); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫"); + gtk_widget_destroy (window); + g_object_unref (entry); +} + +static void unexpected_textview_clipboard_action (GtkTextView *view, gpointer data) +{ + guint *count = data; + GSignalInvocationHint *hint = g_signal_get_invocation_hint (view); + (*count)++; + /* Fail on incorrect dispatch, before GTK's Quartz rich-text path runs. */ + g_signal_stop_emission (view, hint->signal_id, hint->detail); +} + +static void test_macos_textview_shortcuts (void) +{ + GtkWidget *view = g_object_ref_sink (gtk_text_view_new ()); + GtkWidget *window = shortcut_window (view); + GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view)); + GtkTextIter start, end; + gint64 deadline; + guint default_actions = 0; + + g_signal_connect (view, "copy-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions); + g_signal_connect (view, "cut-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions); + g_signal_connect (view, "paste-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions); + + gtk_text_buffer_set_text (buffer, "two\nlines", -1); + g_test_message ("Select multiline text"); + gtk_text_buffer_get_bounds (buffer, &start, &end); + gtk_text_buffer_select_range (buffer, &start, &end); + g_test_message ("Copy multiline text"); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_c, GDK_META_MASK)); + g_assert_cmpuint (default_actions, ==, 0); + assert_clipboard (view, "two\nlines"); + g_test_message ("Cut multiline text"); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_META_MASK)); + g_assert_cmpuint (default_actions, ==, 0); + g_assert_cmpint (gtk_text_buffer_get_char_count (buffer), ==, 0); + assert_clipboard (view, "two\nlines"); + g_test_message ("Paste multiline text"); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK)); + g_assert_cmpuint (default_actions, ==, 0); + deadline = g_get_monotonic_time () + G_TIME_SPAN_SECOND * 2; + while (gtk_text_buffer_get_char_count (buffer) == 0 && + g_get_monotonic_time () < deadline) + { + drain_events (); + g_usleep (1000); + } + g_assert_cmpint (gtk_text_buffer_get_char_count (buffer), ==, 9); + gtk_text_buffer_get_bounds (buffer, &start, &end); + { + char *text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE); + g_assert_cmpstr (text, ==, "two\nlines"); + g_free (text); + } + g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_c, GDK_CONTROL_MASK)); + g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_CONTROL_MASK)); + g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_CONTROL_MASK)); + gtk_widget_destroy (window); + g_object_unref (view); +} + +static void test_macos_chat_copy (Fixture *f, gconstpointer data) +{ + GtkWidget *entry = g_object_ref_sink (gtk_entry_new ()); + GtkWidget *window = shortcut_window (entry); + GdkEventKey event = {0}; + + append (f, "chat selection 猫"); + select_entries (f, f->xtext->buffer->text_first, f->xtext->buffer->text_last); + event.keyval = GDK_KEY_C; /* Caps Lock must not disable chat copying. */ + event.state = GDK_META_MASK | GDK_LOCK_MASK | GDK_MOD2_MASK; + g_assert_true (macos_shortcuts_copy_chat (entry, &event, f->xtext)); + assert_clipboard (entry, "chat selection 猫"); + + /* Selected input takes priority over an older selection in scrollback. */ + gtk_entry_set_text (GTK_ENTRY (entry), "input"); + gtk_editable_select_region (GTK_EDITABLE (entry), 0, -1); + g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext)); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK)); + assert_clipboard (entry, "input"); + gtk_editable_select_region (GTK_EDITABLE (entry), 0, 0); + event.state = GDK_META_MASK | GDK_SHIFT_MASK; + g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext)); + event.state = GDK_CONTROL_MASK; + g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext)); + event.state = GDK_META_MASK; + event.keyval = GDK_KEY_x; + g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext)); + assert_clipboard (entry, "input"); + gtk_widget_destroy (window); + g_object_unref (entry); +} + +static void assert_entry_selection (GtkWidget *entry, gint start, gint end) +{ + gint actual_start, actual_end; + g_assert_true (gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), + &actual_start, &actual_end)); + g_assert_cmpint (actual_start, ==, start); + g_assert_cmpint (actual_end, ==, end); +} + +static void test_macos_key_theme_fallback (void) +{ + GtkWidget *entry = gtk_entry_new (); + GtkWidget *window = shortcut_window (entry); + gint start, end; + + /* A key-theme reload must keep our overrides first and retain the + * Mac theme's Emacs-style Control+A/E navigation for other keys. */ + g_object_notify (G_OBJECT (gtk_settings_get_default ()), "gtk-key-theme-name"); + g_object_notify (G_OBJECT (gtk_settings_get_default ()), "gtk-theme-name"); + gtk_entry_set_text (GTK_ENTRY (entry), "one two"); + gtk_editable_set_position (GTK_EDITABLE (entry), -1); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_CONTROL_MASK)); + g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 0); + g_assert_false (gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), &start, &end)); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_e, GDK_CONTROL_MASK)); + g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 7); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_META_MASK)); + assert_entry_selection (entry, 0, 7); + g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_CONTROL_MASK)); + gtk_widget_destroy (window); +} + +static void test_macos_entry_editing (void) +{ + GtkWidget *entry = gtk_entry_new (); + GtkWidget *window = shortcut_window (entry); + + gtk_entry_set_text (GTK_ENTRY (entry), "one two three"); + gtk_editable_set_position (GTK_EDITABLE (entry), 5); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_META_MASK)); + assert_entry_selection (entry, 0, 13); + gtk_editable_set_position (GTK_EDITABLE (entry), 5); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_META_MASK)); + g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 0); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_META_MASK)); + g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 13); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_META_MASK | GDK_SHIFT_MASK)); + assert_entry_selection (entry, 0, 13); + gtk_editable_set_position (GTK_EDITABLE (entry), 13); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_MOD1_MASK)); + g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 8); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_MOD1_MASK | GDK_SHIFT_MASK)); + assert_entry_selection (entry, 4, 8); + gtk_editable_set_position (GTK_EDITABLE (entry), 0); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_MOD1_MASK)); + g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 3); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_MOD1_MASK | GDK_SHIFT_MASK)); + assert_entry_selection (entry, 3, 7); + gtk_editable_set_position (GTK_EDITABLE (entry), 13); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_MOD1_MASK)); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "one two "); + gtk_entry_set_text (GTK_ENTRY (entry), "猫 abc def"); + gtk_editable_set_position (GTK_EDITABLE (entry), 6); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK)); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "def"); + gtk_editable_select_region (GTK_EDITABLE (entry), 0, 2); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK)); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "f"); + gtk_editable_set_editable (GTK_EDITABLE (entry), FALSE); + gtk_editable_set_position (GTK_EDITABLE (entry), -1); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_MOD1_MASK)); + g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK)); + g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "f"); + gtk_widget_destroy (window); +} + +static void assert_buffer_text (GtkTextBuffer *buffer, const char *expected) +{ + GtkTextIter start, end; + char *text; + gtk_text_buffer_get_bounds (buffer, &start, &end); + text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE); + g_assert_cmpstr (text, ==, expected); + g_free (text); +} + +static void paste_completed (GtkTextBuffer *buffer, GtkClipboard *clipboard, guint *count) +{ + (*count)++; +} + +static void wait_for_paste (guint *count, guint expected) +{ + gint64 deadline = g_get_monotonic_time () + 2 * G_TIME_SPAN_SECOND; + while (*count < expected && g_get_monotonic_time () < deadline) + { + drain_events (); + g_usleep (1000); + } + g_assert_cmpuint (*count, ==, expected); +} + +static void text_with_native_type (GtkClipboard *clipboard, GtkSelectionData *selection, + guint info, gpointer data) +{ + if (info == 0) + gtk_selection_data_set_text (selection, data, -1); +} + +static void test_macos_multiline_paste (void) +{ + GtkWidget *view = gtk_text_view_new (); + GtkWidget *window = shortcut_window (view); + GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view)); + GtkClipboard *clipboard = gtk_widget_get_clipboard (view, GDK_SELECTION_CLIPBOARD); + GtkTextIter start, end; + guint pasted = 0; + GtkTargetEntry targets[] = { + {(gchar *) "UTF8_STRING", 0, 0}, + /* This base UTI has no preferred MIME name on Quartz. A text paste + * must not enumerate/convert it through GTK's rich-text fallback. */ + {(gchar *) "public.item", 0, 1} + }; + + g_signal_connect (buffer, "paste-done", G_CALLBACK (paste_completed), &pasted); + gtk_text_buffer_set_text (buffer, "before after", -1); + gtk_text_buffer_get_iter_at_offset (buffer, &start, 7); + gtk_text_buffer_get_end_iter (buffer, &end); + gtk_text_buffer_select_range (buffer, &start, &end); + g_assert_true (gtk_clipboard_set_with_data (clipboard, targets, G_N_ELEMENTS (targets), + text_with_native_type, NULL, (gpointer) "猫\npasted")); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK)); + wait_for_paste (&pasted, 1); + assert_buffer_text (buffer, "before 猫\npasted"); + + gtk_text_view_set_editable (GTK_TEXT_VIEW (view), FALSE); + gtk_text_buffer_get_bounds (buffer, &start, &end); + gtk_text_buffer_select_range (buffer, &start, &end); + gtk_clipboard_set_text (clipboard, "blocked", -1); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK)); + wait_for_paste (&pasted, 2); + assert_buffer_text (buffer, "before 猫\npasted"); + g_assert_false (gtk_text_view_get_editable (GTK_TEXT_VIEW (view))); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_META_MASK)); + assert_buffer_text (buffer, "before 猫\npasted"); + assert_clipboard (view, "before 猫\npasted"); + gtk_clipboard_clear (clipboard); + gtk_widget_destroy (window); +} + +static void test_macos_multiline_editing (void) +{ + GtkWidget *view = gtk_text_view_new (); + GtkWidget *window = shortcut_window (view); + GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view)); + GtkTextIter start, end; + + gtk_text_buffer_set_text (buffer, "first\nsecond", -1); + gtk_text_buffer_get_iter_at_offset (buffer, &start, 9); + gtk_text_buffer_place_cursor (buffer, &start); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Left, GDK_META_MASK)); + gtk_text_buffer_get_iter_at_mark (buffer, &start, gtk_text_buffer_get_insert (buffer)); + g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 6); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Right, GDK_META_MASK | GDK_SHIFT_MASK)); + g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end)); + g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 6); + g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 12); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_a, GDK_META_MASK)); + g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end)); + g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 0); + g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 12); + gtk_text_buffer_get_iter_at_offset (buffer, &start, 9); + gtk_text_buffer_place_cursor (buffer, &start); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK)); + assert_buffer_text (buffer, "first\nond"); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Down, GDK_META_MASK)); + gtk_text_buffer_get_iter_at_mark (buffer, &start, gtk_text_buffer_get_insert (buffer)); + g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 9); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Up, GDK_META_MASK | GDK_SHIFT_MASK)); + g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end)); + g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 0); + g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 9); + /* An existing selection is deleted without removing the rest of its line. */ + gtk_text_buffer_get_iter_at_offset (buffer, &start, 7); + gtk_text_buffer_get_iter_at_offset (buffer, &end, 8); + gtk_text_buffer_select_range (buffer, &start, &end); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK)); + assert_buffer_text (buffer, "first\nod"); + gtk_text_view_set_editable (GTK_TEXT_VIEW (view), FALSE); + gtk_text_buffer_get_end_iter (buffer, &end); + gtk_text_buffer_place_cursor (buffer, &end); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK)); + assert_buffer_text (buffer, "first\nod"); + gtk_text_view_set_editable (GTK_TEXT_VIEW (view), TRUE); + { + GtkTextTag *protected = gtk_text_buffer_create_tag (buffer, "protected", "editable", FALSE, NULL); + gtk_text_buffer_get_iter_at_offset (buffer, &start, 6); + gtk_text_buffer_get_iter_at_offset (buffer, &end, 7); + gtk_text_buffer_apply_tag (buffer, protected, &start, &end); + gtk_text_buffer_get_end_iter (buffer, &end); + gtk_text_buffer_place_cursor (buffer, &end); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK)); + assert_buffer_text (buffer, "first\no"); + } + gtk_text_buffer_set_text (buffer, "one two\nthree four", -1); + gtk_text_buffer_get_end_iter (buffer, &end); + gtk_text_buffer_place_cursor (buffer, &end); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_MOD1_MASK)); + assert_buffer_text (buffer, "one two\nthree "); + gtk_text_buffer_get_iter_at_offset (buffer, &start, 4); + gtk_text_buffer_get_iter_at_offset (buffer, &end, 7); + gtk_text_buffer_select_range (buffer, &start, &end); + g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_MOD1_MASK)); + assert_buffer_text (buffer, "one \nthree "); + gtk_widget_destroy (window); +} + +static guint preferences_opened; + +static void test_open_preferences (void) +{ + preferences_opened++; +} + +static void count_minimize (GtkWindow *window, guint *count) +{ + (*count)++; + /* Check action routing without requiring an X11 window manager. */ + g_signal_stop_emission_by_name (window, "zoitechat-macos-minimize"); +} + +static void test_macos_window_shortcuts (void) +{ + GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL); + guint minimized = 0; + preferences_opened = 0; + g_signal_connect (window, "zoitechat-macos-minimize", G_CALLBACK (count_minimize), &minimized); + g_assert_true (gtk_bindings_activate (G_OBJECT (window), GDK_KEY_comma, GDK_META_MASK)); + g_assert_cmpuint (preferences_opened, ==, 1); + g_assert_true (gtk_bindings_activate (G_OBJECT (window), GDK_KEY_m, GDK_META_MASK)); + g_assert_cmpuint (minimized, ==, 1); + gtk_widget_destroy (window); +} + +static void test_macos_legacy_defaults (void) +{ + const struct { + guint key, new_key; + GdkModifierType mod, new_mod; + const char *action, *data1, *data2; + gboolean remapped; + } cases[] = { + {GDK_KEY_Tab, GDK_KEY_Tab, GDK_META_MASK, GDK_CONTROL_MASK, "Complete nick/command", "Up", NULL, TRUE}, + {GDK_KEY_Left, GDK_KEY_Left, GDK_MOD1_MASK, GDK_META_MASK | GDK_MOD1_MASK, "Move front tab left", NULL, NULL, TRUE}, + {GDK_KEY_Right, GDK_KEY_Right, GDK_MOD1_MASK, GDK_META_MASK | GDK_MOD1_MASK, "Move front tab right", "", "", TRUE}, + {GDK_KEY_m, GDK_KEY_R, GDK_META_MASK, GDK_META_MASK | GDK_SHIFT_MASK, "Menu Shortcut", "reset-marker", NULL, TRUE}, + {GDK_KEY_m, GDK_KEY_m, GDK_META_MASK, GDK_META_MASK, "Menu Shortcut", "search-text", NULL, FALSE}, + {GDK_KEY_m, GDK_KEY_m, GDK_CONTROL_MASK, GDK_CONTROL_MASK, "Menu Shortcut", "reset-marker", NULL, FALSE}, + {GDK_KEY_Left, GDK_KEY_Left, GDK_MOD1_MASK, GDK_MOD1_MASK, "Move front tab left", "custom", NULL, FALSE}, + {GDK_KEY_Tab, GDK_KEY_Tab, GDK_META_MASK, GDK_META_MASK, "Complete nick/command", "Up", "custom", FALSE}, + }; + guint i; + for (i = 0; i < G_N_ELEMENTS (cases); i++) + { + guint key = cases[i].key; + GdkModifierType mod = cases[i].mod; + g_assert_cmpint (macos_shortcuts_remap_default (&key, &mod, cases[i].action, + cases[i].data1, cases[i].data2), ==, cases[i].remapped); + g_assert_cmpuint (key, ==, cases[i].new_key); + g_assert_cmpuint (mod, ==, cases[i].new_mod); + /* Saved migrated shortcuts must be stable on subsequent launches. */ + g_assert_false (macos_shortcuts_remap_default (&key, &mod, cases[i].action, + cases[i].data1, cases[i].data2)); + } +} + static void test_search (Fixture *f, gconstpointer data) { GError *error = NULL; @@ -567,7 +1032,44 @@ int main (int argc, char **argv) g_test_message ("GTK display unavailable; run under Xvfb for widget coverage"); return 77; } + g_object_set (gtk_settings_get_default (), "gtk-enable-animations", FALSE, NULL); + /* Quartz installs this CSS key theme automatically. Exercise its higher + * priority bindings on Linux too, rather than only testing GTK classes. */ + g_object_set (gtk_settings_get_default (), "gtk-key-theme-name", "Mac", NULL); + if (!gtk_binding_set_find ("gtk-mac-cmd-c")) + { + /* Some Linux packages omit GTK's Mac theme. Reproduce the upstream + * CSS clipboard bindings so this regression is still tested there. */ + GtkCssProvider *keys = gtk_css_provider_new (); + g_test_message ("Using portable Mac key-theme fixture"); + gtk_css_provider_load_from_data (keys, + "@binding-set zoitechat-test-mac-clipboard {" + " bind 'c' { 'copy-clipboard' () };" + " bind 'x' { 'cut-clipboard' () };" + " bind 'v' { 'paste-clipboard' () };" + " bind 'a' { 'move-cursor' (paragraph-ends, -1, 0) };" + " bind 'e' { 'move-cursor' (paragraph-ends, 1, 0) }; }" + "entry, textview { -gtk-key-bindings: zoitechat-test-mac-clipboard; }", + -1, NULL); + gtk_style_context_add_provider_for_screen (gdk_screen_get_default (), + GTK_STYLE_PROVIDER (keys), GTK_STYLE_PROVIDER_PRIORITY_SETTINGS); + g_object_unref (keys); + } + else + g_test_message ("Using GTK's installed Mac key theme"); prefs.hex_gui_mouse_scroll_speed = 10; + /* Exercise the macOS implementation with real GTK widgets on every OS. + * Production only installs these bindings on macOS. */ + macos_shortcuts_init (test_open_preferences); + g_test_add_data_func ("/macos-shortcuts/entry", GINT_TO_POINTER (FALSE), test_macos_entry_shortcuts); + g_test_add_data_func ("/macos-shortcuts/search-entry", GINT_TO_POINTER (TRUE), test_macos_entry_shortcuts); + g_test_add_func ("/macos-shortcuts/textview", test_macos_textview_shortcuts); + g_test_add_func ("/macos-shortcuts/key-theme-fallback", test_macos_key_theme_fallback); + g_test_add_func ("/macos-shortcuts/entry-editing", test_macos_entry_editing); + g_test_add_func ("/macos-shortcuts/multiline-editing", test_macos_multiline_editing); + g_test_add_func ("/macos-shortcuts/multiline-paste", test_macos_multiline_paste); + g_test_add_func ("/macos-shortcuts/window", test_macos_window_shortcuts); + g_test_add_func ("/macos-shortcuts/legacy-defaults", test_macos_legacy_defaults); #define ADD(name, function) g_test_add ("/xtext/" name, Fixture, NULL, fixture_setup, function, fixture_teardown) ADD ("append/empty-bounded", test_empty_and_bounded_append); ADD ("append/utf8-limits", test_utf8_limits); @@ -577,6 +1079,7 @@ int main (int argc, char **argv) ADD ("layout/wide-metrics", test_wide_metrics); ADD ("render/formatting-timestamps", test_formatting); ADD ("selection/clipboard", test_selection_and_clipboard); + ADD ("selection/macos-command-copy", test_macos_chat_copy); ADD ("search/navigation-regex-clear", test_search); ADD ("search/follow", test_follow_search); ADD ("search/unicode-offsets", test_search_unicode_offsets);