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); +} + +/* --------------------------------------------------------------------------------------------- */