diff --git a/doc/man/mc.1.in b/doc/man/mc.1.in
index 69285dd5f..97e9b0154 100644
--- a/doc/man/mc.1.in
+++ b/doc/man/mc.1.in
@@ -3683,6 +3683,20 @@ and that is the default value.
Controls if scrolling with the mouse is done by pages or line by line
on the internal file viewer.
.TP
+.I quickview_image_protocol
+Selects how images are shown in the quick view panel. Possible values are:
+.B auto
+(default; detect the terminal from the environment),
+.B iterm2
+(iTerm2 inline images protocol, used by iTerm2, WezTerm and others; all
+image formats supported by the terminal),
+.B kitty
+(kitty graphics protocol, used by kitty, Ghostty and others; PNG files
+only) and
+.B none
+(never show images). Explicitly selected protocol is used even if the
+terminal can't be detected, but never inside tmux or screen.
+.TP
.I only_leading_plus_minus
Allow special treatment for '+', '\-', '*' in the command line (select,
unselect, reverse selection) only if the command line is empty. You
diff --git a/src/setup.c b/src/setup.c
index fb0844915..090a58de7 100644
--- a/src/setup.c
+++ b/src/setup.c
@@ -419,6 +419,7 @@ static const struct
{ "editor_stop_format_chars", &edit_options.stop_format_chars, "-+*\\,.;:&>" },
#endif
{ "mcview_eof", &mcview_show_eof, "" },
+ { "quickview_image_protocol", &mcview_image_protocol, "auto" },
{ NULL, NULL, NULL },
};
diff --git a/src/viewer/Makefile.am b/src/viewer/Makefile.am
index 9bf164840..a243fdd70 100644
--- a/src/viewer/Makefile.am
+++ b/src/viewer/Makefile.am
@@ -10,6 +10,7 @@ libmcviewer_la_SOURCES = \
display.c \
growbuf.c \
hex.c \
+ image.c \
internal.h \
lib.c \
mcviewer.c \
diff --git a/src/viewer/actions_cmd.c b/src/viewer/actions_cmd.c
index c51c80727..a0d66533a 100644
--- a/src/viewer/actions_cmd.c
+++ b/src/viewer/actions_cmd.c
@@ -719,6 +719,7 @@ mcview_callback (Widget *w, Widget *sender, widget_msg_t msg, int parm, void *da
if (mc_global.midnight_shutdown)
mcview_ok_to_quit (view);
}
+ mcview_image_forget (view, FALSE);
mcview_done (view);
mcview_remove_ext_script (view);
return MSG_HANDLED;
diff --git a/src/viewer/display.c b/src/viewer/display.c
index 1284b10f3..70f1047ac 100644
--- a/src/viewer/display.c
+++ b/src/viewer/display.c
@@ -237,6 +237,16 @@ mcview_update (WView *view)
void
mcview_display (WView *view)
{
+ if (mcview_image_active (view))
+ {
+ mcview_display_clean (view);
+ mcview_display_status (view);
+ mcview_image_draw (view);
+ return;
+ }
+
+ mcview_image_forget (view, TRUE);
+
if (view->mode_flags.hex)
mcview_display_hex (view);
else
diff --git a/src/viewer/image.c b/src/viewer/image.c
new file mode 100644
index 000000000..02d9029b5
--- /dev/null
+++ b/src/viewer/image.c
@@ -0,0 +1,385 @@
+/*
+ Internal file viewer for the Midnight Commander
+ Function for inline image preview (iTerm2 inline images protocol)
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#ifdef HAVE_SYS_IOCTL_H
+#include
+#endif
+#include // in some systems (like Solaris) TIOCGWINSZ is defined here
+
+#include "lib/global.h"
+#include "lib/tty/tty.h"
+#include "lib/vfs/vfs.h"
+#include "lib/widget.h"
+
+#include "internal.h"
+#include "mcviewer.h"
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+// Do not try to preview files bigger than this
+#define IMAGE_MAX_SIZE (16 * 1024 * 1024)
+
+// Max size of a base64 payload in one kitty graphics escape code
+#define KITTY_CHUNK_SIZE 4096
+
+// Delete all images and free their data
+#define KITTY_DELETE_ALL "\033_Ga=d,d=A,q=2\033\\"
+
+/*** file scope type declarations ****************************************************************/
+
+/*** forward declarations (file scope functions) *************************************************/
+
+/*** file scope variables ************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+static mcview_image_proto_t
+mcview_image_detect_protocol (void)
+{
+ const char *setting, *term, *term_program, *lc_terminal;
+
+ // multiplexers need passthrough, which is not supported
+ if (getenv ("TMUX") != NULL || getenv ("STY") != NULL)
+ return MCVIEW_IMAGE_NONE;
+
+ setting = (mcview_image_protocol != NULL) ? mcview_image_protocol : "auto";
+ if (g_ascii_strcasecmp (setting, "none") == 0)
+ return MCVIEW_IMAGE_NONE;
+ if (g_ascii_strcasecmp (setting, "iterm2") == 0)
+ return MCVIEW_IMAGE_ITERM2;
+ if (g_ascii_strcasecmp (setting, "kitty") == 0)
+ return MCVIEW_IMAGE_KITTY;
+ // anything else, including "auto", means autodetection
+
+ term = getenv ("TERM");
+ term_program = getenv ("TERM_PROGRAM");
+ lc_terminal = getenv ("LC_TERMINAL"); // survives ssh
+
+ // prefer iTerm2 protocol: the terminal decodes all formats itself
+ if (g_strcmp0 (term_program, "iTerm.app") == 0 || g_strcmp0 (lc_terminal, "iTerm2") == 0
+ || g_strcmp0 (term_program, "WezTerm") == 0)
+ return MCVIEW_IMAGE_ITERM2;
+
+ if (getenv ("KITTY_WINDOW_ID") != NULL || g_strcmp0 (term, "xterm-kitty") == 0
+ || g_strcmp0 (term_program, "ghostty") == 0)
+ return MCVIEW_IMAGE_KITTY;
+
+ return MCVIEW_IMAGE_NONE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+mcview_image_sniff (const unsigned char *d, size_t len)
+{
+ if (len >= 8 && memcmp (d, "\x89PNG\r\n\x1a\n", 8) == 0)
+ return TRUE;
+ if (len >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) // JPEG
+ return TRUE;
+ if (len >= 6 && (memcmp (d, "GIF87a", 6) == 0 || memcmp (d, "GIF89a", 6) == 0))
+ return TRUE;
+ if (len >= 12 && memcmp (d, "RIFF", 4) == 0 && memcmp (d + 8, "WEBP", 4) == 0)
+ return TRUE;
+ if (len >= 4 && (memcmp (d, "II*\0", 4) == 0 || memcmp (d, "MM\0*", 4) == 0)) // TIFF
+ return TRUE;
+ if (len >= 26 && d[0] == 'B' && d[1] == 'M') // BMP: check reserved fields
+ return d[6] == 0 && d[7] == 0 && d[8] == 0 && d[9] == 0;
+
+ return FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/** Get size of a PNG image from its IHDR chunk */
+
+static gboolean
+mcview_image_png_size (const unsigned char *d, size_t len, int *width, int *height)
+{
+ guint32 w, h;
+
+ if (len < 24 || memcmp (d, "\x89PNG\r\n\x1a\n", 8) != 0 || memcmp (d + 12, "IHDR", 4) != 0)
+ return FALSE;
+
+ w = ((guint32) d[16] << 24) | ((guint32) d[17] << 16) | ((guint32) d[18] << 8) | d[19];
+ h = ((guint32) d[20] << 24) | ((guint32) d[21] << 16) | ((guint32) d[22] << 8) | d[23];
+ if (w == 0 || h == 0 || w > G_MAXINT || h > G_MAXINT)
+ return FALSE;
+
+ *width = (int) w;
+ *height = (int) h;
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * kitty scales the image to the given cell box ignoring the aspect ratio, so fit it ourselves.
+ */
+
+static void
+mcview_image_fit (const WView *view, int *cols, int *lines)
+{
+ const WRect *r = &view->data_area;
+#ifdef TIOCGWINSZ
+ struct winsize ws;
+#endif
+ double cw = 1.0, ch = 2.0; // cell size; only the ratio matters. Typical cell is 1:2
+ double box_w, box_h;
+
+#ifdef TIOCGWINSZ
+ if (ioctl (STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col != 0 && ws.ws_row != 0
+ && ws.ws_xpixel != 0 && ws.ws_ypixel != 0)
+ {
+ cw = (double) ws.ws_xpixel / ws.ws_col;
+ ch = (double) ws.ws_ypixel / ws.ws_row;
+ }
+#endif
+
+ box_w = r->cols * cw;
+ box_h = r->lines * ch;
+
+ if ((double) view->image_width * box_h > (double) view->image_height * box_w)
+ {
+ // width is the limit
+ *cols = r->cols;
+ *lines = (int) (view->image_height * box_w / view->image_width / ch + 0.5);
+ }
+ else
+ {
+ *lines = r->lines;
+ *cols = (int) (view->image_width * box_h / view->image_height / cw + 0.5);
+ }
+
+ *cols = CLAMP (*cols, 1, r->cols);
+ *lines = CLAMP (*lines, 1, r->lines);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+mcview_image_write_all (const char *s, size_t len)
+{
+ while (len != 0)
+ {
+ ssize_t n;
+
+ n = write (STDOUT_FILENO, s, len);
+ if (n < 0)
+ return;
+ s += n;
+ len -= (size_t) n;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Try to load the current file as an image for preview. On success view->image_b64 is set.
+ */
+
+void
+mcview_image_load (WView *view)
+{
+ vfs_path_t *vpath = view->filename_vpath;
+ mcview_image_proto_t proto;
+ unsigned char *data;
+ struct stat st;
+ size_t got = 0;
+ int fd;
+
+ MC_PTR_FREE (view->image_b64);
+ view->image_proto = MCVIEW_IMAGE_NONE;
+
+ if (!mcview_is_in_panel (view) || vpath == NULL)
+ return;
+
+ proto = mcview_image_detect_protocol ();
+ if (proto == MCVIEW_IMAGE_NONE)
+ return;
+
+ fd = mc_open (vpath, O_RDONLY | O_NONBLOCK);
+ if (fd == -1)
+ return;
+
+ if (mc_fstat (fd, &st) == -1 || !S_ISREG (st.st_mode) || st.st_size <= 0
+ || st.st_size > IMAGE_MAX_SIZE)
+ {
+ mc_close (fd);
+ return;
+ }
+
+ data = g_try_malloc ((gsize) st.st_size);
+ if (data != NULL)
+ {
+ while (got < (size_t) st.st_size)
+ {
+ ssize_t n;
+
+ n = mc_read (fd, data + got, (size_t) st.st_size - got);
+ if (n <= 0)
+ break;
+ got += (size_t) n;
+ }
+
+ if (got == (size_t) st.st_size && mcview_image_sniff (data, got))
+ {
+ // kitty can show PNG only
+ if (proto == MCVIEW_IMAGE_ITERM2
+ || mcview_image_png_size (data, got, &view->image_width, &view->image_height))
+ {
+ view->image_b64 = g_base64_encode (data, got);
+ view->image_proto = proto;
+ }
+ }
+
+ g_free (data);
+ }
+
+ mc_close (fd);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+gboolean
+mcview_image_active (const WView *view)
+{
+ return view->image_b64 != NULL && !view->mode_flags.hex;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Draw the image into the data area. Must be called after the area was cleaned.
+ */
+
+void
+mcview_image_draw (WView *view)
+{
+ const WRect *r = &view->data_area;
+ const Widget *w = WIDGET (view);
+ GString *s;
+
+ if (r->lines <= 0 || r->cols <= 0)
+ return;
+
+ // make curses output the cleaned area before we write over it behind its back
+ tty_refresh ();
+
+ s = g_string_sized_new (strlen (view->image_b64) + 256);
+ // save cursor, move to the data area
+ g_string_append_printf (s, "\0337\033[%d;%dH", w->rect.y + r->y + 1, w->rect.x + r->x + 1);
+
+ if (view->image_proto == MCVIEW_IMAGE_KITTY)
+ {
+ const char *p = view->image_b64;
+ size_t left = strlen (p);
+ int cols, lines;
+
+ mcview_image_fit (view, &cols, &lines);
+
+ // don't pile up copies of the same image on repaints
+ g_string_append (s, KITTY_DELETE_ALL);
+
+ // transmit and display (a=T) PNG (f=100), don't move cursor (C=1), no responses (q=2)
+ while (left != 0)
+ {
+ size_t n = MIN (left, KITTY_CHUNK_SIZE);
+
+ if (p == view->image_b64)
+ g_string_append_printf (s, "\033_Ga=T,f=100,c=%d,r=%d,C=1,q=2,m=%d;", cols, lines,
+ left > n ? 1 : 0);
+ else
+ g_string_append_printf (s, "\033_Gq=2,m=%d;", left > n ? 1 : 0);
+
+ g_string_append_len (s, p, (gssize) n);
+ g_string_append (s, "\033\\");
+ p += n;
+ left -= n;
+ }
+ }
+ else
+ {
+ g_string_append_printf (
+ s, "\033]1337;File=inline=1;width=%d;height=%d;preserveAspectRatio=1:", r->cols,
+ r->lines);
+ g_string_append (s, view->image_b64);
+ // BEL terminates OSC
+ g_string_append_c (s, '\a');
+ }
+
+ // restore cursor
+ g_string_append (s, "\0338");
+
+ mcview_image_write_all (s->str, s->len);
+ g_string_free (s, TRUE);
+
+ view->image_shown = TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Remove the image from the screen.
+ *
+ * kitty can delete images explicitly. iTerm2 keeps the image under any text written over it, and
+ * curses doesn't know about the image at all, so wipe the terminal screen and make curses repaint
+ * everything. If @redraw is FALSE, the caller is going to repaint the dialog itself.
+ */
+
+void
+mcview_image_forget (WView *view, gboolean redraw)
+{
+ Widget *owner;
+
+ if (!view->image_shown)
+ return;
+
+ view->image_shown = FALSE;
+
+ if (view->image_proto == MCVIEW_IMAGE_KITTY)
+ {
+ mcview_image_write_all (KITTY_DELETE_ALL, strlen (KITTY_DELETE_ALL));
+ return;
+ }
+
+ mcview_image_write_all ("\033[0m\033[2J", 8);
+ tty_clear_screen ();
+
+ owner = WIDGET (WIDGET (view)->owner);
+ if (redraw && owner != NULL)
+ widget_draw (owner);
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/internal.h b/src/viewer/internal.h
index 819c678fd..8cf83968d 100644
--- a/src/viewer/internal.h
+++ b/src/viewer/internal.h
@@ -89,6 +89,13 @@ typedef struct
struct mcview_nroff_struct;
+typedef enum
+{
+ MCVIEW_IMAGE_NONE = 0,
+ MCVIEW_IMAGE_ITERM2, // OSC 1337, the terminal decodes any image format
+ MCVIEW_IMAGE_KITTY // kitty graphics protocol, PNG only
+} mcview_image_proto_t;
+
struct WView
{
Widget widget;
@@ -191,6 +198,11 @@ struct WView
int *dir_idx; /* Index of current file in dir structure.
* Pointer is used here as reference to WPanel::dir::count */
vfs_path_t *ext_script; // Temporary script file created by regex_command_for()
+
+ char *image_b64; // Base64 of the image file shown in quick view, or NULL
+ mcview_image_proto_t image_proto; // How to show the image
+ int image_width, image_height; // Size in pixels, if known (kitty only)
+ gboolean image_shown; // Inline image is currently on the screen
};
typedef struct mcview_nroff_struct
@@ -289,6 +301,12 @@ void mcview_toggle_wrap_mode (WView *view);
void mcview_toggle_nroff_mode (WView *view);
void mcview_toggle_hex_mode (WView *view);
void mcview_init (WView *view);
+
+// image.c
+void mcview_image_load (WView *view);
+gboolean mcview_image_active (const WView *view);
+void mcview_image_draw (WView *view);
+void mcview_image_forget (WView *view, gboolean redraw);
void mcview_done (WView *view);
void mcview_select_encoding (WView *view);
void mcview_set_codeset (WView *view);
diff --git a/src/viewer/lib.c b/src/viewer/lib.c
index 5de9313c9..0466ccaef 100644
--- a/src/viewer/lib.c
+++ b/src/viewer/lib.c
@@ -219,6 +219,7 @@ mcview_done (WView *view)
vfs_path_free (view->workdir_vpath, TRUE);
view->workdir_vpath = NULL;
MC_PTR_FREE (view->command);
+ MC_PTR_FREE (view->image_b64);
mcview_close_datasource (view);
// the growing buffer is freed with the datasource
diff --git a/src/viewer/mcviewer.c b/src/viewer/mcviewer.c
index f276ef67b..5c1ce888c 100644
--- a/src/viewer/mcviewer.c
+++ b/src/viewer/mcviewer.c
@@ -68,6 +68,9 @@ gboolean mcview_mouse_move_pages = TRUE;
/* end of file will be showen from mcview_show_eof */
char *mcview_show_eof = NULL;
+/* protocol for images in quick view: "auto", "iterm2", "kitty" or "none" */
+char *mcview_image_protocol = NULL;
+
/*** file scope macro definitions ****************************************************************/
/*** file scope type declarations ****************************************************************/
@@ -411,6 +414,7 @@ mcview_load (WView *view, const char *command, const char *file, int start_line,
}
mcview_set_datasource_file (view, fd, &st);
+ mcview_image_load (view);
}
retval = TRUE;
}
diff --git a/src/viewer/mcviewer.h b/src/viewer/mcviewer.h
index 29af9174d..e5654efbf 100644
--- a/src/viewer/mcviewer.h
+++ b/src/viewer/mcviewer.h
@@ -36,6 +36,9 @@ extern int mcview_max_dirt_limit;
extern gboolean mcview_mouse_move_pages;
extern char *mcview_show_eof;
+// Protocol used to show images in quick view: auto, iterm2, kitty or none
+extern char *mcview_image_protocol;
+
/*** declarations of public functions ************************************************************/
/* Creates a new WView object with the given properties. */
diff --git a/tests/src/Makefile.am b/tests/src/Makefile.am
index 95f054ad5..4383ca693 100644
--- a/tests/src/Makefile.am
+++ b/tests/src/Makefile.am
@@ -29,6 +29,7 @@ TESTS = \
execute__execute_external_editor_or_viewer \
execute__execute_get_external_cmd_opts_from_config \
file_history \
+ viewer_image \
usermenu__test_condition \
usermenu__test_expand_format
@@ -48,6 +49,9 @@ execute__execute_get_external_cmd_opts_from_config_SOURCES = \
file_history_SOURCES = \
file_history.c
+viewer_image_SOURCES = \
+ viewer_image.c
+
usermenu__test_condition_SOURCES = \
usermenu__test_condition.c
diff --git a/tests/src/viewer_image.c b/tests/src/viewer_image.c
new file mode 100644
index 000000000..79787a400
--- /dev/null
+++ b/tests/src/viewer_image.c
@@ -0,0 +1,443 @@
+/*
+ src/viewer/image - tests for inline image preview in quick view
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#define TEST_SUITE_NAME "/src/viewer"
+
+#include "tests/mctest.h"
+
+#include // g_unlink()
+#include
+#include
+
+#include "lib/strutil.h"
+#include "lib/vfs/vfs.h"
+#include "src/vfs/local/local.c"
+
+#include "src/viewer/image.c"
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+setup (void)
+{
+ str_init_strings (NULL);
+ vfs_init ();
+ vfs_init_localfs ();
+ vfs_setup_work_dir ();
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+teardown (void)
+{
+ vfs_shut ();
+ str_uninit_strings ();
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+// PNG signature and IHDR chunk header with 640x480 size
+static const unsigned char png_header[] = { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n',
+ 0, 0, 0, 13, 'I', 'H', 'D', 'R',
+ 0, 0, 2, 0x80, 0, 0, 1, 0xE0 };
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_sniff_known_formats)
+{
+ const unsigned char jpeg[] = { 0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10 };
+ const unsigned char gif87[] = "GIF87a\x01\x00";
+ const unsigned char gif89[] = "GIF89a\x01\x00";
+ const unsigned char webp[] = "RIFF\x00\x00\x00\x00WEBPVP8 ";
+ const unsigned char tiff_le[] = { 'I', 'I', '*', 0, 8, 0, 0, 0 };
+ const unsigned char tiff_be[] = { 'M', 'M', 0, '*', 0, 0, 0, 8 };
+ unsigned char bmp[30] = { 'B', 'M' };
+
+ ck_assert (mcview_image_sniff (png_header, sizeof (png_header)));
+ ck_assert (mcview_image_sniff (jpeg, sizeof (jpeg)));
+ ck_assert (mcview_image_sniff (gif87, 8));
+ ck_assert (mcview_image_sniff (gif89, 8));
+ ck_assert (mcview_image_sniff (webp, 16));
+ ck_assert (mcview_image_sniff (tiff_le, sizeof (tiff_le)));
+ ck_assert (mcview_image_sniff (tiff_be, sizeof (tiff_be)));
+ ck_assert (mcview_image_sniff (bmp, sizeof (bmp)));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_sniff_rejects_other_data)
+{
+ const unsigned char text[] = "#!/bin/sh\necho hello\n";
+ const unsigned char riff_wav[] = "RIFF\x00\x00\x00\x00WAVEfmt ";
+ unsigned char fake_bmp[30] = { 'B', 'M', 'a', 'b', 'c', 'd', 'e', 'f' }; // "BM" in a text file
+
+ ck_assert (!mcview_image_sniff (text, sizeof (text) - 1));
+ ck_assert (!mcview_image_sniff (riff_wav, 16));
+ ck_assert (!mcview_image_sniff (fake_bmp, sizeof (fake_bmp)));
+ ck_assert (!mcview_image_sniff ((const unsigned char *) "", 0));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_sniff_truncated_data)
+{
+ // every proper prefix of a PNG header is not enough to decide
+ for (size_t len = 0; len < 8; len++)
+ ck_assert (!mcview_image_sniff (png_header, len));
+
+ ck_assert (!mcview_image_sniff ((const unsigned char *) "GIF89", 5));
+ ck_assert (!mcview_image_sniff ((const unsigned char *) "BM", 2));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *const term_vars[] = { "TERM", "TERM_PROGRAM", "LC_TERMINAL", "KITTY_WINDOW_ID",
+ "TMUX", "STY" };
+
+static void
+save_and_clear_term_env (char *saved[])
+{
+ for (size_t i = 0; i < G_N_ELEMENTS (term_vars); i++)
+ {
+ saved[i] = g_strdup (g_getenv (term_vars[i]));
+ g_unsetenv (term_vars[i]);
+ }
+}
+
+static void
+restore_term_env (char *saved[])
+{
+ for (size_t i = 0; i < G_N_ELEMENTS (term_vars); i++)
+ {
+ if (saved[i] != NULL)
+ g_setenv (term_vars[i], saved[i], TRUE);
+ else
+ g_unsetenv (term_vars[i]);
+ g_free (saved[i]);
+ }
+}
+
+START_TEST (test_detect_protocol)
+{
+ char *saved[G_N_ELEMENTS (term_vars)];
+
+ save_and_clear_term_env (saved);
+
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+
+ g_setenv ("TERM_PROGRAM", "iTerm.app", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+
+ // inside tmux/screen the escape sequence would be swallowed
+ g_setenv ("TMUX", "/tmp/tmux-1000/default,1,0", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+ g_unsetenv ("TMUX");
+
+ g_setenv ("STY", "1234.pts-0.host", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+ g_unsetenv ("STY");
+
+ g_setenv ("TERM_PROGRAM", "Apple_Terminal", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+
+ g_setenv ("TERM_PROGRAM", "WezTerm", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+
+ g_setenv ("TERM_PROGRAM", "ghostty", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_KITTY);
+
+ // ssh case: only LC_TERMINAL is forwarded
+ g_unsetenv ("TERM_PROGRAM");
+ g_setenv ("LC_TERMINAL", "iTerm2", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+ g_unsetenv ("LC_TERMINAL");
+
+ g_setenv ("KITTY_WINDOW_ID", "1", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_KITTY);
+ g_unsetenv ("KITTY_WINDOW_ID");
+
+ g_setenv ("TERM", "xterm-kitty", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_KITTY);
+
+ restore_term_env (saved);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_detect_protocol_setting)
+{
+ char *saved[G_N_ELEMENTS (term_vars)];
+
+ save_and_clear_term_env (saved);
+
+ // unset setting means "auto"
+ mcview_image_protocol = NULL;
+ g_setenv ("TERM_PROGRAM", "iTerm.app", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+
+ mcview_image_protocol = (char *) "auto";
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+
+ // garbage falls back to autodetection
+ mcview_image_protocol = (char *) "bogus";
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+
+ mcview_image_protocol = (char *) "none";
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+
+ // explicit value overrides detection, case insensitive
+ mcview_image_protocol = (char *) "Kitty";
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_KITTY);
+
+ // ... and works in a terminal we know nothing about
+ g_unsetenv ("TERM_PROGRAM");
+ mcview_image_protocol = (char *) "iterm2";
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_ITERM2);
+ mcview_image_protocol = (char *) "auto";
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+
+ // but never inside a multiplexer
+ mcview_image_protocol = (char *) "kitty";
+ g_setenv ("TMUX", "/tmp/tmux-1000/default,1,0", TRUE);
+ ck_assert_int_eq (mcview_image_detect_protocol (), MCVIEW_IMAGE_NONE);
+
+ mcview_image_protocol = NULL;
+ restore_term_env (saved);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_png_size)
+{
+ int w = 0, h = 0;
+ unsigned char bad_chunk[24];
+
+ ck_assert (mcview_image_png_size (png_header, sizeof (png_header), &w, &h));
+ ck_assert_int_eq (w, 640);
+ ck_assert_int_eq (h, 480);
+
+ // truncated
+ ck_assert (!mcview_image_png_size (png_header, 23, &w, &h));
+
+ // first chunk is not IHDR
+ memcpy (bad_chunk, png_header, sizeof (bad_chunk));
+ bad_chunk[12] = 'X';
+ ck_assert (!mcview_image_png_size (bad_chunk, sizeof (bad_chunk), &w, &h));
+
+ // zero width
+ memcpy (bad_chunk, png_header, sizeof (bad_chunk));
+ bad_chunk[18] = bad_chunk[19] = 0;
+ ck_assert (!mcview_image_png_size (bad_chunk, sizeof (bad_chunk), &w, &h));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fit)
+{
+ WView view;
+ int cols, lines;
+
+ memset (&view, 0, sizeof (view));
+ view.data_area.cols = 80;
+ view.data_area.lines = 24;
+
+ // Without pixel size of the terminal in the test, a cell is assumed to be 1:2, so the box
+ // is 80x48 in "pixel" units.
+
+ // wide image is limited by width: 80x40 -> 40 rows of 2 units
+ view.image_width = 200;
+ view.image_height = 100;
+ mcview_image_fit (&view, &cols, &lines);
+ ck_assert_int_le (cols, 80);
+ ck_assert_int_le (lines, 24);
+ ck_assert_int_ge (cols, 1);
+ ck_assert_int_ge (lines, 1);
+
+ // very tall image is limited by height and never exceeds the area
+ view.image_width = 10;
+ view.image_height = 10000;
+ mcview_image_fit (&view, &cols, &lines);
+ ck_assert_int_eq (lines, 24);
+ ck_assert_int_eq (cols, 1);
+
+ // very wide image is limited by width and keeps at least one row
+ view.image_width = 10000;
+ view.image_height = 10;
+ mcview_image_fit (&view, &cols, &lines);
+ ck_assert_int_eq (cols, 80);
+ ck_assert_int_eq (lines, 1);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* @DataSource("image_load_ds") */
+/* *INDENT-OFF* */
+static const struct image_load_ds
+{
+ const char *term_program; // iTerm2 or kitty (ghostty)
+ const char *content;
+ gsize len;
+ gboolean in_panel;
+ gboolean expect_image;
+} image_load_ds[] = {
+ // PNG file in quick view panel
+ { "iTerm.app", (const char *) png_header, sizeof (png_header), TRUE, TRUE },
+ { "ghostty", (const char *) png_header, sizeof (png_header), TRUE, TRUE },
+ // the same file in the full screen viewer: no preview
+ { "iTerm.app", (const char *) png_header, sizeof (png_header), FALSE, FALSE },
+ // JPEG: iTerm2 decodes it, kitty protocol can't
+ { "iTerm.app", "\xFF\xD8\xFF\xE0\x00\x10JFIF", 10, TRUE, TRUE },
+ { "ghostty", "\xFF\xD8\xFF\xE0\x00\x10JFIF", 10, TRUE, FALSE },
+ // PNG signature without a valid IHDR: kitty needs the size
+ { "ghostty", "\x89PNG\r\n\x1a\nxxxxxxxxxxxxxxxx", 24, TRUE, FALSE },
+ // plain text
+ { "iTerm.app", "hello world\n", 12, TRUE, FALSE },
+ // empty file
+ { "iTerm.app", "", 0, TRUE, FALSE },
+};
+/* *INDENT-ON* */
+
+START_PARAMETRIZED_TEST (test_image_load, image_load_ds)
+{
+ // given
+ char *saved[G_N_ELEMENTS (term_vars)];
+ char *path = NULL;
+ GError *error = NULL;
+ int fd;
+ WView view;
+
+ save_and_clear_term_env (saved);
+ g_setenv ("TERM_PROGRAM", data->term_program, TRUE);
+
+ fd = g_file_open_tmp ("mc-viewer-image-XXXXXX", &path, &error);
+ ck_assert_msg (fd != -1, "cannot create temp file");
+ close (fd);
+ ck_assert (g_file_set_contents (path, data->content, (gssize) data->len, &error));
+
+ memset (&view, 0, sizeof (view));
+ view.dpy_frame_size = data->in_panel ? 1 : 0;
+ view.filename_vpath = vfs_path_from_str (path);
+ view.image_b64 = g_strdup ("stale"); // must be dropped on reload
+
+ // when
+ mcview_image_load (&view);
+
+ // then
+ if (data->expect_image)
+ {
+ gchar *expected = g_base64_encode ((const guchar *) data->content, data->len);
+
+ ck_assert_ptr_nonnull (view.image_b64);
+ ck_assert_str_eq (view.image_b64, expected);
+ g_free (expected);
+ ck_assert_int_eq (view.image_proto,
+ strcmp (data->term_program, "iTerm.app") == 0 ? MCVIEW_IMAGE_ITERM2
+ : MCVIEW_IMAGE_KITTY);
+ }
+ else
+ {
+ ck_assert_ptr_null (view.image_b64);
+ ck_assert_int_eq (view.image_proto, MCVIEW_IMAGE_NONE);
+ }
+
+ ck_assert_int_eq (mcview_image_active (&view), data->expect_image);
+
+ // in hex mode the raw bytes are shown instead of the picture
+ view.mode_flags.hex = TRUE;
+ ck_assert (!mcview_image_active (&view));
+
+ // cleanup
+ g_unlink (path);
+ g_free (path);
+ g_free (view.image_b64);
+ vfs_path_free (view.filename_vpath, TRUE);
+ restore_term_env (saved);
+}
+END_PARAMETRIZED_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_image_load_unsupported_terminal)
+{
+ char *saved[G_N_ELEMENTS (term_vars)];
+ char *path = NULL;
+ GError *error = NULL;
+ WView view;
+ int fd;
+
+ save_and_clear_term_env (saved);
+
+ fd = g_file_open_tmp ("mc-viewer-image-XXXXXX", &path, &error);
+ ck_assert_msg (fd != -1, "cannot create temp file");
+ close (fd);
+ ck_assert (g_file_set_contents (path, (const char *) png_header, sizeof (png_header), &error));
+
+ memset (&view, 0, sizeof (view));
+ view.dpy_frame_size = 1;
+ view.filename_vpath = vfs_path_from_str (path);
+
+ mcview_image_load (&view);
+
+ ck_assert_ptr_null (view.image_b64);
+
+ g_unlink (path);
+ g_free (path);
+ vfs_path_free (view.filename_vpath, TRUE);
+ restore_term_env (saved);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ TCase *tc_core;
+
+ tc_core = tcase_create ("Core");
+
+ tcase_add_checked_fixture (tc_core, setup, teardown);
+
+ // Add new tests here: ***************
+ tcase_add_test (tc_core, test_sniff_known_formats);
+ tcase_add_test (tc_core, test_sniff_rejects_other_data);
+ tcase_add_test (tc_core, test_sniff_truncated_data);
+ tcase_add_test (tc_core, test_detect_protocol);
+ tcase_add_test (tc_core, test_detect_protocol_setting);
+ tcase_add_test (tc_core, test_png_size);
+ tcase_add_test (tc_core, test_fit);
+ mctest_add_parameterized_test (tc_core, test_image_load, image_load_ds);
+ tcase_add_test (tc_core, test_image_load_unsupported_terminal);
+ // ***********************************
+
+ return mctest_run_all (tc_core);
+}
+
+/* --------------------------------------------------------------------------------------------- */