From aa198484997003294fe053d72e3f37471dca162f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Mon, 7 Sep 2020 19:08:04 +0200 Subject: [PATCH] Add an experimental GTK+ UI It has a potential to stay simpler than the TUI, while having a wider feature set. Not building this toy by default, it needs some time investment. --- CMakeLists.txt | 9 ++ src/sdgtk.c | 418 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 src/sdgtk.c diff --git a/CMakeLists.txt b/CMakeLists.txt index e9b75b0..8771632 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,6 +179,15 @@ add_executable (${PROJECT_NAME} ${project_sources} ${project_headers} ${project_common_sources}) target_link_libraries (${PROJECT_NAME} ${project_common_libraries}) +# Experimental GTK+ frontend, we link it with ncurses but we don't care +pkg_check_modules (gtk gtk+-3.0) +if (gtk_FOUND) + add_executable (sdgtk EXCLUDE_FROM_ALL + src/sdgtk.c ${project_common_sources}) + target_include_directories (sdgtk PUBLIC ${gtk_INCLUDE_DIRS}) + target_link_libraries (sdgtk ${gtk_LIBRARIES} ${project_common_libraries}) +endif (gtk_FOUND) + # Tools set (tools add-pronunciation query-tool transform) foreach (tool ${tools}) diff --git a/src/sdgtk.c b/src/sdgtk.c new file mode 100644 index 0000000..30d2e57 --- /dev/null +++ b/src/sdgtk.c @@ -0,0 +1,418 @@ +/* + * StarDict GTK+ UI + * + * Copyright (c) 2020, Přemysl Eric Janouch + * + * 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 +#include + +#include + +#include "config.h" +#include "stardict.h" +#include "utils.h" + +typedef struct dictionary Dictionary; + +struct dictionary +{ + const gchar *filename; ///< Filename + StardictDict *dict; ///< Stardict dictionary data + gchar *name; ///< Name to show + guint position; ///< Current position +}; + +static struct +{ + GtkWidget *window; ///< Top-level window + GtkWidget *notebook; ///< Notebook with tabs + GtkWidget *entry; ///< Search entry widget + GtkWidget *grid; ///< Entries container + + gint dictionary; ///< Index of the current dictionary + Dictionary *dictionaries; ///< All open dictionaries + gsize dictionaries_len; ///< Total number of dictionaries + + gboolean watch_selection; ///< Following X11 PRIMARY? +} +g; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static gboolean +dictionary_load (Dictionary *self, gchar *filename, GError **e) +{ + self->filename = filename; + if (!(self->dict = stardict_dict_new (self->filename, e))) + return FALSE; + + if (!self->name) + { + self->name = g_strdup (stardict_info_get_book_name + (stardict_dict_get_info (self->dict))); + } + return TRUE; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static gboolean +init (gchar **filenames, GError **e) +{ + while (filenames[g.dictionaries_len]) + g.dictionaries_len++; + + g.dictionaries = g_malloc0_n (sizeof *g.dictionaries, g.dictionaries_len); + for (gsize i = 0; i < g.dictionaries_len; i++) + { + Dictionary *dict = &g.dictionaries[i]; + if (!dictionary_load (dict, filenames[i], e)) + return FALSE; + } + return TRUE; +} + +static void +add_row (StardictIterator *iterator, gint row, gint *height_acc) +{ + Dictionary *dict = &g.dictionaries[g.dictionary]; + + StardictEntry *entry = stardict_iterator_get_entry (iterator); + g_return_if_fail (entry != NULL); + StardictEntryField *field = entry->fields->data; + g_return_if_fail (g_ascii_islower (field->type)); + + GtkEntryBuffer *buf = gtk_entry_get_buffer (GTK_ENTRY (g.entry)); + const gchar *input_utf8 = gtk_entry_buffer_get_text (buf); + g_return_if_fail (input_utf8 != NULL); + + const gchar *word_str = stardict_iterator_get_word (iterator); + gsize common_prefix = stardict_longest_common_collation_prefix + (dict->dict, word_str, input_utf8); + gchar *pre = g_markup_escape_text (word_str, common_prefix), + *post = g_markup_escape_text (word_str + common_prefix, -1), + *marked_up = g_strdup_printf ("%s%s", pre, post); + + GtkWidget *word = gtk_label_new (marked_up); + gtk_label_set_use_markup (GTK_LABEL (word), TRUE); + gtk_label_set_ellipsize (GTK_LABEL (word), PANGO_ELLIPSIZE_END); + gtk_label_set_selectable (GTK_LABEL (word), TRUE); + gtk_label_set_xalign (GTK_LABEL (word), 0); + gtk_label_set_yalign (GTK_LABEL (word), 0); + // FIXME: they can't be deselected by just clicking outside of them + gtk_widget_set_can_focus (word, FALSE); + + g_free (pre); + g_free (post); + g_free (marked_up); + + GtkWidget *desc = gtk_label_new (field->data); + gtk_label_set_ellipsize (GTK_LABEL (desc), PANGO_ELLIPSIZE_END); + gtk_label_set_selectable (GTK_LABEL (desc), TRUE); + gtk_label_set_xalign (GTK_LABEL (desc), 0); + gtk_widget_set_can_focus (desc, FALSE); + + g_object_unref (entry); + + if (iterator->offset % 2 == 0) + { + GtkStyleContext *ctx; + ctx = gtk_widget_get_style_context (word); + gtk_style_context_add_class (ctx, "odd"); + ctx = gtk_widget_get_style_context (desc); + gtk_style_context_add_class (ctx, "odd"); + } + + gtk_grid_attach (GTK_GRID (g.grid), word, 0, row, 1, 1); + gtk_grid_attach (GTK_GRID (g.grid), desc, 1, row, 1, 1); + + gtk_widget_show (word); + gtk_widget_show (desc); + + gint minimum_word = 0, minimum_desc = 0; + gtk_widget_get_preferred_height (word, &minimum_word, NULL); + gtk_widget_get_preferred_height (desc, &minimum_desc, NULL); + *height_acc += MAX (minimum_word, minimum_desc); +} + +static void +reload (GtkWidget *grid) +{ + Dictionary *dict = &g.dictionaries[g.dictionary]; + + GList *children = gtk_container_get_children (GTK_CONTAINER (grid)); + for (GList *iter = children; iter != NULL; iter = g_list_next (iter)) + gtk_widget_destroy (GTK_WIDGET (iter->data)); + g_list_free (children); + + gint window_height = 0; + gtk_window_get_size (GTK_WINDOW (g.window), NULL, &window_height); + if (window_height <= 0) + return; + + StardictIterator *iterator = + stardict_iterator_new (dict->dict, dict->position); + gint row = 0, height_acc = 0; + while (stardict_iterator_is_valid (iterator)) + { + add_row (iterator, row++, &height_acc); + if (height_acc >= window_height) + break; + + stardict_iterator_next (iterator); + } + gtk_widget_show_all (grid); + g_object_unref (iterator); +} + +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); + + StardictIterator *iterator = + stardict_dict_search (dict->dict, input_utf8, NULL); + dict->position = stardict_iterator_get_offset (iterator); + g_object_unref (iterator); +} + +static void +on_changed (G_GNUC_UNUSED GtkWidget *widget, G_GNUC_UNUSED gpointer data) +{ + search (&g.dictionaries[g.dictionary]); + reload (g.grid); +} + +static void +on_selection_received (G_GNUC_UNUSED GtkClipboard *clipboard, const gchar *text, + G_GNUC_UNUSED gpointer data) +{ + if (!text) + return; + + gtk_entry_set_text (GTK_ENTRY (g.entry), text); + 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 + && 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.dictionary = page_num; + search (&g.dictionaries[g.dictionary]); + reload (g.grid); +} + +static gboolean +on_key_press (G_GNUC_UNUSED GtkWidget *widget, GdkEvent *event, + G_GNUC_UNUSED gpointer data) +{ + if (event->key.state == GDK_CONTROL_MASK) + { + if (event->key.keyval == GDK_KEY_Page_Up) + { + gtk_notebook_prev_page (GTK_NOTEBOOK (g.notebook)); + return TRUE; + } + if (event->key.keyval == GDK_KEY_Page_Down) + { + gtk_notebook_next_page (GTK_NOTEBOOK (g.notebook)); + return TRUE; + } + } + if (event->key.state == 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 + (GTK_NOTEBOOK (g.notebook), n ? (n - 1) : 10); + return TRUE; + } + } + return FALSE; +} + +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 +main (int argc, char *argv[]) +{ + if (!setlocale (LC_ALL, "")) + g_printerr ("%s: %s\n", _("Warning"), _("failed to set the locale")); + + bindtextdomain (GETTEXT_PACKAGE, GETTEXT_DIRNAME); + bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8"); + textdomain (GETTEXT_PACKAGE); + + gchar **filenames = NULL; + GOptionEntry option_entries[] = + { + {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &filenames, + NULL, N_("FILE...")}, + {}, + }; + + GError *error = NULL; + gtk_init_with_args (&argc, &argv, N_("- StarDict GTK+ UI"), + option_entries, GETTEXT_PACKAGE, &error); + if (error) + { + g_warning ("%s", error->message); + g_error_free (error); + return 1; + } + + if (!filenames) + { + // TODO: eventually just load all dictionaries from configuration + die_with_dialog ("No arguments have been passed."); + } + if (!init (filenames, &error)) + die_with_dialog (error->message); + + // Some Adwaita stupidity and our own additions + const char *style = "notebook header tab { padding: 2px 8px; margin: 0; }" + "grid { border-top: 1px solid rgba(0, 0, 0, 0.2); background: white; }" + "grid label { padding: 0 5px; " + "/*border-bottom: 1px solid rgba(0, 0, 0, 0.2);*/ }" + "grid label.odd { background: rgba(0, 0, 0, 0.05); }"; + + 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); + + g.grid = gtk_grid_new (); + gtk_grid_set_column_homogeneous (GTK_GRID (g.grid), TRUE); + + // FIXME: we'd rather like to trim the contents, not make it scrollable. + // This just limits the allocation. + // TODO: probably create a whole new custom widget, everything is text + // anyway and mostly handled by Pango, including pango_layout_xy_to_index() + // - I don't know where to get selection colour but inversion works, too + GtkWidget *scrolled_window = gtk_scrolled_window_new (NULL, NULL); + gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scrolled_window), + GTK_POLICY_NEVER, GTK_POLICY_EXTERNAL); + gtk_widget_set_can_focus (scrolled_window, FALSE); + gtk_container_add (GTK_CONTAINER (scrolled_window), g.grid); + + 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); + + g.watch_selection = TRUE; + GtkWidget *item = + gtk_check_menu_item_new_with_label (_("Follow selection")); + gtk_check_menu_item_set_active + (GTK_CHECK_MENU_ITEM (item), g.watch_selection); + g_signal_connect (item, "toggled", + G_CALLBACK (on_selection_watch_toggle), NULL); + + GtkWidget *menu = gtk_menu_new (); + gtk_menu_shell_append (GTK_MENU_SHELL (menu), item); + gtk_widget_show_all (menu); + + GtkWidget *hamburger = gtk_menu_button_new (); + gtk_menu_button_set_direction (GTK_MENU_BUTTON (hamburger), GTK_ARROW_NONE); + gtk_menu_button_set_popup (GTK_MENU_BUTTON (hamburger), menu); + gtk_button_set_relief (GTK_BUTTON (hamburger), GTK_RELIEF_NONE); + gtk_widget_show (hamburger); + + gtk_notebook_set_action_widget + (GTK_NOTEBOOK (g.notebook), hamburger, GTK_PACK_END); + + // FIXME: when the clear icon shows, the widget changes in height + g.entry = gtk_search_entry_new (); + // TODO: attach to the "key-press-event" signal and implement ^W at least, + // though ^U is working already! Note that bindings can be done in CSS + // as well, if we have any extra specially for the editor + g_signal_connect (g.entry, "changed", G_CALLBACK (on_changed), g.grid); + gtk_entry_set_has_frame (GTK_ENTRY (g.entry), FALSE); + + // TODO: supposedly attach to "key-press-event" here and react to + // PageUp/PageDown and up/down arrow keys... either here or in the Entry + g.window = gtk_window_new (GTK_WINDOW_TOPLEVEL); + 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_box_pack_end (GTK_BOX (superbox), scrolled_window, TRUE, TRUE, 0); + + for (gsize i = 0; i < g.dictionaries_len; i++) + { + Dictionary *dict = &g.dictionaries[i]; + GtkWidget *dummy = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *label = gtk_label_new (dict->name); + gtk_notebook_append_page (GTK_NOTEBOOK (g.notebook), dummy, label); + } + + GtkClipboard *clipboard = gtk_clipboard_get (GDK_SELECTION_PRIMARY); + g_signal_connect (clipboard, "owner-change", + G_CALLBACK (on_selection), NULL); + + // Make sure to fill up the window with entries once we're resized + // XXX: this is rather inefficient as we rebuild everything each time + g_signal_connect (g.window, "configure-event", + G_CALLBACK (on_changed), NULL); + g_signal_connect (g.window, "map-event", + G_CALLBACK (on_changed), NULL); + + gtk_widget_grab_focus (g.entry); + gtk_widget_show_all (g.window); + gtk_main (); + + g_strfreev (filenames); + return 0; +}