Files
fiv/fiv-context-menu.c
Přemysl Eric Janouch 690e60cd74
All checks were successful
Alpine 3.22 Success
Arch Linux Success
Arch Linux AUR Success
Debian Bookworm Success
Fedora 39 Success
OpenBSD 7.8 Success
openSUSE 15.5 Success
Build an application bundle on macOS
This is far from done, but nonetheless constitutes a big improvement.

macOS application bundles are more or less necessary for:
 - showing a nice icon;
 - having spawned off instances actually be brought to the foreground;
 - file associations (yet files currently do not open properly);
 - having a reasonable method of distribution.

Also resolving a bunch of minor issues:
 - The context menu had duplicate items,
   and might needlessly end up with (null) labels.
2025-11-11 19:28:45 +01:00

581 lines
18 KiB
C

//
// fiv-context-menu.c: popup menu
//
// 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.
//
// 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 "config.h"
#include "fiv-collection.h"
#include "fiv-context-menu.h"
G_DEFINE_QUARK(fiv-context-menu-cancellable-quark, fiv_context_menu_cancellable)
static GtkWidget *
info_start_group(GtkWidget *vbox, const char *group)
{
GtkWidget *label = gtk_label_new(group);
gtk_widget_set_hexpand(label, TRUE);
gtk_widget_set_halign(label, GTK_ALIGN_FILL);
PangoAttrList *attrs = pango_attr_list_new();
pango_attr_list_insert(attrs, pango_attr_weight_new(PANGO_WEIGHT_BOLD));
gtk_label_set_attributes(GTK_LABEL(label), attrs);
pango_attr_list_unref(attrs);
GtkWidget *grid = gtk_grid_new();
GtkWidget *expander = gtk_expander_new(NULL);
gtk_expander_set_label_widget(GTK_EXPANDER(expander), label);
gtk_expander_set_expanded(GTK_EXPANDER(expander), TRUE);
gtk_container_add(GTK_CONTAINER(expander), grid);
gtk_grid_set_column_spacing(GTK_GRID(grid), 10);
gtk_box_pack_start(GTK_BOX(vbox), expander, FALSE, FALSE, 0);
return grid;
}
static GtkWidget *
info_parse(char *tsv)
{
GtkSizeGroup *sg = gtk_size_group_new(GTK_SIZE_GROUP_HORIZONTAL);
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
const char *last_group = NULL;
GtkWidget *grid = NULL;
int line = 1, row = 0;
for (char *nl; (nl = strchr(tsv, '\n')); line++, tsv = ++nl) {
*nl = 0;
if (nl > tsv && nl[-1] == '\r')
nl[-1] = 0;
char *group = tsv, *tag = strchr(group, '\t');
if (!tag) {
g_warning("ExifTool parse error on line %d", line);
continue;
}
*tag++ = 0;
for (char *p = group; *p; p++)
if (*p == '_')
*p = ' ';
char *value = strchr(tag, '\t');
if (!value) {
g_warning("ExifTool parse error on line %d", line);
continue;
}
*value++ = 0;
if (!last_group || strcmp(last_group, group)) {
grid = info_start_group(vbox, (last_group = group));
row = 0;
}
GtkWidget *a = gtk_label_new(tag);
gtk_size_group_add_widget(sg, a);
gtk_label_set_selectable(GTK_LABEL(a), TRUE);
gtk_label_set_xalign(GTK_LABEL(a), 0.);
gtk_grid_attach(GTK_GRID(grid), a, 0, row, 1, 1);
GtkWidget *b = gtk_label_new(value);
gtk_label_set_selectable(GTK_LABEL(b), TRUE);
gtk_label_set_xalign(GTK_LABEL(b), 0.);
gtk_label_set_line_wrap(GTK_LABEL(b), TRUE);
gtk_widget_set_hexpand(b, TRUE);
gtk_grid_attach(GTK_GRID(grid), b, 1, row, 1, 1);
row++;
}
g_object_unref(sg);
return vbox;
}
static GtkWidget *
info_make_bar(const char *message)
{
GtkWidget *info = gtk_info_bar_new();
gtk_info_bar_set_message_type(GTK_INFO_BAR(info), GTK_MESSAGE_WARNING);
GtkWidget *info_area = gtk_info_bar_get_content_area(GTK_INFO_BAR(info));
// When the label is made selectable, Escape doesn't work when it has focus.
gtk_container_add(GTK_CONTAINER(info_area), gtk_label_new(message));
return info;
}
static void
info_redirect_error(gpointer dialog, GError *error)
{
// The dialog has been closed and destroyed.
if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
g_error_free(error);
return;
}
GtkContainer *content_area =
GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog)));
gtk_container_foreach(content_area, (GtkCallback) gtk_widget_destroy, NULL);
gtk_container_add(content_area, info_make_bar(error->message));
if (g_error_matches(error, G_SPAWN_ERROR, G_SPAWN_ERROR_NOENT)) {
gtk_box_pack_start(GTK_BOX(content_area),
gtk_label_new("Please install ExifTool."), TRUE, FALSE, 12);
}
g_error_free(error);
gtk_widget_show_all(GTK_WIDGET(dialog));
}
static gchar *
bytes_to_utf8(GBytes *bytes)
{
gsize length = 0;
gconstpointer data = g_bytes_get_data(bytes, &length);
gchar *utf8 = data ? g_utf8_make_valid(data, length) : g_strdup("");
g_bytes_unref(bytes);
return utf8;
}
static void
on_info_finished(GObject *source_object, GAsyncResult *res, gpointer user_data)
{
GError *error = NULL;
GBytes *bytes_out = NULL, *bytes_err = NULL;
if (!g_subprocess_communicate_finish(
G_SUBPROCESS(source_object), res, &bytes_out, &bytes_err, &error)) {
info_redirect_error(user_data, error);
return;
}
gchar *out = bytes_to_utf8(bytes_out);
gchar *err = bytes_to_utf8(bytes_err);
GtkWidget *dialog = GTK_WIDGET(user_data);
GtkWidget *content_area = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
gtk_container_foreach(
GTK_CONTAINER(content_area), (GtkCallback) gtk_widget_destroy, NULL);
GtkWidget *scroller = gtk_scrolled_window_new(NULL, NULL);
gtk_box_pack_start(GTK_BOX(content_area), scroller, TRUE, TRUE, 0);
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(scroller), vbox);
if (*err)
gtk_container_add(GTK_CONTAINER(vbox), info_make_bar(g_strstrip(err)));
GtkWidget *info = info_parse(out);
gtk_style_context_add_class(
gtk_widget_get_style_context(info), "fiv-information");
gtk_box_pack_start(GTK_BOX(vbox), info, TRUE, TRUE, 0);
g_free(out);
g_free(err);
gtk_widget_show_all(dialog);
gtk_widget_grab_focus(scroller);
}
static void
info_spawn(GtkWidget *dialog, const char *path, GBytes *bytes_in)
{
int flags = G_SUBPROCESS_FLAGS_STDOUT_PIPE | G_SUBPROCESS_FLAGS_STDERR_PIPE;
if (bytes_in)
flags |= G_SUBPROCESS_FLAGS_STDIN_PIPE;
GSubprocessLauncher *launcher = g_subprocess_launcher_new(flags);
#ifdef G_OS_WIN32
// Both to find wperl, and then to let wperl find the nearby exiftool.
gchar *prefix = g_win32_get_package_installation_directory_of_module(NULL);
g_subprocess_launcher_set_cwd(launcher, prefix);
g_free(prefix);
#endif
// TODO(p): Add a fallback to internal capabilities.
// The simplest is to specify the filename and the resolution.
GError *error = NULL;
GSubprocess *subprocess = g_subprocess_launcher_spawn(launcher, &error,
#ifdef G_OS_WIN32
"wperl",
#endif
"exiftool", "-tab", "-groupNames", "-duplicates", "-extractEmbedded",
"--binary", "-quiet", "--", path, NULL);
g_object_unref(launcher);
if (error) {
info_redirect_error(dialog, error);
return;
}
GCancellable *cancellable = g_object_get_qdata(
G_OBJECT(dialog), fiv_context_menu_cancellable_quark());
g_subprocess_communicate_async(
subprocess, bytes_in, cancellable, on_info_finished, dialog);
g_object_unref(subprocess);
}
static void
on_info_loaded(GObject *source_object, GAsyncResult *res, gpointer user_data)
{
gchar *file_data = NULL;
gsize file_len = 0;
GError *error = NULL;
if (!g_file_load_contents_finish(
G_FILE(source_object), res, &file_data, &file_len, NULL, &error)) {
info_redirect_error(user_data, error);
return;
}
GtkWidget *dialog = GTK_WIDGET(user_data);
GBytes *bytes_in = g_bytes_new_take(file_data, file_len);
info_spawn(dialog, "-", bytes_in);
g_bytes_unref(bytes_in);
}
static void
on_info_queried(GObject *source_object, GAsyncResult *res, gpointer user_data)
{
GFile *file = G_FILE(source_object);
GError *error = NULL;
GFileInfo *info = g_file_query_info_finish(file, res, &error);
gboolean cancelled =
error && g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED);
g_clear_error(&error);
if (cancelled)
return;
gchar *path = NULL;
const char *target_uri = g_file_info_get_attribute_string(
info, G_FILE_ATTRIBUTE_STANDARD_TARGET_URI);
if (target_uri) {
GFile *target = g_file_new_for_uri(target_uri);
path = g_file_get_path(target);
g_object_unref(target);
}
g_object_unref(info);
GtkWidget *dialog = GTK_WIDGET(user_data);
GCancellable *cancellable = g_object_get_qdata(
G_OBJECT(dialog), fiv_context_menu_cancellable_quark());
if (path) {
info_spawn(dialog, path, NULL);
g_free(path);
} else {
g_file_load_contents_async(file, cancellable, on_info_loaded, dialog);
}
}
void
fiv_context_menu_information(GtkWindow *parent, const char *uri)
{
GtkWidget *dialog = gtk_widget_new(GTK_TYPE_DIALOG,
"use-header-bar", TRUE,
"title", "Information",
"transient-for", parent,
"destroy-with-parent", TRUE, NULL);
// When the window closes, we cancel all asynchronous calls.
GCancellable *cancellable = g_cancellable_new();
g_object_set_qdata_full(G_OBJECT(dialog),
fiv_context_menu_cancellable_quark(), cancellable, g_object_unref);
g_signal_connect_swapped(
dialog, "destroy", G_CALLBACK(g_cancellable_cancel), cancellable);
GtkWidget *spinner = gtk_spinner_new();
gtk_spinner_start(GTK_SPINNER(spinner));
gtk_box_pack_start(GTK_BOX(gtk_dialog_get_content_area(GTK_DIALOG(dialog))),
spinner, TRUE, TRUE, 12);
gtk_window_set_default_size(GTK_WINDOW(dialog), 600, 800);
gtk_widget_show_all(dialog);
// Mostly to identify URIs with no local path--we pipe these into ExifTool.
GFile *file = g_file_new_for_uri(uri);
gchar *parse_name = g_file_get_parse_name(file);
gtk_header_bar_set_subtitle(
GTK_HEADER_BAR(gtk_dialog_get_header_bar(GTK_DIALOG(dialog))),
parse_name);
g_free(parse_name);
gchar *path = g_file_get_path(file);
if (path) {
info_spawn(dialog, path, NULL);
g_free(path);
} else {
// Several GVfs schemes contain pseudo-symlinks
// that don't give out filesystem paths directly.
g_file_query_info_async(file, G_FILE_ATTRIBUTE_STANDARD_TARGET_URI,
G_FILE_QUERY_INFO_NONE, G_PRIORITY_DEFAULT, cancellable,
on_info_queried, dialog);
}
g_object_unref(file);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
typedef struct _OpenContext {
GWeakRef window; ///< Parent window for any dialogs
GFile *file; ///< The file in question
gchar *content_type;
GAppInfo *app_info;
} OpenContext;
static void
open_context_finalize(gpointer data)
{
OpenContext *self = data;
g_weak_ref_clear(&self->window);
g_clear_object(&self->app_info);
g_clear_object(&self->file);
g_free(self->content_type);
}
static void
open_context_unref(gpointer data, G_GNUC_UNUSED GClosure *closure)
{
g_rc_box_release_full(data, open_context_finalize);
}
static void
show_error_dialog(GtkWindow *parent, GError *error)
{
GtkWidget *dialog =
gtk_message_dialog_new(GTK_WINDOW(parent), GTK_DIALOG_MODAL,
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
open_context_launch(GtkWidget *widget, OpenContext *self)
{
GdkAppLaunchContext *context =
gdk_display_get_app_launch_context(gtk_widget_get_display(widget));
gdk_app_launch_context_set_screen(context, gtk_widget_get_screen(widget));
gdk_app_launch_context_set_timestamp(context, gtk_get_current_event_time());
GList *files = g_list_append(NULL, self->file);
GError *error = NULL;
if (g_app_info_launch(
self->app_info, files, G_APP_LAUNCH_CONTEXT(context), &error)) {
(void) g_app_info_set_as_last_used_for_type(
self->app_info, self->content_type, NULL);
} else {
GtkWindow *window = g_weak_ref_get(&self->window);
show_error_dialog(window, error);
g_clear_object(&window);
}
g_list_free(files);
g_object_unref(context);
}
static void
append_opener(GtkWidget *menu, GAppInfo *opener, const OpenContext *template)
{
OpenContext *ctx = g_rc_box_alloc0(sizeof *ctx);
g_weak_ref_init(&ctx->window, NULL);
ctx->file = g_object_ref(template->file);
ctx->content_type = g_strdup(template->content_type);
ctx->app_info = opener;
// On Linux, this prefers the obsoleted X-GNOME-FullName.
const char *display_name = g_app_info_get_display_name(opener);
// Ironically, GIO reads CFBundleName and can't read CFBundleDisplayName.
if (!display_name)
display_name = g_app_info_get_executable(opener);
gchar *name = g_strdup_printf("Open With %s", display_name);
// It's documented that we can touch the child, if we want to use markup.
#if 0
GtkWidget *item = gtk_menu_item_new_with_label(name);
#else
// GtkImageMenuItem overrides the toggle_size_request class method
// to get the image shown in the "margin"--too much work to duplicate.
G_GNUC_BEGIN_IGNORE_DEPRECATIONS;
GtkWidget *item = gtk_image_menu_item_new_with_label(name);
GIcon *icon = g_app_info_get_icon(opener);
if (icon) {
GtkWidget *image = gtk_image_new_from_gicon(icon, GTK_ICON_SIZE_MENU);
gtk_image_menu_item_set_image(GTK_IMAGE_MENU_ITEM(item), image);
gtk_image_menu_item_set_always_show_image(
GTK_IMAGE_MENU_ITEM(item), TRUE);
}
G_GNUC_END_IGNORE_DEPRECATIONS;
#endif
g_free(name);
g_signal_connect_data(item, "activate", G_CALLBACK(open_context_launch),
ctx, open_context_unref, 0);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item);
}
static void
on_chooser_activate(GtkMenuItem *item, gpointer user_data)
{
OpenContext *ctx = user_data;
GtkWindow *window = g_weak_ref_get(&ctx->window);
GtkWidget *dialog = gtk_app_chooser_dialog_new_for_content_type(window,
GTK_DIALOG_DESTROY_WITH_PARENT | GTK_DIALOG_MODAL, ctx->content_type);
g_clear_object(&window);
#if 0
// This exists as a concept in mimeapps.list, but GNOME infuriatingly
// infers it from the last used application if missing.
gtk_app_chooser_widget_set_show_default(
GTK_APP_CHOOSER_WIDGET(gtk_app_chooser_dialog_get_widget(
GTK_APP_CHOOSER_DIALOG(dialog))), TRUE);
#endif
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_OK) {
ctx->app_info = gtk_app_chooser_get_app_info(GTK_APP_CHOOSER(dialog));
open_context_launch(GTK_WIDGET(item), ctx);
}
gtk_widget_destroy(dialog);
}
static void
on_info_activate(G_GNUC_UNUSED GtkMenuItem *item, gpointer user_data)
{
OpenContext *ctx = user_data;
GtkWindow *window = g_weak_ref_get(&ctx->window);
gchar *uri = g_file_get_uri(ctx->file);
fiv_context_menu_information(window, uri);
g_clear_object(&window);
g_free(uri);
}
void
fiv_context_menu_remove(GtkWindow *parent, GFile *file)
{
// TODO(p): Use g_file_trash_async(), for which we need a task manager.
GError *error = NULL;
if (!g_file_trash(file, NULL, &error))
show_error_dialog(parent, error);
}
static void
on_trash_activate(G_GNUC_UNUSED GtkMenuItem *item, gpointer user_data)
{
OpenContext *ctx = user_data;
GtkWindow *window = g_weak_ref_get(&ctx->window);
fiv_context_menu_remove(window, ctx->file);
g_clear_object(&window);
}
static gboolean
destroy_widget_idle_source_func(GtkWidget *widget)
{
// The whole menu is deactivated /before/ any item is activated,
// and a destroyed child item will not activate.
gtk_widget_destroy(widget);
return FALSE;
}
GtkMenu *
fiv_context_menu_new(GtkWidget *widget, GFile *file)
{
GFileInfo *info = g_file_query_info(file,
G_FILE_ATTRIBUTE_STANDARD_TYPE
"," G_FILE_ATTRIBUTE_STANDARD_NAME
"," G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE
"," G_FILE_ATTRIBUTE_STANDARD_TARGET_URI,
G_FILE_QUERY_INFO_NONE, NULL, NULL);
if (!info)
return NULL;
GtkWindow *window = NULL;
if (widget && GTK_IS_WINDOW((widget = gtk_widget_get_toplevel(widget))))
window = GTK_WINDOW(widget);
// This will have no application pre-assigned, for use with GTK+'s dialog.
OpenContext *ctx = g_rc_box_alloc0(sizeof *ctx);
g_weak_ref_init(&ctx->window, window);
if (!(ctx->content_type = g_strdup(g_file_info_get_content_type(info))))
ctx->content_type = g_content_type_guess(NULL, NULL, 0, NULL);
GFileType type = g_file_info_get_file_type(info);
const char *target_uri = g_file_info_get_attribute_string(
info, G_FILE_ATTRIBUTE_STANDARD_TARGET_URI);
ctx->file = target_uri && g_file_has_uri_scheme(file, FIV_COLLECTION_SCHEME)
? g_file_new_for_uri(target_uri)
: g_object_ref(file);
g_object_unref(info);
GAppInfo *default_ =
g_app_info_get_default_for_type(ctx->content_type, FALSE);
GtkWidget *menu = gtk_menu_new();
if (default_) {
append_opener(menu, default_, ctx);
gtk_menu_shell_append(
GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
}
GList *recommended = g_app_info_get_recommended_for_type(ctx->content_type);
for (GList *iter = recommended; iter; iter = iter->next) {
if (!default_ || !g_app_info_equal(iter->data, default_))
append_opener(menu, iter->data, ctx);
else
g_object_unref(iter->data);
}
if (recommended) {
g_list_free(recommended);
gtk_menu_shell_append(
GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
}
// The implementation returns the same data for both,
// we'd have to filter out the recommended ones from here.
#ifndef __APPLE__
GList *fallback = g_app_info_get_fallback_for_type(ctx->content_type);
for (GList *iter = fallback; iter; iter = iter->next) {
if (!default_ || !g_app_info_equal(iter->data, default_))
append_opener(menu, iter->data, ctx);
else
g_object_unref(iter->data);
}
if (fallback) {
g_list_free(fallback);
gtk_menu_shell_append(
GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
}
#endif
GtkWidget *item = gtk_menu_item_new_with_label("Open With...");
g_signal_connect_data(item, "activate", G_CALLBACK(on_chooser_activate),
ctx, open_context_unref, 0);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item);
// TODO(p): Can we avoid using the "trash" string constant for this check?
if (!g_file_has_uri_scheme(file, "trash")) {
gtk_menu_shell_append(
GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
item = gtk_menu_item_new_with_mnemonic("Move to _Trash");
g_signal_connect_data(item, "activate", G_CALLBACK(on_trash_activate),
g_rc_box_acquire(ctx), open_context_unref, 0);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item);
}
if (type == G_FILE_TYPE_REGULAR) {
gtk_menu_shell_append(
GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
item = gtk_menu_item_new_with_mnemonic("_Information");
g_signal_connect_data(item, "activate", G_CALLBACK(on_info_activate),
g_rc_box_acquire(ctx), open_context_unref, 0);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item);
}
// As per GTK+ 3 Common Questions, 1.5.
g_object_ref_sink(menu);
g_signal_connect_swapped(menu, "deactivate",
G_CALLBACK(g_idle_add), destroy_widget_idle_source_func);
g_signal_connect(menu, "destroy", G_CALLBACK(g_object_unref), NULL);
gtk_widget_show_all(menu);
return GTK_MENU(menu);
}