diff options
Diffstat (limited to 'src/tdv-gui.c')
-rw-r--r-- | src/tdv-gui.c | 576 |
1 files changed, 576 insertions, 0 deletions
diff --git a/src/tdv-gui.c b/src/tdv-gui.c new file mode 100644 index 0000000..fcb254f --- /dev/null +++ b/src/tdv-gui.c @@ -0,0 +1,576 @@ +/* + * StarDict GTK+ UI + * + * Copyright (c) 2020 - 2022, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include <gtk/gtk.h> +#include <glib/gi18n.h> + +#include <stdlib.h> + +#include "config.h" +#include "stardict.h" +#include "utils.h" +#include "stardict-view.h" + +static struct +{ + GtkWidget *window; ///< Top-level window + GtkWidget *notebook; ///< Notebook with tabs + GtkWidget *hamburger; ///< Hamburger menu + GtkWidget *entry; ///< Search entry widget + GtkWidget *view; ///< Entries view + + gint dictionary; ///< Index of the current dictionary + gint last; ///< The last dictionary index + GPtrArray *dictionaries; ///< All open dictionaries + + gboolean loading; ///< Dictionaries are being loaded + + gboolean watch_selection; ///< Following X11 PRIMARY? +} +g; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +load_from_filenames (GPtrArray *out, gchar **filenames) +{ + for (gsize i = 0; filenames[i]; i++) + { + Dictionary *dict = g_malloc0 (sizeof *dict); + dict->filename = g_strdup (filenames[i]); + g_ptr_array_add (out, dict); + } +} + +// TODO: try to deduplicate, similar to app_load_config_values() +static gboolean +load_from_key_file (GPtrArray *out, GKeyFile *kf, GError **error) +{ + const gchar *dictionaries = "Dictionaries"; + gchar **names = g_key_file_get_keys (kf, dictionaries, NULL, NULL); + if (!names) + return TRUE; + + for (gsize i = 0; names[i]; i++) + { + Dictionary *dict = g_malloc0 (sizeof *dict); + dict->name = names[i]; + g_ptr_array_add (out, dict); + } + g_free (names); + + for (gsize i = 0; i < out->len; i++) + { + Dictionary *dict = g_ptr_array_index (out, i); + gchar *path = + g_key_file_get_string (kf, dictionaries, dict->name, error); + if (!path) + return FALSE; + + // Try to resolve relative paths and expand tildes + if (!(dict->filename = + resolve_filename (path, resolve_relative_config_filename))) + dict->filename = path; + else + g_free (path); + } + return TRUE; +} + +static gboolean +load_from_config (GPtrArray *out, GError **error) +{ + GKeyFile *key_file = load_project_config_file (error); + if (!key_file) + return FALSE; + + gboolean result = load_from_key_file (out, key_file, error); + g_key_file_free (key_file); + return result; +} + +static void +search (Dictionary *dict) +{ + GtkEntryBuffer *buf = gtk_entry_get_buffer (GTK_ENTRY (g.entry)); + const gchar *input_utf8 = gtk_entry_buffer_get_text (buf); + if (!dict->dict) + return; + + StardictIterator *iterator = + stardict_dict_search (dict->dict, input_utf8, NULL); + stardict_view_set_position (STARDICT_VIEW (g.view), + dict->dict, stardict_iterator_get_offset (iterator)); + g_object_unref (iterator); + + stardict_view_set_matched (STARDICT_VIEW (g.view), input_utf8); +} + +static void +on_changed (G_GNUC_UNUSED GtkWidget *widget, G_GNUC_UNUSED gpointer data) +{ + search (g_ptr_array_index (g.dictionaries, g.dictionary)); +} + +static void +on_send (G_GNUC_UNUSED StardictView *view, + const char *word, G_GNUC_UNUSED gpointer data) +{ + GtkEntryBuffer *buf = gtk_entry_get_buffer (GTK_ENTRY (g.entry)); + gtk_entry_buffer_set_text (buf, word, -1); + gtk_editable_select_region (GTK_EDITABLE (g.entry), 0, -1); +} + +static void +on_selection_received (G_GNUC_UNUSED GtkClipboard *clipboard, const gchar *text, + G_GNUC_UNUSED gpointer data) +{ + if (!text) + return; + + gchar *trimmed = g_strstrip (g_strdup (text)); + gtk_entry_set_text (GTK_ENTRY (g.entry), trimmed); + g_free (trimmed); + g_signal_emit_by_name (g.entry, + "move-cursor", GTK_MOVEMENT_BUFFER_ENDS, 1, FALSE); +} + +static void +on_selection (GtkClipboard *clipboard, GdkEvent *event, + G_GNUC_UNUSED gpointer data) +{ + if (g.watch_selection + && !gtk_window_has_toplevel_focus (GTK_WINDOW (g.window)) + && event->owner_change.owner != NULL) + gtk_clipboard_request_text (clipboard, on_selection_received, NULL); +} + +static void +on_selection_watch_toggle (GtkCheckMenuItem *item, G_GNUC_UNUSED gpointer data) +{ + g.watch_selection = gtk_check_menu_item_get_active (item); +} + +static void +on_switch_page (G_GNUC_UNUSED GtkWidget *widget, G_GNUC_UNUSED GtkWidget *page, + guint page_num, G_GNUC_UNUSED gpointer data) +{ + g.last = g.dictionary; + g.dictionary = page_num; + search (g_ptr_array_index (g.dictionaries, g.dictionary)); + + // Hack: Make right-clicking notebook arrows also re-focus the entry. + GdkEvent *event = gtk_get_current_event (); + if (event && event->type == GDK_BUTTON_PRESS) + gtk_widget_grab_focus (g.entry); +} + +static gboolean +accelerate_hamburger (GdkEvent *event) +{ + gchar *accelerator = NULL; + g_object_get (gtk_widget_get_settings (g.window), "gtk-menu-bar-accel", + &accelerator, NULL); + if (!accelerator) + return FALSE; + + guint key = 0; + GdkModifierType mods = 0; + gtk_accelerator_parse (accelerator, &key, &mods); + g_free (accelerator); + + guint mask = gtk_accelerator_get_default_mod_mask (); + if (!key || event->key.keyval != key || (event->key.state & mask) != mods) + return FALSE; + + gtk_button_clicked (GTK_BUTTON (g.hamburger)); + return TRUE; +} + +static gboolean +on_key_press (G_GNUC_UNUSED GtkWidget *widget, GdkEvent *event, + G_GNUC_UNUSED gpointer data) +{ + // The "activate" signal of the GtkMenuButton cannot be used + // from a real accelerator, due to "no trigger event for menu popup". + if (accelerate_hamburger (event)) + return TRUE; + + GtkNotebook *notebook = GTK_NOTEBOOK (g.notebook); + guint mods = event->key.state & gtk_accelerator_get_default_mod_mask (); + if (mods == GDK_CONTROL_MASK) + { + // Can't use gtk_widget_add_accelerator() to change-current-page(-1/+1) + // because that signal has arguments, which cannot be passed. + gint current = gtk_notebook_get_current_page (notebook); + if (event->key.keyval == GDK_KEY_Page_Up) + return gtk_notebook_set_current_page (notebook, --current), TRUE; + if (event->key.keyval == GDK_KEY_Page_Down) + return gtk_notebook_set_current_page (notebook, + ++current % gtk_notebook_get_n_pages (notebook)), TRUE; + } + if (mods == GDK_MOD1_MASK) + { + if (event->key.keyval >= GDK_KEY_0 + && event->key.keyval <= GDK_KEY_9) + { + gint n = event->key.keyval - GDK_KEY_0; + gtk_notebook_set_current_page (notebook, (n ? n : 10) - 1); + return TRUE; + } + if (event->key.keyval == GDK_KEY_Tab) + { + gtk_notebook_set_current_page (notebook, g.last); + return TRUE; + } + } + if (mods == 0) + { + StardictView *view = STARDICT_VIEW (g.view); + if (event->key.keyval == GDK_KEY_Page_Up) + return stardict_view_scroll (view, GTK_SCROLL_PAGES, -0.5), TRUE; + if (event->key.keyval == GDK_KEY_Page_Down) + return stardict_view_scroll (view, GTK_SCROLL_PAGES, +0.5), TRUE; + if (event->key.keyval == GDK_KEY_Up) + return stardict_view_scroll (view, GTK_SCROLL_STEPS, -1), TRUE; + if (event->key.keyval == GDK_KEY_Down) + return stardict_view_scroll (view, GTK_SCROLL_STEPS, +1), TRUE; + } + return FALSE; +} + +static gboolean +on_tab_focus (G_GNUC_UNUSED GtkWidget *widget, + G_GNUC_UNUSED GtkDirectionType direction, G_GNUC_UNUSED gpointer user_data) +{ + // Hack: Make it so that tab headers don't retain newly gained focus + // when clicked, re-focus the entry instead. + GdkEvent *event = gtk_get_current_event (); + if (!event || event->type != GDK_BUTTON_PRESS + || event->button.button != GDK_BUTTON_PRIMARY) + return FALSE; + + gtk_widget_grab_focus (g.entry); + return TRUE; +} + +static void +init_tabs (void) +{ + for (gsize i = g.dictionaries->len; i--; ) + { + Dictionary *dict = g_ptr_array_index (g.dictionaries, i); + GtkWidget *dummy = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0); + g_signal_connect (dummy, "focus", G_CALLBACK (on_tab_focus), NULL); + GtkWidget *label = gtk_label_new (dict->name); + gtk_notebook_insert_page (GTK_NOTEBOOK (g.notebook), dummy, label, 0); + } + + gtk_widget_show_all (g.notebook); + gtk_widget_grab_focus (g.entry); +} + +static void +show_error_dialog (GError *error) +{ + GtkWidget *dialog = gtk_message_dialog_new (GTK_WINDOW (g.window), 0, + GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", error->message); + gtk_dialog_run (GTK_DIALOG (dialog)); + gtk_widget_destroy (dialog); + g_error_free (error); +} + +static void +on_new_dictionaries_loaded (G_GNUC_UNUSED GObject* source_object, + GAsyncResult* res, G_GNUC_UNUSED gpointer user_data) +{ + g.loading = FALSE; + + GError *error = NULL; + GPtrArray *new_dictionaries = + g_task_propagate_pointer (G_TASK (res), &error); + if (!new_dictionaries) + { + show_error_dialog (error); + return; + } + + while (gtk_notebook_get_n_pages (GTK_NOTEBOOK (g.notebook))) + gtk_notebook_remove_page (GTK_NOTEBOOK (g.notebook), -1); + + g.dictionary = -1; + if (g.dictionaries) + g_ptr_array_free (g.dictionaries, TRUE); + + stardict_view_set_position (STARDICT_VIEW (g.view), NULL, 0); + g.dictionaries = new_dictionaries; + init_tabs (); +} + +static void +on_reload_dictionaries_task (GTask *task, G_GNUC_UNUSED gpointer source_object, + gpointer task_data, G_GNUC_UNUSED GCancellable *cancellable) +{ + GError *error = NULL; + if (load_dictionaries (task_data, &error)) + { + g_task_return_pointer (task, + g_ptr_array_ref (task_data), (GDestroyNotify) g_ptr_array_unref); + } + else + g_task_return_error (task, error); +} + +static gboolean +reload_dictionaries (GPtrArray *new_dictionaries, GError **error) +{ + // TODO: We could cancel that task. + if (g.loading) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "already loading dictionaries"); + return FALSE; + } + + // TODO: Some other kind of indication. + // Note that "action widgets" aren't visible without GtkNotebook tabs. + g.loading = TRUE; + + GTask *task = g_task_new (NULL, NULL, on_new_dictionaries_loaded, NULL); + g_task_set_name (task, __func__); + g_task_set_task_data (task, + new_dictionaries, (GDestroyNotify) g_ptr_array_unref); + g_task_run_in_thread (task, on_reload_dictionaries_task); + g_object_unref (task); + return TRUE; +} + +static void +on_open (G_GNUC_UNUSED GtkMenuItem *item, G_GNUC_UNUSED gpointer data) +{ + // The default is local-only. Paths are returned absolute. + GtkWidget *dialog = gtk_file_chooser_dialog_new (_("Open dictionary"), + GTK_WINDOW (g.window), GTK_FILE_CHOOSER_ACTION_OPEN, + _("_Cancel"), GTK_RESPONSE_CANCEL, + _("_Open"), GTK_RESPONSE_ACCEPT, NULL); + + GtkFileFilter *filter = gtk_file_filter_new (); + gtk_file_filter_add_pattern (filter, "*.ifo"); + gtk_file_filter_set_name (filter, "*.ifo"); + GtkFileChooser *chooser = GTK_FILE_CHOOSER (dialog); + gtk_file_chooser_add_filter (chooser, filter); + gtk_file_chooser_set_select_multiple (chooser, TRUE); + + GPtrArray *new_dictionaries = + g_ptr_array_new_with_free_func ((GDestroyNotify) dictionary_destroy); + if (gtk_dialog_run (GTK_DIALOG (dialog)) == GTK_RESPONSE_ACCEPT) + { + GSList *paths = gtk_file_chooser_get_filenames (chooser); + for (GSList *iter = paths; iter; iter = iter->next) + { + Dictionary *dict = g_malloc0 (sizeof *dict); + dict->filename = iter->data; + g_ptr_array_add (new_dictionaries, dict); + } + g_slist_free (paths); + } + + gtk_widget_destroy (dialog); + + GError *error = NULL; + if (!new_dictionaries->len + || !reload_dictionaries (new_dictionaries, &error)) + g_ptr_array_free (new_dictionaries, TRUE); + + if (error) + show_error_dialog (error); +} + +static void +on_drag_data_received (G_GNUC_UNUSED GtkWidget *widget, GdkDragContext *context, + G_GNUC_UNUSED gint x, G_GNUC_UNUSED gint y, GtkSelectionData *data, + G_GNUC_UNUSED guint info, guint time, G_GNUC_UNUSED gpointer user_data) +{ + GError *error = NULL; + gchar **dropped_uris = gtk_selection_data_get_uris (data); + if (!dropped_uris) + return; + + GPtrArray *new_dictionaries = + g_ptr_array_new_with_free_func ((GDestroyNotify) dictionary_destroy); + for (gsize i = 0; !error && dropped_uris[i]; i++) + { + Dictionary *dict = g_malloc0 (sizeof *dict); + dict->filename = g_filename_from_uri (dropped_uris[i], NULL, &error); + g_ptr_array_add (new_dictionaries, dict); + } + + g_strfreev (dropped_uris); + if (!new_dictionaries->len + || !reload_dictionaries (new_dictionaries, &error)) + g_ptr_array_free (new_dictionaries, TRUE); + + gtk_drag_finish (context, error == NULL, FALSE, time); + + if (error) + show_error_dialog (error); +} + +static void +on_destroy (G_GNUC_UNUSED GtkWidget *widget, G_GNUC_UNUSED gpointer data) +{ + gtk_main_quit (); +} + +static void +die_with_dialog (const gchar *message) +{ + GtkWidget *dialog = gtk_message_dialog_new (NULL, 0, + GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", message); + gtk_dialog_run (GTK_DIALOG (dialog)); + gtk_widget_destroy (dialog); + exit (EXIT_FAILURE); +} + +int +gui_main (char *argv[]) +{ + // Just like with GtkApplication, argv has been parsed by the option group. + gtk_init (NULL, NULL); + + gtk_window_set_default_icon_name (PROJECT_NAME); + + GError *error = NULL; + GPtrArray *new_dictionaries = + g_ptr_array_new_with_free_func ((GDestroyNotify) dictionary_destroy); + if (argv[0]) + load_from_filenames (new_dictionaries, argv); + else if (!load_from_config (new_dictionaries, &error) && error) + die_with_dialog (error->message); + + if (!new_dictionaries->len) + die_with_dialog (_("No dictionaries found either in " + "the configuration or on the command line")); + + // Some Adwaita stupidity, plus defaults for our own widget. + // All the named colours have been there since GNOME 3.4 + // (see gnome-extra-themes git history, Adwaita used to live there). + const char *style = "notebook header tab { padding: 2px 8px; margin: 0; }" + // `gsettings set org.gnome.desktop.interface gtk-key-theme "Emacs"` + // isn't quite what I want, and note that ^U works by default + "@binding-set Readline {" + "bind '<Control>H' { 'delete-from-cursor' (chars, -1) };" + "bind '<Control>W' { 'delete-from-cursor' (word-ends, -1) }; }" + "entry { -gtk-key-bindings: Readline; border-radius: 0; }" + "stardict-view { padding: 0 .25em; }" + "stardict-view.odd {" + "background: @theme_base_color; " + "color: @theme_text_color; }" + "stardict-view.odd:backdrop {" + "background: @theme_unfocused_base_color; " + "color: @theme_fg_color; /* should be more faded than 'text' */ }" + "stardict-view.even {" + "background: mix(@theme_base_color, @theme_text_color, 0.03); " + "color: @theme_text_color; }" + "stardict-view.even:backdrop {" + "background: mix(@theme_unfocused_base_color, " + "@theme_fg_color, 0.03); " + "color: @theme_fg_color; /* should be more faded than 'text' */ }" + "stardict-view:selected {" + "background-color: @theme_selected_bg_color; " + "color: @theme_selected_fg_color; }"; + + GdkScreen *screen = gdk_screen_get_default (); + GtkCssProvider *provider = gtk_css_provider_new (); + gtk_css_provider_load_from_data (provider, style, strlen (style), NULL); + gtk_style_context_add_provider_for_screen (screen, + GTK_STYLE_PROVIDER (provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + + GtkWidget *item_open = gtk_menu_item_new_with_mnemonic (_("_Open...")); + g_signal_connect (item_open, "activate", G_CALLBACK (on_open), NULL); + + g.watch_selection = TRUE; + GtkWidget *item_selection = + gtk_check_menu_item_new_with_mnemonic (_("_Follow selection")); + gtk_check_menu_item_set_active + (GTK_CHECK_MENU_ITEM (item_selection), g.watch_selection); + g_signal_connect (item_selection, "toggled", + G_CALLBACK (on_selection_watch_toggle), NULL); + + GtkWidget *menu = gtk_menu_new (); + gtk_widget_set_halign (menu, GTK_ALIGN_END); + gtk_menu_shell_append (GTK_MENU_SHELL (menu), item_open); +#ifndef G_OS_WIN32 + gtk_menu_shell_append (GTK_MENU_SHELL (menu), item_selection); +#endif // ! G_OS_WIN32 + gtk_widget_show_all (menu); + + g.hamburger = gtk_menu_button_new (); + gtk_button_set_relief (GTK_BUTTON (g.hamburger), GTK_RELIEF_NONE); + gtk_button_set_image (GTK_BUTTON (g.hamburger), gtk_image_new_from_icon_name + ("open-menu-symbolic", GTK_ICON_SIZE_BUTTON)); + gtk_menu_button_set_popup (GTK_MENU_BUTTON (g.hamburger), menu); + gtk_widget_show (g.hamburger); + + g.notebook = gtk_notebook_new (); + g_signal_connect (g.notebook, "switch-page", + G_CALLBACK (on_switch_page), NULL); + gtk_notebook_set_scrollable (GTK_NOTEBOOK (g.notebook), TRUE); + gtk_notebook_set_action_widget + (GTK_NOTEBOOK (g.notebook), g.hamburger, GTK_PACK_END); + + g.entry = gtk_search_entry_new (); + g_signal_connect (g.entry, "changed", G_CALLBACK (on_changed), g.view); + + g.window = gtk_window_new (GTK_WINDOW_TOPLEVEL); + gtk_window_set_title (GTK_WINDOW (g.window), PROJECT_NAME); + gtk_window_set_default_size (GTK_WINDOW (g.window), 300, 600); + g_signal_connect (g.window, "destroy", + G_CALLBACK (on_destroy), NULL); + g_signal_connect (g.window, "key-press-event", + G_CALLBACK (on_key_press), NULL); + + GtkWidget *superbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 1); + gtk_container_add (GTK_CONTAINER (g.window), superbox); + gtk_container_add (GTK_CONTAINER (superbox), g.notebook); + gtk_container_add (GTK_CONTAINER (superbox), g.entry); + gtk_container_add (GTK_CONTAINER (superbox), + gtk_separator_new (GTK_ORIENTATION_HORIZONTAL)); + + g.view = stardict_view_new (); + gtk_box_pack_end (GTK_BOX (superbox), g.view, TRUE, TRUE, 0); + + GtkClipboard *clipboard = gtk_clipboard_get (GDK_SELECTION_PRIMARY); + g_signal_connect (clipboard, "owner-change", + G_CALLBACK (on_selection), NULL); + + gtk_drag_dest_set (g.view, + GTK_DEST_DEFAULT_ALL, NULL, 0, GDK_ACTION_COPY); + gtk_drag_dest_add_uri_targets (g.view); + g_signal_connect (g.view, "drag-data-received", + G_CALLBACK (on_drag_data_received), NULL); + g_signal_connect (g.view, "send", + G_CALLBACK (on_send), NULL); + + if (!reload_dictionaries (new_dictionaries, &error)) + die_with_dialog (error->message); + + gtk_widget_show_all (g.window); + gtk_main (); + return 0; +} |