commit f5defee322437ec0c63a9d2872b7ef10936e9140
parent 022ad164b6516bbb4c5109e45c68248c17ff7115
Author: lumidify <nobody@lumidify.org>
Date:   Tue, 14 May 2024 16:55:16 +0200
Add selectool
Diffstat:
| M | .gitignore |  |  | 1 | + | 
| M | CHANGELOG |  |  | 6 | ++++++ | 
| M | LICENSE |  |  | 2 | +- | 
| M | Makefile |  |  | 28 | +++++++++++++++++++++------- | 
| M | README |  |  | 12 | +++++++++++- | 
| M | TODO |  |  | 4 | ++++ | 
| A | common.c |  |  | 332 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | common.h |  |  | 83 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| M | croptool.1 |  |  | 14 | +++++++++----- | 
| M | croptool.c |  |  | 489 | ++++++++++++++++--------------------------------------------------------------- | 
| A | selectool.1 |  |  | 140 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | selectool.c |  |  | 423 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
12 files changed, 1131 insertions(+), 403 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,2 +1,3 @@
 croptool
 croptool_crop
+selectool
diff --git a/CHANGELOG b/CHANGELOG
@@ -1,3 +1,9 @@
+1.2.1 -> 1.3.0-dev
+* IMPORTANT: Change behavior of croptool so it only prints the
+  cropping commands when exited by pressing 'q'
+* Add selectool
+* Fix compilation and linking on some systems
+
 1.2.0 -> 1.2.1
 * Fix minor style issues
 * Fix minor bug in parsing of cropping rectangle in croptool_crop
diff --git a/LICENSE b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021-2023 lumidify <nobody@lumidify.org>
+Copyright (c) 2021-2024 lumidify <nobody@lumidify.org>
 
 Permission to use, copy, modify, and/or distribute this software for any
 purpose with or without fee is hereby granted, provided that the above
diff --git a/Makefile b/Makefile
@@ -1,15 +1,18 @@
 .POSIX:
 
 NAME = croptool
-VERSION = 1.2.1
+VERSION = 1.3.0-dev
 
 PREFIX = /usr/local
 MANPREFIX = ${PREFIX}/man
 
-BIN = ${NAME} croptool_crop
+BIN = ${NAME} croptool_crop selectool
 SRC = ${BIN:=.c}
 MAN1 = ${BIN:=.1}
-MISCFILES = Makefile README CHANGELOG LICENSE TODO
+MISCFILES = Makefile README CHANGELOG LICENSE TODO common.c common.h
+
+DEBUG = 0
+SANITIZE = 0
 
 # Configuration options:
 
@@ -20,14 +23,25 @@ DB_LDFLAGS = `pkg-config --libs xext`
 #DB_CFLAGS = -DNODB
 #DB_LDFLAGS =
 
+EXTRA_CFLAGS_DEBUG0 =
+EXTRA_CFLAGS_DEBUG1 = -g
+EXTRA_FLAGS_SANITIZE0 =
+EXTRA_FLAGS_SANITIZE1 = -fsanitize=address,undefined
+
 # Note: Older systems might need `imlib2-config --cflags` and `imlib2-config --libs` instead of pkg-config.
-CROP_CFLAGS = ${CFLAGS} ${DB_CFLAGS} -Wall -Wextra -D_POSIX_C_SOURCE=200809L `pkg-config --cflags x11 imlib2`
-CROP_LDFLAGS = ${LDFLAGS} ${DB_LDFLAGS} `pkg-config --libs x11 imlib2` -lm
+CROP_CFLAGS = ${CFLAGS} ${DB_CFLAGS} ${EXTRA_FLAGS_SANITIZE${SANITIZE}} ${EXTRA_CFLAGS_DEBUG${DEBUG}} -Wall -Wextra -pedantic -D_POSIX_C_SOURCE=200809L `pkg-config --cflags x11 imlib2`
+CROP_LDFLAGS = ${LDFLAGS} ${DB_LDFLAGS} ${EXTRA_FLAGS_SANITIZE${SANITIZE}} `pkg-config --libs x11 imlib2` -lm
 
 all: ${BIN}
 
-.c:
-	${CC} -o $@ $< ${CROP_CFLAGS} ${CROP_LDFLAGS}
+croptool: croptool.c common.c common.h
+	${CC} -o $@ croptool.c common.c ${CROP_CFLAGS} ${CROP_LDFLAGS}
+
+selectool: selectool.c common.c common.h
+	${CC} -o $@ selectool.c common.c ${CROP_CFLAGS} ${CROP_LDFLAGS}
+
+croptool_crop: croptool_crop.c
+	${CC} -o $@ croptool_crop.c ${CROP_CFLAGS} ${CROP_LDFLAGS}
 
 install: all
 	mkdir -p "${DESTDIR}${PREFIX}/bin"
diff --git a/README b/README
@@ -1,4 +1,5 @@
 croptool - mass image cropping tool
+selectool - image selection tool
 
 REQUIREMENTS: xlib, imlib2
 OPTIONAL: xext (for double-buffering extension)
@@ -6,6 +7,15 @@ OPTIONAL: xext (for double-buffering extension)
 croptool is a simple tool to select cropping rectangles
 on images and print out a command to crop each image.
 
+selectool is a simple tool to select images and output
+a command for each selected image. It is mainly meant
+to help quickly delete images that have been recovered
+using programs like photorec or foremost.
+
 See Makefile for compile-time options.
 
-See croptool.1 and croptool_crop.1 for usage information.
+See croptool.1, croptool_crop.1, and selectool.1 for usage information.
+
+Note: I know the names aren't very creative and might
+cause issues if this ever makes its way into any package
+repositories. Let me know if you have any better ideas.
diff --git a/TODO b/TODO
@@ -1,4 +1,8 @@
 * Proper path handling (allow paths including "'", etc.)
+  - Option 1: Implement command parsing inside croptool/selectool
+    and call commands directly instead of printing them.
+  - Option 2: Add option 'escape chars' for characters that
+    must be escaped in the filename (kind of hacky).
 * Draw pixmap on exposure events instead of doing the
   expensive image resizing each time
 * Maybe add zooming support
