diff options
Diffstat (limited to 'fiv.c')
-rw-r--r-- | fiv.c | 1309 |
1 files changed, 885 insertions, 424 deletions
@@ -1,7 +1,7 @@ // // fiv.c: fuck-if-I-know-how-to-name-it image browser and viewer // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -27,6 +27,7 @@ #include <stdarg.h> #include <stdio.h> #include <stdlib.h> +#include <string.h> #ifdef G_OS_WIN32 #include <io.h> @@ -38,6 +39,7 @@ #include "fiv-browser.h" #include "fiv-collection.h" #include "fiv-io.h" +#include "fiv-io-model.h" #include "fiv-sidebar.h" #include "fiv-thumbnail.h" #include "fiv-view.h" @@ -94,16 +96,16 @@ struct key_section { static struct key help_keys_general[] = { {"F1", "Show help"}, {"F10", "Open menu"}, - {"<Control>comma", "Preferences"}, - {"<Control>question", "Keyboard shortcuts"}, - {"q <Control>q", "Quit"}, - {"<Control>w", "Quit"}, + {"<Primary>comma", "Preferences"}, + {"<Primary>question", "Keyboard shortcuts"}, + {"q <Primary>q", "Quit"}, + {"<Primary>w", "Quit"}, {} }; static struct key help_keys_navigation[] = { - {"<Control>l", "Open location..."}, - {"<Control>n", "Open a new window"}, + {"<Primary>l", "Open location..."}, + {"<Primary>n", "Open a new window"}, {"<Alt>Left", "Go back in history"}, {"<Alt>Right", "Go forward in history"}, {} @@ -120,16 +122,20 @@ static struct key_group help_keys_browser[] = { {"General: Navigation", help_keys_navigation}, {"General: View", help_keys_view}, {"Navigation", (struct key[]) { - {"<Alt>Up", "Go to parent directory"}, {"<Alt>Home", "Go home"}, + {"<Alt>Up", "Go to parent directory"}, + {"bracketleft", "Go to previous directory in tree"}, + {"bracketright", "Go to next directory in tree"}, {"Return", "Open selected item"}, {"<Alt>Return", "Show file information"}, {} }}, {"View", (struct key[]) { + {"F7", "Toggle toolbar"}, {"F9", "Toggle navigation sidebar"}, {"F5 r <Control>r", "Reload"}, {"h <Control>h", "Toggle hiding unsupported files"}, + {"t <Control>t", "Toggle showing filenames"}, {"<Control>plus", "Larger thumbnails"}, {"<Control>minus", "Smaller thumbnails"}, {} @@ -148,7 +154,7 @@ static struct key_group help_keys_viewer[] = { {} }}, {"View", (struct key[]) { - {"F9", "Toggle toolbar"}, + {"F7", "Toggle toolbar"}, {"F5 r <Primary>r", "Reload"}, {} }}, @@ -383,12 +389,6 @@ on_about_draw(GtkWidget *widget, cairo_t *cr, gpointer user_data) GtkStyleContext *style = gtk_widget_get_style_context(widget); gtk_render_background(style, cr, 0, 0, allocation.width, allocation.height); - // The transformation matrix turns out/is applied wrongly on Quartz. - gboolean broken_backend = cairo_surface_get_type(cairo_get_target(cr)) == - CAIRO_SURFACE_TYPE_QUARTZ; - if (broken_backend) - cairo_push_group(cr); - cairo_translate(cr, (allocation.width - ABOUT_SIZE * ABOUT_SCALE) / 2, ABOUT_SIZE * ABOUT_SCALE / 4); cairo_scale(cr, ABOUT_SCALE, ABOUT_SCALE); @@ -414,11 +414,6 @@ on_about_draw(GtkWidget *widget, cairo_t *cr, gpointer user_data) cairo_restore(cr); draw_ligature(cr); - - if (broken_backend) { - cairo_pop_group_to_source(cr); - cairo_paint(cr); - } return TRUE; } @@ -459,7 +454,7 @@ show_about_dialog(GtkWidget *parent) GtkWidget *website = gtk_label_new(NULL); gtk_label_set_selectable(GTK_LABEL(website), TRUE); - const char *url = "https://git.janouch.name/p/" PROJECT_NAME; + const char *url = PROJECT_URL; gchar *link = g_strdup_printf("<a href='%s'>%s</a>", url, url); gtk_label_set_markup(GTK_LABEL(website), link); g_free(link); @@ -515,12 +510,146 @@ show_about_dialog(GtkWidget *parent) cairo_pattern_destroy(ctx.v_pattern); } +// --- Settings ---------------------------------------------------------------- + +static void +preferences_make_row( + GtkWidget *grid, int *row, GSettings *settings, GSettingsSchemaKey *key) +{ + const char *name = g_settings_schema_key_get_name(key); + const char *summary = g_settings_schema_key_get_summary(key); + const char *description = g_settings_schema_key_get_description(key); + + GtkWidget *widget = NULL; + const GVariantType *type = g_settings_schema_key_get_value_type(key); + if (g_variant_type_equal(type, G_VARIANT_TYPE_BOOLEAN)) { + widget = gtk_switch_new(); + g_settings_bind( + settings, name, widget, "active", G_SETTINGS_BIND_DEFAULT); + } else { + const gchar *type = NULL; + GVariant *value = NULL, *range = g_settings_schema_key_get_range(key); + g_variant_get(range, "(&sv)", &type, &value); + GVariantIter iter = {}; + g_variant_iter_init(&iter, value); + if (g_str_equal(type, "enum")) { + widget = gtk_combo_box_text_new(); + + GVariant *child = NULL; + while ((child = g_variant_iter_next_value(&iter))) { + const char *id = g_variant_get_string(child, NULL); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(widget), id, id); + g_variant_unref(child); + } + + g_settings_bind( + settings, name, widget, "active-id", G_SETTINGS_BIND_DEFAULT); + } + g_variant_unref(value); + g_variant_unref(range); + } + + // Ignore unimplemented value types. + if (!widget) + return; + + GtkWidget *label = gtk_label_new(summary ? summary : name); + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_widget_set_hexpand(label, TRUE); + gtk_grid_attach(GTK_GRID(grid), label, 0, (*row), 1, 1); + gtk_widget_set_halign(widget, GTK_ALIGN_END); + gtk_grid_attach(GTK_GRID(grid), widget, 1, (*row)++, 1, 1); + + if (description) { + GtkWidget *label = gtk_label_new(description); + PangoAttrList *attr_list = pango_attr_list_new(); + pango_attr_list_insert( + attr_list, pango_attr_scale_new(PANGO_SCALE_SMALL)); + gtk_label_set_attributes( + GTK_LABEL(label), pango_attr_list_ref(attr_list)); + pango_attr_list_unref(attr_list); + + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_widget_set_sensitive(label, FALSE); + gtk_widget_set_size_request(label, 0, -1); + gtk_grid_attach(GTK_GRID(grid), label, 0, (*row)++, 1, 1); + } +} + +static void +show_preferences(GtkWidget *parent) +{ + GSettingsSchema *schema = NULL; + GSettings *settings = g_settings_new(PROJECT_NS PROJECT_NAME); + g_object_get(settings, "settings-schema", &schema, NULL); + + GtkWidget *dialog = gtk_widget_new(GTK_TYPE_DIALOG, + "use-header-bar", TRUE, + "title", "Preferences", + "transient-for", parent, + "destroy-with-parent", TRUE, NULL); + + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 12); + gtk_grid_set_column_spacing(GTK_GRID(grid), 24); + g_object_set(grid, "margin", 12, NULL); + gtk_box_pack_start(GTK_BOX(gtk_dialog_get_content_area(GTK_DIALOG(dialog))), + grid, TRUE, TRUE, 0); + + int row = 0; + gchar **keys = g_settings_schema_list_keys(schema); + for (gchar **p = keys; *p; p++) { +#ifndef GDK_WINDOWING_X11 + if (g_str_equal(*p, "native-view-window")) + continue; +#endif + GSettingsSchemaKey *key = g_settings_schema_get_key(schema, *p); + preferences_make_row(grid, &row, settings, key); + g_settings_schema_key_unref(key); + } + g_strfreev(keys); + g_object_unref(settings); + + gtk_window_set_default_size(GTK_WINDOW(dialog), 600, -1); + gtk_widget_show_all(dialog); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); +} + // --- Main -------------------------------------------------------------------- // TODO(p): See if it's possible to give separators room to shrink // by some minor amount of pixels, margin-wise. #define B make_toolbar_button #define T make_toolbar_toggle +#define R make_toolbar_radio +#define BROWSEBAR(XX) \ + XX(SIDEBAR, T("sidebar-show-symbolic", "Show sidebar")) \ + XX(S1, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(DIR_PREVIOUS, B("go-previous-symbolic", "Previous directory")) \ + XX(DIR_NEXT, B("go-next-symbolic", "Next directory")) \ + XX(S2, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(PLUS, B("zoom-in-symbolic", "Larger thumbnails")) \ + XX(MINUS, B("zoom-out-symbolic", "Smaller thumbnails")) \ + XX(S3, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(FILENAMES, T("text-symbolic", "Show filenames")) \ + XX(FILTER, T("funnel-symbolic", "Hide unsupported files")) \ + XX(S4, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(SORT_DIR, B("view-sort-ascending-symbolic", "Sort ascending")) \ + XX(SORT_NAME, R("Name", "Sort by filename")) \ + XX(SORT_TIME, R("Time", "Sort by time of last modification")) \ + XX(S5, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + /* We are YouTube. */ \ + XX(FULLSCREEN, B("view-fullscreen-symbolic", "Fullscreen")) + +enum { +#define XX(id, constructor) BROWSEBAR_ ## id, + BROWSEBAR(XX) +#undef XX + BROWSEBAR_COUNT +}; + #define TOOLBAR(XX) \ XX(BROWSE, B("view-grid-symbolic", "Browse")) \ XX(FILE_PREVIOUS, B("go-previous-symbolic", "Previous file")) \ @@ -572,10 +701,9 @@ struct { gchar *directory; ///< URI of the currently browsed directory GList *directory_back; ///< History paths as URIs going backwards GList *directory_forward; ///< History paths as URIs going forwards - GPtrArray *files; ///< "directory" contents as URIs gchar *uri; ///< Current image URI, if any - gint files_index; ///< Where "uri" is within "files" + gint files_index; ///< Where "uri" is within the model's files GtkWidget *window; GtkWidget *menu; @@ -583,11 +711,8 @@ struct { GtkWidget *browser_paned; GtkWidget *browser_sidebar; - GtkWidget *plus; - GtkWidget *minus; - GtkWidget *funnel; - GtkWidget *sort_field[FIV_IO_MODEL_SORT_COUNT]; - GtkWidget *sort_direction[2]; + GtkWidget *browser_toolbar; + GtkWidget *browsebar[BROWSEBAR_COUNT]; GtkWidget *browser_scroller; GtkWidget *browser; @@ -663,56 +788,38 @@ parent_uri(GFile *child_file) static void update_files_index(void) { + gsize files_len = 0; + FivIoModelEntry *const *files = fiv_io_model_get_files(g.model, &files_len); + g.files_index = -1; - for (guint i = 0; i < g.files->len; i++) - if (!g_strcmp0(g.uri, g_ptr_array_index(g.files, i))) + for (guint i = 0; i < files_len; i++) + if (!g_strcmp0(g.uri, files[i]->uri)) g.files_index = i; } static void -load_directory_without_reload(const char *uri) +change_directory_without_reload(const char *uri) { - gchar *uri_duplicated = g_strdup(uri); - if (g.directory_back && !strcmp(uri, g.directory_back->data)) { - // We're going back in history. - if (g.directory) { - g.directory_forward = - g_list_prepend(g.directory_forward, g.directory); - g.directory = NULL; - } + if (g.directory) { + // Note that this function can be passed g.directory directly. + if (!strcmp(uri, g.directory)) + return; - GList *link = g.directory_back; - g.directory_back = g_list_remove_link(g.directory_back, link); - g_list_free_full(link, g_free); - } else if (g.directory_forward && !strcmp(uri, g.directory_forward->data)) { - // We're going forward in history. - if (g.directory) { - g.directory_back = - g_list_prepend(g.directory_back, g.directory); - g.directory = NULL; - } - - GList *link = g.directory_forward; - g.directory_forward = g_list_remove_link(g.directory_forward, link); - g_list_free_full(link, g_free); - } else if (g.directory && strcmp(uri, g.directory)) { // We're on a new subpath. g_list_free_full(g.directory_forward, g_free); g.directory_forward = NULL; g.directory_back = g_list_prepend(g.directory_back, g.directory); - g.directory = NULL; } - g_free(g.directory); - g.directory = uri_duplicated; + g.directory = g_strdup(uri); } static void load_directory_without_switching(const char *uri) { if (uri) { - load_directory_without_reload(uri); + change_directory_without_reload(uri); GtkAdjustment *vadjustment = gtk_scrolled_window_get_vadjustment( GTK_SCROLLED_WINDOW(g.browser_scroller)); @@ -723,7 +830,7 @@ load_directory_without_switching(const char *uri) GError *error = NULL; GFile *file = g_file_new_for_uri(g.directory); if (fiv_io_model_open(g.model, file, &error)) { - // This is handled by our ::files-changed callback. + // This is handled by our ::reloaded callback. } else if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED)) { g_error_free(error); } else { @@ -738,9 +845,6 @@ load_directory(const char *uri) { load_directory_without_switching(uri); - // XXX: When something outside the filtered entries is open, the index is - // kept at -1, and browsing doesn't work. How to behave here? - // Should we add it to the pointer array as an exception? if (uri) { switch_to_browser_noselect(); @@ -750,23 +854,71 @@ load_directory(const char *uri) } static void -on_model_files_changed(FivIoModel *model, G_GNUC_UNUSED gpointer user_data) +go_back(void) +{ + if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) { + switch_to_browser_noselect(); + } else if (g.directory_back) { + if (g.directory) + g.directory_forward = + g_list_prepend(g.directory_forward, g.directory); + + const gchar *uri = g.directory = g.directory_back->data; + + GList *link = g.directory_back; + g.directory_back = g_list_remove_link(g.directory_back, link); + g_list_free(link); + + load_directory(uri); + } +} + +static void +go_forward(void) +{ + if (g.directory_forward) { + if (g.directory) + g.directory_back = + g_list_prepend(g.directory_back, g.directory); + + const gchar *uri = g.directory = g.directory_forward->data; + + GList *link = g.directory_forward; + g.directory_forward = g_list_remove_link(g.directory_forward, link); + g_list_free(link); + + load_directory(uri); + } else if (g.uri) { + switch_to_view(); + } +} + +static void +on_model_reloaded(FivIoModel *model, G_GNUC_UNUSED gpointer user_data) { g_return_if_fail(model == g.model); - gsize len = 0; - const FivIoModelEntry *files = fiv_io_model_get_files(g.model, &len); - g_ptr_array_free(g.files, TRUE); - g.files = g_ptr_array_new_full(len, g_free); - for (gsize i = 0; i < len; i++) - g_ptr_array_add(g.files, g_strdup(files[i].uri)); + gsize files_len = 0; + (void) fiv_io_model_get_files(g.model, &files_len); update_files_index(); - gtk_widget_set_sensitive( - g.toolbar[TOOLBAR_FILE_PREVIOUS], g.files->len > 1); - gtk_widget_set_sensitive( - g.toolbar[TOOLBAR_FILE_NEXT], g.files->len > 1); + gtk_widget_set_sensitive(g.toolbar[TOOLBAR_FILE_PREVIOUS], files_len > 1); + gtk_widget_set_sensitive(g.toolbar[TOOLBAR_FILE_NEXT], files_len > 1); +} + +static void +on_model_files_changed(FivIoModel *model, G_GNUC_UNUSED FivIoModelEntry *old, + G_GNUC_UNUSED FivIoModelEntry *new, G_GNUC_UNUSED gpointer user_data) +{ + on_model_reloaded(model, NULL); +} + +static void +on_sidebar_toggled(GtkToggleButton *button, G_GNUC_UNUSED gpointer user_data) +{ + gboolean active = gtk_toggle_button_get_active(button); + gtk_widget_set_visible(g.browser_sidebar, active); } static void @@ -777,21 +929,33 @@ on_filtering_toggled(GtkToggleButton *button, G_GNUC_UNUSED gpointer user_data) } static void -on_sort_field(G_GNUC_UNUSED GtkMenuItem *item, gpointer data) +on_filenames_toggled(GtkToggleButton *button, G_GNUC_UNUSED gpointer user_data) +{ + gboolean active = gtk_toggle_button_get_active(button); + g_object_set(g.browser, "show-labels", active, NULL); +} + +static void +on_sort_field(G_GNUC_UNUSED GtkToggleButton *button, gpointer data) { - int old = -1, new = (int) (intptr_t) data; + gboolean active = gtk_toggle_button_get_active(button); + if (!active) + return; + + FivIoModelSort old = FIV_IO_MODEL_SORT_COUNT; + FivIoModelSort new = (FivIoModelSort) (intptr_t) data; g_object_get(g.model, "sort-field", &old, NULL); if (old != new) g_object_set(g.model, "sort-field", new, NULL); } static void -on_sort_direction(G_GNUC_UNUSED GtkMenuItem *item, gpointer data) +on_sort_direction(G_GNUC_UNUSED GtkToggleButton *button, + G_GNUC_UNUSED gpointer data) { - gboolean old = FALSE, new = (gboolean) (intptr_t) data; + gboolean old = FALSE; g_object_get(g.model, "sort-descending", &old, NULL); - if (old != new) - g_object_set(g.model, "sort-descending", new, NULL); + g_object_set(g.model, "sort-descending", !old, NULL); } static void @@ -828,13 +992,17 @@ open_image(const char *uri) // So that load_directory() itself can be used for reloading. gchar *parent = parent_uri(file); g_object_unref(file); - if (!g.files->len /* hack to always load the directory after launch */ || - !g.directory || strcmp(parent, g.directory)) + if (!fiv_io_model_get_location(g.model) || !g.directory || + strcmp(parent, g.directory)) load_directory_without_switching(parent); else update_files_index(); g_free(parent); + // XXX: When something outside currently filtered entries is open, + // g.files_index is kept at -1, and browsing doesn't work. + // How to behave here? + switch_to_view(); } @@ -902,18 +1070,22 @@ on_open(void) static void on_previous(void) { + gsize files_len = 0; + FivIoModelEntry *const *files = fiv_io_model_get_files(g.model, &files_len); if (g.files_index >= 0) { - int previous = (g.files->len + g.files_index - 1) % g.files->len; - open_image(g_ptr_array_index(g.files, previous)); + int previous = (files_len + g.files_index - 1) % files_len; + open_image(files[previous]->uri); } } static void on_next(void) { + gsize files_len = 0; + FivIoModelEntry *const *files = fiv_io_model_get_files(g.model, &files_len); if (g.files_index >= 0) { - int next = (g.files_index + 1) % g.files->len; - open_image(g_ptr_array_index(g.files, next)); + int next = (g.files_index + 1) % files_len; + open_image(files[next]->uri); } } @@ -1089,6 +1261,40 @@ on_view_drag_data_received(G_GNUC_UNUSED GtkWidget *widget, } static void +on_notify_sidebar_visible( + GObject *object, GParamSpec *param_spec, G_GNUC_UNUSED gpointer user_data) +{ + gboolean b = FALSE; + g_object_get(object, g_param_spec_get_name(param_spec), &b, NULL); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_SIDEBAR]), b); +} + +static void +on_dir_previous(void) +{ + GFile *directory = fiv_io_model_get_previous_directory(g.model); + if (directory) { + gchar *uri = g_file_get_uri(directory); + g_object_unref(directory); + load_directory(uri); + g_free(uri); + } +} + +static void +on_dir_next(void) +{ + GFile *directory = fiv_io_model_get_next_directory(g.model); + if (directory) { + gchar *uri = g_file_get_uri(directory); + g_object_unref(directory); + load_directory(uri); + g_free(uri); + } +} + +static void on_toolbar_zoom(G_GNUC_UNUSED GtkButton *button, gpointer user_data) { FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; @@ -1105,10 +1311,22 @@ static void on_notify_thumbnail_size( GObject *object, GParamSpec *param_spec, G_GNUC_UNUSED gpointer user_data) { - FivThumbnailSize size = 0; + FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; g_object_get(object, g_param_spec_get_name(param_spec), &size, NULL); - gtk_widget_set_sensitive(g.plus, size < FIV_THUMBNAIL_SIZE_MAX); - gtk_widget_set_sensitive(g.minus, size > FIV_THUMBNAIL_SIZE_MIN); + gtk_widget_set_sensitive( + g.browsebar[BROWSEBAR_PLUS], size < FIV_THUMBNAIL_SIZE_MAX); + gtk_widget_set_sensitive( + g.browsebar[BROWSEBAR_MINUS], size > FIV_THUMBNAIL_SIZE_MIN); +} + +static void +on_notify_show_labels( + GObject *object, GParamSpec *param_spec, G_GNUC_UNUSED gpointer user_data) +{ + gboolean show_labels = 0; + g_object_get(object, g_param_spec_get_name(param_spec), &show_labels, NULL); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_FILENAMES]), show_labels); } static void @@ -1117,7 +1335,8 @@ on_notify_filtering( { gboolean b = FALSE; g_object_get(object, g_param_spec_get_name(param_spec), &b, NULL); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g.funnel), b); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_FILTER]), b); } static void @@ -1126,8 +1345,8 @@ on_notify_sort_field( { gint field = -1; g_object_get(object, g_param_spec_get_name(param_spec), &field, NULL); - gtk_check_menu_item_set_active( - GTK_CHECK_MENU_ITEM(g.sort_field[field]), TRUE); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_SORT_NAME + field]), TRUE); } static void @@ -1136,8 +1355,18 @@ on_notify_sort_descending( { gboolean b = FALSE; g_object_get(object, g_param_spec_get_name(param_spec), &b, NULL); - gtk_check_menu_item_set_active( - GTK_CHECK_MENU_ITEM(g.sort_direction[b]), TRUE); + + const char *title = b + ? "Sort ascending" + : "Sort descending"; + const char *name = b + ? "view-sort-ascending-symbolic" + : "view-sort-descending-symbolic"; + + GtkButton *button = GTK_BUTTON(g.browsebar[BROWSEBAR_SORT_DIR]); + GtkImage *image = GTK_IMAGE(gtk_button_get_image(button)); + gtk_widget_set_tooltip_text(GTK_WIDGET(button), title); + gtk_image_set_from_icon_name(image, name, GTK_ICON_SIZE_BUTTON); } static void @@ -1161,9 +1390,14 @@ on_window_state_event(G_GNUC_UNUSED GtkWidget *widget, ? "view-restore-symbolic" : "view-fullscreen-symbolic"; - GtkButton *button = GTK_BUTTON(g.toolbar[TOOLBAR_FULLSCREEN]); - GtkImage *image = GTK_IMAGE(gtk_button_get_image(button)); - gtk_image_set_from_icon_name(image, name, GTK_ICON_SIZE_BUTTON); + gtk_image_set_from_icon_name( + GTK_IMAGE(gtk_button_get_image( + GTK_BUTTON(g.toolbar[TOOLBAR_FULLSCREEN]))), + name, GTK_ICON_SIZE_BUTTON); + gtk_image_set_from_icon_name( + GTK_IMAGE(gtk_button_get_image( + GTK_BUTTON(g.browsebar[BROWSEBAR_FULLSCREEN]))), + name, GTK_ICON_SIZE_BUTTON); } static void @@ -1214,20 +1448,6 @@ show_help_shortcuts(void) } static void -show_preferences(void) -{ - char *argv[] = {"dconf-editor", PROJECT_NS PROJECT_NAME, NULL}; - GError *error = NULL; - if (!g_spawn_async( - NULL, argv, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, NULL, &error)) { - if (g_error_matches(error, G_SPAWN_ERROR, G_SPAWN_ERROR_NOENT)) - g_prefix_error_literal(&error, - "Please install dconf-editor, or use the gsettings utility.\n"); - show_error_dialog(error); - } -} - -static void toggle_sunlight(void) { GtkSettings *settings = gtk_settings_get_default(); @@ -1237,94 +1457,16 @@ toggle_sunlight(void) g_object_set(settings, property, !set, NULL); } -// Cursor keys, e.g., simply cannot be bound through accelerators -// (and GtkWidget::keynav-failed would arguably be an awful solution). -// -// GtkBindingSets can be added directly through GtkStyleContext, -// but that would still require setting up action signals on the widget class, -// which is extremely cumbersome. GtkWidget::move-focus has no return value, -// so we can't override that and abort further handling. -// -// Therefore, bind directly to keypresses. Order can be fine-tuned with -// g_signal_connect{,after}(), or overriding the handler and either tactically -// chaining up or using gtk_window_propagate_key_event(). static gboolean on_key_press(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, G_GNUC_UNUSED gpointer data) { switch (event->state & gtk_accelerator_get_default_mod_mask()) { - case GDK_MOD1_MASK | GDK_SHIFT_MASK: - if (event->keyval == GDK_KEY_D) - toggle_sunlight(); - break; case GDK_CONTROL_MASK: - case GDK_CONTROL_MASK | GDK_SHIFT_MASK: switch (event->keyval) { case GDK_KEY_h: - gtk_button_clicked(GTK_BUTTON(g.funnel)); - return TRUE; - case GDK_KEY_l: - fiv_sidebar_show_enter_location(FIV_SIDEBAR(g.browser_sidebar)); - return TRUE; - case GDK_KEY_n: - if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) - spawn_uri(g.uri); - else - spawn_uri(g.directory); - return TRUE; - case GDK_KEY_o: - on_open(); - return TRUE; - case GDK_KEY_q: - case GDK_KEY_w: - gtk_widget_destroy(g.window); - return TRUE; - - case GDK_KEY_question: - show_help_shortcuts(); - return TRUE; - case GDK_KEY_comma: - show_preferences(); - return TRUE; - } - break; - case GDK_MOD1_MASK: - switch (event->keyval) { - case GDK_KEY_Left: - if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) - switch_to_browser_noselect(); - else if (g.directory_back) - load_directory(g.directory_back->data); - return TRUE; - case GDK_KEY_Right: - if (g.directory_forward) - load_directory(g.directory_forward->data); - else if (g.uri) - switch_to_view(); - return TRUE; - } - break; - case GDK_SHIFT_MASK: - switch (event->keyval) { - case GDK_KEY_F1: - show_about_dialog(g.window); - return TRUE; - } - break; - case 0: - switch (event->keyval) { - case GDK_KEY_q: - gtk_widget_destroy(g.window); - return TRUE; - case GDK_KEY_o: - on_open(); - return TRUE; - case GDK_KEY_F1: - show_help_contents(); - return TRUE; - case GDK_KEY_F11: - case GDK_KEY_f: - toggle_fullscreen(); + // XXX: Command-H is already occupied on macOS. + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILTER])); return TRUE; } } @@ -1340,8 +1482,15 @@ on_key_press(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, gtk_accelerator_parse(accelerator, &key, &mods); g_free(accelerator); + // TODO(p): See how Unity 7 behaves, + // we might want to keep GtkApplicationWindow:show-menubar then. + gboolean shell_shows_menubar = FALSE; + (void) g_object_get(gtk_settings_get_default(), + "gtk-shell-shows-menubar", &shell_shows_menubar, NULL); + guint mask = gtk_accelerator_get_default_mod_mask(); - if (key && event->keyval == key && (event->state & mask) == mods) { + if (key && event->keyval == key && (event->state & mask) == mods && + !shell_shows_menubar) { gtk_widget_show(g.menu); // _gtk_menu_shell_set_keyboard_mode() is private. @@ -1351,6 +1500,17 @@ on_key_press(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, return FALSE; } +// Cursor keys, e.g., simply cannot be bound through accelerators +// (and GtkWidget::keynav-failed would arguably be an awful solution). +// +// GtkBindingSets can be added directly through GtkStyleContext, +// but that would still require setting up action signals on the widget class, +// which is extremely cumbersome. GtkWidget::move-focus has no return value, +// so we can't override that and abort further handling. +// +// Therefore, bind directly to keypresses. Order can be fine-tuned with +// g_signal_connect{,after}(), or overriding the handler and either tactically +// chaining up or using gtk_window_propagate_key_event(). static gboolean on_key_press_view(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, G_GNUC_UNUSED gpointer data) @@ -1358,7 +1518,7 @@ on_key_press_view(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, switch (event->state & gtk_accelerator_get_default_mod_mask()) { case 0: switch (event->keyval) { - case GDK_KEY_F9: + case GDK_KEY_F7: gtk_widget_set_visible(g.view_toolbar, !gtk_widget_is_visible(g.view_toolbar)); return TRUE; @@ -1395,6 +1555,9 @@ on_key_press_browser_paned(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, case GDK_KEY_r: load_directory(NULL); return TRUE; + case GDK_KEY_t: + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILENAMES])); + return TRUE; } break; case GDK_MOD1_MASK: @@ -1417,21 +1580,35 @@ on_key_press_browser_paned(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, break; case 0: switch (event->keyval) { + case GDK_KEY_F7: + gtk_widget_set_visible(g.browser_toolbar, + !gtk_widget_is_visible(g.browser_toolbar)); + return TRUE; case GDK_KEY_F9: gtk_widget_set_visible(g.browser_sidebar, !gtk_widget_is_visible(g.browser_sidebar)); return TRUE; + case GDK_KEY_bracketleft: + on_dir_previous(); + return TRUE; + case GDK_KEY_bracketright: + on_dir_next(); + return TRUE; + case GDK_KEY_Escape: fiv_browser_select(FIV_BROWSER(g.browser), NULL); return TRUE; case GDK_KEY_h: - gtk_button_clicked(GTK_BUTTON(g.funnel)); + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILTER])); return TRUE; case GDK_KEY_F5: case GDK_KEY_r: load_directory(NULL); return TRUE; + case GDK_KEY_t: + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILENAMES])); + return TRUE; } } return FALSE; @@ -1445,7 +1622,7 @@ on_button_press_view(G_GNUC_UNUSED GtkWidget *widget, GdkEventButton *event) switch (event->button) { case 4: // back (GdkWin32, GdkQuartz) case 8: // back - switch_to_browser_noselect(); + go_back(); return TRUE; case GDK_BUTTON_PRIMARY: if (event->type == GDK_2BUTTON_PRESS) { @@ -1467,15 +1644,11 @@ on_button_press_browser_paned( switch (event->button) { case 4: // back (GdkWin32, GdkQuartz) case 8: // back - if (g.directory_back) - load_directory(g.directory_back->data); + go_back(); return TRUE; case 5: // forward (GdkWin32, GdkQuartz) case 9: // forward - if (g.directory_forward) - load_directory(g.directory_forward->data); - else if (g.uri) - switch_to_view(); + go_forward(); return TRUE; default: return FALSE; @@ -1510,6 +1683,81 @@ make_toolbar_toggle(const char *symbolic, const char *tooltip) return button; } +static GtkWidget * +make_toolbar_radio(const char *label, const char *tooltip) +{ + GtkWidget *button = gtk_radio_button_new_with_label(NULL, label); + gtk_widget_set_tooltip_text(button, tooltip); + gtk_widget_set_focus_on_click(button, FALSE); + gtk_toggle_button_set_mode(GTK_TOGGLE_BUTTON(button), FALSE); + gtk_style_context_add_class( + gtk_widget_get_style_context(button), GTK_STYLE_CLASS_FLAT); + return button; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +browsebar_connect(int index, GCallback callback) +{ + g_signal_connect_swapped(g.browsebar[index], "clicked", callback, NULL); +} + +static GtkWidget * +make_browser_toolbar(void) +{ +#define XX(id, constructor) g.browsebar[BROWSEBAR_ ## id] = constructor; + BROWSEBAR(XX) +#undef XX + + // GtkStatusBar solves a problem we do not have here. + GtkWidget *browser_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_style_context_add_class( + gtk_widget_get_style_context(browser_toolbar), "fiv-toolbar"); + GtkBox *box = GTK_BOX(browser_toolbar); + + // Exploring different versions of awkward layouts. + for (int i = 0; i <= BROWSEBAR_S2; i++) + gtk_box_pack_start(box, g.browsebar[i], FALSE, FALSE, 0); + for (int i = BROWSEBAR_COUNT; --i >= BROWSEBAR_S5; ) + gtk_box_pack_end(box, g.browsebar[i], FALSE, FALSE, 0); + + GtkWidget *center = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + for (int i = BROWSEBAR_S2; ++i < BROWSEBAR_S5; ) + gtk_box_pack_start(GTK_BOX(center), g.browsebar[i], FALSE, FALSE, 0); + gtk_box_set_center_widget(box, center); + + g_signal_connect(g.browsebar[BROWSEBAR_SIDEBAR], "toggled", + G_CALLBACK(on_sidebar_toggled), NULL); + + browsebar_connect(BROWSEBAR_DIR_PREVIOUS, G_CALLBACK(on_dir_previous)); + browsebar_connect(BROWSEBAR_DIR_NEXT, G_CALLBACK(on_dir_next)); + browsebar_connect(BROWSEBAR_SORT_DIR, G_CALLBACK(on_sort_direction)); + browsebar_connect(BROWSEBAR_FULLSCREEN, G_CALLBACK(toggle_fullscreen)); + + g_signal_connect(g.browsebar[BROWSEBAR_PLUS], "clicked", + G_CALLBACK(on_toolbar_zoom), (gpointer) +1); + g_signal_connect(g.browsebar[BROWSEBAR_MINUS], "clicked", + G_CALLBACK(on_toolbar_zoom), (gpointer) -1); + + g_signal_connect(g.browsebar[BROWSEBAR_FILTER], "toggled", + G_CALLBACK(on_filtering_toggled), NULL); + g_signal_connect(g.browsebar[BROWSEBAR_FILENAMES], "toggled", + G_CALLBACK(on_filenames_toggled), NULL); + + GtkRadioButton *last = GTK_RADIO_BUTTON(g.browsebar[BROWSEBAR_SORT_NAME]); + for (int i = BROWSEBAR_SORT_NAME; i <= BROWSEBAR_SORT_TIME; i++) { + GtkRadioButton *radio = GTK_RADIO_BUTTON(g.browsebar[i]); + g_signal_connect(radio, "toggled", G_CALLBACK(on_sort_field), + (gpointer) (gintptr) i - BROWSEBAR_SORT_NAME); + gtk_radio_button_join_group(radio, last); + last = radio; + } + return browser_toolbar; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + static void on_view_actions_changed(void) { @@ -1655,7 +1903,8 @@ make_view_toolbar(void) // GtkStatusBar solves a problem we do not have here. GtkWidget *view_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_set_name(view_toolbar, "toolbar"); + gtk_style_context_add_class( + gtk_widget_get_style_context(view_toolbar), "fiv-toolbar"); GtkBox *box = GTK_BOX(view_toolbar); // Exploring different versions of awkward layouts. @@ -1746,124 +1995,202 @@ make_browser_sidebar(FivIoModel *model) g_signal_connect(sidebar, "open-location", G_CALLBACK(on_open_location), NULL); - g.plus = gtk_button_new_from_icon_name("zoom-in-symbolic", - GTK_ICON_SIZE_BUTTON); - gtk_widget_set_tooltip_text(g.plus, "Larger thumbnails"); - g_signal_connect(g.plus, "clicked", - G_CALLBACK(on_toolbar_zoom), (gpointer) +1); - - g.minus = gtk_button_new_from_icon_name("zoom-out-symbolic", - GTK_ICON_SIZE_BUTTON); - gtk_widget_set_tooltip_text(g.minus, "Smaller thumbnails"); - g_signal_connect(g.minus, "clicked", - G_CALLBACK(on_toolbar_zoom), (gpointer) -1); + g_signal_connect(sidebar, "notify::visible", + G_CALLBACK(on_notify_sidebar_visible), NULL); - GtkWidget *zoom_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_style_context_add_class( - gtk_widget_get_style_context(zoom_group), GTK_STYLE_CLASS_LINKED); - gtk_box_pack_start(GTK_BOX(zoom_group), g.plus, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(zoom_group), g.minus, FALSE, FALSE, 0); - - g.funnel = gtk_toggle_button_new(); - gtk_container_add(GTK_CONTAINER(g.funnel), - gtk_image_new_from_icon_name("funnel-symbolic", GTK_ICON_SIZE_BUTTON)); - gtk_widget_set_tooltip_text(g.funnel, "Hide unsupported files"); - g_signal_connect(g.funnel, "toggled", - G_CALLBACK(on_filtering_toggled), NULL); - - GtkWidget *menu = gtk_menu_new(); - g.sort_field[0] = gtk_radio_menu_item_new_with_mnemonic(NULL, "By _Name"); - g.sort_field[1] = gtk_radio_menu_item_new_with_mnemonic( - gtk_radio_menu_item_get_group(GTK_RADIO_MENU_ITEM(g.sort_field[0])), - "By _Modification Time"); - for (int i = FIV_IO_MODEL_SORT_MIN; i <= FIV_IO_MODEL_SORT_MAX; i++) { - g_signal_connect(g.sort_field[i], "activate", - G_CALLBACK(on_sort_field), (void *) (intptr_t) i); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), g.sort_field[i]); - } - - g.sort_direction[0] = - gtk_radio_menu_item_new_with_mnemonic(NULL, "_Ascending"); - g.sort_direction[1] = gtk_radio_menu_item_new_with_mnemonic( - gtk_radio_menu_item_get_group(GTK_RADIO_MENU_ITEM(g.sort_direction[0])), - "_Descending"); - g_signal_connect(g.sort_direction[0], "activate", - G_CALLBACK(on_sort_direction), (void *) 0); - g_signal_connect(g.sort_direction[1], "activate", - G_CALLBACK(on_sort_direction), (void *) 1); - - gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), g.sort_direction[0]); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), g.sort_direction[1]); - gtk_widget_show_all(menu); - - GtkWidget *sort = gtk_menu_button_new(); - gtk_widget_set_tooltip_text(sort, "Sort order"); - gtk_button_set_image(GTK_BUTTON(sort), - gtk_image_new_from_icon_name( - "view-sort-ascending-symbolic", GTK_ICON_SIZE_BUTTON)); - gtk_menu_button_set_popup(GTK_MENU_BUTTON(sort), menu); - - GtkWidget *model_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_style_context_add_class( - gtk_widget_get_style_context(model_group), GTK_STYLE_CLASS_LINKED); - gtk_box_pack_start(GTK_BOX(model_group), g.funnel, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(model_group), sort, FALSE, FALSE, 0); - - GtkBox *toolbar = fiv_sidebar_get_toolbar(FIV_SIDEBAR(sidebar)); - gtk_box_pack_start(toolbar, zoom_group, FALSE, FALSE, 0); - gtk_box_pack_start(toolbar, model_group, FALSE, FALSE, 0); - gtk_widget_set_halign(GTK_WIDGET(toolbar), GTK_ALIGN_CENTER); + g_object_notify(G_OBJECT(sidebar), "visible"); g_signal_connect(g.browser, "notify::thumbnail-size", G_CALLBACK(on_notify_thumbnail_size), NULL); + g_signal_connect(g.browser, "notify::show-labels", + G_CALLBACK(on_notify_show_labels), NULL); g_signal_connect(model, "notify::filtering", G_CALLBACK(on_notify_filtering), NULL); g_signal_connect(model, "notify::sort-field", G_CALLBACK(on_notify_sort_field), NULL); g_signal_connect(model, "notify::sort-descending", G_CALLBACK(on_notify_sort_descending), NULL); + on_toolbar_zoom(NULL, (gpointer) 0); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g.funnel), TRUE); - // TODO(p): Invoke sort configuration notifications explicitly. + + g_object_notify(G_OBJECT(g.model), "filtering"); + g_object_notify(G_OBJECT(g.model), "sort-field"); + g_object_notify(G_OBJECT(g.model), "sort-descending"); return sidebar; } +// --- Actions ----------------------------------------------------------------- + +#define ACTION(name) static void on_action_ ## name(void) + +ACTION(new_window) { + if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) + spawn_uri(g.uri); + else + spawn_uri(g.directory); +} + +ACTION(quit) { + gtk_widget_destroy(g.window); +} + +ACTION(location) { + fiv_sidebar_show_enter_location(FIV_SIDEBAR(g.browser_sidebar)); +} + +ACTION(preferences) { + show_preferences(g.window); +} + +ACTION(about) { + show_about_dialog(g.window); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +typedef struct { + const char *name; ///< Unprefixed action name + GCallback callback; ///< Simple callback + const char **accels; ///< NULL-terminated accelerator list +} ActionEntry; + +static ActionEntry g_actions[] = { + {"preferences", on_action_preferences, + (const char *[]) {"<Primary>comma", NULL}}, + {"new-window", on_action_new_window, + (const char *[]) {"<Primary>n", NULL}}, + {"open", on_open, + (const char *[]) {"<Primary>o", "o", NULL}}, + {"quit", on_action_quit, + (const char *[]) {"<Primary>q", "<Primary>w", "q", NULL}}, + {"toggle-fullscreen", toggle_fullscreen, + (const char *[]) {"F11", "f", NULL}}, + {"toggle-sunlight", toggle_sunlight, + (const char *[]) {"<Alt><Shift>d", NULL}}, + {"go-back", go_back, + (const char *[]) {"<Alt>Left", "BackSpace", NULL}}, + {"go-forward", go_forward, + (const char *[]) {"<Alt>Right", NULL}}, + {"go-location", on_action_location, + (const char *[]) {"<Primary>l", NULL}}, + {"help", show_help_contents, + (const char *[]) {"F1", NULL}}, + {"shortcuts", show_help_shortcuts, + // Similar to win.show-help-overlay in gtkapplication.c. + (const char *[]) {"<Primary>question", "<Primary>F1", NULL}}, + {"about", on_action_about, + (const char *[]) {"<Shift>F1", NULL}}, + {} +}; + +static void +dispatch_action(G_GNUC_UNUSED GSimpleAction *action, + G_GNUC_UNUSED GVariant *parameter, gpointer user_data) +{ + GCallback callback = user_data; + callback(); +} + +static void +set_up_action(GtkApplication *app, const ActionEntry *a) +{ + GSimpleAction *action = g_simple_action_new(a->name, NULL); + g_signal_connect(action, "activate", + G_CALLBACK(dispatch_action), a->callback); + g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(action)); + g_object_unref(action); + + gchar *full_name = g_strdup_printf("app.%s", a->name); + gtk_application_set_accels_for_action(app, full_name, a->accels); + g_free(full_name); +} + +// --- Menu -------------------------------------------------------------------- + +typedef struct { + const char *label; ///< Label, with a mnemonic + const char *action; ///< Prefixed action name + gboolean macos; ///< Show in the macOS global menu? +} MenuItem; + +typedef struct { + const char *label; ///< Label, with a mnemonic + const MenuItem *items; ///< ""-sectioned menu items +} MenuRoot; + +// We're single-instance, skip the "win" namespace for simplicity. +static MenuRoot g_menu[] = { + {"_File", (MenuItem[]) { + {"_New Window", "app.new-window", TRUE}, + {"_Open...", "app.open", TRUE}, + {"", NULL, TRUE}, + {"_Quit", "app.quit", FALSE}, + {} + }}, + {"_Go", (MenuItem[]) { + {"_Back", "app.go-back", TRUE}, + {"_Forward", "app.go-forward", TRUE}, + {"", NULL, TRUE}, + {"_Location...", "app.go-location", TRUE}, + {} + }}, + {"_Help", (MenuItem[]) { + {"_Contents", "app.help", TRUE}, + {"_Keyboard Shortcuts", "app.shortcuts", TRUE}, + {"_About", "app.about", FALSE}, + {} + }}, + {} +}; + +static GMenuModel * +make_submenu(const MenuItem *items) +{ + GMenu *menu = g_menu_new(); + while (items->label) { + GMenu *section = g_menu_new(); + for (; items->label; items++) { + // Empty strings are interpreted as separators. + if (!*items->label) { + items++; + break; + } + + GMenuItem *subitem = g_menu_item_new(items->label, items->action); + if (!items->macos) { + g_menu_item_set_attribute( + subitem, "hidden-when", "s", "macos-menubar"); + } + + g_menu_append_item(section, subitem); + g_object_unref(subitem); + } + g_menu_append_section(menu, NULL, G_MENU_MODEL(section)); + g_object_unref(section); + } + return G_MENU_MODEL(menu); +} + +static GMenuModel * +make_menu_model(void) +{ + GMenu *menu = g_menu_new(); + for (const MenuRoot *root = g_menu; root->label; root++) { + GMenuModel *submenu = make_submenu(root->items); + g_menu_append_submenu(menu, root->label, submenu); + g_object_unref(submenu); + } + return G_MENU_MODEL(menu); +} + static GtkWidget * -make_menu_bar(void) -{ - g.menu = gtk_menu_bar_new(); - - GtkWidget *item_quit = gtk_menu_item_new_with_mnemonic("_Quit"); - g_signal_connect_swapped(item_quit, "activate", - G_CALLBACK(gtk_widget_destroy), g.window); - - GtkWidget *menu_file = gtk_menu_new(); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_file), item_quit); - GtkWidget *item_file = gtk_menu_item_new_with_mnemonic("_File"); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_file), menu_file); - gtk_menu_shell_append(GTK_MENU_SHELL(g.menu), item_file); - - GtkWidget *item_contents = gtk_menu_item_new_with_mnemonic("_Contents"); - g_signal_connect_swapped(item_contents, "activate", - G_CALLBACK(show_help_contents), NULL); - GtkWidget *item_shortcuts = - gtk_menu_item_new_with_mnemonic("_Keyboard Shortcuts"); - g_signal_connect_swapped(item_shortcuts, "activate", - G_CALLBACK(show_help_shortcuts), NULL); - GtkWidget *item_about = gtk_menu_item_new_with_mnemonic("_About"); - g_signal_connect_swapped(item_about, "activate", - G_CALLBACK(show_about_dialog), g.window); - - GtkWidget *menu_help = gtk_menu_new(); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_help), item_contents); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_help), item_shortcuts); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_help), item_about); - GtkWidget *item_help = gtk_menu_item_new_with_mnemonic("_Help"); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_help), menu_help); - gtk_menu_shell_append(GTK_MENU_SHELL(g.menu), item_help); +make_menu_bar(GMenuModel *model) +{ + g.menu = gtk_menu_bar_new_from_model(model); // Don't let it take up space by default. Firefox sets a precedent here. + // (gtk_application_window_set_show_menubar() doesn't seem viable for use + // for this purpose.) gtk_widget_show_all(g.menu); gtk_widget_set_no_show_all(g.menu, TRUE); gtk_widget_hide(g.menu); @@ -1871,6 +2198,8 @@ make_menu_bar(void) return g.menu; } +// --- Application ------------------------------------------------------------- + // This is incredibly broken https://stackoverflow.com/a/51054396/76313 // thus resolving the problem using overlaps. // We're trying to be universal for light and dark themes both. It's hard. @@ -1878,12 +2207,14 @@ static const char stylesheet[] = "@define-color fiv-tile @content_view_bg; \ @define-color fiv-semiselected \ mix(@theme_selected_bg_color, @content_view_bg, 0.5); \ fiv-view, fiv-browser { background: @content_view_bg; } \ - placessidebar.fiv .toolbar { padding: 2px 6px; } \ placessidebar.fiv box > separator { margin: 4px 0; } \ - #toolbar button { padding-left: 0; padding-right: 0; } \ - #toolbar > button:first-child { padding-left: 4px; } \ - #toolbar > button:last-child { padding-right: 4px; } \ - #toolbar separator { \ + placessidebar.fiv row { min-height: 2em; } \ + .fiv-toolbar button { padding-left: 0; padding-right: 0; } \ + .fiv-toolbar button.text-button { \ + padding-left: 4px; padding-right: 4px; } \ + .fiv-toolbar > button:first-child { padding-left: 4px; } \ + .fiv-toolbar > button:last-child { padding-right: 4px; } \ + .fiv-toolbar separator { \ background: mix(@insensitive_fg_color, \ @insensitive_bg_color, 0.4); margin: 6px 8px; \ } \ @@ -1902,6 +2233,12 @@ static const char stylesheet[] = "@define-color fiv-tile @content_view_bg; \ background-size: 40px 40px; \ background-position: 0 0, 0 20px, 20px -20px, -20px 0px; \ } \ + fiv-browser.item.label, fiv-browser.item.symbolic.label { \ + color: @theme_fg_color; \ + } \ + fiv-browser.item.label:backdrop:not(:selected) { \ + color: @theme_unfocused_fg_color; \ + } \ fiv-browser.item:selected { \ color: @theme_selected_bg_color; \ border-color: @theme_selected_bg_color; \ @@ -1935,113 +2272,19 @@ static const char stylesheet[] = "@define-color fiv-tile @content_view_bg; \ .fiv-information label { padding: 0 4px; }"; static void -output_thumbnail(gchar **uris, gboolean extract, const char *size_arg) -{ - if (!uris) - exit_fatal("No path given"); - if (uris[1]) - exit_fatal("Only one thumbnail at a time may be produced"); - - FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; - if (size_arg) { - for (size = 0; size < FIV_THUMBNAIL_SIZE_COUNT; size++) { - if (!strcmp( - fiv_thumbnail_sizes[size].thumbnail_spec_name, size_arg)) - break; - } - if (size >= FIV_THUMBNAIL_SIZE_COUNT) - exit_fatal("unknown thumbnail size: %s", size_arg); - } - -#ifdef G_OS_WIN32 - _setmode(fileno(stdout), _O_BINARY); -#endif - - GError *error = NULL; - GFile *file = g_file_new_for_uri(uris[0]); - cairo_surface_t *surface = NULL; - if (extract && (surface = fiv_thumbnail_extract(file, size, &error))) - fiv_io_serialize_to_stdout(surface, FIV_IO_SERIALIZE_LOW_QUALITY); - else if (size_arg && - (g_clear_error(&error), - (surface = fiv_thumbnail_produce(file, size, &error)))) - fiv_io_serialize_to_stdout(surface, 0); - else - g_assert(error != NULL); - - g_object_unref(file); - if (error) - exit_fatal("%s", error->message); - - cairo_surface_destroy(surface); -} - -int -main(int argc, char *argv[]) +on_app_startup(GApplication *app, G_GNUC_UNUSED gpointer user_data) { - gboolean show_version = FALSE, show_supported_media_types = FALSE, - invalidate_cache = FALSE, browse = FALSE, extract_thumbnail = FALSE; - gchar **args = NULL, *thumbnail_size = NULL; - const GOptionEntry options[] = { - {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &args, - NULL, "[PATH | URI]..."}, - {"list-supported-media-types", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &show_supported_media_types, - "Output supported media types and exit", NULL}, - {"browse", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &browse, - "Start in filesystem browsing mode", NULL}, - {"extract-thumbnail", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &extract_thumbnail, - "Output any embedded thumbnail (superseding --thumbnail)", NULL}, - {"thumbnail", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_STRING, &thumbnail_size, - "Generate thumbnails, up to SIZE, and output that size", "SIZE"}, - {"invalidate-cache", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &invalidate_cache, - "Invalidate the wide thumbnail cache", NULL}, - {"version", 'V', G_OPTION_FLAG_IN_MAIN, G_OPTION_ARG_NONE, - &show_version, "Output version information and exit", NULL}, - {}, - }; - - GError *error = NULL; - gboolean initialized = gtk_init_with_args( - &argc, &argv, " - Image browser and viewer", options, NULL, &error); - if (show_version) { - const char *version = PROJECT_VERSION; - printf("%s %s\n", PROJECT_NAME, &version[*version == 'v']); - return 0; - } - if (show_supported_media_types) { - for (char **types = fiv_io_all_supported_media_types(); *types; ) - g_print("%s\n", *types++); - return 0; - } - if (invalidate_cache) { - fiv_thumbnail_invalidate(); - return 0; - } - if (!initialized) - exit_fatal("%s", error->message); - - // Normalize all arguments to URIs. - for (gsize i = 0; args && args[i]; i++) { - GFile *resolved = g_file_new_for_commandline_arg(args[i]); - g_free(args[i]); - args[i] = g_file_get_uri(resolved); - g_object_unref(resolved); - } - if (extract_thumbnail || thumbnail_size) { - output_thumbnail(args, extract_thumbnail, thumbnail_size); - return 0; - } + // We can't prevent GApplication from adding --gapplication-service. + if (g_application_get_flags(app) & G_APPLICATION_IS_SERVICE) + exit(EXIT_FAILURE); // It doesn't make much sense to have command line arguments able to // resolve to the VFS they may end up being contained within. fiv_collection_register(); g.model = g_object_new(FIV_TYPE_IO_MODEL, NULL); + g_signal_connect(g.model, "reloaded", + G_CALLBACK(on_model_reloaded), NULL); g_signal_connect(g.model, "files-changed", G_CALLBACK(on_model_files_changed), NULL); @@ -2068,7 +2311,7 @@ main(int argc, char *argv[]) G_CALLBACK(on_view_drag_data_received), NULL); gtk_container_add(GTK_CONTAINER(view_scroller), g.view); - // We need to hide it together with the separator. + // We need to hide it together with its separator. g.view_toolbar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_pack_start(GTK_BOX(g.view_toolbar), make_view_toolbar(), FALSE, FALSE, 0); @@ -2108,10 +2351,23 @@ main(int argc, char *argv[]) G_CALLBACK(on_item_activated), NULL); gtk_container_add(GTK_CONTAINER(g.browser_scroller), g.browser); + // We need to hide it together with its separator. + g.browser_toolbar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(g.browser_toolbar), + make_browser_toolbar(), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(g.browser_toolbar), + gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0); + + GtkWidget *browser_right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(browser_right), + g.browser_toolbar, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(browser_right), + g.browser_scroller, TRUE, TRUE, 0); + g.browser_sidebar = make_browser_sidebar(g.model); g.browser_paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); gtk_paned_add1(GTK_PANED(g.browser_paned), g.browser_sidebar); - gtk_paned_add2(GTK_PANED(g.browser_paned), g.browser_scroller); + gtk_paned_add2(GTK_PANED(g.browser_paned), browser_right); g_signal_connect(g.browser_paned, "key-press-event", G_CALLBACK(on_key_press_browser_paned), NULL); g_signal_connect(g.browser_paned, "button-press-event", @@ -2123,18 +2379,35 @@ main(int argc, char *argv[]) gtk_container_add(GTK_CONTAINER(g.stack), g.view_box); gtk_container_add(GTK_CONTAINER(g.stack), g.browser_paned); - g.window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - g_signal_connect(g.window, "destroy", - G_CALLBACK(gtk_main_quit), NULL); + g.window = gtk_application_window_new(GTK_APPLICATION(app)); + g_signal_connect_swapped(g.window, "destroy", + G_CALLBACK(g_application_quit), app); g_signal_connect(g.window, "key-press-event", G_CALLBACK(on_key_press), NULL); g_signal_connect(g.window, "window-state-event", G_CALLBACK(on_window_state_event), NULL); + for (const ActionEntry *a = g_actions; a->name; a++) + set_up_action(GTK_APPLICATION(app), a); + + // GtkApplicationWindow overrides GtkContainer/GtkWidget virtual methods + // so that it has the menu bar as an extra child (if it so decides). + // However, we currently want this menu bar to only show on a key press, + // and to hide as soon as it's no longer being used. + // Messing with the window's internal state seems at best quirky, + // so we'll manage the menu entirely by ourselves. + gtk_application_window_set_show_menubar( + GTK_APPLICATION_WINDOW(g.window), FALSE); + + GMenuModel *menu = make_menu_model(); + gtk_application_set_menubar(GTK_APPLICATION(app), menu); + // The default "app menu" is good, in particular for macOS, so keep it. + GtkWidget *menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_container_add(GTK_CONTAINER(menu_box), make_menu_bar()); + gtk_container_add(GTK_CONTAINER(menu_box), make_menu_bar(menu)); gtk_container_add(GTK_CONTAINER(menu_box), g.stack); gtk_container_add(GTK_CONTAINER(g.window), menu_box); + g_object_unref(menu); GSettings *settings = g_settings_new(PROJECT_NS PROJECT_NAME); if (g_settings_get_boolean(settings, "dark-theme")) @@ -2145,6 +2418,8 @@ main(int argc, char *argv[]) gtk_widget_show_all(menu_box); gtk_widget_set_visible(g.browser_sidebar, g_settings_get_boolean(settings, "show-browser-sidebar")); + gtk_widget_set_visible(g.browser_toolbar, + g_settings_get_boolean(settings, "show-browser-toolbar")); gtk_widget_set_visible(g.view_toolbar, g_settings_get_boolean(settings, "show-view-toolbar")); @@ -2174,24 +2449,34 @@ main(int argc, char *argv[]) // XXX: The widget wants to read the display's profile. The realize is ugly. gtk_widget_realize(g.view); +} + +static struct { + gboolean browse, collection, extract_thumbnail; + gchar **args, *thumbnail_size, *thumbnail_size_search; +} o; +static void +on_app_activate( + G_GNUC_UNUSED GApplication *app, G_GNUC_UNUSED gpointer user_data) +{ // XXX: We follow the behaviour of Firefox and Eye of GNOME, which both // interpret multiple command line arguments differently, as a collection. - // However, single-element collections are unrepresentable this way. - // Should we allow multiple targets only in a special new mode? - g.files = g_ptr_array_new_full(0, g_free); - if (args) { - const gchar *target = *args; - if (args[1]) { - fiv_collection_reload(args); + // However, single-element collections are unrepresentable this way, + // so we have a switch to enforce it. + g.files_index = -1; + if (o.args) { + const gchar *target = *o.args; + if (o.args[1] || o.collection) { + fiv_collection_reload(o.args); target = FIV_COLLECTION_SCHEME ":/"; } GFile *file = g_file_new_for_uri(target); - open_any_file(file, browse); + open_any_file(file, o.browse); g_object_unref(file); - g_strfreev(args); } + if (!g.directory) { GFile *file = g_file_new_for_path("."); open_any_file(file, FALSE); @@ -2199,6 +2484,182 @@ main(int argc, char *argv[]) } gtk_widget_show(g.window); - gtk_main(); - return 0; +} + +// --- Plumbing ---------------------------------------------------------------- + +static FivThumbnailSize +output_thumbnail_prologue(gchar **uris, const char *size_arg) +{ + if (!uris) + exit_fatal("No path given"); + if (uris[1]) + exit_fatal("Only one thumbnail at a time may be produced"); + + FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; + if (size_arg) { + for (size = 0; size < FIV_THUMBNAIL_SIZE_COUNT; size++) { + if (!strcmp( + fiv_thumbnail_sizes[size].thumbnail_spec_name, size_arg)) + break; + } + if (size >= FIV_THUMBNAIL_SIZE_COUNT) + exit_fatal("unknown thumbnail size: %s", size_arg); + } + +#ifdef G_OS_WIN32 + _setmode(fileno(stdout), _O_BINARY); +#endif + return size; +} + +static void +output_thumbnail_for_search(gchar **uris, const char *size_arg) +{ + FivThumbnailSize size = output_thumbnail_prologue(uris, size_arg); + + GError *error = NULL; + GFile *file = g_file_new_for_uri(uris[0]); + cairo_surface_t *surface = NULL; + GBytes *bytes = NULL; + if ((surface = fiv_thumbnail_produce(file, size, &error)) && + (bytes = fiv_io_serialize_for_search(surface, &error))) { + fwrite( + g_bytes_get_data(bytes, NULL), 1, g_bytes_get_size(bytes), stdout); + g_bytes_unref(bytes); + } else { + g_assert(error != NULL); + } + + g_object_unref(file); + if (error) + exit_fatal("%s", error->message); + + cairo_surface_destroy(surface); +} + +static void +output_thumbnail(gchar **uris, gboolean extract, const char *size_arg) +{ + FivThumbnailSize size = output_thumbnail_prologue(uris, size_arg); + + GError *error = NULL; + GFile *file = g_file_new_for_uri(uris[0]); + cairo_surface_t *surface = NULL; + if (extract && (surface = fiv_thumbnail_extract(file, size, &error))) + fiv_io_serialize_to_stdout(surface, FIV_IO_SERIALIZE_LOW_QUALITY); + else if (size_arg && + (g_clear_error(&error), + (surface = fiv_thumbnail_produce(file, size, &error)))) + fiv_io_serialize_to_stdout(surface, 0); + else + g_assert(error != NULL); + + g_object_unref(file); + if (error) + exit_fatal("%s", error->message); + + cairo_surface_destroy(surface); +} + +static gint +on_app_handle_local_options(G_GNUC_UNUSED GApplication *app, + GVariantDict *options, G_GNUC_UNUSED gpointer user_data) +{ + if (g_variant_dict_contains(options, "version")) { + const char *version = PROJECT_VERSION; + printf("%s %s\n", PROJECT_NAME, &version[*version == 'v']); + return 0; + } + if (g_variant_dict_contains(options, "list-supported-media-types")) { + char **types = fiv_io_all_supported_media_types(); + for (char **p = types; *p; p++) + g_print("%s\n", *p); + g_strfreev(types); + return 0; + } + if (g_variant_dict_contains(options, "invalidate-cache")) { + fiv_thumbnail_invalidate(); + return 0; + } + + // Normalize all arguments to URIs, and run thumbnailing modes first. + for (gsize i = 0; o.args && o.args[i]; i++) { + GFile *resolved = g_file_new_for_commandline_arg(o.args[i]); + g_free(o.args[i]); + o.args[i] = g_file_get_uri(resolved); + g_object_unref(resolved); + } + + // These come from an option group that doesn't get copied to "options". + if (o.thumbnail_size_search) { + output_thumbnail_for_search(o.args, o.thumbnail_size_search); + return 0; + } + if (o.extract_thumbnail || o.thumbnail_size) { + output_thumbnail(o.args, o.extract_thumbnail, o.thumbnail_size); + return 0; + } + return -1; +} + +int +main(int argc, char *argv[]) +{ + const GOptionEntry options[] = { + {G_OPTION_REMAINING, 0, 0, + G_OPTION_ARG_FILENAME_ARRAY, &o.args, + NULL, "[PATH | URI]..."}, + {"browse", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, &o.browse, + "Start in filesystem browsing mode", NULL}, + {"collection", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, &o.collection, + "Always put arguments in a collection (implies --browse)", NULL}, + {"invalidate-cache", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, NULL, + "Invalidate the wide thumbnail cache", NULL}, + {"list-supported-media-types", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, NULL, + "Output supported media types and exit", NULL}, + {"version", 'V', G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, NULL, + "Output version information and exit", NULL}, + {}, + }; + const GOptionEntry options_internal[] = { + {"extract-thumbnail", 0, 0, + G_OPTION_ARG_NONE, &o.extract_thumbnail, + "Output any embedded thumbnail (superseding --thumbnail)", NULL}, + {"thumbnail", 0, 0, + G_OPTION_ARG_STRING, &o.thumbnail_size, + "Generate thumbnails, up to SIZE, and output that size", "SIZE"}, + {"thumbnail-for-search", 0, 0, + G_OPTION_ARG_STRING, &o.thumbnail_size_search, + "Output an image file suitable for searching by content", "SIZE"}, + {}, + }; + + // We never get the ::open signal, thanks to G_OPTION_ARG_FILENAME_ARRAY. + GtkApplication *app = gtk_application_new(NULL, G_APPLICATION_NON_UNIQUE); + g_application_set_option_context_parameter_string( + G_APPLICATION(app), " - Image browser and viewer"); + g_application_add_main_option_entries(G_APPLICATION(app), options); + + GOptionGroup *internals = g_option_group_new( + "internal", "Internal Options:", "Show internal options", NULL, NULL); + g_option_group_add_entries(internals, options_internal); + g_application_add_option_group(G_APPLICATION(app), internals); + + g_signal_connect(app, "handle-local-options", + G_CALLBACK(on_app_handle_local_options), NULL); + g_signal_connect(app, "startup", + G_CALLBACK(on_app_startup), NULL); + g_signal_connect(app, "activate", + G_CALLBACK(on_app_activate), NULL); + + int status = g_application_run(G_APPLICATION(app), argc, argv); + g_object_unref(app); + g_strfreev(o.args); + return status; } |