aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--LICENSE2
-rw-r--r--README.adoc15
-rw-r--r--fiv-browser.c29
-rw-r--r--fiv-context-menu.c15
-rw-r--r--fiv-io-model.c5
-rw-r--r--fiv-sidebar.c7
-rw-r--r--fiv-view.c195
-rw-r--r--fiv-view.h1
-rw-r--r--fiv.c192
-rw-r--r--macos-Info.plist.in52
-rwxr-xr-xmacos-configure.sh25
-rwxr-xr-xmacos-install.sh115
-rwxr-xr-xmacos-svg2icns.sh22
-rw-r--r--meson.build57
-rwxr-xr-xmsys2-configure.sh2
-rw-r--r--tools/benchmark-raw.c428
17 files changed, 1122 insertions, 41 deletions
diff --git a/.gitignore b/.gitignore
index 836b2c5..c5a005f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+/.qtcreator
/meson.build.user
/subprojects/*
!/subprojects/*.wrap
diff --git a/LICENSE b/LICENSE
index 96d7caf..1ffc0ba 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 - 2024, Přemysl Eric Janouch <p@janouch.name>
+Copyright (c) 2021 - 2025, 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.
diff --git a/README.adoc b/README.adoc
index 46740eb..e9b734e 100644
--- a/README.adoc
+++ b/README.adoc
@@ -2,7 +2,7 @@ fiv
===
'fiv' is a slightly unconventional, general-purpose image browser and viewer
-for Linux and Windows (macOS still has major issues).
+for Linux and Windows (macOS also kind of works).
image::docs/fiv.webp["Screenshot of both the browser and the viewer"]
@@ -69,6 +69,11 @@ you can get a quick and dirty installation package for testing purposes using:
$ meson compile deb
# dpkg -i fiv-*.deb
+And in case you keep the default installation prefix rather than _/usr_,
+it is necessary to:
+
+ # glib-compile-schemas /usr/local/share/glib-2.0/schemas
+
Windows
~~~~~~~
'fiv' can be cross-compiled for Windows, provided that you install a bunch of
@@ -91,6 +96,14 @@ _mingw-w64-lcms2_ with the following change:
sed -i 's/meson setup /&-Dfastfloat=true /' PKGCONFIG
+macOS
+~~~~~
+Support for this operating system isn't as good.
+If you install Homebrew, you can get an application bundle with:
+
+ $ sh -e macos-configure.sh builddir
+ $ meson install -C builddir
+
Documentation
-------------
For information concerning usage, refer to link:docs/fiv.html[the user guide],
diff --git a/fiv-browser.c b/fiv-browser.c
index 4a904f0..e1c9dfe 100644
--- a/fiv-browser.c
+++ b/fiv-browser.c
@@ -1,7 +1,7 @@
//
// fiv-browser.c: filesystem browsing widget
//
-// Copyright (c) 2021 - 2024, Přemysl Eric Janouch <p@janouch.name>
+// Copyright (c) 2021 - 2025, 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.
@@ -1308,6 +1308,15 @@ fiv_browser_button_press_event(GtkWidget *widget, GdkEventButton *event)
}
static gboolean
+modifier_state_opens_new_window(GtkWidget *widget, guint state)
+{
+ GdkModifierType primary = gdk_keymap_get_modifier_mask(
+ gdk_keymap_get_for_display(gtk_widget_get_display(widget)),
+ GDK_MODIFIER_INTENT_PRIMARY_ACCELERATOR);
+ return state == primary || state == GDK_SHIFT_MASK;
+}
+
+static gboolean
fiv_browser_button_release_event(GtkWidget *widget, GdkEventButton *event)
{
FivBrowser *self = FIV_BROWSER(widget);
@@ -1323,11 +1332,13 @@ fiv_browser_button_release_event(GtkWidget *widget, GdkEventButton *event)
if (!entry || entry != entry_at(self, event->x, event->y))
return GDK_EVENT_PROPAGATE;
+
guint state = event->state & gtk_accelerator_get_default_mod_mask();
if ((event->button == GDK_BUTTON_PRIMARY && state == 0))
return open_entry(widget, entry, FALSE);
- if ((event->button == GDK_BUTTON_PRIMARY && state == GDK_CONTROL_MASK) ||
- (event->button == GDK_BUTTON_MIDDLE && state == 0))
+ if ((event->button == GDK_BUTTON_MIDDLE && state == 0) ||
+ (event->button == GDK_BUTTON_PRIMARY &&
+ modifier_state_opens_new_window(widget, state)))
return open_entry(widget, entry, TRUE);
return GDK_EVENT_PROPAGATE;
}
@@ -1578,7 +1589,8 @@ static gboolean
fiv_browser_key_press_event(GtkWidget *widget, GdkEventKey *event)
{
FivBrowser *self = FIV_BROWSER(widget);
- switch ((event->state & gtk_accelerator_get_default_mod_mask())) {
+ guint state = event->state & gtk_accelerator_get_default_mod_mask();
+ switch (state) {
case 0:
switch (event->keyval) {
case GDK_KEY_Delete:
@@ -1635,6 +1647,15 @@ fiv_browser_key_press_event(GtkWidget *widget, GdkEventKey *event)
}
}
+ if (modifier_state_opens_new_window(widget, state)) {
+ switch (event->keyval) {
+ case GDK_KEY_Return:
+ if (self->selected)
+ return open_entry(widget, self->selected, TRUE);
+ return GDK_EVENT_STOP;
+ }
+ }
+
return GTK_WIDGET_CLASS(fiv_browser_parent_class)
->key_press_event(widget, event);
}
diff --git a/fiv-context-menu.c b/fiv-context-menu.c
index 678c616..3284a2e 100644
--- a/fiv-context-menu.c
+++ b/fiv-context-menu.c
@@ -380,8 +380,11 @@ append_opener(GtkWidget *menu, GAppInfo *opener, const OpenContext *template)
ctx->app_info = opener;
// On Linux, this prefers the obsoleted X-GNOME-FullName.
- gchar *name =
- g_strdup_printf("Open With %s", g_app_info_get_display_name(opener));
+ 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
@@ -503,8 +506,6 @@ fiv_context_menu_new(GtkWidget *widget, GFile *file)
GAppInfo *default_ =
g_app_info_get_default_for_type(ctx->content_type, FALSE);
- GList *recommended = g_app_info_get_recommended_for_type(ctx->content_type);
- GList *fallback = g_app_info_get_fallback_for_type(ctx->content_type);
GtkWidget *menu = gtk_menu_new();
if (default_) {
@@ -513,6 +514,7 @@ fiv_context_menu_new(GtkWidget *widget, GFile *file)
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);
@@ -525,6 +527,10 @@ fiv_context_menu_new(GtkWidget *widget, GFile *file)
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);
@@ -536,6 +542,7 @@ fiv_context_menu_new(GtkWidget *widget, GFile *file)
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),
diff --git a/fiv-io-model.c b/fiv-io-model.c
index 3309702..6da3e3c 100644
--- a/fiv-io-model.c
+++ b/fiv-io-model.c
@@ -382,6 +382,9 @@ on_monitor_changed(G_GNUC_UNUSED GFileMonitor *monitor, GFile *file,
switch (event_type) {
case G_FILE_MONITOR_EVENT_CHANGED:
case G_FILE_MONITOR_EVENT_ATTRIBUTE_CHANGED:
+ // On macOS, we seem to not receive _CHANGED for child files.
+ // And while this seems to arrive too early, it's a mild improvement.
+ case G_FILE_MONITOR_EVENT_CHANGES_DONE_HINT:
event = MONITOR_CHANGING;
new_entry_file = file;
break;
@@ -400,8 +403,6 @@ on_monitor_changed(G_GNUC_UNUSED GFileMonitor *monitor, GFile *file,
new_entry_file = file;
break;
- case G_FILE_MONITOR_EVENT_CHANGES_DONE_HINT:
- // TODO(p): Figure out if we can't make use of _CHANGES_DONE_HINT.
case G_FILE_MONITOR_EVENT_PRE_UNMOUNT:
case G_FILE_MONITOR_EVENT_UNMOUNTED:
// TODO(p): Figure out how to handle _UNMOUNTED sensibly.
diff --git a/fiv-sidebar.c b/fiv-sidebar.c
index 900c8a8..5445fd0 100644
--- a/fiv-sidebar.c
+++ b/fiv-sidebar.c
@@ -537,6 +537,13 @@ on_show_enter_location(
g_signal_connect(entry, "changed",
G_CALLBACK(on_enter_location_changed), self);
+ GFile *location = fiv_io_model_get_location(self->model);
+ if (location) {
+ gchar *parse_name = g_file_get_parse_name(location);
+ gtk_entry_set_text(GTK_ENTRY(entry), parse_name);
+ g_free(parse_name);
+ }
+
// Can't have it ellipsized and word-wrapped at the same time.
GtkWidget *protocols = gtk_label_new("");
gtk_label_set_ellipsize(GTK_LABEL(protocols), PANGO_ELLIPSIZE_END);
diff --git a/fiv-view.c b/fiv-view.c
index cd3cc51..76fcdcb 100644
--- a/fiv-view.c
+++ b/fiv-view.c
@@ -1,7 +1,7 @@
//
// fiv-view.c: image viewing widget
//
-// Copyright (c) 2021 - 2024, Přemysl Eric Janouch <p@janouch.name>
+// Copyright (c) 2021 - 2025, 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.
@@ -77,6 +77,8 @@ struct _FivView {
bool fixate : 1; ///< Keep zoom and position
double scale; ///< Scaling factor
double drag_start[2]; ///< Adjustment values for drag origin
+ double zoom_gesture_center[2]; ///< Pinch gesture widget coordinates
+ double zoom_gesture_surface[2]; ///< Pinch gesture surface coordinates
FivIoImage *enhance_swap; ///< Quick swap in/out
FivIoProfile *screen_cms_profile; ///< Target colour profile for widget
@@ -1354,6 +1356,63 @@ fiv_view_unmap(GtkWidget *widget)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static void
+on_zoom_begin(GtkGestureZoom *gesture,
+ G_GNUC_UNUSED GdkEventSequence *sequence, gpointer user_data)
+{
+ FivView *self = FIV_VIEW(user_data);
+ self->drag_start[0] = self->scale;
+
+ // Store widget coordinates and convert to surface coordinates ONCE.
+ double x = 0, y = 0;
+ gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &x, &y);
+ self->zoom_gesture_center[0] = x;
+ self->zoom_gesture_center[1] = y;
+
+ widget_to_surface(self, &x, &y);
+ self->zoom_gesture_surface[0] = x;
+ self->zoom_gesture_surface[1] = y;
+}
+
+static void
+on_zoom_scale_changed(
+ GtkGestureZoom *gesture, gdouble scale, gpointer user_data)
+{
+ FivView *self = FIV_VIEW(user_data);
+ double new_scale = self->drag_start[0] * scale;
+
+ if (self->scale == new_scale)
+ return;
+
+ GtkAllocation allocation;
+ gtk_widget_get_allocation(GTK_WIDGET(self), &allocation);
+
+ // Use the stored widget and surface coordinates from gesture start.
+ double focus_x = self->zoom_gesture_center[0];
+ double focus_y = self->zoom_gesture_center[1];
+ double surface_x = self->zoom_gesture_surface[0];
+ double surface_y = self->zoom_gesture_surface[1];
+
+ self->scale = new_scale;
+ g_object_notify_by_pspec(G_OBJECT(self), view_properties[PROP_SCALE]);
+ prescale_page(self);
+
+ if (self->hadjustment && self->vadjustment) {
+ Dimensions surface_dimensions = get_surface_dimensions(self);
+ update_adjustments(self);
+
+ if (surface_dimensions.width * self->scale > allocation.width)
+ gtk_adjustment_set_value(
+ self->hadjustment, surface_x * self->scale - focus_x);
+ if (surface_dimensions.height * self->scale > allocation.height)
+ gtk_adjustment_set_value(
+ self->vadjustment, surface_y * self->scale - focus_y);
+ }
+
+ gtk_widget_queue_resize(GTK_WIDGET(self));
+ set_scale_to_fit(self, false);
+}
+
+static void
on_drag_begin(GtkGestureDrag *drag, G_GNUC_UNUSED gdouble start_x,
G_GNUC_UNUSED gdouble start_y, gpointer user_data)
{
@@ -1438,6 +1497,127 @@ get_toplevel(GtkWidget *widget)
return NULL;
}
+struct zoom_ask_context {
+ GtkWidget *result_left, *result_right;
+ Dimensions dimensions;
+};
+
+static void
+on_zoom_ask_spin_changed(GtkSpinButton *spin, G_GNUC_UNUSED gpointer user_data)
+{
+ // We don't want to call gtk_spin_button_update(),
+ // that would immediately replace whatever the user has typed in.
+ gdouble scale = -1;
+ const gchar *text = gtk_entry_get_text(GTK_ENTRY(spin));
+ if (*text) {
+ gchar *end = NULL;
+ gdouble value = g_strtod(text, &end);
+ if (!*end)
+ scale = value / 100.;
+ }
+
+ struct zoom_ask_context *data = user_data;
+ GtkStyleContext *style = gtk_widget_get_style_context(GTK_WIDGET(spin));
+ if (scale <= 0) {
+ gtk_style_context_add_class(style, GTK_STYLE_CLASS_WARNING);
+ gtk_label_set_text(GTK_LABEL(data->result_left), "—");
+ gtk_label_set_text(GTK_LABEL(data->result_right), "—");
+ } else {
+ gtk_style_context_remove_class(style, GTK_STYLE_CLASS_WARNING);
+ gchar *left = g_strdup_printf("%.0f", data->dimensions.width * scale);
+ gchar *right = g_strdup_printf("%.0f", data->dimensions.height * scale);
+ gtk_label_set_text(GTK_LABEL(data->result_left), left);
+ gtk_label_set_text(GTK_LABEL(data->result_right), right);
+ g_free(left);
+ g_free(right);
+ }
+}
+
+static void
+zoom_ask(FivView *self)
+{
+ GtkWidget *dialog = gtk_dialog_new_with_buttons("Set zoom level",
+ get_toplevel(GTK_WIDGET(self)),
+ GTK_DIALOG_DESTROY_WITH_PARENT | GTK_DIALOG_MODAL |
+ GTK_DIALOG_USE_HEADER_BAR,
+ "_OK", GTK_RESPONSE_ACCEPT, "_Cancel", GTK_RESPONSE_CANCEL, NULL);
+
+ Dimensions dimensions = get_surface_dimensions(self);
+ gchar *original_width = g_strdup_printf("%.0f", dimensions.width);
+ gchar *original_height = g_strdup_printf("%.0f", dimensions.height);
+ GtkWidget *original_left = gtk_label_new(original_width);
+ GtkWidget *original_middle = gtk_label_new("×");
+ GtkWidget *original_right = gtk_label_new(original_height);
+ g_free(original_width);
+ g_free(original_height);
+ gtk_label_set_xalign(GTK_LABEL(original_left), 1.);
+ gtk_label_set_xalign(GTK_LABEL(original_right), 0.);
+
+ GtkWidget *original_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
+ gtk_box_pack_start(
+ GTK_BOX(original_box), original_left, TRUE, TRUE, 0);
+ gtk_box_pack_start(
+ GTK_BOX(original_box), original_middle, FALSE, FALSE, 0);
+ gtk_box_pack_start(
+ GTK_BOX(original_box), original_right, TRUE, TRUE, 0);
+
+ // FIXME: This widget's behaviour is absolutely miserable.
+ // For example, we would like to be flexible with decimal spaces.
+ GtkAdjustment *adjustment = gtk_adjustment_new(
+ self->scale * 100, 0., 100000., 1., 10., 0.);
+ GtkWidget *spin = gtk_spin_button_new(adjustment, 1., 2);
+ gtk_spin_button_set_update_policy(
+ GTK_SPIN_BUTTON(spin), GTK_UPDATE_IF_VALID);
+ gtk_entry_set_activates_default(GTK_ENTRY(spin), TRUE);
+
+ GtkWidget *zoom_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
+ GtkWidget *zoom_label = gtk_label_new_with_mnemonic("_Zoom:");
+ gtk_label_set_mnemonic_widget(GTK_LABEL(zoom_label), spin);
+ gtk_box_pack_start(GTK_BOX(zoom_box), zoom_label, FALSE, FALSE, 0);
+ gtk_box_pack_start(GTK_BOX(zoom_box), spin, TRUE, TRUE, 0);
+ gtk_box_pack_start(GTK_BOX(zoom_box), gtk_label_new("%"), FALSE, FALSE, 0);
+
+ GtkWidget *result_left = gtk_label_new(NULL);
+ GtkWidget *result_middle = gtk_label_new("×");
+ GtkWidget *result_right = gtk_label_new(NULL);
+ gtk_label_set_xalign(GTK_LABEL(result_left), 1.);
+ gtk_label_set_xalign(GTK_LABEL(result_right), 0.);
+
+ GtkSizeGroup *group = gtk_size_group_new(GTK_SIZE_GROUP_HORIZONTAL);
+ gtk_size_group_add_widget(group, original_left);
+ gtk_size_group_add_widget(group, original_right);
+ gtk_size_group_add_widget(group, result_left);
+ gtk_size_group_add_widget(group, result_right);
+
+ GtkWidget *result_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
+ gtk_box_pack_start(GTK_BOX(result_box), result_left, TRUE, TRUE, 0);
+ gtk_box_pack_start(GTK_BOX(result_box), result_middle, FALSE, FALSE, 0);
+ gtk_box_pack_start(GTK_BOX(result_box), result_right, TRUE, TRUE, 0);
+
+ struct zoom_ask_context data = { result_left, result_right, dimensions };
+ g_signal_connect(spin, "changed",
+ G_CALLBACK(on_zoom_ask_spin_changed), &data);
+ on_zoom_ask_spin_changed(GTK_SPIN_BUTTON(spin), &data);
+
+ GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
+ g_object_set(content, "margin", 12, NULL);
+ gtk_box_set_spacing(GTK_BOX(content), 6);
+ gtk_container_add(GTK_CONTAINER(content), original_box);
+ gtk_container_add(GTK_CONTAINER(content), zoom_box);
+ gtk_container_add(GTK_CONTAINER(content), result_box);
+ gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_ACCEPT);
+ gtk_window_set_skip_taskbar_hint(GTK_WINDOW(dialog), TRUE);
+ gtk_widget_show_all(dialog);
+
+ if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
+ double value = gtk_spin_button_get_value(GTK_SPIN_BUTTON(spin));
+ if (value > 0)
+ set_scale(self, value / 100., NULL);
+ }
+ gtk_widget_destroy(dialog);
+ g_object_unref(group);
+}
+
static void
copy(FivView *self)
{
@@ -1782,6 +1962,17 @@ fiv_view_init(FivView *self)
G_CALLBACK(on_drag_update), self);
g_signal_connect(drag, "drag-end",
G_CALLBACK(on_drag_end), self);
+
+ GtkGesture *zoom = gtk_gesture_zoom_new(GTK_WIDGET(self));
+ gtk_event_controller_set_propagation_phase(
+ GTK_EVENT_CONTROLLER(zoom), GTK_PHASE_BUBBLE);
+ g_object_set_data_full(
+ G_OBJECT(self), "fiv-view-zoom-gesture", zoom, g_object_unref);
+
+ g_signal_connect(zoom, "begin",
+ G_CALLBACK(on_zoom_begin), self);
+ g_signal_connect(zoom, "scale-changed",
+ G_CALLBACK(on_zoom_scale_changed), self);
}
// --- Public interface --------------------------------------------------------
@@ -2028,6 +2219,8 @@ fiv_view_command(FivView *self, FivViewCommand command)
set_scale(self, self->scale / SCALE_STEP, NULL);
break; case FIV_VIEW_COMMAND_ZOOM_1:
set_scale(self, 1.0, NULL);
+ break; case FIV_VIEW_COMMAND_ZOOM_ASK:
+ zoom_ask(self);
break; case FIV_VIEW_COMMAND_FIT_WIDTH:
set_scale_to_fit_width(self);
break; case FIV_VIEW_COMMAND_FIT_HEIGHT:
diff --git a/fiv-view.h b/fiv-view.h
index d43480b..768d988 100644
--- a/fiv-view.h
+++ b/fiv-view.h
@@ -59,6 +59,7 @@ typedef enum _FivViewCommand {
XX(FIV_VIEW_COMMAND_ZOOM_IN, "zoom-in") \
XX(FIV_VIEW_COMMAND_ZOOM_OUT, "zoom-out") \
XX(FIV_VIEW_COMMAND_ZOOM_1, "zoom-1") \
+ XX(FIV_VIEW_COMMAND_ZOOM_ASK, "zoom-ask") \
XX(FIV_VIEW_COMMAND_FIT_WIDTH, "fit-width") \
XX(FIV_VIEW_COMMAND_FIT_HEIGHT, "fit-height") \
XX(FIV_VIEW_COMMAND_TOGGLE_SCALE_TO_FIT, "toggle-scale-to-fit") \
diff --git a/fiv.c b/fiv.c
index 43041b0..5a850af 100644
--- a/fiv.c
+++ b/fiv.c
@@ -1,7 +1,7 @@
//
// fiv.c: fuck-if-I-know-how-to-name-it image browser and viewer
//
-// Copyright (c) 2021 - 2024, Přemysl Eric Janouch <p@janouch.name>
+// Copyright (c) 2021 - 2025, 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.
@@ -73,6 +73,138 @@ slist_to_strv(GSList *slist)
return strv;
}
+// --- macOS utilities ---------------------------------------------------------
+
+#ifdef __APPLE__
+#include <CoreFoundation/CoreFoundation.h>
+
+static gchar *
+cfurlref_to_path(CFURLRef urlref)
+{
+ CFStringRef path = CFURLCopyFileSystemPath(urlref, kCFURLPOSIXPathStyle);
+ if (!path)
+ return NULL;
+
+ CFIndex size = CFStringGetMaximumSizeForEncoding(
+ CFStringGetLength(path), kCFStringEncodingUTF8) + 1;
+ gchar *string = g_malloc(size);
+
+ Boolean ok = CFStringGetCString(path, string, size, kCFStringEncodingUTF8);
+ CFRelease(path);
+ if (!ok) {
+ g_free(string);
+ return NULL;
+ }
+ return string;
+}
+
+static gchar *
+get_application_bundle_path(void)
+{
+ gchar *result = NULL;
+ CFBundleRef bundle = CFBundleGetMainBundle();
+ if (!bundle)
+ goto fail_1;
+
+ // When launched from outside a bundle, it will make up one,
+ // but these paths will then be equal.
+ CFURLRef bundle_url = CFBundleCopyBundleURL(bundle);
+ if (!bundle_url)
+ goto fail_1;
+ CFURLRef resources_url = CFBundleCopyResourcesDirectoryURL(bundle);
+ if (!resources_url)
+ goto fail_2;
+
+ if (!CFEqual(bundle_url, resources_url))
+ result = cfurlref_to_path(bundle_url);
+
+ CFRelease(resources_url);
+fail_2:
+ CFRelease(bundle_url);
+fail_1:
+ return result;
+}
+
+static gchar *
+prepend_path_string(const gchar *prepended, const gchar *original)
+{
+ if (!prepended)
+ return g_strdup(original ? original : "");
+ if (!original || !*original)
+ return g_strdup(prepended);
+
+ GHashTable *seen = g_hash_table_new(g_str_hash, g_str_equal);
+ GPtrArray *unique = g_ptr_array_new();
+ g_ptr_array_add(unique, (gpointer) prepended);
+ g_hash_table_add(seen, (gpointer) prepended);
+
+ gchar **components = g_strsplit(original, ":", -1);
+ for (gchar **p = components; *p; p++) {
+ if (g_hash_table_contains(seen, *p))
+ continue;
+
+ g_ptr_array_add(unique, *p);
+ g_hash_table_add(seen, *p);
+ }
+
+ g_ptr_array_add(unique, NULL);
+ gchar *result = g_strjoinv(":", (gchar **) unique->pdata);
+ g_hash_table_destroy(seen);
+ g_ptr_array_free(unique, TRUE);
+
+ g_strfreev(components);
+ return result;
+}
+
+// We reuse foreign dependencies, so we need to prevent them from loading
+// any system-wide files, and point them in the right direction.
+static void
+adjust_environment(void)
+{
+ gchar *bundle_dir = get_application_bundle_path();
+ if (!bundle_dir)
+ return;
+
+ gchar *contents_dir = g_build_filename(bundle_dir, "Contents", NULL);
+ gchar *macos_dir = g_build_filename(contents_dir, "MacOS", NULL);
+ gchar *resources_dir = g_build_filename(contents_dir, "Resources", NULL);
+ gchar *datadir = g_build_filename(resources_dir, "share", NULL);
+ gchar *libdir = g_build_filename(resources_dir, "lib", NULL);
+ g_free(bundle_dir);
+
+ gchar *new_path = prepend_path_string(macos_dir, g_getenv("PATH"));
+ g_setenv("PATH", new_path, TRUE);
+ g_free(new_path);
+
+ const gchar *data_dirs = g_getenv("XDG_DATA_DIRS");
+ gchar *new_data_dirs = data_dirs && *data_dirs
+ ? prepend_path_string(datadir, data_dirs)
+ : prepend_path_string(datadir, "/usr/local/share:/usr/share");
+ g_setenv("XDG_DATA_DIRS", new_data_dirs, TRUE);
+ g_free(new_data_dirs);
+
+ gchar *schemas_dir = g_build_filename(datadir, "glib-2.0", "schemas", NULL);
+ g_setenv("GSETTINGS_SCHEMA_DIR", schemas_dir, TRUE);
+ g_free(schemas_dir);
+
+ gchar *gdk_pixbuf_module_file =
+ g_build_filename(libdir, "gdk-pixbuf-2.0", "loaders.cache", NULL);
+ g_setenv("GDK_PIXBUF_MODULE_FILE", gdk_pixbuf_module_file, TRUE);
+ g_free(gdk_pixbuf_module_file);
+
+ // GTK+ is smart enough to also consider application bundles,
+ // but let there be a single source of truth.
+ g_setenv("GTK_EXE_PREFIX", resources_dir, TRUE);
+
+ g_free(libdir);
+ g_free(datadir);
+ g_free(resources_dir);
+ g_free(macos_dir);
+ g_free(contents_dir);
+}
+
+#endif
+
// --- Keyboard shortcuts ------------------------------------------------------
// Fuck XML, this can be easily represented in static structures.
// Though it would be nice if the accelerators could be customized.
@@ -667,7 +799,7 @@ enum {
XX(S3, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \
XX(FIXATE, T("pin2-symbolic", "Keep zoom and position")) \
XX(MINUS, B("zoom-out-symbolic", "Zoom out")) \
- XX(SCALE, gtk_label_new("")) \
+ XX(SCALE, B(NULL, "Set zoom level")) \
XX(PLUS, B("zoom-in-symbolic", "Zoom in")) \
XX(ONE, B("zoom-original-symbolic", "Original size")) \
XX(FIT, T("zoom-fit-best-symbolic", "Scale to fit")) \
@@ -1092,9 +1224,22 @@ on_next(void)
static gchar **
build_spawn_argv(const char *uri)
{
- // Because we only pass URIs, there is no need to prepend "--" here.
GPtrArray *a = g_ptr_array_new();
- g_ptr_array_add(a, g_strdup(PROJECT_NAME));
+#ifdef __APPLE__
+ // Otherwise we would always launch ourselves in the background.
+ gchar *bundle_dir = get_application_bundle_path();
+ if (bundle_dir) {
+ g_ptr_array_add(a, g_strdup("open"));
+ g_ptr_array_add(a, g_strdup("-a"));
+ g_ptr_array_add(a, bundle_dir);
+ // At least with G_APPLICATION_NON_UNIQUE, this is necessary:
+ g_ptr_array_add(a, g_strdup("-n"));
+ g_ptr_array_add(a, g_strdup("--args"));
+ }
+#endif
+ // Because we only pass URIs, there is no need to prepend "--" after this.
+ if (!a->len)
+ g_ptr_array_add(a, g_strdup(PROJECT_NAME));
// Process-local VFS URIs need to be resolved to globally accessible URIs.
// It doesn't seem possible to reliably tell if a GFile is process-local,
@@ -1403,15 +1548,24 @@ on_window_state_event(G_GNUC_UNUSED GtkWidget *widget,
static void
show_help_contents(void)
{
- gchar *filename = g_strdup_printf("%s.html", PROJECT_NAME);
#ifdef G_OS_WIN32
gchar *prefix = g_win32_get_package_installation_directory_of_module(NULL);
- gchar *path = g_build_filename(prefix, PROJECT_DOCDIR, filename, NULL);
- g_free(prefix);
+#elif defined __APPLE__
+ gchar *prefix = get_application_bundle_path();
+ if (!prefix) {
+ show_error_dialog(g_error_new(
+ G_FILE_ERROR, G_FILE_ERROR_FAILED, "Cannot locate bundle"));
+ return;
+ }
#else
- gchar *path = g_build_filename(PROJECT_DOCDIR, filename, NULL);
+ gchar *prefix = g_strdup(PROJECT_PREFIX);
#endif
+
+ gchar *filename = g_strdup_printf("%s.html", PROJECT_NAME);
+ gchar *path = g_build_filename(prefix, PROJECT_DOCDIR, filename, NULL);
+ g_free(prefix);
g_free(filename);
+
GError *error = NULL;
gchar *uri = g_filename_to_uri(path, NULL, &error);
g_free(path);
@@ -1661,8 +1815,10 @@ static GtkWidget *
make_toolbar_button(const char *symbolic, const char *tooltip)
{
GtkWidget *button = gtk_button_new();
- gtk_button_set_image(GTK_BUTTON(button),
- gtk_image_new_from_icon_name(symbolic, GTK_ICON_SIZE_BUTTON));
+ if (symbolic) {
+ gtk_button_set_image(GTK_BUTTON(button),
+ gtk_image_new_from_icon_name(symbolic, GTK_ICON_SIZE_BUTTON));
+ }
gtk_widget_set_tooltip_text(button, tooltip);
gtk_widget_set_focus_on_click(button, FALSE);
gtk_style_context_add_class(
@@ -1808,7 +1964,8 @@ on_notify_view_scale(
g_object_get(object, g_param_spec_get_name(param_spec), &scale, NULL);
gchar *scale_str = g_strdup_printf("%.0f%%", round(scale * 100));
- gtk_label_set_text(GTK_LABEL(g.toolbar[TOOLBAR_SCALE]), scale_str);
+ gtk_label_set_text(GTK_LABEL(
+ gtk_bin_get_child(GTK_BIN(g.toolbar[TOOLBAR_SCALE]))), scale_str);
g_free(scale_str);
// FIXME: The label doesn't immediately assume its new width.
@@ -1893,13 +2050,11 @@ make_view_toolbar(void)
TOOLBAR(XX)
#undef XX
- gtk_widget_set_margin_start(g.toolbar[TOOLBAR_SCALE], 5);
- gtk_widget_set_margin_end(g.toolbar[TOOLBAR_SCALE], 5);
-
+ GtkWidget *scale_label = gtk_label_new("");
+ gtk_container_add(GTK_CONTAINER(g.toolbar[TOOLBAR_SCALE]), scale_label);
// So that the width doesn't jump around in the usual zoom range.
// Ideally, we'd measure the widest digit and use width(NNN%).
- gtk_label_set_width_chars(GTK_LABEL(g.toolbar[TOOLBAR_SCALE]), 5);
- gtk_widget_set_halign(g.toolbar[TOOLBAR_SCALE], GTK_ALIGN_CENTER);
+ gtk_label_set_width_chars(GTK_LABEL(scale_label), 5);
// GtkStatusBar solves a problem we do not have here.
GtkWidget *view_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
@@ -1930,6 +2085,7 @@ make_view_toolbar(void)
toolbar_command(TOOLBAR_PLAY_PAUSE, FIV_VIEW_COMMAND_TOGGLE_PLAYBACK);
toolbar_command(TOOLBAR_SEEK_FORWARD, FIV_VIEW_COMMAND_FRAME_NEXT);
toolbar_command(TOOLBAR_MINUS, FIV_VIEW_COMMAND_ZOOM_OUT);
+ toolbar_command(TOOLBAR_SCALE, FIV_VIEW_COMMAND_ZOOM_ASK);
toolbar_command(TOOLBAR_PLUS, FIV_VIEW_COMMAND_ZOOM_IN);
toolbar_command(TOOLBAR_ONE, FIV_VIEW_COMMAND_ZOOM_1);
toolbar_toggler(TOOLBAR_FIT, "scale-to-fit");
@@ -2640,6 +2796,10 @@ main(int argc, char *argv[])
{},
};
+#ifdef __APPLE__
+ adjust_environment();
+#endif
+
// 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(
diff --git a/macos-Info.plist.in b/macos-Info.plist.in
new file mode 100644
index 0000000..b518a62
--- /dev/null
+++ b/macos-Info.plist.in
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+ <dict>
+ <key>CFBundleExecutable</key>
+ <string>@ProjectName@</string>
+ <key>CFBundleIdentifier</key>
+ <string>@ProjectNS@@ProjectName@</string>
+ <key>CFBundleName</key>
+ <string>@ProjectName@</string>
+ <key>CFBundleIconFile</key>
+ <string>@ProjectName@.icns</string>
+ <key>CFBundleShortVersionString</key>
+ <string>@ProjectVersion@</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+
+ <!-- Although mostly static, this should eventually be generated. -->
+ <!-- In particular, we should expand image/x-dcraw, -->
+ <!-- using information we can collect from shared-mime-info. -->
+ <key>CFBundleDocumentTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeName</key>
+ <string>Image File</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ <key>LSHandlerRank</key>
+ <string>Default</string>
+ <key>LSItemContentTypes</key>
+ <array>
+ <string>com.apple.icns</string>
+ <string>com.apple.quicktime-image</string>
+ <string>com.compuserve.gif</string>
+ <string>com.microsoft.bmp</string>
+ <string>com.microsoft.ico</string>
+ <string>org.webmproject.webp</string>
+ <string>public.avif</string>
+ <string>public.heic</string>
+ <string>public.heif</string>
+ <string>public.jpeg</string>
+ <string>public.png</string>
+ <string>public.svg-image</string>
+ <string>public.tiff</string>
+ <string>public.xbitmap-image</string>
+ </array>
+ </dict>
+ </array>
+ </dict>
+</plist>
diff --git a/macos-configure.sh b/macos-configure.sh
new file mode 100755
index 0000000..b7a87db
--- /dev/null
+++ b/macos-configure.sh
@@ -0,0 +1,25 @@
+#!/bin/sh -e
+# macos-configure.sh: set up a Homebrew-based macOS build
+#
+# Meson has no special support for macOS application bundles whatsoever.
+#
+# gtk-mac-bundler doesn't do anything particularly miraculous,
+# and it doesn't directly support Homebrew.
+#
+# It would be cleaner and more reproducible to set up a special HOMEBREW_PREFIX,
+# though right now we're happy to build an app bundle at all.
+#
+# It would also allow us to make a custom Little CMS build that includes
+# the fast float plugin, which is a bit of a big deal.
+
+# TODO: exiftool (Perl is part of macOS, at least for now)
+HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_ASK=1 brew install
+ coreutils meson pkgconf shared-mime-info adwaita-icon-theme \
+ gtk+3 jpeg-xl libavif libheif libraw librsvg little-cms2 webp
+
+sourcedir=$(grealpath "${2:-$(dirname "$0")}")
+builddir=$(grealpath "${1:-builddir}")
+appdir=$builddir/fiv.app
+meson setup --buildtype=debugoptimized --prefix="$appdir" \
+ --bindir=Contents/MacOS --libdir=Contents/Resources/lib \
+ --datadir=Contents/Resources/share "$builddir" "$sourcedir"
diff --git a/macos-install.sh b/macos-install.sh
new file mode 100755
index 0000000..884e1b1
--- /dev/null
+++ b/macos-install.sh
@@ -0,0 +1,115 @@
+#!/bin/sh -e
+export LC_ALL=C
+cd "$MESON_INSTALL_DESTDIR_PREFIX"
+
+# Input: Half-baked application bundle linked against Homebrew.
+# Output: Portable application bundle.
+source=/opt/homebrew
+bindir=Contents/MacOS
+libdir=Contents/Resources/lib
+datadir=Contents/Resources/share
+
+mkdir -p "$datadir"/glib-2.0/schemas
+cp -p "$source"/share/glib-2.0/schemas/org.gtk.Settings.* \
+ "$datadir"/glib-2.0/schemas
+mkdir -p "$datadir"/icons
+cp -pRL "$source"/share/icons/Adwaita "$datadir/"icons
+mkdir -p "$datadir"/icons/hicolor
+cp -p "$source"/share/icons/hicolor/index.theme "$datadir"/icons/hicolor
+mkdir -p "$datadir/mime"
+# GIO doesn't use the database on macOS, this subset is for us.
+find "$source"/share/mime/ -maxdepth 1 -type f -exec cp -p {} "$datadir"/mime \;
+
+# Copy binaries we directly or indirectly depend on.
+#
+# Homebrew is a bit chaotic in that some libraries are linked against locations
+# in /opt/homebrew/Cellar, and some against /opt/homebrew/opt symlinks.
+# We'll process things in such a way that it does not matter.
+#
+# As a side note, libraries in /usr/lib are now actually being served from
+# a shared cache by the dynamic linker and aren't visible on the filesystem.
+# There is an alternative to "otool -L" which can see them but it isn't
+# particularly nicer to parse: "dyld_info -dependents/-linked_dylibs".
+rm -rf "$libdir"
+mkdir -p "$libdir"
+
+pixbufdir=$libdir/gdk-pixbuf-2.0
+loadersdir=$pixbufdir/loaders
+cp -RL "$source"/lib/gdk-pixbuf-2.0/* "$pixbufdir"
+
+# Fix a piece of crap loader that needs to be special.
+svg=$loadersdir/libpixbufloader_svg.so
+rm -f "$loadersdir"/libpixbufloader_svg.dylib
+otool -L "$svg" | grep -o '@rpath/[^ ]*' | while IFS= read -r bad
+do install_name_tool -change "$bad" "$source/lib/$(basename "$bad")" "$svg"
+done
+
+GDK_PIXBUF_MODULEDIR=$loadersdir gdk-pixbuf-query-loaders \
+ | sed "s,$libdir,@rpath," > "$pixbufdir/loaders.cache"
+
+gtkdir=$libdir/gtk-3.0
+printbackendsdir=$gtkdir/printbackends
+cp -RL "$source"/lib/gtk-3.0/* "$gtkdir"
+
+# TODO: Figure out how to make gtk-query-immodules-3.0 pick up exactly
+# what it needs to. So far I'm not sure if this is at all even useful.
+rm -rf "$gtkdir"/immodules*
+
+find "$bindir" "$loadersdir" "$printbackendsdir" -type f -maxdepth 1 | awk '
+ function collect(binary, command, line) {
+ if (seen[binary]++)
+ return
+
+ command = "otool -L \"" binary "\""
+ while ((command | getline line) > 0)
+ if (match(line, /^\t\/opt\/.+ \(/))
+ collect(substr(line, RSTART + 1, RLENGTH - 3))
+ close(command)
+ } {
+ collect($0)
+ delete seen[$0]
+ } END {
+ for (library in seen)
+ print library
+ }
+' | while IFS= read -r binary
+do test -f "$libdir/$(basename "$binary")" || cp "$binary" "$libdir"
+done
+
+# Now redirect all binaries to internal linking.
+# A good overview of how this works is "man dyld" and:
+# https://itwenty.me/posts/01-understanding-rpath/
+rewrite() {
+ otool -L "$1" | sed -n 's,^\t\(.*\) (.*,\1,p' | grep '^/opt/' \
+ | while IFS= read -r lib
+ do install_name_tool -change "$lib" "@rpath/$(basename "$lib")" "$1"
+ done
+}
+
+find "$bindir" -type f -maxdepth 1 | while IFS= read -r binary
+do
+ install_name_tool -add_rpath @executable_path/../Resources/lib "$binary"
+ rewrite "$binary"
+done
+
+find "$libdir" -type f \( -name '*.so' -o -name '*.dylib' \) \
+ | while IFS= read -r binary
+do
+ chmod 644 "$binary"
+ install_name_tool -id "@rpath/${binary#$libdir/}" "$binary"
+ rewrite "$binary"
+
+ # Discard pointless @loader_path/../lib and absolute Homebrew paths.
+ otool -l "$binary" | awk '
+ $1 == "cmd" { command = $2 }
+ command == "LC_RPATH" && $1 == "path" { print $2 }
+ ' | xargs -R 1 -I % install_name_tool -delete_rpath % "$binary"
+
+ # Replace freshly invalidated code signatures with ad-hoc ones.
+ codesign --force --sign - "$binary"
+done
+
+glib-compile-schemas "$datadir"/glib-2.0/schemas
+
+# This may speed up program start-up a little bit.
+gtk-update-icon-cache "$datadir"/icons/Adwaita
diff --git a/macos-svg2icns.sh b/macos-svg2icns.sh
new file mode 100755
index 0000000..791e37e
--- /dev/null
+++ b/macos-svg2icns.sh
@@ -0,0 +1,22 @@
+#!/bin/sh -e
+# macos-svg2icns.sh: convert an SVG to the macOS .icns format
+if [ $# -ne 2 ]
+then
+ echo >&2 "Usage: $0 INPUT.svg OUTPUT.icns"
+ exit 2
+fi
+
+svg=$1 icns=$2 tmpdir=$(mktemp -d)
+trap 'rm -rf "$tmpdir"' EXIT
+
+iconset="$tmpdir/$(basename "$icns" .icns).iconset"
+mkdir -p "$iconset"
+for size in 16 32 128 256 512
+do
+ size2x=$((size * 2))
+ rsvg-convert --output="$iconset/icon_${size}x${size}.png" \
+ --width=$size --height=$size "$svg"
+ rsvg-convert --output="$iconset/icon_${size}x${size}@2x.png" \
+ --width=$size2x --height=$size2x "$svg"
+done
+iconutil -c icns -o "$icns" "$iconset"
diff --git a/meson.build b/meson.build
index 5f1409e..8e282a0 100644
--- a/meson.build
+++ b/meson.build
@@ -18,6 +18,8 @@ add_project_arguments(
#endif
win32 = host_machine.system() == 'windows'
+macos = host_machine.system() == 'darwin' \
+ and host_machine.subsystem() == 'macos'
# The likelihood of this being installed is nearly zero. Enable the wrap.
libjpegqs = dependency('libjpegqs', required : get_option('libjpegqs'),
@@ -97,15 +99,20 @@ docdir = get_option('datadir') / 'doc' / meson.project_name()
application_ns = 'name.janouch.'
application_url = 'https://janouch.name/p/' + meson.project_name()
+rawconf = configuration_data({
+ 'ProjectName' : meson.project_name(),
+ 'ProjectVersion' : meson.project_version(),
+ 'ProjectNS' : application_ns,
+ 'ProjectURL' : application_url,
+})
+
conf = configuration_data()
conf.set_quoted('PROJECT_NAME', meson.project_name())
conf.set_quoted('PROJECT_VERSION', '@VCS_TAG@')
conf.set_quoted('PROJECT_NS', application_ns)
conf.set_quoted('PROJECT_URL', application_url)
-conf.set_quoted('PROJECT_DOCDIR', get_option('prefix') / docdir)
-if win32
- conf.set_quoted('PROJECT_DOCDIR', docdir)
-endif
+conf.set_quoted('PROJECT_PREFIX', get_option('prefix'))
+conf.set_quoted('PROJECT_DOCDIR', docdir)
conf.set('HAVE_JPEG_QS', libjpegqs.found())
conf.set('HAVE_LCMS2', lcms2.found())
@@ -147,6 +154,15 @@ if win32
output : 'fiv.ico', input : icon_png_list,
command : [icotool, '-c', '-o', '@OUTPUT@', '@INPUT@'])
rc += windows.compile_resources('fiv.rc', depends : icon_ico)
+elif macos
+ # Meson is really extremely brain-dead and retarded.
+ # There is no real reason why this would have to be a shell script.
+ svg2icns = find_program('macos-svg2icns.sh')
+ icon_icns = custom_target('fiv.icns',
+ output : 'fiv.icns', input : 'fiv.svg',
+ command : [svg2icns, '@INPUT@', '@OUTPUT@'],
+ install : true,
+ install_dir : 'Contents/Resources')
endif
gnome = import('gnome')
@@ -200,6 +216,9 @@ if get_option('tools').enabled()
c_args: tools_c_args)
endforeach
+ executable('benchmark-raw', 'tools/benchmark-raw.c',
+ objects : iolib,
+ dependencies : dependencies + tools_dependencies)
if gdkpixbuf.found()
executable('benchmark-io', 'tools/benchmark-io.c',
objects : iolib,
@@ -214,13 +233,12 @@ foreach schema : gsettings_schemas
input : schema,
output : application_ns + schema,
copy : true,
- install: true,
+ install : true,
install_dir : get_option('datadir') / 'glib-2.0' / 'schemas')
endforeach
# For the purposes of development: make the program find its GSettings schemas.
gnome.compile_schemas(depend_files : files(gsettings_schemas))
-gnome.post_install(glib_compile_schemas : true, gtk_update_icon_cache : true)
# Meson is broken on Windows and removes the backslashes, so this ends up empty.
symbolics = run_command(find_program('sed', required : false, disabler : true),
@@ -256,7 +274,26 @@ install_data('fiv.svg',
install_subdir('docs',
install_dir : docdir, strip_directory : true)
-if not win32
+if macos
+ # We're going all in on application bundles, seeing as it doesn't make
+ # much sense to install the application as in the block below.
+ #
+ # macOS has other mechanisms we can use to launch the JPEG cropper,
+ # or the reverse search utilities.
+ configure_file(
+ input : 'macos-Info.plist.in',
+ output : 'Info.plist',
+ configuration : rawconf,
+ install : true,
+ install_dir : 'Contents')
+
+ meson.add_install_script('macos-install.sh')
+elif not win32
+ gnome.post_install(
+ glib_compile_schemas : true,
+ gtk_update_icon_cache : true,
+ )
+
asciidoctor = find_program('asciidoctor', required : false)
a2x = find_program('a2x', required : false)
if not asciidoctor.found() and not a2x.found()
@@ -357,11 +394,7 @@ elif meson.is_cross_build()
wxs = configure_file(
input : 'fiv.wxs.in',
output : 'fiv.wxs',
- configuration : configuration_data({
- 'ProjectName' : meson.project_name(),
- 'ProjectVersion' : meson.project_version(),
- 'ProjectURL' : application_url,
- }),
+ configuration : rawconf,
)
msi = meson.project_name() + '-' + meson.project_version() + \
'-' + host_machine.cpu() + '.msi'
diff --git a/msys2-configure.sh b/msys2-configure.sh
index 7b7724e..8cbff30 100755
--- a/msys2-configure.sh
+++ b/msys2-configure.sh
@@ -130,6 +130,8 @@ setup() {
--bindir . --libdir . --cross-file="$toolchain" "$builddir" "$sourcedir"
}
+# Note: you may need GNU coreutils realpath for non-existent build directories
+# (macOS and busybox will probably not work).
sourcedir=$(realpath "${2:-$(dirname "$0")}")
builddir=$(realpath "${1:-builddir}")
toolchain=$builddir/msys2-cross-toolchain.meson
diff --git a/tools/benchmark-raw.c b/tools/benchmark-raw.c
new file mode 100644
index 0000000..a818efa
--- /dev/null
+++ b/tools/benchmark-raw.c
@@ -0,0 +1,428 @@
+//
+// benchmark-raw.c: measure loading times of raw images and their thumbnails
+//
+// This is a tool to help decide on criteria for fast thumbnail extraction.
+//
+// Copyright (c) 2023, 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 <gio/gio.h>
+#include <jv.h>
+#include <libraw.h>
+
+#include <stdbool.h>
+#include <time.h>
+
+#if LIBRAW_VERSION < LIBRAW_MAKE_VERSION(0, 21, 0)
+#error LibRaw 0.21.0 or newer is required.
+#endif
+
+#include "fiv-io.h"
+#include "fiv-thumbnail.h"
+
+// --- Analysis ----------------------------------------------------------------
+// Functions duplicated from info.h and benchmark-io.c.
+
+static jv
+add_to_subarray(jv o, const char *key, jv value)
+{
+ // Invalid values are not allocated, and we use up any valid one.
+ // Beware that jv_get() returns jv_null() rather than jv_invalid().
+ // Also, the header comment is lying, jv_is_valid() doesn't unreference.
+ jv a = jv_object_get(jv_copy(o), jv_string(key));
+ return jv_set(o, jv_string(key),
+ jv_is_valid(a) ? jv_array_append(a, value) : JV_ARRAY(value));
+}
+
+static jv
+add_warning(jv o, const char *message)
+{
+ return add_to_subarray(o, "warnings", jv_string(message));
+}
+
+static jv
+add_error(jv o, const char *message)
+{
+ return jv_object_set(o, jv_string("error"), jv_string(message));
+}
+
+static double
+timestamp(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return ts.tv_sec + ts.tv_nsec / 1.e9;
+}
+
+// --- Raw image files ---------------------------------------------------------
+
+static bool extract_mode = false;
+
+// Copied function from fiv-thumbnail.c.
+static FivIoImage *
+orient_thumbnail(FivIoImage *image)
+{
+ if (image->orientation <= FivIoOrientation0)
+ return image;
+
+ double w = 0, h = 0;
+ cairo_matrix_t matrix =
+ fiv_io_orientation_apply(image, image->orientation, &w, &h);
+ FivIoImage *oriented = fiv_io_image_new(image->format, w, h);
+ if (!oriented) {
+ g_warning("image allocation failure");
+ return image;
+ }
+
+ cairo_surface_t *surface = fiv_io_image_to_surface_noref(oriented);
+ cairo_t *cr = cairo_create(surface);
+ cairo_surface_destroy(surface);
+
+ surface = fiv_io_image_to_surface(image);
+ cairo_set_source_surface(cr, surface, 0, 0);
+ cairo_surface_destroy(surface);
+ cairo_pattern_set_matrix(cairo_get_source(cr), &matrix);
+ cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE);
+ cairo_paint(cr);
+ cairo_destroy(cr);
+ return oriented;
+}
+
+// Modified function from fiv-thumbnail.c.
+static FivIoImage *
+adjust_thumbnail(FivIoImage *thumbnail, double row_height)
+{
+ // Hardcode orientation.
+ FivIoOrientation orientation = thumbnail->orientation;
+
+ double w = 0, h = 0;
+ cairo_matrix_t matrix =
+ fiv_io_orientation_apply(thumbnail, orientation, &w, &h);
+
+ double scale_x = 1;
+ double scale_y = 1;
+ if (w > FIV_THUMBNAIL_WIDE_COEFFICIENT * h) {
+ scale_x = FIV_THUMBNAIL_WIDE_COEFFICIENT * row_height / w;
+ scale_y = round(scale_x * h) / h;
+ } else {
+ scale_y = row_height / h;
+ scale_x = round(scale_y * w) / w;
+ }
+
+ // NOTE: Ignoring renderable images.
+
+ if (orientation <= FivIoOrientation0 && scale_x == 1 && scale_y == 1)
+ return fiv_io_image_ref(thumbnail);
+
+ cairo_format_t format = thumbnail->format;
+ int projected_width = round(scale_x * w);
+ int projected_height = round(scale_y * h);
+ FivIoImage *scaled = fiv_io_image_new(
+ (format == CAIRO_FORMAT_RGB24 || format == CAIRO_FORMAT_RGB30)
+ ? CAIRO_FORMAT_RGB24
+ : CAIRO_FORMAT_ARGB32,
+ projected_width, projected_height);
+ if (!scaled)
+ return fiv_io_image_ref(thumbnail);
+
+ cairo_surface_t *surface = fiv_io_image_to_surface_noref(scaled);
+ cairo_t *cr = cairo_create(surface);
+ cairo_surface_destroy(surface);
+
+ cairo_scale(cr, scale_x, scale_y);
+
+ surface = fiv_io_image_to_surface_noref(thumbnail);
+ cairo_set_source_surface(cr, surface, 0, 0);
+ cairo_surface_destroy(surface);
+
+ cairo_pattern_t *pattern = cairo_get_source(cr);
+ // CAIRO_FILTER_BEST, for some reason, works bad with CAIRO_FORMAT_RGB30.
+ cairo_pattern_set_filter(pattern, CAIRO_FILTER_GOOD);
+ cairo_pattern_set_extend(pattern, CAIRO_EXTEND_PAD);
+ cairo_pattern_set_matrix(pattern, &matrix);
+
+ cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE);
+ cairo_paint(cr);
+
+ // NOTE: Ignoring silent cairo errors.
+
+ cairo_destroy(cr);
+ return scaled;
+}
+
+// Copied function from fiv-thumbnail.c.
+// LibRaw does a weird permutation here, so follow the documentation,
+// which assumes that mirrored orientations never happen.
+static FivIoOrientation
+extract_libraw_unflip(int flip)
+{
+ switch (flip) {
+ break; case 0:
+ return FivIoOrientation0;
+ break; case 3:
+ return FivIoOrientation180;
+ break; case 5:
+ return FivIoOrientation270;
+ break; case 6:
+ return FivIoOrientation90;
+ break; default:
+ return FivIoOrientationUnknown;
+ }
+}
+
+// Modified function from fiv-thumbnail.c.
+static FivIoImage *
+extract_libraw_bitmap(libraw_processed_image_t *image, int flip)
+{
+ // Anything else is extremely rare.
+ if (image->colors != 3 || image->bits != 8)
+ return NULL;
+
+ FivIoImage *I = fiv_io_image_new(
+ CAIRO_FORMAT_RGB24, image->width, image->height);
+ if (!I)
+ return NULL;
+
+ guint32 *out = (guint32 *) I->data;
+ const unsigned char *in = image->data;
+ for (guint64 i = 0; i < image->width * image->height; in += 3)
+ out[i++] = in[0] << 16 | in[1] << 8 | in[2];
+
+ I->orientation = extract_libraw_unflip(flip);
+ return I;
+}
+
+static jv
+process_thumbnail(
+ jv o, FivIoOpenContext *ctx, libraw_data_t *iprc, int i)
+{
+ double since = timestamp();
+
+ int err = 0;
+ if ((err = libraw_unpack_thumb_ex(iprc, i))) {
+ if (err != LIBRAW_NO_THUMBNAIL)
+ o = add_warning(o, libraw_strerror(err));
+ return o;
+ }
+
+ libraw_thumbnail_item_t *item = iprc->thumbs_list.thumblist + i;
+ jv to = JV_OBJECT(
+ jv_string("width"), jv_number(item->twidth),
+ jv_string("height"), jv_number(item->theight));
+
+ libraw_processed_image_t *image = libraw_dcraw_make_mem_thumb(iprc, &err);
+ if (!image) {
+ o = add_warning(o, libraw_strerror(err));
+ goto fail;
+ }
+
+ FivIoImage *I = NULL;
+ FivIoOrientation orientation = 0;
+ switch (image->type) {
+ break; case LIBRAW_IMAGE_JPEG:
+ I = fiv_io_open_from_data(
+ (const char *) image->data, image->data_size, ctx, NULL);
+ orientation = I->orientation;
+ break; case LIBRAW_IMAGE_BITMAP:
+ I = extract_libraw_bitmap(image, item->tflip);
+ orientation = I->orientation;
+ break; default:
+ o = add_warning(o, "unsupported embedded thumbnail");
+ }
+ if (!I)
+ goto fail_render;
+
+ if (item->tflip != 0xffff &&
+ extract_libraw_unflip(item->tflip) != orientation) {
+ gchar *m = g_strdup_printf("Orientation mismatch: tflip %d, Exif %d",
+ extract_libraw_unflip(item->tflip), orientation);
+ o = add_warning(o, m);
+ g_free(m);
+ }
+
+ double width = 0, height = 0;
+ fiv_io_orientation_dimensions(I, orientation, &width, &height);
+ to = jv_set(to, jv_string("width"), jv_number(width));
+ to = jv_set(to, jv_string("height"), jv_number(height));
+
+ to = jv_set(to, jv_string("pixels_percent"),
+ jv_number(100 * (width * height) /
+ ((float) iprc->sizes.iwidth * iprc->sizes.iheight)));
+
+ float main_ratio = (float) iprc->sizes.iwidth / iprc->sizes.iheight;
+ to = jv_set(to, jv_string("ratio_difference_percent"),
+ jv_number(fabs((main_ratio - width / height) * 100)));
+
+ // Resize, hardcode orientation. This may take a long time.
+ to = jv_set(to, jv_string("duration_decode_ms"),
+ jv_number((timestamp() - since) * 1000));
+ fiv_io_image_unref(adjust_thumbnail(I, 256.));
+ to = jv_set(to, jv_string("duration_ms"),
+ jv_number((timestamp() - since) * 1000));
+
+ // Luckily, large thumbnails are typically JPEGs, which don't need encoding.
+ gchar *path = NULL;
+ GError *error = NULL;
+ if (extract_mode && (path = g_filename_from_uri(ctx->uri, NULL, &error))) {
+ gchar *thumbnail_path = NULL;
+ if (image->type == LIBRAW_IMAGE_JPEG) {
+ thumbnail_path = g_strdup_printf("%s.thumb.%d.jpg", path, i);
+ g_file_set_contents(thumbnail_path,
+ (const char *) image->data, image->data_size, &error);
+ } else {
+ thumbnail_path = g_strdup_printf("%s.thumb.%d.webp", path, i);
+ I = orient_thumbnail(I);
+ fiv_io_save(I, I, NULL, thumbnail_path, &error);
+ }
+
+ g_clear_pointer(&thumbnail_path, g_free);
+ g_clear_pointer(&path, g_free);
+ }
+ if (error) {
+ o = add_warning(o, error->message);
+ g_clear_error(&error);
+ }
+
+ g_clear_pointer(&I, fiv_io_image_unref);
+fail_render:
+ libraw_dcraw_clear_mem(image);
+fail:
+ return add_to_subarray(o, "thumbnails", to);
+}
+
+static jv
+process_raw(jv o, const char *filename, const uint8_t *data, size_t len)
+{
+ libraw_data_t *iprc = libraw_init(LIBRAW_OPIONS_NO_DATAERR_CALLBACK);
+ if (!iprc)
+ return add_error(o, "failed to obtain a LibRaw handle");
+
+ // First, bail out if this isn't a raw image file.
+ int err = 0;
+ if ((err = libraw_open_buffer(iprc, data, len)) ||
+ (err = libraw_adjust_sizes_info_only(iprc))) {
+ o = add_error(o, libraw_strerror(err));
+ goto fail;
+ }
+
+ // Run our entire stack, like the render() function in fiv-thumbnail.c does.
+ // Note that this may use the TIFF/EP shortcut code.
+ double since = timestamp();
+ GFile *file = g_file_new_for_commandline_arg(filename);
+ FivIoCmm *cmm = fiv_io_cmm_get_default();
+ FivIoOpenContext ctx = {
+ .uri = g_file_get_uri(file),
+ .cmm = cmm,
+ .screen_profile = fiv_io_cmm_get_profile_sRGB(cmm),
+ .screen_dpi = 96,
+ .warnings = g_ptr_array_new_with_free_func(g_free),
+ };
+ g_clear_object(&file);
+
+ // This is really slow, let's decouple the mode from measurement a bit.
+ if (!extract_mode) {
+ GError *error = NULL;
+ FivIoImage *image =
+ fiv_io_open_from_data((const char *) data, len, &ctx, &error);
+ if (!image) {
+ o = add_error(o, error->message);
+ g_error_free(error);
+ goto fail_context;
+ }
+
+ // Resize, hardcode orientation. This may take a long time.
+ o = jv_set(o, jv_string("duration_decode_ms"),
+ jv_number((timestamp() - since) * 1000));
+ fiv_io_image_unref(adjust_thumbnail(image, 256.));
+ g_clear_pointer(&image, fiv_io_image_unref);
+
+ o = jv_set(o, jv_string("duration_ms"),
+ jv_number((timestamp() - since) * 1000));
+ }
+
+ o = jv_set(o, jv_string("thumbnails"), jv_array());
+ for (int i = 0; i < iprc->thumbs_list.thumbcount; i++)
+ o = process_thumbnail(o, &ctx, iprc, i);
+
+fail_context:
+ g_free((char *) ctx.uri);
+ if (ctx.screen_profile)
+ fiv_io_profile_free(ctx.screen_profile);
+
+ for (guint i = 0; i < ctx.warnings->len; i++)
+ o = add_warning(o, ctx.warnings->pdata[i]);
+ g_ptr_array_free(ctx.warnings, TRUE);
+
+fail:
+ libraw_close(iprc);
+ return o;
+}
+
+// --- I/O ---------------------------------------------------------------------
+
+static jv
+do_file(const char *filename, jv o)
+{
+ const char *err = NULL;
+ FILE *fp = fopen(filename, "rb");
+ if (!fp) {
+ err = strerror(errno);
+ goto error;
+ }
+
+ uint8_t *data = NULL, buf[256 << 10];
+ size_t n, len = 0;
+ while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) {
+ data = realloc(data, len + n);
+ memcpy(data + len, buf, n);
+ len += n;
+ }
+ if (ferror(fp)) {
+ err = strerror(errno);
+ goto error_read;
+ }
+
+ o = process_raw(o, filename, data, len);
+
+error_read:
+ fclose(fp);
+ free(data);
+error:
+ if (err)
+ o = add_error(o, err);
+ return o;
+}
+
+int
+main(int argc, char *argv[])
+{
+ // We don't need to call gdk_cairo_surface_create_from_pixbuf() here,
+ // so don't bother initializing GDK.
+
+ // A mode to just extract all thumbnails to files for closer inspection.
+ extract_mode = !!getenv("BENCHMARK_RAW_EXTRACT");
+
+ // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes.
+ // Usage: find . -type f -print0 | xargs -0 ./benchmark-raw
+ for (int i = 1; i < argc; i++) {
+ const char *filename = argv[i];
+
+ jv o = jv_object();
+ o = jv_object_set(o, jv_string("filename"), jv_string(filename));
+ o = do_file(filename, o);
+ jv_dumpf(o, stdout, 0 /* Might consider JV_PRINT_SORTED. */);
+ fputc('\n', stdout);
+ }
+ return 0;
+}