diff --git a/common.c b/common.c
@@ -0,0 +1,332 @@
+/*
+ * Copyright (c) 2021-2024 lumidify <nobody@lumidify.org>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * 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 <stdio.h>
+#include <errno.h>
+#include <string.h>
+#include <stdlib.h>
+#include <limits.h>
+
+#include <X11/X.h>
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#ifndef NODB
+#include <X11/extensions/Xdbe.h>
+#endif
+
+#include <Imlib2.h>
+
+#include "common.h"
+
+void
+setup_x(GraphicsContext *ctx, int window_w, int window_h, int line_width, int cache_size) {
+	XSetWindowAttributes attrs;
+	XGCValues gcv;
+
+	ctx->dpy = XOpenDisplay(NULL);
+	ctx->screen = DefaultScreen(ctx->dpy);
+	ctx->vis = DefaultVisual(ctx->dpy, ctx->screen);
+	ctx->depth = DefaultDepth(ctx->dpy, ctx->screen);
+	ctx->cm = DefaultColormap(ctx->dpy, ctx->screen);
+	ctx->dirty = 1;
+
+	#ifndef NODB
+	ctx->db_enabled = 0;
+	/* based on http://wili.cc/blog/xdbe.html */
+	int major, minor;
+	if (XdbeQueryExtension(ctx->dpy, &major, &minor)) {
+		int num_screens = 1;
+		Drawable screens[] = { DefaultRootWindow(ctx->dpy) };
+		XdbeScreenVisualInfo *info = XdbeGetVisualInfo(
+		    ctx->dpy, screens, &num_screens
+		);
+		if (!info || num_screens < 1 || info->count < 1) {
+			fprintf(stderr,
+			    "Warning: No visuals support Xdbe, "
+			    "double buffering disabled.\n"
+			);
+		} else {
+			XVisualInfo xvisinfo_templ;
+			xvisinfo_templ.visualid = info->visinfo[0].visual;
+			xvisinfo_templ.screen = 0;
+			xvisinfo_templ.depth = info->visinfo[0].depth;
+			int matches;
+			XVisualInfo *xvisinfo_match = XGetVisualInfo(
+			    ctx->dpy,
+			    VisualIDMask | VisualScreenMask | VisualDepthMask,
+			    &xvisinfo_templ, &matches
+			);
+			if (!xvisinfo_match || matches < 1) {
+				fprintf(stderr,
+				    "Warning: Couldn't match a Visual with "
+				    "double buffering, double buffering disabled.\n"
+				);
+			} else {
+				ctx->vis = xvisinfo_match->visual;
+				ctx->depth = xvisinfo_match->depth;
+				ctx->db_enabled = 1;
+			}
+			XFree(xvisinfo_match);
+		}
+		XdbeFreeVisualInfo(info);
+	} else {
+		fprintf(stderr, "Warning: No Xdbe support, double buffering disabled.\n");
+	}
+	#endif
+
+	memset(&attrs, 0, sizeof(attrs));
+	attrs.background_pixel = BlackPixel(ctx->dpy, ctx->screen);
+	attrs.colormap = ctx->cm;
+	/* this causes the window contents to be kept
+	 * when it is resized, leading to less flicker */
+	attrs.bit_gravity = NorthWestGravity;
+	ctx->win = XCreateWindow(ctx->dpy, DefaultRootWindow(ctx->dpy), 0, 0,
+	    window_w, window_h, 0, ctx->depth,
+	    InputOutput, ctx->vis, CWBackPixel | CWColormap | CWBitGravity, &attrs);
+
+	#ifndef NODB
+	if (ctx->db_enabled) {
+		ctx->back_buf = XdbeAllocateBackBufferName(
+		    ctx->dpy, ctx->win, XdbeCopied
+		);
+		ctx->drawable = ctx->back_buf;
+	} else {
+		ctx->drawable = ctx->win;
+	}
+	#else
+	ctx->drawable = ctx->win;
+	#endif
+
+	memset(&gcv, 0, sizeof(gcv));
+	gcv.line_width = line_width;
+	ctx->gc = XCreateGC(ctx->dpy, ctx->win, GCLineWidth, &gcv);
+
+	XSelectInput(
+	    ctx->dpy, ctx->win,
+	    StructureNotifyMask | KeyPressMask | ButtonPressMask |
+	    ButtonReleaseMask | PointerMotionMask | ExposureMask
+	);
+
+	ctx->wm_delete_msg = XInternAtom(ctx->dpy, "WM_DELETE_WINDOW", False);
+	XSetWMProtocols(ctx->dpy, ctx->win, &ctx->wm_delete_msg, 1);
+
+	/* note: since cache_size is <= 1024, this definitely fits in long */
+	long cs = (long)cache_size * 1024 * 1024;
+	if (cs > INT_MAX) {
+		fprintf(stderr, "Cache size would cause integer overflow.\n");
+		exit(1);
+	}
+	imlib_set_cache_size((int)cs);
+	imlib_set_color_usage(128);
+	imlib_context_set_dither(1);
+	imlib_context_set_display(ctx->dpy);
+	imlib_context_set_visual(ctx->vis);
+	imlib_context_set_colormap(ctx->cm);
+	imlib_context_set_drawable(ctx->drawable);
+	ctx->updates = imlib_updates_init();
+	ctx->cur_image = NULL;
+}
+
+void
+cleanup_x(GraphicsContext *ctx) {
+	if (ctx->cur_image) {
+		imlib_context_set_image(ctx->cur_image);
+		imlib_free_image();
+	}
+	XDestroyWindow(ctx->dpy, ctx->win);
+	XCloseDisplay(ctx->dpy);
+}
+
+int
+parse_int(const char *str, int min, int max, int *value) {
+	char *end;
+	long l = strtol(str, &end, 10);
+	if (min > max)
+		return 1;
+	if (str == end || *end != '\0') {
+		return 1;
+	} else if (l < min || l > max || ((l == LONG_MIN ||
+	    l == LONG_MAX) && errno == ERANGE)) {
+		return 1;
+	}
+	*value = (int)l;
+
+	return 0;
+}
+
+void
+queue_area_update(GraphicsContext *ctx, ImageSize *sz, int x, int y, int w, int h) {
+	if (x > sz->scaled_w || y > sz->scaled_h)
+		return;
+	ctx->updates = imlib_update_append_rect(
+	    ctx->updates, x, y,
+	    w + x > sz->scaled_w ? sz->scaled_w - x : w,
+	    h + y > sz->scaled_h ? sz->scaled_h - y : h
+	);
+	ctx->dirty = 1;
+}
+
+void
+clear_screen(GraphicsContext *ctx) {
+
+	/* clear the window completely */
+	XSetForeground(ctx->dpy, ctx->gc, BlackPixel(ctx->dpy, ctx->screen));
+	XFillRectangle(
+	    ctx->dpy, ctx->drawable, ctx->gc,
+	    0, 0, ctx->window_w, ctx->window_h
+	);
+}
+
+void
+draw_image_updates(GraphicsContext *ctx, ImageSize *sz) {
+	Imlib_Image buffer;
+	Imlib_Updates current_update;
+
+	/* draw the parts of the image that need to be redrawn */
+	ctx->updates = imlib_updates_merge_for_rendering(
+	    ctx->updates, sz->scaled_w, sz->scaled_h
+	);
+	/* FIXME: check since when imlib_render_image_updates_on_drawable supported, also maybe just render_on_drawable part scaled */
+	for (current_update = ctx->updates; current_update;
+	    current_update = imlib_updates_get_next(current_update)) {
+		int up_x, up_y, up_w, up_h;
+		imlib_updates_get_coordinates(current_update, &up_x, &up_y, &up_w, &up_h);
+		buffer = imlib_create_image(up_w, up_h);
+		imlib_context_set_blend(0);
+		imlib_context_set_image(buffer);
+		imlib_blend_image_onto_image(
+		    ctx->cur_image, 0, 0, 0,
+		    sz->orig_w, sz->orig_h,
+		    -up_x, -up_y,
+		    sz->scaled_w, sz->scaled_h);
+		imlib_render_image_on_drawable(up_x, up_y);
+		imlib_free_image();
+	}
+	if (ctx->updates)
+		imlib_updates_free(ctx->updates);
+	ctx->updates = imlib_updates_init();
+}
+
+void
+wipe_around_image(GraphicsContext *ctx, ImageSize *sz) {
+
+	/* wipe the black area around the image */
+	XSetForeground(ctx->dpy, ctx->gc, BlackPixel(ctx->dpy, ctx->screen));
+	XFillRectangle(
+	    ctx->dpy, ctx->drawable, ctx->gc,
+	    0, sz->scaled_h, sz->scaled_w, ctx->window_h - sz->scaled_h
+	);
+	XFillRectangle(
+	    ctx->dpy, ctx->drawable, ctx->gc,
+	    sz->scaled_w, 0, ctx->window_w - sz->scaled_w, ctx->window_h
+	);
+}
+
+void
+swap_buffers(GraphicsContext *ctx) {
+	#ifndef NODB
+	if (ctx->db_enabled) {
+		XdbeSwapInfo swap_info;
+		swap_info.swap_window = ctx->win;
+		swap_info.swap_action = XdbeCopied;
+
+		if (!XdbeSwapBuffers(ctx->dpy, &swap_info, 1))
+			fprintf(stderr, "Warning: Unable to swap buffers.\n");
+	}
+	#endif
+	ctx->dirty = 0;
+}
+
+/* get the scaled size of an image based on the current window size */
+void
+get_scaled_size(GraphicsContext *ctx, int orig_w, int orig_h, int *scaled_w, int *scaled_h) {
+	double scale_w, scale_h;
+	scale_w = (double)ctx->window_w / (double)orig_w;
+	scale_h = (double)ctx->window_h / (double)orig_h;
+	if (orig_w <= ctx->window_w && orig_h <= ctx->window_h) {
+		*scaled_w = orig_w;
+		*scaled_h = orig_h;
+	} else if (scale_w * orig_h > ctx->window_h) {
+		*scaled_w = (int)(scale_h * orig_w);
+		*scaled_h = ctx->window_h;
+	} else {
+		*scaled_w = ctx->window_w;
+		*scaled_h = (int)(scale_w * orig_h);
+	}
+}
+
+void
+next_picture(int cur_selection, char **filenames, int num_files, int copy_box) {
+	if (cur_selection + 1 >= num_files)
+		return;
+	Imlib_Image tmp_image = NULL;
+	int tmp_cur_selection = cur_selection;
+	/* loop until we find a loadable file */
+	while (!tmp_image && tmp_cur_selection + 1 < num_files) {
+		tmp_cur_selection++;
+		if (!filenames[tmp_cur_selection])
+			continue;
+		tmp_image = imlib_load_image_immediately(
+		    filenames[tmp_cur_selection]
+		);
+		if (!tmp_image) {
+			fprintf(
+				stderr, "Warning: Unable to load image '%s'.\n",
+				filenames[tmp_cur_selection]
+			);
+			filenames[tmp_cur_selection] = NULL;
+		}
+	}
+	/* immediately exit program if no loadable image is found on startup */
+	if (cur_selection < 0 && !tmp_image) {
+		fprintf(stderr, "No loadable images found.\n");
+		cleanup();
+		exit(1);
+	}
+	if (!tmp_image)
+		return;
+
+	change_picture(tmp_image, tmp_cur_selection, copy_box);
+}
+
+void
+last_picture(int cur_selection, char **filenames, int copy_box) {
+	if (cur_selection <= 0)
+		return;
+	Imlib_Image tmp_image = NULL;
+	int tmp_cur_selection = cur_selection;
+	/* loop until we find a loadable file */
+	while (!tmp_image && tmp_cur_selection > 0) {
+		tmp_cur_selection--;
+		if (!filenames[tmp_cur_selection])
+			continue;
+		tmp_image = imlib_load_image_immediately(
+		    filenames[tmp_cur_selection]
+		);
+		if (!tmp_image) {
+			fprintf(
+				stderr, "Warning: Unable to load image '%s'.\n",
+				filenames[tmp_cur_selection]
+			);
+			filenames[tmp_cur_selection] = NULL;
+		}
+	}
+
+	if (!tmp_image)
+		return;
+
+	change_picture(tmp_image, tmp_cur_selection, copy_box);
+}
diff --git a/common.h b/common.h
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2021-2024 lumidify <nobody@lumidify.org>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * 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.
+ */
+
+#ifndef CROPTOOL_COMMON
+#define CROPTOOL_COMMON
+
+#include <X11/X.h>
+#include <X11/Xlib.h>
+#ifndef NODB
+#include <X11/extensions/Xdbe.h>
+#endif
+#include <Imlib2.h>
+
+typedef struct {
+        int orig_w;
+        int orig_h;
+        int scaled_w;
+        int scaled_h;
+} ImageSize;
+
+typedef struct {
+        Display *dpy;
+        GC gc;
+        Window win;
+        Visual *vis;
+        Drawable drawable;
+        #ifndef NODB
+        XdbeBackBuffer back_buf;
+        int db_enabled;
+        #endif
+        Colormap cm;
+        int screen;
+        int depth;
+
+        int window_w;
+        int window_h;
+        char dirty;
+        Atom wm_delete_msg;
+        Imlib_Image cur_image;
+        Imlib_Updates updates;
+} GraphicsContext;
+
+void setup_x(GraphicsContext *ctx, int window_w, int window_h, int line_height, int cache_size);
+void cleanup_x(GraphicsContext *ctx);
+/* Parse integer between min and max (inclusive).
+   Returns 1 on error, 0 otherwise.
+   The result is stored in *value.
+   Based on OpenBSD's strtonum. */
+int parse_int(const char *str, int min, int max, int *value);
+/* queue a part of the image for redrawing */
+void queue_area_update(GraphicsContext *ctx, ImageSize *sz, int x, int y, int w, int h);
+void clear_screen(GraphicsContext *ctx);
+void draw_image_updates(GraphicsContext *ctx, ImageSize *sz);
+void wipe_around_image(GraphicsContext *ctx, ImageSize *sz);
+void swap_buffers(GraphicsContext *ctx);
+void get_scaled_size(GraphicsContext *ctx, int orig_w, int orig_h, int *scaled_w, int *scaled_h);
+/* show the next image in the argument list - unloadable files are skipped
+ * copy_box determines whether the current selection is copied
+ * (only relevant in croptool, not in selectool) */
+void next_picture(int cur_selection, char **filenames, int num_files, int copy_box);
+/* show the previous image in the argument list - unloadable files are skipped
+ * copy_box determines whether the current selection is copied
+ * (only relevant in croptool, not in selectool) */
+void last_picture(int cur_selection, char **filenames, int copy_box);
+
+/* these are actually defined in croptool.c and selectool.c */
+void cleanup(void);
+void change_picture(Imlib_Image new_image, int new_selection, int copy_box);
+
+#endif /* CROPTOOL_COMMON */
diff --git a/croptool.1 b/croptool.1
@@ -1,4 +1,4 @@
-.Dd August 18, 2023
+.Dd May 14, 2024
 .Dt CROPTOOL 1
 .Os
 .Sh NAME
@@ -113,15 +113,18 @@ though the pixels covered in the original image are different.
 Go to the previous image, copying the current cropping rectangle.
 The same caveat as above applies.
 .It TAB
-Switch the color of the cropping rectangle between the primary and secondary colors.
+Switch the color of the cropping rectangle between the primary and
+secondary colors.
 .It DELETE
-Delete the cropping rectangle of the current image.
+Remove the cropping rectangle of the current image.
 .It SPACE
 Redraw the window.
 This is useful when automatic redrawing is disabled with
 .Fl m .
 .It q
-Exit the program.
+Exit the program, printing the cropping command for any images with a
+cropping rectangle set.
+If the window is closed through some other means, no commands are printed.
 .El
 .Sh MOUSE ACTIONS
 .Bl -tag -width Ds
@@ -168,7 +171,8 @@ filenames containing quotes).
 .Sh SEE ALSO
 .Xr convert 1 ,
 .Xr croptool_crop 1 ,
-.Xr mogrify 1
+.Xr mogrify 1 ,
+.Xr selectool 1
 .Sh AUTHORS
 .An lumidify Aq Mt nobody@lumidify.org
 .Sh BUGS
diff --git a/croptool.c b/croptool.c
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2021-2023 lumidify <nobody@lumidify.org>
+ * Copyright (c) 2021-2024 lumidify <nobody@lumidify.org>
  *
  * Permission to use, copy, modify, and/or distribute this software for any
  * purpose with or without fee is hereby granted, provided that the above
@@ -16,21 +16,19 @@
 
 #include <math.h>
 #include <stdio.h>
-#include <errno.h>
-#include <string.h>
 #include <stdlib.h>
-#include <limits.h>
 #include <unistd.h>
+
+#include <X11/X.h>
 #include <X11/Xlib.h>
 #include <X11/Xutil.h>
 #include <X11/keysym.h>
-#include <X11/XF86keysym.h>
 #include <X11/cursorfont.h>
-#ifndef NODB
-#include <X11/extensions/Xdbe.h>
-#endif
+
 #include <Imlib2.h>
 
+#include "common.h"
+
 /* The number of pixels to check on each side when checking
  * if a corner or edge of the selection box was clicked 
  * (in order to change the size of the box) */
@@ -77,47 +75,28 @@ struct Point {
 
 struct Selection {
 	struct Rect rect;
-	int orig_w;
-	int orig_h;
-	int scaled_w;
-	int scaled_h;
+	ImageSize sz;
 	char valid;
 };
 
 static struct {
-	Display *dpy;
-	GC gc;
-	Window win;
-	Visual *vis;
-	Drawable drawable;
-	#ifndef NODB
-	XdbeBackBuffer back_buf;
-	int db_enabled;
-	#endif
-	Colormap cm;
-	int screen;
-	int depth;
+	GraphicsContext ctx;
 
 	struct Selection *selections;
 	char **filenames;
 	int cur_selection;
 	int num_files;
-	int window_w;
-	int window_h;
 	int cursor_x;
 	int cursor_y;
 	struct Point move_handle;
+	XColor col1;
+	XColor col2;
+	int cur_col;
 	char moving;
 	char resizing;
 	char lock_x;
 	char lock_y;
-	char dirty;
-	XColor col1;
-	XColor col2;
-	int cur_col;
-	Atom wm_delete_msg;
-	Imlib_Image cur_image;
-	Imlib_Updates updates;
+	char print_on_exit;
 } state;
 
 static struct {
@@ -135,7 +114,6 @@ static struct {
 static void usage(void);
 static void mainloop(void);
 static void setup(int argc, char *argv[]);
-static void cleanup(void);
 static void sort_coordinates(int *x0, int *y0, int *x1, int *y1);
 static void swap(int *a, int *b);
 static void redraw(void);
@@ -146,14 +124,11 @@ static int collide_line(int x, int y, int x0, int y0, int x1, int y1);
 static int collide_rect(int x, int y, struct Rect rect);
 static void switch_color(void);
 static void clear_selection(void);
-static void next_picture(int copy_box);
-static void last_picture(int copy_box);
-static void change_picture(Imlib_Image new_image, int new_selection, int copy_box);
-static void get_scaled_size(int orig_w, int orig_h, int *scaled_w, int *scaled_h);
 static void set_selection(
     struct Selection *sel, int rect_x0, int rect_y0, int rect_x1,
     int rect_y1, int orig_w, int orig_h, int scaled_w, int scaled_h
 );
+static void queue_update(int x, int y, int w, int h);
 static void queue_rectangle_redraw(int x0, int y0, int x1, int y1);
 static void set_cursor(struct Rect rect);
 static void drag_motion(XEvent event);
@@ -161,8 +136,6 @@ static void resize_window(int w, int h);
 static void button_release(void);
 static void button_press(XEvent event);
 static int key_press(XEvent event);
-static void queue_update(int x, int y, int w, int h);
-static int parse_int(const char *str, int min, int max, int *value);
 
 static void
 usage(void) {
@@ -229,9 +202,11 @@ main(int argc, char *argv[]) {
 
 	mainloop();
 
-	for (int i = 0; i < argc; i++) {
-		if (state.selections[i].valid) {
-			print_selection(&state.selections[i], state.filenames[i]);
+	if (state.print_on_exit) {
+		for (int i = 0; i < argc; i++) {
+			if (state.selections[i].valid) {
+				print_selection(&state.selections[i], state.filenames[i]);
+			}
 		}
 	}
 
@@ -247,7 +222,7 @@ mainloop(void) {
 
 	while (running) {
 		do {
-			XNextEvent(state.dpy, &event);
+			XNextEvent(state.ctx.dpy, &event);
 			switch (event.type) {
 			case Expose:
 				if (RESIZE_REDRAW)
@@ -276,12 +251,12 @@ mainloop(void) {
 				running = key_press(event);
 				break;
 			case ClientMessage:
-				if ((Atom)event.xclient.data.l[0] == state.wm_delete_msg)
+				if ((Atom)event.xclient.data.l[0] == state.ctx.wm_delete_msg)
 					running = 0;
 			default:
 				break;
 			}
-		} while (XPending(state.dpy));
+		} while (XPending(state.ctx.dpy));
 
 		redraw();
 	}
@@ -289,10 +264,6 @@ mainloop(void) {
 
 static void
 setup(int argc, char *argv[]) {
-	XSetWindowAttributes attrs;
-	XGCValues gcv;
-
-	state.cur_image = NULL;
 	state.selections = malloc(argc * sizeof(struct Selection));
 	if (!state.selections) {
 		fprintf(stderr, "Unable to allocate memory.\n");
@@ -305,155 +276,50 @@ setup(int argc, char *argv[]) {
 	state.resizing = 0;
 	state.lock_x = 0;
 	state.lock_y = 0;
-	state.window_w = 500;
-	state.window_h = 500;
 	state.cursor_x = 0;
 	state.cursor_y = 0;
 	state.cur_col = 1;
+	state.print_on_exit = 0;
 
 	for (int i = 0; i < argc; i++) {
 		state.selections[i].valid = 0;
 	}
 
-	state.dpy = XOpenDisplay(NULL);
-	state.screen = DefaultScreen(state.dpy);
-	state.vis = DefaultVisual(state.dpy, state.screen);
-	state.depth = DefaultDepth(state.dpy, state.screen);
-	state.cm = DefaultColormap(state.dpy, state.screen);
-
-	#ifndef NODB
-	state.db_enabled = 0;
-	/* based on http://wili.cc/blog/xdbe.html */
-	int major, minor;
-	if (XdbeQueryExtension(state.dpy, &major, &minor)) {
-		int num_screens = 1;
-		Drawable screens[] = { DefaultRootWindow(state.dpy) };
-		XdbeScreenVisualInfo *info = XdbeGetVisualInfo(
-		    state.dpy, screens, &num_screens
-		);
-		if (!info || num_screens < 1 || info->count < 1) {
-			fprintf(stderr,
-			    "Warning: No visuals support Xdbe, "
-			    "double buffering disabled.\n"
-			);
-		} else {
-			XVisualInfo xvisinfo_templ;
-			xvisinfo_templ.visualid = info->visinfo[0].visual;
-			xvisinfo_templ.screen = 0;
-			xvisinfo_templ.depth = info->visinfo[0].depth;
-			int matches;
-			XVisualInfo *xvisinfo_match = XGetVisualInfo(
-			    state.dpy,
-			    VisualIDMask | VisualScreenMask | VisualDepthMask,
-			    &xvisinfo_templ, &matches
-			);
-			if (!xvisinfo_match || matches < 1) {
-				fprintf(stderr,
-				    "Warning: Couldn't match a Visual with "
-				    "double buffering, double buffering disabled.\n"
-				);
-			} else {
-				state.vis = xvisinfo_match->visual;
-				state.depth = xvisinfo_match->depth;
-				state.db_enabled = 1;
-			}
-			XFree(xvisinfo_match);
-		}
-		XdbeFreeVisualInfo(info);
-	} else {
-		fprintf(stderr, "Warning: No Xdbe support, double buffering disabled.\n");
-	}
-	#endif
-
-	memset(&attrs, 0, sizeof(attrs));
-	attrs.background_pixel = BlackPixel(state.dpy, state.screen);
-	attrs.colormap = state.cm;
-	/* this causes the window contents to be kept
-	 * when it is resized, leading to less flicker */
-	attrs.bit_gravity = NorthWestGravity;
-	state.win = XCreateWindow(state.dpy, DefaultRootWindow(state.dpy), 0, 0,
-	    state.window_w, state.window_h, 0, state.depth,
-	    InputOutput, state.vis, CWBackPixel | CWColormap | CWBitGravity, &attrs);
+	setup_x(&state.ctx, 500, 500, LINE_WIDTH, CACHE_SIZE);
 
-	#ifndef NODB
-	if (state.db_enabled) {
-		state.back_buf = XdbeAllocateBackBufferName(
-		    state.dpy, state.win, XdbeCopied
-		);
-		state.drawable = state.back_buf;
-	} else {
-		state.drawable = state.win;
-	}
-	#else
-	state.drawable = state.win;
-	#endif
-
-	memset(&gcv, 0, sizeof(gcv));
-	gcv.line_width = LINE_WIDTH;
-	state.gc = XCreateGC(state.dpy, state.win, GCLineWidth, &gcv);
-
-        if (!XParseColor(state.dpy, state.cm, SELECTION_COLOR1, &state.col1)) {
+        if (!XParseColor(state.ctx.dpy, state.ctx.cm, SELECTION_COLOR1, &state.col1)) {
 		fprintf(stderr, "Primary color invalid.\n");
 		exit(1);
 	}
-        XAllocColor(state.dpy, state.cm, &state.col1);
-        if (!XParseColor(state.dpy, state.cm, SELECTION_COLOR2, &state.col2)) {
+        XAllocColor(state.ctx.dpy, state.ctx.cm, &state.col1);
+        if (!XParseColor(state.ctx.dpy, state.ctx.cm, SELECTION_COLOR2, &state.col2)) {
 		fprintf(stderr, "Secondary color invalid.\n");
 		exit(1);
 	}
-        XAllocColor(state.dpy, state.cm, &state.col2);
-
-	XSelectInput(
-	    state.dpy, state.win,
-	    StructureNotifyMask | KeyPressMask | ButtonPressMask |
-	    ButtonReleaseMask | PointerMotionMask | ExposureMask
-	);
-
-	state.wm_delete_msg = XInternAtom(state.dpy, "WM_DELETE_WINDOW", False);
-	XSetWMProtocols(state.dpy, state.win, &state.wm_delete_msg, 1);
-
-	cursors.top = XCreateFontCursor(state.dpy, XC_top_side);
-	cursors.bottom = XCreateFontCursor(state.dpy, XC_bottom_side);
-	cursors.left = XCreateFontCursor(state.dpy, XC_left_side);
-	cursors.right = XCreateFontCursor(state.dpy, XC_right_side);
-	cursors.topleft = XCreateFontCursor(state.dpy, XC_top_left_corner);
-	cursors.topright = XCreateFontCursor(state.dpy, XC_top_right_corner);
-	cursors.bottomleft = XCreateFontCursor(state.dpy, XC_bottom_left_corner);
-	cursors.bottomright = XCreateFontCursor(state.dpy, XC_bottom_right_corner);
-	cursors.grab = XCreateFontCursor(state.dpy, XC_fleur);
-
-	/* note: since CACHE_SIZE is <= 1024, this definitely fits in long */
-	long cs = (long)CACHE_SIZE * 1024 * 1024;
-	if (cs > INT_MAX) {
-		fprintf(stderr, "Cache size would cause integer overflow.\n");
-		exit(1);
-	}
-	imlib_set_cache_size((int)cs);
-	imlib_set_color_usage(128);
-	imlib_context_set_dither(1);
-	imlib_context_set_display(state.dpy);
-	imlib_context_set_visual(state.vis);
-	imlib_context_set_colormap(state.cm);
-	imlib_context_set_drawable(state.drawable);
-	state.updates = imlib_updates_init();
-
-	next_picture(0);
+        XAllocColor(state.ctx.dpy, state.ctx.cm, &state.col2);
+
+	cursors.top = XCreateFontCursor(state.ctx.dpy, XC_top_side);
+	cursors.bottom = XCreateFontCursor(state.ctx.dpy, XC_bottom_side);
+	cursors.left = XCreateFontCursor(state.ctx.dpy, XC_left_side);
+	cursors.right = XCreateFontCursor(state.ctx.dpy, XC_right_side);
+	cursors.topleft = XCreateFontCursor(state.ctx.dpy, XC_top_left_corner);
+	cursors.topright = XCreateFontCursor(state.ctx.dpy, XC_top_right_corner);
+	cursors.bottomleft = XCreateFontCursor(state.ctx.dpy, XC_bottom_left_corner);
+	cursors.bottomright = XCreateFontCursor(state.ctx.dpy, XC_bottom_right_corner);
+	cursors.grab = XCreateFontCursor(state.ctx.dpy, XC_fleur);
+
+	next_picture(state.cur_selection, state.filenames, state.num_files, 0);
 	/* Only map window here so the program exits immediately if
 	   there are no loadable images, without first opening the
 	   window and closing it again immediately */
-	XMapWindow(state.dpy, state.win);
+	XMapWindow(state.ctx.dpy, state.ctx.win);
 	redraw();
 }
 
-static void
+void
 cleanup(void) {
-	if (state.cur_image) {
-		imlib_context_set_image(state.cur_image);
-		imlib_free_image();
-	}
 	free(state.selections);
-	XDestroyWindow(state.dpy, state.win);
-	XCloseDisplay(state.dpy);
+	cleanup_x(&state.ctx);
 }
 
 /* TODO: Escape filename properly
@@ -465,6 +331,7 @@ print_cmd(const char *filename, int x, int y, int w, int h, int dry_run) {
 	const char *c;
 	int length = 0;
 	int start_index = 0;
+	/* FIXME: just use putc instead of this complex printf dance */
 	for (c = CMD_FORMAT; *c != '\0'; c++) {
 		if (percent)
 			start_index++;
@@ -529,118 +396,34 @@ print_cmd(const char *filename, int x, int y, int w, int h, int dry_run) {
 	}
 }
 
-/* Parse integer between min and max (inclusive).
-   Returns 1 on error, 0 otherwise.
-   The result is stored in *value.
-   Based on OpenBSD's strtonum. */
-static int
-parse_int(const char *str, int min, int max, int *value) {
-	char *end;
-	long l = strtol(str, &end, 10);
-	if (min > max)
-		return 1;
-	if (str == end || *end != '\0') {
-		return 1; 
-	} else if (l < min || l > max || ((l == LONG_MIN ||
-	    l == LONG_MAX) && errno == ERANGE)) {
-		return 1;
-	}
-	*value = (int)l;
-
-	return 0;
-}
-
-/* queue a part of the image for redrawing */
-static void
-queue_update(int x, int y, int w, int h) {
-	if (state.cur_selection < 0 || !state.selections[state.cur_selection].valid)
-		return;
-	state.dirty = 1;
-	struct Selection *sel = &state.selections[state.cur_selection];
-	if (x > sel->scaled_w || y > sel->scaled_h)
-		return;
-	state.updates = imlib_update_append_rect(
-	    state.updates, x, y,
-	    w + x > sel->scaled_w ? sel->scaled_w - x : w,
-	    h + y > sel->scaled_h ? sel->scaled_h - y : h
-	);
-}
-
 static void
 redraw(void) {
-	Imlib_Image buffer;
-	Imlib_Updates current_update;
-	if (!state.dirty)
+	if (!state.ctx.dirty)
+		return;
+	if (!state.ctx.cur_image || state.cur_selection < 0) {
+		clear_screen(&state.ctx);
+		swap_buffers(&state.ctx);
 		return;
-	if (!state.cur_image || state.cur_selection < 0) {
-		/* clear the window completely */
-		XSetForeground(state.dpy, state.gc, BlackPixel(state.dpy, state.screen));
-		XFillRectangle(
-		    state.dpy, state.drawable, state.gc,
-		    0, 0, state.window_w, state.window_h
-		);
-		goto swap_buffers;
 	}
 
 	/* draw the parts of the image that need to be redrawn */
 	struct Selection *sel = &state.selections[state.cur_selection];
-	state.updates = imlib_updates_merge_for_rendering(
-	    state.updates, sel->scaled_w, sel->scaled_h
-	);
-	for (current_update = state.updates; current_update;
-	    current_update = imlib_updates_get_next(current_update)) {
-		int up_x, up_y, up_w, up_h;
-		imlib_updates_get_coordinates(current_update, &up_x, &up_y, &up_w, &up_h);
-		buffer = imlib_create_image(up_w, up_h);
-		imlib_context_set_blend(0);
-		imlib_context_set_image(buffer);
-		imlib_blend_image_onto_image(
-		    state.cur_image, 0, 0, 0,
-		    sel->orig_w, sel->orig_h,
-		    -up_x, -up_y,
-		    sel->scaled_w, sel->scaled_h);
-		imlib_render_image_on_drawable(up_x, up_y);
-		imlib_free_image();
-	}
-	if (state.updates)
-		imlib_updates_free(state.updates);
-	state.updates = imlib_updates_init();
+	draw_image_updates(&state.ctx, &sel->sz);
 
-	/* wipe the black area around the image */
-	XSetForeground(state.dpy, state.gc, BlackPixel(state.dpy, state.screen));
-	XFillRectangle(
-	    state.dpy, state.drawable, state.gc,
-	    0, sel->scaled_h, sel->scaled_w, state.window_h - sel->scaled_h
-	);
-	XFillRectangle(
-	    state.dpy, state.drawable, state.gc,
-	    sel->scaled_w, 0, state.window_w - sel->scaled_w, state.window_h
-	);
+	wipe_around_image(&state.ctx, &sel->sz);
 
 	/* draw the rectangle */
 	struct Rect rect = sel->rect;
 	if (rect.x0 != -200) {
 		XColor col = state.cur_col == 1 ? state.col1 : state.col2;
-		XSetForeground(state.dpy, state.gc, col.pixel);
+		XSetForeground(state.ctx.dpy, state.ctx.gc, col.pixel);
 		sort_coordinates(&rect.x0, &rect.y0, &rect.x1, &rect.y1);
 		XDrawRectangle(
-		    state.dpy, state.drawable, state.gc,
+		    state.ctx.dpy, state.ctx.drawable, state.ctx.gc,
 		    rect.x0, rect.y0, rect.x1 - rect.x0, rect.y1 - rect.y0
 		);
 	}
-
-swap_buffers:
-	#ifndef NODB
-	if (state.db_enabled) {
-		XdbeSwapInfo swap_info;
-		swap_info.swap_window = state.win;
-		swap_info.swap_action = XdbeCopied;
-
-		if (!XdbeSwapBuffers(state.dpy, &swap_info, 1))
-			fprintf(stderr, "Warning: Unable to swap buffers.\n");
-	}
-	#endif
-	state.dirty = 0;
+	swap_buffers(&state.ctx);
 }
 
 static void
@@ -665,7 +448,7 @@ print_selection(struct Selection *sel, const char *filename) {
 	/* The box was never actually used */
 	if (sel->rect.x0 == -200)
 		return;
-	double scale = (double)sel->orig_w / sel->scaled_w;
+	double scale = (double)sel->sz.orig_w / sel->sz.scaled_w;
 	int x0 = sel->rect.x0, y0 = sel->rect.y0;
 	int x1 = sel->rect.x1, y1 = sel->rect.y1;
 	sort_coordinates(&x0, &y0, &x1, &y1);
@@ -674,13 +457,13 @@ print_selection(struct Selection *sel, const char *filename) {
 	x1 = round(x1 * scale);
 	y1 = round(y1 * scale);
 	/* The box is completely outside of the picture. */
-	if (x0 >= sel->orig_w || y0 >= sel->orig_h)
+	if (x0 >= sel->sz.orig_w || y0 >= sel->sz.orig_h)
 		return;
 	/* Cut the bounding box if it goes past the end of the picture. */
 	x0 = x0 < 0 ? 0 : x0;
 	y0 = y0 < 0 ? 0 : y0;
-	x1 = x1 > sel->orig_w ? sel->orig_w : x1;
-	y1 = y1 > sel->orig_h ? sel->orig_h : y1;
+	x1 = x1 > sel->sz.orig_w ? sel->sz.orig_w : x1;
+	y1 = y1 > sel->sz.orig_h ? sel->sz.orig_h : y1;
 	print_cmd(filename, x0, y0, x1 - x0, y1 - y0, 0);
 }
 
@@ -768,6 +551,14 @@ button_press(XEvent event) {
 }
 
 static void
+queue_update(int x, int y, int w, int h) {
+	if (state.cur_selection < 0 || !state.selections[state.cur_selection].valid)
+		return;
+	struct Selection *sel = &state.selections[state.cur_selection];
+	queue_area_update(&state.ctx, &sel->sz, x, y, w, h);
+}
+
+static void
 button_release(void) {
 	state.moving = 0;
 	state.resizing = 0;
@@ -776,35 +567,35 @@ button_release(void) {
 	/* redraw everything if automatic redrawing of the rectangle
 	   is disabled (so it's redrawn when the mouse is released) */
 	if (!SELECTION_REDRAW)
-		queue_update(0, 0, state.window_w, state.window_h);
+		queue_update(0, 0, state.ctx.window_w, state.ctx.window_h);
 }
 
 static void
 resize_window(int w, int h) {
 	int actual_w, actual_h;
 	struct Selection *sel;
-	state.window_w = w;
-	state.window_h = h;
+	state.ctx.window_w = w;
+	state.ctx.window_h = h;
 
 	if (state.cur_selection < 0 || !state.selections[state.cur_selection].valid)
 		return;
 	sel = &state.selections[state.cur_selection];
-	get_scaled_size(sel->orig_w, sel->orig_h, &actual_w, &actual_h);
-	if (actual_w != sel->scaled_w) {
+	get_scaled_size(&state.ctx, sel->sz.orig_w, sel->sz.orig_h, &actual_w, &actual_h);
+	if (actual_w != sel->sz.scaled_w) {
 		if (sel->rect.x0 != -200) {
 			/* If there is a selection, we need to convert it to
 			 * the new scale. This only takes width into account
 			 * because the aspect ratio should have been preserved
 			 * anyways */
-			double scale = (double)actual_w / sel->scaled_w;
+			double scale = (double)actual_w / sel->sz.scaled_w;
 			sel->rect.x0 = round(sel->rect.x0 * scale);
 			sel->rect.y0 = round(sel->rect.y0 * scale);
 			sel->rect.x1 = round(sel->rect.x1 * scale);
 			sel->rect.y1 = round(sel->rect.y1 * scale);
 		}
-		sel->scaled_w = actual_w;
-		sel->scaled_h = actual_h;
-		queue_update(0, 0, sel->scaled_w, sel->scaled_h);
+		sel->sz.scaled_w = actual_w;
+		sel->sz.scaled_h = actual_h;
+		queue_update(0, 0, sel->sz.scaled_w, sel->sz.scaled_h);
 	}
 }
 
@@ -874,7 +665,7 @@ set_cursor(struct Rect rect) {
 	} else if (collide_rect(state.cursor_x, state.cursor_y, rect)) {
 		c = cursors.grab;
 	}
-	XDefineCursor(state.dpy, state.win, c);
+	XDefineCursor(state.ctx.dpy, state.ctx.win, c);
 }
 
 static void
@@ -937,28 +728,10 @@ set_selection(
 	sel->rect.y0 = rect_y0;
 	sel->rect.x1 = rect_x1;
 	sel->rect.y1 = rect_y1;
-	sel->orig_w = orig_w;
-	sel->orig_h = orig_h;
-	sel->scaled_w = scaled_w;
-	sel->scaled_h = scaled_h;
-}
-
-/* get the scaled size of an image based on the current window size */
-static void
-get_scaled_size(int orig_w, int orig_h, int *scaled_w, int *scaled_h) {
-	double scale_w, scale_h;
-	scale_w = (double)state.window_w / (double)orig_w;
-	scale_h = (double)state.window_h / (double)orig_h;
-	if (orig_w <= state.window_w && orig_h <= state.window_h) {
-		*scaled_w = orig_w;
-		*scaled_h = orig_h;
-	} else if (scale_w * orig_h > state.window_h) {
-		*scaled_w = (int)(scale_h * orig_w);
-		*scaled_h = state.window_h;
-	} else {
-		*scaled_w = state.window_w;
-		*scaled_h = (int)(scale_w * orig_h);
-	}
+	sel->sz.orig_w = orig_w;
+	sel->sz.orig_h = orig_h;
+	sel->sz.scaled_w = scaled_w;
+	sel->sz.scaled_h = scaled_h;
 }
 
 /* change the shown image
@@ -966,27 +739,27 @@ get_scaled_size(int orig_w, int orig_h, int *scaled_w, int *scaled_h) {
  * copy_box determines whether the cropping rectangle of the current
  * selection should be copied (i.e. this is a true value when return
  * is pressed) */
-static void
+void
 change_picture(Imlib_Image new_image, int new_selection, int copy_box) {
 	int orig_w, orig_h, actual_w, actual_h;
 	/* set window title to filename */
 	XSetStandardProperties(
-	    state.dpy, state.win,
+	    state.ctx.dpy, state.ctx.win,
 	    state.filenames[new_selection],
 	    NULL, None, NULL, 0, NULL
 	);
-	if (state.cur_image) {
-		imlib_context_set_image(state.cur_image);
+	if (state.ctx.cur_image) {
+		imlib_context_set_image(state.ctx.cur_image);
 		imlib_free_image();
 	}
-	state.cur_image = new_image;
-	imlib_context_set_image(state.cur_image);
+	state.ctx.cur_image = new_image;
+	imlib_context_set_image(state.ctx.cur_image);
 	int old_selection = state.cur_selection;
 	state.cur_selection = new_selection;
 
 	orig_w = imlib_image_get_width();
 	orig_h = imlib_image_get_height();
-	get_scaled_size(orig_w, orig_h, &actual_w, &actual_h);
+	get_scaled_size(&state.ctx, orig_w, orig_h, &actual_w, &actual_h);
 
 	struct Selection *sel = &state.selections[state.cur_selection];
 	if (copy_box && old_selection >= 0 && old_selection < state.num_files) {
@@ -1004,95 +777,32 @@ change_picture(Imlib_Image new_image, int new_selection, int copy_box) {
 		    -200, -200, -200, -200,
 		    orig_w, orig_h, actual_w, actual_h
 		);
-	} else if (sel->rect.x0 != -200 && actual_w != sel->scaled_w) {
+	} else if (sel->rect.x0 != -200 && actual_w != sel->sz.scaled_w) {
 		/* If there is a selection, we need to convert it to the
 		 * new scale. This only takes width into account because
 		 * the aspect ratio should have been preserved anyways */
-		double scale = (double)actual_w / sel->scaled_w;
+		double scale = (double)actual_w / sel->sz.scaled_w;
 		sel->rect.x0 = round(sel->rect.x0 * scale);
 		sel->rect.y0 = round(sel->rect.y0 * scale);
 		sel->rect.x1 = round(sel->rect.x1 * scale);
 		sel->rect.y1 = round(sel->rect.y1 * scale);
 	}
-	sel->scaled_w = actual_w;
-	sel->scaled_h = actual_h;
+	sel->sz.scaled_w = actual_w;
+	sel->sz.scaled_h = actual_h;
 	sel->valid = 1;
-	queue_update(0, 0, sel->scaled_w, sel->scaled_h);
+	queue_update(0, 0, sel->sz.scaled_w, sel->sz.scaled_h);
 
 	/* set the cursor since the cropping rectangle may have changed */
 	set_cursor(sel->rect);
 }
 
-/* show the next image in the argument list - unloadable files are skipped
- * copy_box determines whether the current selection is copied */
-static void
-next_picture(int copy_box) {
-	if (state.cur_selection + 1 >= state.num_files)
-		return;
-	Imlib_Image tmp_image = NULL;
-	int tmp_cur_selection = state.cur_selection;
-	/* loop until we find a loadable file */
-	while (!tmp_image && tmp_cur_selection + 1 < state.num_files) {
-		tmp_cur_selection++;
-		if (!state.filenames[tmp_cur_selection])
-			continue;
-		tmp_image = imlib_load_image_immediately(
-		    state.filenames[tmp_cur_selection]
-		);
-		if (!tmp_image) {
-			fprintf(stderr, "Warning: Unable to load image '%s'.\n",
-			    state.filenames[tmp_cur_selection]);
-			state.filenames[tmp_cur_selection] = NULL;
-		}
-	}
-	/* immediately exit program if no loadable image is found on startup */
-	if (state.cur_selection < 0 && !tmp_image) {
-		fprintf(stderr, "No loadable images found.\n");
-		cleanup();
-		exit(1);
-	}
-	if (!tmp_image)
-		return;
-
-	change_picture(tmp_image, tmp_cur_selection, copy_box);
-}
-
-/* show the previous image in the argument list - unloadable files are skipped
- * copy_box determines whether the current selection is copied */
-static void
-last_picture(int copy_box) {
-	if (state.cur_selection <= 0)
-		return;
-	Imlib_Image tmp_image = NULL;
-	int tmp_cur_selection = state.cur_selection;
-	/* loop until we find a loadable file */
-	while (!tmp_image && tmp_cur_selection > 0) {
-		tmp_cur_selection--;
-		if (!state.filenames[tmp_cur_selection])
-			continue;
-		tmp_image = imlib_load_image_immediately(
-		    state.filenames[tmp_cur_selection]
-		);
-		if (!tmp_image) {
-			fprintf(stderr, "Warning: Unable to load image '%s'.\n",
-			    state.filenames[tmp_cur_selection]);
-			state.filenames[tmp_cur_selection] = NULL;
-		}
-	}
-
-	if (!tmp_image)
-		return;
-
-	change_picture(tmp_image, tmp_cur_selection, copy_box);
-}
-
 static void
 clear_selection(void) {
 	if (state.cur_selection < 0 || !state.selections[state.cur_selection].valid)
 		return;
 	struct Selection *sel = &state.selections[state.cur_selection];
 	sel->rect.x0 = sel->rect.x1 = sel->rect.y0 = sel->rect.y1 = -200;
-	queue_update(0, 0, sel->scaled_w, sel->scaled_h);
+	queue_update(0, 0, sel->sz.scaled_w, sel->sz.scaled_h);
 }
 
 static void
@@ -1100,7 +810,7 @@ switch_color(void) {
 	if (state.cur_selection < 0 || !state.selections[state.cur_selection].valid)
 		return;
 	state.cur_col = state.cur_col == 1 ? 2 : 1;
-	queue_update(0, 0, state.window_w, state.window_h);
+	queue_update(0, 0, state.ctx.window_w, state.ctx.window_h);
 }
 
 static int
@@ -1111,16 +821,16 @@ key_press(XEvent event) {
 	XLookupString(&event.xkey, buf, sizeof(buf), &sym, NULL);
 	switch (sym) {
 	case XK_Left:
-		last_picture(0);
+		last_picture(state.cur_selection, state.filenames, 0);
 		break;
 	case XK_Right:
-		next_picture(0);
+		next_picture(state.cur_selection, state.filenames, state.num_files, 0);
 		break;
 	case XK_Return:
 		if (event.xkey.state & ShiftMask)
-			last_picture(1);
+			last_picture(state.cur_selection, state.filenames, 1);
 		else
-			next_picture(1);
+			next_picture(state.cur_selection, state.filenames, state.num_files, 1);
 		break;
 	case XK_Delete:
 		clear_selection();
@@ -1129,13 +839,14 @@ key_press(XEvent event) {
 		switch_color();
 		break;
 	case XK_space:
-		XGetWindowAttributes(state.dpy, state.win, &attrs);
+		XGetWindowAttributes(state.ctx.dpy, state.ctx.win, &attrs);
 		resize_window(attrs.width, attrs.height);
 		/* queue update separately so it also redraws when
 		   size didn't change */
-		queue_update(0, 0, state.window_w, state.window_h);
+		queue_update(0, 0, state.ctx.window_w, state.ctx.window_h);
 		break;
 	case XK_q:
+		state.print_on_exit = 1;
 		return 0;
 	default:
 		break;
diff --git a/selectool.1 b/selectool.1
@@ -0,0 +1,140 @@
+.Dd May 14, 2024
+.Dt SELECTOOL 1
+.Os
+.Sh NAME
+.Nm selectool
+.Nd image selection tool
+.Sh SYNOPSIS
+.Nm
+.Op Ar -ms
+.Op Ar -f format
+.Op Ar -w width
+.Op Ar -c color
+.Op Ar -z size
+.Ar file ...
+.Sh DESCRIPTION
+.Nm
+shows each of the given images and allows them to be selected or deselected.
+On exit, the given command is printed for each of the files.
+.Sh OPTIONS
+.Bl -tag -width Ds
+.It Fl m
+Disable automatic redrawing when the window is resized (the
+.Fl m
+stands for 'manual').
+This may be useful on older machines that start accelerating global
+warming when the image is redrawn constantly while resizing.
+Note that this also disables exposure events, so the window has to be
+manually redrawn when switching back to it from another window.
+.It Fl s
+Select all images by default.
+.It Fl f Ar format
+Set the format to be used when the commands are output.
+See
+.Sx OUTPUT FORMAT
+for details.
+.It Fl w Ar width
+Set the line width of the cross that is drawn over selected images
+in pixels (valid values: 1-99).
+Default: 5.
+.It Fl c Ar color
+Set the color of the cross that is drawn over selected images.
+Default: #FF0000.
+.It Fl z Ar size
+Set the Imlib2 in-memory cache to
+.Ar size
+MiB (valid values: 0-1024).
+Default: 4.
+.El
+.Sh OUTPUT FORMAT
+The command for each selected image is output using the format given by
+.Fl f ,
+or the default of
+.Ql rm -- '%f' .
+.Pp
+The following substitutions are performed:
+.Bl -tag -width Ds
+.It %%
+Print
+.Ql % .
+.It %f
+Print the filename of the image.
+Warning: This is printed as is, without any escaping.
+.El
+.Pp
+If an unknown substitution is encountered, a warning is printed to
+standard error and the characters are printed verbatim.
+.Sh KEYBINDS
+.Bl -tag -width Ds
+.It ARROW LEFT
+Go to the previous image.
+.It ARROW RIGHT
+Go to the next image.
+.It RETURN
+Deselect the current image and go to the next image.
+.It SHIFT + RETURN
+Deselect the current image and go to the previous image.
+.It d
+Select the current image and go to the next image.
+.It D
+Select the current image and go to the previous image.
+.It t
+Toggle the selection status of the current image.
+.It SPACE
+Redraw the window.
+This is useful when automatic redrawing is disabled with
+.Fl m .
+.It q
+Exit the program, printing the set command for all selected images.
+If the window is closed through some other means, no commands are printed.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh EXAMPLES
+Normal usage to delete selected images:
+.Bd -literal
+$ selectool *.jpg > tmp.sh
+$ sh tmp.sh
+.Ed
+.Pp
+Or, if you're brave:
+.Bd -literal
+$ selectool *.jpg | sh
+.Ed
+.Pp
+The original use case for
+.Nm
+was to quickly delete images that have been recovered using programs like
+.Xr photorec 8
+or
+.Xr foremost 8 .
+When used on a system partition, these programs generally recover a lot of
+images that aren't important, which then need to be sorted manually.
+Other programs that the author used for this task in the past were not ideal
+because they either were much too slow or allowed mistakes to be made too
+easily by deleting images immediately.
+.Pp
+It is also possible to do more advanced things.
+For instance, to move the selected images into a different directory,
+something like this can be done:
+.Bd -literal
+$ selectool -f "mv -- '%f' '/path/to/dir/'" *.jpg | sh
+.Ed
+.Pp
+Note that no great care has been taken to deal with filenames containing
+single or double quotes.
+That is left as an exercise to the reader (hint: just don't have
+filenames containing quotes).
+.Sh SEE ALSO
+.Xr croptool 1 ,
+.Xr rm 1 ,
+.Xr foremost 8 ,
+.Xr photorec 8
+.Sh AUTHORS
+.An lumidify Aq Mt nobody@lumidify.org
+.Sh BUGS
+The filenames are printed without any escaping, so filenames with
+quotes may cause issues depending on the output format.
+.Pp
+Transparent portions of images should probably be shown differently,
+but I'm too lazy to fix that and don't really care at the moment.
diff --git a/selectool.c b/selectool.c
@@ -0,0 +1,423 @@
+/*
+ * Copyright (c) 2024 lumidify <nobody@lumidify.org>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * 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 <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#include <X11/X.h>
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#include <X11/keysym.h>
+
+#include <Imlib2.h>
+
+#include "common.h"
+
+/* Whether to select all images by default */
+static int SELECT_DEFAULT = 0;
+/* The color of the selection box */
+static const char *SELECTION_COLOR = "#FF0000";
+/* The width of the selection line */
+static int LINE_WIDTH = 5;
+/* When set to 1, the display is redrawn on window resize */
+static short RESIZE_REDRAW = 1;
+/*
+  The command printed for each image.
+  %f: Filename of image.
+*/
+static const char *CMD_FORMAT = "rm -- '%f'";
+/* Size of Imlib2 in-memory cache in MiB */
+static int CACHE_SIZE = 4;
+
+extern char *optarg;
+extern int optind;
+
+struct Selection {
+	ImageSize sz;
+	char selected;
+};
+
+static struct {
+	GraphicsContext ctx;
+
+	struct Selection *selections;
+	char **filenames;
+	int cur_selection;
+	int num_files;
+	int cursor_x;
+	int cursor_y;
+	XColor col;
+	char print_on_exit;
+} state;
+
+static void usage(void);
+static void mainloop(void);
+static void setup(int argc, char *argv[]);
+static void redraw(void);
+static void set_selection(char selected);
+static void toggle_selection(void);
+static void resize_window(int w, int h);
+static int key_press(XEvent event);
+static void queue_update(int x, int y, int w, int h);
+static void print_cmd(const char *filename, int dry_run);
+
+static void
+usage(void) {
+	fprintf(stderr, "USAGE: deletetool [-mrs] [-f format] "
+	    "[-w width] [-c color] "
+	    "[-z size] file ...\n");
+}
+
+int
+main(int argc, char *argv[]) {
+	char c;
+
+	while ((c = getopt(argc, argv, "f:w:c:msz:")) != -1) {
+		switch (c) {
+		case 'f':
+			CMD_FORMAT = optarg;
+			break;
+		case 'm':
+			RESIZE_REDRAW = 0;
+			break;
+		case 'c':
+			SELECTION_COLOR = optarg;
+			break;
+		case 'w':
+			if (parse_int(optarg, 1, 99, &LINE_WIDTH)) {
+				fprintf(stderr, "Invalid line width.\n");
+				exit(1);
+			}
+			break;
+		case 'z':
+			if (parse_int(optarg, 0, 1024, &CACHE_SIZE)) {
+				fprintf(stderr, "Invalid cache size.\n");
+				exit(1);
+			}
+			break;
+		case 's':
+			SELECT_DEFAULT = 1;
+			break;
+		default:
+			usage();
+			exit(1);
+			break;
+		}
+	}
+
+	/* print warning if command format is invalid */
+	print_cmd("", 1);
+
+	argc -= optind;
+	argv += optind;
+	if (argc < 1) {
+		usage();
+		exit(1);
+	}
+	setup(argc, argv);
+
+	mainloop();
+
+	if (state.print_on_exit) {
+		for (int i = 0; i < argc; i++) {
+			if (state.selections[i].selected)
+				print_cmd(state.filenames[i], 0);
+		}
+	}
+
+	cleanup();
+
+	return 0;
+}
+
+static void
+mainloop(void) {
+	XEvent event;
+	int running = 1;
+
+	while (running) {
+		do {
+			XNextEvent(state.ctx.dpy, &event);
+			switch (event.type) {
+			case Expose:
+				if (RESIZE_REDRAW)
+					queue_update(event.xexpose.x, event.xexpose.y,
+					    event.xexpose.width, event.xexpose.height);
+				break;
+			case ConfigureNotify:
+				if (RESIZE_REDRAW)
+					resize_window(
+					    event.xconfigure.width,
+					    event.xconfigure.height
+					);
+				break;
+			case KeyPress:
+				running = key_press(event);
+				break;
+			case ClientMessage:
+				if ((Atom)event.xclient.data.l[0] == state.ctx.wm_delete_msg)
+					running = 0;
+			default:
+				break;
+			}
+		} while (XPending(state.ctx.dpy));
+
+		redraw();
+	}
+}
+
+static void
+setup(int argc, char *argv[]) {
+	state.selections = malloc(argc * sizeof(struct Selection));
+	if (!state.selections) {
+		fprintf(stderr, "Unable to allocate memory.\n");
+		exit(1);
+	}
+	state.num_files = argc;
+	state.filenames = argv;
+	state.cur_selection = -1;
+	state.print_on_exit = 0;
+
+	for (int i = 0; i < argc; i++) {
+		state.selections[i].selected = SELECT_DEFAULT;
+	}
+
+	setup_x(&state.ctx, 500, 500, LINE_WIDTH, CACHE_SIZE);
+
+        if (!XParseColor(state.ctx.dpy, state.ctx.cm, SELECTION_COLOR, &state.col)) {
+		fprintf(stderr, "Selection color invalid.\n");
+		exit(1);
+	}
+        XAllocColor(state.ctx.dpy, state.ctx.cm, &state.col);
+
+	next_picture(state.cur_selection, state.filenames, state.num_files, 0);
+	/* Only map window here so the program exits immediately if
+	   there are no loadable images, without first opening the
+	   window and closing it again immediately */
+	XMapWindow(state.ctx.dpy, state.ctx.win);
+	redraw();
+}
+
+void
+cleanup(void) {
+	free(state.selections);
+	cleanup_x(&state.ctx);
+}
+
+/* queue a part of the image for redrawing */
+static void
+queue_update(int x, int y, int w, int h) {
+	if (state.cur_selection < 0)
+		return;
+	struct Selection *sel = &state.selections[state.cur_selection];
+	queue_area_update(&state.ctx, &sel->sz, x, y, w, h);
+}
+
+/* TODO: Escape filename properly
+ * -> But how? Since the format can be set by the user,
+ * it isn't really clear *what* needs to be escaped. */
+static void
+print_cmd(const char *filename, int dry_run) {
+	short percent = 0;
+	const char *c;
+	int length = 0;
+	int start_index = 0;
+	/* FIXME: just use putc instead of this complex printf dance */
+	for (c = CMD_FORMAT; *c != '\0'; c++) {
+		if (percent)
+			start_index++;
+		if (*c == '%') {
+			if (length) {
+				if (!dry_run)
+					printf("%.*s", length, CMD_FORMAT + start_index);
+				start_index += length;
+				length = 0;
+			}
+			if (percent && !dry_run)
+				printf("%%");
+			percent++;
+			percent %= 2;
+			start_index++;
+		} else if (percent && *c == 'f') {
+			if (!dry_run)
+				printf("%s", filename);
+			percent = 0;
+		} else if (percent) {
+			if (dry_run) {
+				fprintf(stderr,
+				    "Warning: Unknown substitution '%c' "
+				    "in format string.\n", *c
+				);
+			} else {
+				printf("%%%c", *c);
+			}
+			percent = 0;
+		} else {
+			length++;
+		}
+	}
+	if (!dry_run) {
+		if (length)
+			printf("%.*s", length, CMD_FORMAT + start_index);
+		printf("\n");
+	}
+}
+
+static void
+redraw(void) {
+	if (!state.ctx.dirty)
+		return;
+	if (!state.ctx.cur_image || state.cur_selection < 0) {
+		clear_screen(&state.ctx);
+		swap_buffers(&state.ctx);
+		return;
+	}
+
+	/* draw the parts of the image that need to be redrawn */
+	struct Selection *sel = &state.selections[state.cur_selection];
+	draw_image_updates(&state.ctx, &sel->sz);
+
+	wipe_around_image(&state.ctx, &sel->sz);
+
+	/* draw the 'X' */
+	if (sel->selected) {
+		XSetForeground(state.ctx.dpy, state.ctx.gc, state.col.pixel);
+		XDrawLine(
+		    state.ctx.dpy, state.ctx.drawable, state.ctx.gc,
+		    0, 0, sel->sz.scaled_w, sel->sz.scaled_h
+		);
+		XDrawLine(
+		    state.ctx.dpy, state.ctx.drawable, state.ctx.gc,
+		    0, sel->sz.scaled_h, sel->sz.scaled_w, 0
+		);
+	}
+	swap_buffers(&state.ctx);
+}
+
+static void
+resize_window(int w, int h) {
+	int actual_w, actual_h;
+	struct Selection *sel;
+	state.ctx.window_w = w;
+	state.ctx.window_h = h;
+
+	if (state.cur_selection < 0)
+		return;
+	sel = &state.selections[state.cur_selection];
+	get_scaled_size(&state.ctx, sel->sz.orig_w, sel->sz.orig_h, &actual_w, &actual_h);
+	if (actual_w != sel->sz.scaled_w) {
+		sel->sz.scaled_w = actual_w;
+		sel->sz.scaled_h = actual_h;
+		queue_update(0, 0, sel->sz.scaled_w, sel->sz.scaled_h);
+	}
+}
+
+/* change the shown image
+ * new_selection is the index of the new selection */
+void
+change_picture(Imlib_Image new_image, int new_selection, int copy_box) {
+	(void)copy_box;
+	int orig_w, orig_h, actual_w, actual_h;
+	/* set window title to filename */
+	XSetStandardProperties(
+	    state.ctx.dpy, state.ctx.win,
+	    state.filenames[new_selection],
+	    NULL, None, NULL, 0, NULL
+	);
+	if (state.ctx.cur_image) {
+		imlib_context_set_image(state.ctx.cur_image);
+		imlib_free_image();
+	}
+	state.ctx.cur_image = new_image;
+	imlib_context_set_image(state.ctx.cur_image);
+	state.cur_selection = new_selection;
+
+	orig_w = imlib_image_get_width();
+	orig_h = imlib_image_get_height();
+	get_scaled_size(&state.ctx, orig_w, orig_h, &actual_w, &actual_h);
+
+	struct Selection *sel = &state.selections[state.cur_selection];
+	sel->sz.orig_w = orig_w;
+	sel->sz.orig_h = orig_h;
+	sel->sz.scaled_w = actual_w;
+	sel->sz.scaled_h = actual_h;
+	queue_update(0, 0, sel->sz.scaled_w, sel->sz.scaled_h);
+}
+
+static void
+set_selection(char selected) {
+	if (state.cur_selection < 0)
+		return;
+	struct Selection *sel = &state.selections[state.cur_selection];
+	sel->selected = selected;
+	queue_update(0, 0, sel->sz.scaled_w, sel->sz.scaled_h);
+}
+
+static void
+toggle_selection(void) {
+	if (state.cur_selection < 0)
+		return;
+	struct Selection *sel = &state.selections[state.cur_selection];
+	set_selection(!sel->selected);
+}
+
+static int
+key_press(XEvent event) {
+	XWindowAttributes attrs;
+	char buf[32];
+	KeySym sym;
+	XLookupString(&event.xkey, buf, sizeof(buf), &sym, NULL);
+	switch (sym) {
+	case XK_Left:
+		last_picture(state.cur_selection, state.filenames, 0);
+		break;
+	case XK_Right:
+		next_picture(state.cur_selection, state.filenames, state.num_files, 0);
+		break;
+	case XK_Return:
+		set_selection(0);
+		if (event.xkey.state & ShiftMask)
+			last_picture(state.cur_selection, state.filenames, 0);
+		else
+			next_picture(state.cur_selection, state.filenames, state.num_files, 0);
+		break;
+	case XK_d:
+		set_selection(1);
+		next_picture(state.cur_selection, state.filenames, state.num_files, 0);
+		break;
+	case XK_D:
+		set_selection(1);
+		last_picture(state.cur_selection, state.filenames, 0);
+		break;
+	case XK_space:
+		XGetWindowAttributes(state.ctx.dpy, state.ctx.win, &attrs);
+		resize_window(attrs.width, attrs.height);
+		/* queue update separately so it also redraws when
+		   size didn't change */
+		queue_update(0, 0, state.ctx.window_w, state.ctx.window_h);
+		break;
+	case XK_q:
+		state.print_on_exit = 1;
+		return 0;
+	case XK_t:
+		toggle_selection();
+		break;
+	default:
+		break;
+	}
+	return 1;
+}