From 5af90592526f81e4e1a644dea0441b09c9ca6c4a Mon Sep 17 00:00:00 2001
From: noctuum <25441068+noctuum@users.noreply.github.com>
Date: Fri, 13 Mar 2026 16:59:54 +0700
Subject: [PATCH 1/6] mcviewer (ansi.c): add ANSI SGR escape sequence parser.
Add a state machine parser for ANSI SGR (Select Graphic Rendition)
escape sequences. Supports standard colors (0-7), bright (8-15),
256-color (38;5;N / 48;5;N), and truecolor (38;2;R;G;B / 48;2;R;G;B)
which is approximated to the nearest xterm 256-color palette index.
Bold, underline, italic, and individual/global resets are handled.
Includes 23 unit tests covering color parsing, attribute tracking,
truecolor approximation, escape sequence edge cases, and combined
sequences.
Signed-off-by: noctuum <25441068+noctuum@users.noreply.github.com>
---
configure.ac | 1 +
src/viewer/Makefile.am | 1 +
src/viewer/ansi.c | 321 +++++++++++++++++++
src/viewer/ansi.h | 59 ++++
src/viewer/internal.h | 2 +
tests/src/Makefile.am | 2 +-
tests/src/viewer/Makefile.am | 22 ++
tests/src/viewer/ansi_parser.c | 569 +++++++++++++++++++++++++++++++++
8 files changed, 976 insertions(+), 1 deletion(-)
create mode 100644 src/viewer/ansi.c
create mode 100644 src/viewer/ansi.h
create mode 100644 tests/src/viewer/Makefile.am
create mode 100644 tests/src/viewer/ansi_parser.c
diff --git a/configure.ac b/configure.ac
index 5bdc626a87..a1f64185af 100644
--- a/configure.ac
+++ b/configure.ac
@@ -698,6 +698,7 @@ tests/src/Makefile
tests/src/filemanager/Makefile
tests/src/editor/Makefile
tests/src/editor/edit_complete_word_cmd_test_data.txt
+tests/src/viewer/Makefile
tests/src/vfs/Makefile
tests/src/vfs/extfs/Makefile
tests/src/vfs/extfs/helpers-list/Makefile
diff --git a/src/viewer/Makefile.am b/src/viewer/Makefile.am
index 9bf1648408..72a321dea2 100644
--- a/src/viewer/Makefile.am
+++ b/src/viewer/Makefile.am
@@ -3,6 +3,7 @@ noinst_LTLIBRARIES = libmcviewer.la
libmcviewer_la_SOURCES = \
actions_cmd.c \
+ ansi.c ansi.h \
ascii.c \
coord_cache.c \
datasource.c \
diff --git a/src/viewer/ansi.c b/src/viewer/ansi.c
new file mode 100644
index 0000000000..293c93af79
--- /dev/null
+++ b/src/viewer/ansi.c
@@ -0,0 +1,321 @@
+/*
+ lib/viewer - ANSI SGR escape sequence parser
+
+ 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 .
+ */
+
+/** \file ansi.c
+ * \brief Source: ANSI SGR escape sequence parser for mcview
+ *
+ * Parses ANSI escape sequences (ESC[...m) from byte stream and extracts
+ * foreground/background color, bold, and underline attributes.
+ *
+ * State machine: NORMAL --ESC--> ESCAPE --[--> CSI --digit/;--> CSI
+ * --m--> apply SGR, back to NORMAL
+ * --letter--> consume, back to NORMAL
+ * ESCAPE --non-[--> consume, back to NORMAL
+ */
+
+#include
+
+#include "lib/global.h"
+
+#include "ansi.h"
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+#define ESC_CHAR '\033'
+
+/*** file scope type declarations ****************************************************************/
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static int mcview_ansi_color_cube_index (int v);
+static int mcview_ansi_rgb_to_256 (int r, int g, int b);
+static void mcview_ansi_csi_finalize_param (mcview_ansi_state_t *state);
+static void mcview_ansi_apply_sgr (mcview_ansi_state_t *state);
+static void mcview_ansi_apply_one_sgr_param (mcview_ansi_state_t *state, int idx);
+
+/*** file scope variables ************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+
+/*** file scope functions ************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Map an RGB component (0-255) to the nearest 6x6x6 cube index (0-5).
+ * Cube component values: 0, 95, 135, 175, 215, 255.
+ * Thresholds are midpoints between adjacent values.
+ */
+static int
+mcview_ansi_color_cube_index (int v)
+{
+ if (v < 48)
+ return 0;
+ if (v < 115)
+ return 1;
+ if (v < 155)
+ return 2;
+ if (v < 195)
+ return 3;
+ if (v < 235)
+ return 4;
+ return 5;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Convert 24-bit RGB to the nearest xterm 256-color palette index.
+ * Uses the 6x6x6 color cube (indices 16-231).
+ */
+static int
+mcview_ansi_rgb_to_256 (int r, int g, int b)
+{
+ r = CLAMP (r, 0, 255);
+ g = CLAMP (g, 0, 255);
+ b = CLAMP (b, 0, 255);
+
+ return 16 + 36 * mcview_ansi_color_cube_index (r) + 6 * mcview_ansi_color_cube_index (g)
+ + mcview_ansi_color_cube_index (b);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Push the current parameter (if any) into params[] array.
+ */
+static void
+mcview_ansi_csi_finalize_param (mcview_ansi_state_t *state)
+{
+ if (state->has_current_param && state->param_count < MCVIEW_ANSI_MAX_PARAMS)
+ {
+ state->params[state->param_count] = state->current_param;
+ state->param_count++;
+ }
+
+ state->current_param = 0;
+ state->has_current_param = FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Apply one SGR parameter at index idx.
+ * Handles extended color sequences (38;5;N and 48;5;N) by consuming
+ * additional parameters from the params[] array.
+ */
+static void
+mcview_ansi_apply_one_sgr_param (mcview_ansi_state_t *state, int idx)
+{
+ int code;
+
+ if (idx >= state->param_count)
+ return;
+
+ code = state->params[idx];
+
+ if (code == 0)
+ {
+ // reset all
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bold = FALSE;
+ state->underline = FALSE;
+ }
+ else if (code == 1)
+ state->bold = TRUE;
+ else if (code == 4)
+ state->underline = TRUE;
+ else if (code == 22)
+ state->bold = FALSE;
+ else if (code == 24)
+ state->underline = FALSE;
+ else if (code >= 30 && code <= 37)
+ state->fg = code - 30;
+ else if (code == 38)
+ {
+ if (idx + 2 < state->param_count && state->params[idx + 1] == 5)
+ // extended foreground 256-color: 38;5;N
+ state->fg = state->params[idx + 2];
+ else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
+ // truecolor foreground: 38;2;R;G;B → approximate to 256-color
+ state->fg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
+ state->params[idx + 4]);
+ }
+ else if (code == 39)
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ else if (code >= 40 && code <= 47)
+ state->bg = code - 40;
+ else if (code == 48)
+ {
+ if (idx + 2 < state->param_count && state->params[idx + 1] == 5)
+ // extended background 256-color: 48;5;N
+ state->bg = state->params[idx + 2];
+ else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
+ // truecolor background: 48;2;R;G;B → approximate to 256-color
+ state->bg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
+ state->params[idx + 4]);
+ }
+ else if (code == 49)
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ else if (code >= 90 && code <= 97)
+ state->fg = code - 90 + 8;
+ else if (code >= 100 && code <= 107)
+ state->bg = code - 100 + 8;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Apply all accumulated SGR parameters to the color state.
+ * Called when 'm' terminates a CSI sequence.
+ */
+static void
+mcview_ansi_apply_sgr (mcview_ansi_state_t *state)
+{
+ int i;
+
+ // ESC[m with no params is equivalent to ESC[0m (reset)
+ if (state->param_count == 0)
+ {
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bold = FALSE;
+ state->underline = FALSE;
+ return;
+ }
+
+ for (i = 0; i < state->param_count; i++)
+ {
+ int code;
+
+ code = state->params[i];
+
+ // skip sub-parameters consumed by extended color sequences
+ if (code == 38 || code == 48)
+ {
+ mcview_ansi_apply_one_sgr_param (state, i);
+
+ if (i + 2 < state->param_count && state->params[i + 1] == 5)
+ i += 2; // 256-color: 38;5;N — skip 2
+ else if (i + 4 < state->param_count && state->params[i + 1] == 2)
+ i += 4; // truecolor: 38;2;R;G;B — skip 4
+ }
+ else
+ mcview_ansi_apply_one_sgr_param (state, i);
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/*** public functions ****************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+mcview_ansi_state_init (mcview_ansi_state_t *state)
+{
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bold = FALSE;
+ state->underline = FALSE;
+ state->in_escape = FALSE;
+ state->in_csi = FALSE;
+ state->param_count = 0;
+ state->current_param = 0;
+ state->has_current_param = FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+mcview_ansi_result_t
+mcview_ansi_parse_char (mcview_ansi_state_t *state, int ch)
+{
+ // State: just saw ESC, waiting for '['
+ if (state->in_escape)
+ {
+ state->in_escape = FALSE;
+
+ if (ch == '[')
+ {
+ // enter CSI mode
+ state->in_csi = TRUE;
+ state->param_count = 0;
+ state->current_param = 0;
+ state->has_current_param = FALSE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // ESC followed by non-'[': consume the char (e.g., ESC c = RIS)
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // State: inside CSI sequence (ESC[...)
+ if (state->in_csi)
+ {
+ if (ch >= '0' && ch <= '9')
+ {
+ // accumulate digit into current parameter (clamped to prevent overflow)
+ if (state->current_param <= 65535)
+ state->current_param = state->current_param * 10 + (ch - '0');
+ state->has_current_param = TRUE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ if (ch == ';')
+ {
+ // parameter separator
+ mcview_ansi_csi_finalize_param (state);
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // any letter (0x40-0x7E) terminates CSI
+ if (ch >= 0x40 && ch <= 0x7E)
+ {
+ mcview_ansi_csi_finalize_param (state);
+ state->in_csi = FALSE;
+
+ if (ch == 'm')
+ mcview_ansi_apply_sgr (state);
+
+ // non-'m' terminators: consume without applying to colors
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // intermediate bytes (0x20-0x3F excluding digits and ';'): consume
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // Normal state: check for ESC
+ if (ch == ESC_CHAR)
+ {
+ state->in_escape = TRUE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // regular displayable character
+ return ANSI_RESULT_CHAR;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/ansi.h b/src/viewer/ansi.h
new file mode 100644
index 0000000000..06d22d2ab8
--- /dev/null
+++ b/src/viewer/ansi.h
@@ -0,0 +1,59 @@
+/** \file ansi.h
+ * \brief Header: ANSI SGR escape sequence parser for mcview
+ */
+
+#ifndef MC__VIEWER_ANSI_H
+#define MC__VIEWER_ANSI_H
+
+#include "lib/global.h"
+
+/*** typedefs(not structures) and defined constants **********************************************/
+
+/** Maximum number of CSI parameters we track */
+#define MCVIEW_ANSI_MAX_PARAMS 16
+
+/** Default (unset) color value */
+#define MCVIEW_ANSI_COLOR_DEFAULT (-1)
+
+/*** enums ***************************************************************************************/
+
+/** Result of feeding one byte to the ANSI parser */
+typedef enum
+{
+ ANSI_RESULT_CHAR, /**< regular character — render with current color */
+ ANSI_RESULT_CONSUMED /**< escape sequence byte — skip, don't render */
+} mcview_ansi_result_t;
+
+/*** structures declarations (and typedefs of structures)*****************************************/
+
+/** ANSI SGR parser state */
+typedef struct
+{
+ /* --- public color state (read by renderer) --- */
+ int fg; /**< foreground: 0-7 base, 8-15 bright, -1 default */
+ int bg; /**< background: 0-7 base, 8-15 bright, -1 default */
+ gboolean bold;
+ gboolean underline;
+
+ /* --- internal parser state --- */
+ gboolean in_escape; /**< seen ESC, waiting for '[' */
+ gboolean in_csi; /**< inside CSI sequence (ESC[...) */
+ int params[MCVIEW_ANSI_MAX_PARAMS];
+ int param_count;
+ int current_param; /**< parameter being accumulated */
+ gboolean has_current_param; /**< whether current_param has digits */
+} mcview_ansi_state_t;
+
+/*** declarations of public functions ************************************************************/
+
+/** Initialize parser state to defaults (no color, not in escape) */
+void mcview_ansi_state_init (mcview_ansi_state_t *state);
+
+/** Feed one byte to the parser.
+ * Returns ANSI_RESULT_CHAR if the byte is a displayable character.
+ * Returns ANSI_RESULT_CONSUMED if the byte is part of an escape sequence. */
+mcview_ansi_result_t mcview_ansi_parse_char (mcview_ansi_state_t *state, int ch);
+
+/*** inline functions ****************************************************************************/
+
+#endif /* MC__VIEWER_ANSI_H */
diff --git a/src/viewer/internal.h b/src/viewer/internal.h
index 819c678fd6..197668f679 100644
--- a/src/viewer/internal.h
+++ b/src/viewer/internal.h
@@ -17,6 +17,7 @@
#include "src/filemanager/dir.h" // dir_list
#include "mcviewer.h"
+#include "ansi.h"
/*** typedefs(not structures) and defined constants **********************************************/
@@ -85,6 +86,7 @@ typedef struct
gboolean nroff_underscore_is_underlined; // whether _\b_ is underlined rather than bold
gboolean
print_lonely_combining; // whether lonely combining marks are printed on a dotted circle
+ mcview_ansi_state_t ansi; // ANSI SGR escape sequence parser state
} mcview_state_machine_t;
struct mcview_nroff_struct;
diff --git a/tests/src/Makefile.am b/tests/src/Makefile.am
index abba5ad1f2..62c49148d1 100644
--- a/tests/src/Makefile.am
+++ b/tests/src/Makefile.am
@@ -1,6 +1,6 @@
PACKAGE_STRING = "/src"
-SUBDIRS = . filemanager vfs
+SUBDIRS = . filemanager vfs viewer
if USE_INTERNAL_EDIT
SUBDIRS += editor
diff --git a/tests/src/viewer/Makefile.am b/tests/src/viewer/Makefile.am
new file mode 100644
index 0000000000..e6a185c59c
--- /dev/null
+++ b/tests/src/viewer/Makefile.am
@@ -0,0 +1,22 @@
+PACKAGE_STRING = "/src/viewer"
+
+AM_CPPFLAGS = \
+ $(GLIB_CFLAGS) \
+ -I$(top_srcdir) \
+ @CHECK_CFLAGS@
+
+LIBS = @CHECK_LIBS@ \
+ $(top_builddir)/src/libinternal.la \
+ $(top_builddir)/lib/libmc.la
+
+if ENABLE_MCLIB
+LIBS += $(GLIB_LIBS)
+endif
+
+TESTS = \
+ ansi_parser
+
+check_PROGRAMS = $(TESTS)
+
+ansi_parser_SOURCES = \
+ ansi_parser.c
diff --git a/tests/src/viewer/ansi_parser.c b/tests/src/viewer/ansi_parser.c
new file mode 100644
index 0000000000..dc69d03aa7
--- /dev/null
+++ b/tests/src/viewer/ansi_parser.c
@@ -0,0 +1,569 @@
+/*
+ src/viewer - ANSI SGR parser tests
+
+ 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 "src/viewer/ansi.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* helper: feed a string through the parser and collect displayable chars */
+static GString *
+parse_and_collect (mcview_ansi_state_t *state, const char *input)
+{
+ GString *result;
+ const char *p;
+
+ result = g_string_new ("");
+
+ for (p = input; *p != '\0'; p++)
+ {
+ mcview_ansi_result_t r;
+
+ r = mcview_ansi_parse_char (state, (unsigned char) *p);
+ if (r == ANSI_RESULT_CHAR)
+ g_string_append_c (result, *p);
+ }
+
+ return result;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: init sets default state */
+
+START_TEST (test_ansi_init_defaults)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ // when
+ mcview_ansi_state_init (&state);
+
+ // then
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+ mctest_assert_false (state.bold);
+ mctest_assert_false (state.underline);
+ mctest_assert_false (state.in_escape);
+ mctest_assert_false (state.in_csi);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: plain text passes through unchanged */
+
+START_TEST (test_ansi_plain_text_passthrough)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when
+ result = parse_and_collect (&state, "Hello, World!");
+
+ // then
+ mctest_assert_str_eq (result->str, "Hello, World!");
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ESC[ m (reset) is consumed, no visible output */
+
+START_TEST (test_ansi_reset_consumed)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[m between text
+ result = parse_and_collect (&state, "ab\033[mcd");
+
+ // then — only "abcd" visible
+ mctest_assert_str_eq (result->str, "abcd");
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ESC[0m explicit reset restores defaults */
+
+START_TEST (test_ansi_explicit_reset)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set red, then reset
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+ ck_assert_int_eq (state.fg, 1);
+
+ g_string_free (parse_and_collect (&state, "\033[0m"), TRUE);
+
+ // then — back to defaults
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+ mctest_assert_false (state.bold);
+ mctest_assert_false (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: foreground color SGR codes 30-37 */
+
+START_TEST (test_ansi_foreground_colors)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[31m = red foreground
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 1);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+
+ // when — ESC[34m = blue foreground
+ g_string_free (parse_and_collect (&state, "\033[34m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: background color SGR codes 40-47 */
+
+START_TEST (test_ansi_background_colors)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[42m = green background
+ g_string_free (parse_and_collect (&state, "\033[42m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.bg, 2);
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: bold SGR code 1 */
+
+START_TEST (test_ansi_bold)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[1m = bold
+ g_string_free (parse_and_collect (&state, "\033[1m"), TRUE);
+
+ // then
+ mctest_assert_true (state.bold);
+ mctest_assert_false (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: underline SGR code 4 */
+
+START_TEST (test_ansi_underline)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[4m = underline
+ g_string_free (parse_and_collect (&state, "\033[4m"), TRUE);
+
+ // then
+ mctest_assert_false (state.bold);
+ mctest_assert_true (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: combined parameters ESC[01;34m = bold + blue */
+
+START_TEST (test_ansi_combined_params)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[01;34m = bold blue (source-highlight typical output)
+ g_string_free (parse_and_collect (&state, "\033[01;34m"), TRUE);
+
+ // then
+ mctest_assert_true (state.bold);
+ ck_assert_int_eq (state.fg, 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: bright foreground colors 90-97 */
+
+START_TEST (test_ansi_bright_foreground)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[91m = bright red
+ g_string_free (parse_and_collect (&state, "\033[91m"), TRUE);
+
+ // then — bright colors map to 8-15
+ ck_assert_int_eq (state.fg, 9);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: bright background colors 100-107 */
+
+START_TEST (test_ansi_bright_background)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[101m = bright red background
+ g_string_free (parse_and_collect (&state, "\033[101m"), TRUE);
+
+ // then — bright bg colors map to 8-15
+ ck_assert_int_eq (state.bg, 9);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: color change without reset — state accumulates */
+
+START_TEST (test_ansi_color_change_without_reset)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set red, then set bold without resetting red
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+ g_string_free (parse_and_collect (&state, "\033[1m"), TRUE);
+
+ // then — both red and bold active
+ ck_assert_int_eq (state.fg, 1);
+ mctest_assert_true (state.bold);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: default foreground SGR 39, default background SGR 49 */
+
+START_TEST (test_ansi_default_color_codes)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set colors then reset individually
+ g_string_free (parse_and_collect (&state, "\033[31;42m"), TRUE);
+ ck_assert_int_eq (state.fg, 1);
+ ck_assert_int_eq (state.bg, 2);
+
+ g_string_free (parse_and_collect (&state, "\033[39m"), TRUE);
+
+ // then — fg reset, bg preserved
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, 2);
+
+ // when — reset bg
+ g_string_free (parse_and_collect (&state, "\033[49m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: non-SGR CSI sequence (e.g., cursor movement) is consumed but doesn't affect colors */
+
+START_TEST (test_ansi_non_sgr_csi_ignored)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // set a known color first
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+ ck_assert_int_eq (state.fg, 1);
+
+ // when — ESC[2J = clear screen (not SGR)
+ g_string_free (parse_and_collect (&state, "\033[2J"), TRUE);
+
+ // then — color unchanged, sequence consumed
+ ck_assert_int_eq (state.fg, 1);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: incomplete escape at end of input — chars consumed, no crash */
+
+START_TEST (test_ansi_incomplete_escape)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[ without terminator, then normal text in next call
+ result = parse_and_collect (&state, "ab\033[");
+ mctest_assert_str_eq (result->str, "ab");
+ g_string_free (result, TRUE);
+
+ // parser should be in CSI state
+ mctest_assert_true (state.in_csi);
+
+ // when — continue with normal text (CSI aborted by non-param/non-terminator)
+ result = parse_and_collect (&state, "31mX");
+
+ // then — the "31m" completes the CSI, "X" is displayable
+ mctest_assert_str_eq (result->str, "X");
+ ck_assert_int_eq (state.fg, 1);
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: 256-color foreground ESC[38;5;Nm */
+
+START_TEST (test_ansi_256_color_foreground)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[38;5;196m = 256-color red
+ g_string_free (parse_and_collect (&state, "\033[38;5;196m"), TRUE);
+
+ // then — fg set to 196
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: 256-color background ESC[48;5;Nm */
+
+START_TEST (test_ansi_256_color_background)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[48;5;82m = 256-color green bg
+ g_string_free (parse_and_collect (&state, "\033[48;5;82m"), TRUE);
+
+ // then — bg set to 82
+ ck_assert_int_eq (state.bg, 82);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: real source-highlight output pattern */
+
+START_TEST (test_ansi_source_highlight_pattern)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — typical source-highlight output: ESC[01;34m keyword ESC[m
+ result = parse_and_collect (&state, "\033[01;34mif\033[m (x)");
+
+ // then — visible text is "if (x)"
+ mctest_assert_str_eq (result->str, "if (x)");
+ // after reset, colors should be defaults
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ mctest_assert_false (state.bold);
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ESC followed by non-'[' is consumed (not CSI, just ESC + char) */
+
+START_TEST (test_ansi_esc_non_csi)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC followed by 'c' (not '[') — this is "RIS" reset
+ result = parse_and_collect (&state, "a\033cb");
+
+ // then — ESC and 'c' consumed, only 'a' and 'b' visible
+ mctest_assert_str_eq (result->str, "ab");
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor foreground ESC[38;2;R;G;Bm → approximate to 256-color */
+
+START_TEST (test_ansi_truecolor_foreground)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[38;2;255;0;0m = truecolor red → should map to 196
+ // cube: r=5 g=0 b=0 → 16 + 180 + 0 + 0 = 196
+ g_string_free (parse_and_collect (&state, "\033[38;2;255;0;0m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor background ESC[48;2;R;G;Bm */
+
+START_TEST (test_ansi_truecolor_background)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[48;2;0;0;255m = truecolor blue bg → should map to 21
+ // cube: r=0 g=0 b=5 → 16 + 0 + 0 + 5 = 21
+ g_string_free (parse_and_collect (&state, "\033[48;2;0;0;255m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.bg, 21);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor combined with bold in one sequence */
+
+START_TEST (test_ansi_truecolor_combined)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[1;38;2;255;128;0m = bold + truecolor orange fg
+ // cube: r=5 g=2 b=0 → 16 + 180 + 12 + 0 = 208
+ g_string_free (parse_and_collect (&state, "\033[1;38;2;255;128;0m"), TRUE);
+
+ // then
+ mctest_assert_true (state.bold);
+ ck_assert_int_eq (state.fg, 208);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor R;G;B values not misinterpreted as SGR codes */
+
+START_TEST (test_ansi_truecolor_no_misparse)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[38;2;100;150;200m — R=100 must NOT trigger bright-bg (100-107)
+ // cube: r=1 g=2 b=4 → 16 + 36 + 12 + 4 = 68
+ g_string_free (parse_and_collect (&state, "\033[38;2;100;150;200m"), TRUE);
+
+ // then — bg must stay default (not affected by R=100)
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.fg, 68);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ TCase *tc_core;
+
+ tc_core = tcase_create ("Core");
+
+ // Add new tests here: ***************
+ tcase_add_test (tc_core, test_ansi_init_defaults);
+ tcase_add_test (tc_core, test_ansi_plain_text_passthrough);
+ tcase_add_test (tc_core, test_ansi_reset_consumed);
+ tcase_add_test (tc_core, test_ansi_explicit_reset);
+ tcase_add_test (tc_core, test_ansi_foreground_colors);
+ tcase_add_test (tc_core, test_ansi_background_colors);
+ tcase_add_test (tc_core, test_ansi_bold);
+ tcase_add_test (tc_core, test_ansi_underline);
+ tcase_add_test (tc_core, test_ansi_combined_params);
+ tcase_add_test (tc_core, test_ansi_bright_foreground);
+ tcase_add_test (tc_core, test_ansi_bright_background);
+ tcase_add_test (tc_core, test_ansi_color_change_without_reset);
+ tcase_add_test (tc_core, test_ansi_default_color_codes);
+ tcase_add_test (tc_core, test_ansi_non_sgr_csi_ignored);
+ tcase_add_test (tc_core, test_ansi_incomplete_escape);
+ tcase_add_test (tc_core, test_ansi_256_color_foreground);
+ tcase_add_test (tc_core, test_ansi_256_color_background);
+ tcase_add_test (tc_core, test_ansi_source_highlight_pattern);
+ tcase_add_test (tc_core, test_ansi_esc_non_csi);
+ tcase_add_test (tc_core, test_ansi_truecolor_foreground);
+ tcase_add_test (tc_core, test_ansi_truecolor_background);
+ tcase_add_test (tc_core, test_ansi_truecolor_combined);
+ tcase_add_test (tc_core, test_ansi_truecolor_no_misparse);
+ // ***********************************
+
+ return mctest_run_all (tc_core);
+}
+
+/* --------------------------------------------------------------------------------------------- */
From b7c92e5d7b7122f7443bbd31bb90fde6dc0f54cf Mon Sep 17 00:00:00 2001
From: noctuum <25441068+noctuum@users.noreply.github.com>
Date: Fri, 13 Mar 2026 16:59:54 +0700
Subject: [PATCH 2/6] mcviewer (ascii.c): wire ANSI parser into text rendering
pipeline.
Integrate the ANSI SGR parser into mcview's text display pipeline.
When syntax mode is active, bytes are fed through the parser and
ANSI escape sequences are consumed while displayable characters
are rendered with the appropriate colors.
Key additions:
- mcview_ansi_get_color(): maps ANSI parser state to MC color pairs,
inheriting the viewer skin's fg/bg when ANSI values are default
(fixes background mismatch with source-highlight)
- mcview_get_next_maybe_ansi_char(): new rendering layer between raw
byte reading and nroff processing
- mcview_fill_line_remaining(): fills remaining columns on each line
with spaces using the last drawn color, giving full-width background
- syntax_fill_color tracking across lines for consistent empty-line
coloring
Signed-off-by: noctuum <25441068+noctuum@users.noreply.github.com>
---
src/viewer/ascii.c | 166 +++++++++++++++++++++++++++++++++++++++++-
src/viewer/display.c | 1 +
src/viewer/internal.h | 1 +
src/viewer/mcviewer.h | 9 ++-
4 files changed, 172 insertions(+), 5 deletions(-)
diff --git a/src/viewer/ascii.c b/src/viewer/ascii.c
index fa0773bc84..07c813978c 100644
--- a/src/viewer/ascii.c
+++ b/src/viewer/ascii.c
@@ -148,6 +148,7 @@
#include "lib/global.h"
#include "lib/tty/tty.h"
+#include "lib/tty/color-internal.h" // tty_color_get_name_by_index()
#include "lib/skin.h"
#include "lib/util.h" // is_printable()
#include "lib/charsets.h"
@@ -334,6 +335,131 @@ mcview_get_next_char (WView *view, mcview_state_machine_t *state, int *c)
return TRUE;
}
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * Convert ANSI parser color state to a tty color pair index.
+ *
+ * Maps the fg/bg/bold/underline from the ANSI parser into MC's color system.
+ * When all attributes are default, returns VIEWER_NORMAL_COLOR to avoid
+ * unnecessary color pair allocation.
+ */
+static int
+mcview_ansi_get_color (const mcview_ansi_state_t *ansi)
+{
+ tty_color_pair_t color;
+ tty_color_pair_t *viewer_skin;
+ char fg_buf[16], bg_buf[16], attr_buf[32];
+ const char *fg_name;
+ const char *bg_name;
+
+ // all defaults → use the skin's normal viewer color
+ if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT
+ && !ansi->bold && !ansi->underline)
+ return VIEWER_NORMAL_COLOR;
+
+ // bold-only and underline-only map to existing skin colors
+ if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT)
+ {
+ if (ansi->bold && ansi->underline)
+ return VIEWER_BOLD_UNDERLINED_COLOR;
+ if (ansi->bold)
+ return VIEWER_BOLD_COLOR;
+ if (ansi->underline)
+ return VIEWER_UNDERLINED_COLOR;
+ }
+
+ // Retrieve viewer skin colors so that ANSI-colored text inherits the
+ // viewer's fg/bg rather than the terminal's "default" colors.
+ viewer_skin =
+ (tty_color_pair_t *) g_hash_table_lookup (mc_skin__default.colors, "viewer._default_");
+
+ // build fg color name
+ if (ansi->fg != MCVIEW_ANSI_COLOR_DEFAULT)
+ {
+ fg_name = tty_color_get_name_by_index (ansi->fg);
+ g_strlcpy (fg_buf, fg_name, sizeof (fg_buf));
+ color.fg = fg_buf;
+ }
+ else
+ color.fg = (viewer_skin != NULL) ? viewer_skin->fg : NULL;
+
+ // build bg color name
+ if (ansi->bg != MCVIEW_ANSI_COLOR_DEFAULT)
+ {
+ bg_name = tty_color_get_name_by_index (ansi->bg);
+ g_strlcpy (bg_buf, bg_name, sizeof (bg_buf));
+ color.bg = bg_buf;
+ }
+ else
+ color.bg = (viewer_skin != NULL) ? viewer_skin->bg : NULL;
+
+ // build attributes
+ if (ansi->bold && ansi->underline)
+ {
+ g_strlcpy (attr_buf, "bold+underline", sizeof (attr_buf));
+ color.attrs = attr_buf;
+ }
+ else if (ansi->bold)
+ {
+ g_strlcpy (attr_buf, "bold", sizeof (attr_buf));
+ color.attrs = attr_buf;
+ }
+ else if (ansi->underline)
+ {
+ g_strlcpy (attr_buf, "underline", sizeof (attr_buf));
+ color.attrs = attr_buf;
+ }
+ else
+ color.attrs = NULL;
+
+ color.pair_index = 0;
+
+ return tty_try_alloc_color_pair (&color, TRUE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * ANSI escape sequence processing layer.
+ *
+ * Feeds characters through the ANSI SGR parser. Escape sequence bytes are
+ * consumed (skipped), and displayable characters are returned with the
+ * accumulated ANSI color. This layer sits between raw byte reading and
+ * nroff processing.
+ *
+ * Normally: stores c and color, updates state, returns TRUE.
+ * At EOF: state is unchanged, c and color are undefined, returns FALSE.
+ *
+ * color can be NULL if the caller doesn't care.
+ */
+static gboolean
+mcview_get_next_maybe_ansi_char (WView *view, mcview_state_machine_t *state, int *c, int *color)
+{
+ while (TRUE)
+ {
+ mcview_state_machine_t state_saved;
+ mcview_ansi_result_t result;
+
+ state_saved = *state;
+
+ if (!mcview_get_next_char (view, state, c))
+ {
+ *state = state_saved;
+ return FALSE;
+ }
+
+ result = mcview_ansi_parse_char (&state->ansi, *c);
+
+ if (result == ANSI_RESULT_CHAR)
+ {
+ if (color != NULL)
+ *color = mcview_ansi_get_color (&state->ansi);
+ return TRUE;
+ }
+
+ // ANSI_RESULT_CONSUMED — escape sequence byte, skip and read next
+ }
+}
+
/* --------------------------------------------------------------------------------------------- */
/**
* This function parses the next nroff character and gives it to you along with its desired color,
@@ -364,7 +490,7 @@ mcview_get_next_maybe_nroff_char (WView *view, mcview_state_machine_t *state, in
*color = VIEWER_NORMAL_COLOR;
if (!view->mode_flags.nroff)
- return mcview_get_next_char (view, state, c);
+ return mcview_get_next_maybe_ansi_char (view, state, c, color);
if (!mcview_get_next_char (view, state, c))
return FALSE;
@@ -522,6 +648,31 @@ mcview_next_combining_char_sequence (WView *view, mcview_state_machine_t *state,
return i;
}
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * In syntax mode, fill remaining columns on a visible row with spaces
+ * using the given color, extending the line's background to the viewport edge.
+ */
+static void
+mcview_fill_line_remaining (WView *view, int row, int col, int fill_color, off_t dpy_text_column)
+{
+ const WRect *r = &view->data_area;
+ int scr_col;
+ int x;
+
+ if (!view->mode_flags.syntax || row < 0 || row >= r->lines)
+ return;
+
+ scr_col = ((off_t) col > dpy_text_column) ? (int) ((off_t) col - dpy_text_column) : 0;
+ if (scr_col >= r->cols)
+ return;
+
+ tty_setcolor (fill_color);
+ widget_gotoyx (view, r->y + row, r->x + scr_col);
+ for (x = scr_col; x < r->cols; x++)
+ tty_print_char (' ');
+}
+
/* --------------------------------------------------------------------------------------------- */
/**
* Parse, format and possibly display one visual line of text.
@@ -554,6 +705,7 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
const WRect *r = &view->data_area;
off_t dpy_text_column = view->mode_flags.wrap ? 0 : view->dpy_text_column;
int col = 0;
+ int fill_color = view->syntax_fill_color;
int cs[1 + MAX_COMBINING_CHARS];
char str[(1 + MAX_COMBINING_CHARS) * MB_LEN_MAX + 1];
int i, j;
@@ -586,6 +738,7 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
n = mcview_next_combining_char_sequence (view, state, cs, 1 + MAX_COMBINING_CHARS, &color);
if (n == 0)
{
+ mcview_fill_line_remaining (view, row, col, fill_color, dpy_text_column);
if (linewidth != NULL)
*linewidth = col;
return (col > 0) ? 1 : 0;
@@ -596,6 +749,13 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
if (cs[0] == '\n')
{
+ // For empty lines (col==0), use the newline's own color which may
+ // carry the syntax highlighter's theme background from ANSI state.
+ // For non-empty lines, use the last drawn character's color.
+ int line_fill = (col > 0) ? fill_color : color;
+
+ mcview_fill_line_remaining (view, row, col, line_fill, dpy_text_column);
+
// New line: reset all formatting state for the next paragraph.
mcview_state_machine_init (state, state->offset);
if (linewidth != NULL)
@@ -695,6 +855,9 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
tty_print_anychar ((cs[0] == '\t') ? ' ' : PARTIAL_CJK_AT_RIGHT_MARGIN);
}
}
+
+ fill_color = color;
+ view->syntax_fill_color = color;
}
col += charwidth;
@@ -1022,6 +1185,7 @@ mcview_state_machine_init (mcview_state_machine_t *state, off_t offset)
memset (state, 0, sizeof (*state));
state->offset = offset;
state->print_lonely_combining = TRUE;
+ mcview_ansi_state_init (&state->ansi);
}
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/display.c b/src/viewer/display.c
index 1284b10f3b..c053039f34 100644
--- a/src/viewer/display.c
+++ b/src/viewer/display.c
@@ -363,6 +363,7 @@ mcview_display_clean (WView *view)
widget_erase (w);
if (mcview_is_in_panel (view))
mcview_display_frame (view);
+ view->syntax_fill_color = VIEWER_NORMAL_COLOR;
}
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/internal.h b/src/viewer/internal.h
index 197668f679..676dc00be4 100644
--- a/src/viewer/internal.h
+++ b/src/viewer/internal.h
@@ -156,6 +156,7 @@ struct WView
* text mode */
int cursor_col; // Cursor column
int cursor_row; // Cursor row
+ int syntax_fill_color; // Last drawn char color, for filling empty lines in syntax mode
struct hexedit_change_node *change_list; // Linked list of changes
WRect status_area; // Where the status line is displayed
WRect ruler_area; // Where the ruler is displayed
diff --git a/src/viewer/mcviewer.h b/src/viewer/mcviewer.h
index 29af9174d4..a5d2fb040a 100644
--- a/src/viewer/mcviewer.h
+++ b/src/viewer/mcviewer.h
@@ -19,10 +19,11 @@ typedef struct WView WView;
typedef struct
{
- gboolean wrap; // Wrap text lines to fit them on the screen
- gboolean hex; // Plainview or hexview
- gboolean magic; // Preprocess the file using external programs
- gboolean nroff; // Nroff-style highlighting
+ gboolean wrap; // Wrap text lines to fit them on the screen
+ gboolean hex; // Plainview or hexview
+ gboolean magic; // Preprocess the file using external programs
+ gboolean nroff; // Nroff-style highlighting
+ gboolean syntax; // Syntax highlighting via external command
} mcview_mode_flags_t;
/*** global variables defined in .c file *********************************************************/
From 40c5f38f739938b57052f219d02fc1134e34dfd3 Mon Sep 17 00:00:00 2001
From: noctuum <25441068+noctuum@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:27:48 +0700
Subject: [PATCH 3/6] mcviewer: add syntax highlighting via configurable
external command.
Pipe files through an external syntax highlighter and render the
ANSI-colored output in the internal viewer. Five backends supported
out of the box: bat, chroma, highlight, pygmentize, source-highlight.
Default is source-highlight; user can also set an arbitrary command
via the Shift-S "Custom..." dialog.
Implementation details:
- syntax.c: backend registry, command building with g_shell_quote(),
binary detection via g_find_program_in_path(), options dialog
- mcviewer.c: drain loop for accurate file-size percentage,
empty-output detection with friendly fallback to plain view
- growbuf.c: suppress redundant error dialog in syntax mode
- Toggle with 's' key; Shift-S opens backend chooser
- Syntax and nroff modes are mutually exclusive
- chroma uses --fail to exit fast on unrecognized files
- pygmentize uses -O stripnl=False to preserve leading blank lines
- Status bar shows short backend name: [bat], [chrm], [hi], [pyg],
[src-hl], or [ext] with the actual file type
Includes 12 unit tests for command parsing and binary extraction.
Signed-off-by: noctuum <25441068+noctuum@users.noreply.github.com>
---
lib/keybind.c | 2 +
lib/keybind.h | 2 +
misc/mc.default.keymap | 2 +
misc/mc.emacs.keymap | 2 +
misc/mc.vim.keymap | 2 +
src/keymap.c | 2 +
src/setup.c | 3 +
src/viewer/Makefile.am | 3 +-
src/viewer/actions_cmd.c | 35 +++-
src/viewer/display.c | 129 +++++++++++-
src/viewer/growbuf.c | 28 ++-
src/viewer/internal.h | 4 +
src/viewer/lib.c | 75 +++++++
src/viewer/mcviewer.c | 129 ++++++++++--
src/viewer/syntax.c | 372 ++++++++++++++++++++++++++++++++++
src/viewer/syntax.h | 47 +++++
tests/src/viewer/Makefile.am | 6 +-
tests/src/viewer/syntax_cmd.c | 263 ++++++++++++++++++++++++
18 files changed, 1078 insertions(+), 28 deletions(-)
create mode 100644 src/viewer/syntax.c
create mode 100644 src/viewer/syntax.h
create mode 100644 tests/src/viewer/syntax_cmd.c
diff --git a/lib/keybind.c b/lib/keybind.c
index 194f3ecbc7..887ab3c7dd 100644
--- a/lib/keybind.c
+++ b/lib/keybind.c
@@ -350,6 +350,8 @@ static name_keymap_t command_names[] = {
ADD_KEYMAP_NAME (HexMode),
ADD_KEYMAP_NAME (MagicMode),
ADD_KEYMAP_NAME (NroffMode),
+ ADD_KEYMAP_NAME (SyntaxMode),
+ ADD_KEYMAP_NAME (SyntaxOptions),
ADD_KEYMAP_NAME (BookmarkGoto),
ADD_KEYMAP_NAME (Ruler),
ADD_KEYMAP_NAME (SearchForward),
diff --git a/lib/keybind.h b/lib/keybind.h
index 28be23559e..6b74fa2d5c 100644
--- a/lib/keybind.h
+++ b/lib/keybind.h
@@ -319,6 +319,8 @@ enum
CK_WrapMode = 600L,
CK_MagicMode,
CK_NroffMode,
+ CK_SyntaxMode,
+ CK_SyntaxOptions,
CK_HexMode,
CK_HexEditMode,
CK_BookmarkGoto,
diff --git a/misc/mc.default.keymap b/misc/mc.default.keymap
index 7e107805e6..3f3f9dd5d2 100644
--- a/misc/mc.default.keymap
+++ b/misc/mc.default.keymap
@@ -419,6 +419,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
+SyntaxMode = s
+SyntaxOptions = shift-s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/misc/mc.emacs.keymap b/misc/mc.emacs.keymap
index b03fc8e68c..e291254348 100644
--- a/misc/mc.emacs.keymap
+++ b/misc/mc.emacs.keymap
@@ -421,6 +421,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
+SyntaxMode = s
+SyntaxOptions = shift-s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/misc/mc.vim.keymap b/misc/mc.vim.keymap
index e790b3c6c1..7df1944ceb 100644
--- a/misc/mc.vim.keymap
+++ b/misc/mc.vim.keymap
@@ -288,6 +288,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
+SyntaxMode = shift-s
+SyntaxOptions = alt-s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/src/keymap.c b/src/keymap.c
index 35ef2759eb..1713363a2a 100644
--- a/src/keymap.c
+++ b/src/keymap.c
@@ -563,6 +563,8 @@ static const global_keymap_ini_t default_viewer_keymap[] = {
{ "SearchBackwardContinue", "ctrl-r" },
{ "SearchOppositeContinue", "shift-n" },
{ "History", "alt-shift-e" },
+ { "SyntaxMode", "s" },
+ { "SyntaxOptions", "shift-s" },
{
NULL,
NULL,
diff --git a/src/setup.c b/src/setup.c
index aaa32bd6ad..abdb219e47 100644
--- a/src/setup.c
+++ b/src/setup.c
@@ -68,6 +68,7 @@
#endif
#include "src/viewer/mcviewer.h" // For the externs
+#include "src/viewer/syntax.h" // mcview_syntax_command
#include "setup.h"
@@ -310,6 +311,7 @@ static const struct
{ "mouse_close_dialog", &mouse_close_dialog },
{ "drop_menus", &drop_menus },
{ "wrap_mode", &mcview_global_flags.wrap },
+ { "syntax_mode", &mcview_global_flags.syntax },
{ "old_esc_mode", &old_esc_mode },
{ "cd_symlinks", &mc_global.vfs.cd_symlinks },
{ "show_all_if_ambiguous", &mc_global.widget.show_all_if_ambiguous },
@@ -418,6 +420,7 @@ static const struct
{ "editor_stop_format_chars", &edit_options.stop_format_chars, "-+*\\,.;:&>" },
#endif
{ "mcview_eof", &mcview_show_eof, "" },
+ { "syntax_command", &mcview_syntax_command, MCVIEW_SYNTAX_DEFAULT_CMD },
{ NULL, NULL, NULL },
};
diff --git a/src/viewer/Makefile.am b/src/viewer/Makefile.am
index 72a321dea2..0f9c3ead68 100644
--- a/src/viewer/Makefile.am
+++ b/src/viewer/Makefile.am
@@ -17,6 +17,7 @@ libmcviewer_la_SOURCES = \
mcviewer.h \
move.c \
nroff.c \
- search.c
+ search.c \
+ syntax.c syntax.h
AM_CPPFLAGS = -I$(top_srcdir) $(GLIB_CFLAGS)
diff --git a/src/viewer/actions_cmd.c b/src/viewer/actions_cmd.c
index c51c80727a..856bf123fd 100644
--- a/src/viewer/actions_cmd.c
+++ b/src/viewer/actions_cmd.c
@@ -67,6 +67,7 @@
#include "src/keymap.h"
#include "internal.h"
+#include "syntax.h"
/*** global variables ****************************************************************************/
@@ -156,7 +157,14 @@ mcview_hook (void *v)
if (fe == NULL)
return;
- mcview_done (view);
+ // save global flags — quick view panel refresh must not clobber
+ // flags set by the standalone viewer (e.g. syntax mode toggled via F3)
+ {
+ mcview_mode_flags_t saved_global = mcview_global_flags;
+
+ mcview_done (view);
+ mcview_global_flags = saved_global;
+ }
mcview_init (view);
mcview_load (view, 0, fe->fname->str, 0, 0, 0);
mcview_display (view);
@@ -466,6 +474,31 @@ mcview_execute_cmd (WView *view, long command)
case CK_NroffMode:
mcview_toggle_nroff_mode (view);
break;
+ case CK_SyntaxMode:
+ // warn if trying to enable syntax but highlighter is missing
+ if (!view->mode_flags.syntax && !mcview_syntax_command_available ())
+ {
+ message (D_ERROR, _ ("Syntax Highlighting"), "%s",
+ _ ("No syntax highlighting tool found.\n"
+ "Please install one of:\n"
+ " bat, chroma, highlight,\n"
+ " pygmentize, source-highlight\n"
+ "or press Shift-S to set a custom command."));
+ break;
+ }
+ mcview_toggle_syntax_mode (view);
+ break;
+ case CK_SyntaxOptions:
+ if (mcview_syntax_options_dialog ())
+ {
+ // backend changed — re-toggle to reload with new command
+ if (view->mode_flags.syntax)
+ {
+ view->mode_flags.syntax = FALSE;
+ mcview_toggle_syntax_mode (view);
+ }
+ }
+ break;
case CK_Home:
mcview_moveto_bol (view);
break;
diff --git a/src/viewer/display.c b/src/viewer/display.c
index c053039f34..fe8916043b 100644
--- a/src/viewer/display.c
+++ b/src/viewer/display.c
@@ -49,6 +49,8 @@
#include "src/keymap.h"
#include "internal.h"
+#include "ansi.h" // mcview_ansi_parse_char()
+#include "syntax.h" // mcview_syntax_get_short_name()
/*** global variables ****************************************************************************/
@@ -69,6 +71,35 @@ static enum ruler_type { RULER_NONE, RULER_TOP, RULER_BOTTOM } ruler = RULER_NON
/*** file scope functions ************************************************************************/
/* --------------------------------------------------------------------------------------------- */
+/**
+ * Count content (non-ANSI) bytes in the highlighted stream from 0 to end_offset.
+ * This gives the exact byte position in the original file.
+ */
+static off_t
+mcview_syntax_content_bytes (WView *view, off_t end_offset)
+{
+ mcview_ansi_state_t parser;
+ off_t i;
+ off_t count = 0;
+
+ mcview_ansi_state_init (&parser);
+
+ for (i = 0; i < end_offset; i++)
+ {
+ int byte_val;
+
+ if (!mcview_get_byte (view, i, &byte_val))
+ break;
+
+ if (mcview_ansi_parse_char (&parser, byte_val) == ANSI_RESULT_CHAR)
+ count++;
+ }
+
+ return count;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
/** Define labels and handlers for functional keys */
static void
@@ -163,6 +194,59 @@ mcview_display_status (WView *view)
widget_gotoyx (view, r->y, r->cols - 32);
if (view->mode_flags.hex)
tty_printf ("0x%08" PRIxMAX, (uintmax_t) view->hex_cursor);
+ else if (view->mode_flags.syntax && view->filename_vpath != NULL)
+ {
+ // In syntax mode, byte offsets are inflated by ANSI escape codes.
+ // Use cached file size and content byte count to avoid repeated
+ // mc_stat() calls and O(N) scans on every status bar repaint.
+
+ // cache file size (doesn't change while viewing)
+ if (view->syntax_file_size == 0)
+ {
+ struct stat st;
+
+ if (mc_stat (view->filename_vpath, &st) == 0)
+ view->syntax_file_size = st.st_size;
+ }
+
+ if (view->syntax_file_size > 0)
+ {
+ char buffer[BUF_TRUNC_LEN + 1];
+ off_t original_pos;
+ int percent;
+ int right;
+
+ // cache content byte scan (only rescan when scroll position changes)
+ if (view->dpy_end != view->syntax_content_cache_end)
+ {
+ view->syntax_content_cache_bytes =
+ mcview_syntax_content_bytes (view, view->dpy_end);
+ view->syntax_content_cache_end = view->dpy_end;
+ }
+ original_pos = view->syntax_content_cache_bytes;
+
+ size_trunc_len (buffer, BUF_TRUNC_LEN, view->syntax_file_size, 0,
+ panels_options.kilobyte_si);
+ tty_printf ("%9" PRIuMAX "/%s ", (uintmax_t) original_pos, buffer);
+
+ // draw percent at the standard position
+ if (r->cols > 26)
+ {
+ right = r->x + r->cols;
+
+ if (view->syntax_file_size == 0 || original_pos >= view->syntax_file_size)
+ percent = 100;
+ else if (original_pos > (INT_MAX / 100))
+ percent = (int) (original_pos / (view->syntax_file_size / 100));
+ else
+ percent = (int) (original_pos * 100 / view->syntax_file_size);
+
+ widget_gotoyx (view, r->y, right - 4);
+ tty_printf ("%3d%%", percent);
+ widget_gotoyx (view, r->y, right - 1);
+ }
+ }
+ }
else
{
char buffer[BUF_TRUNC_LEN + 1];
@@ -177,11 +261,52 @@ mcview_display_status (WView *view)
}
widget_gotoyx (view, r->y, r->x);
if (r->cols > 40)
- tty_print_string (str_fit_to_term (file_label, r->cols - 34, J_LEFT_FIT));
+ {
+ int file_width = r->cols - 34;
+
+ if (view->mode_flags.syntax && file_width > 20)
+ {
+ const char *ext = NULL;
+ const char *backend;
+ int tag_width;
+
+ // extract file extension to show as syntax hint
+ if (view->filename_vpath != NULL)
+ {
+ const char *fname;
+
+ fname = vfs_path_get_last_path_str (view->filename_vpath);
+ if (fname != NULL)
+ ext = strrchr (fname, '.');
+ }
+
+ if (ext != NULL)
+ ext++; // skip the dot
+ else
+ ext = "txt";
+
+ backend = mcview_syntax_get_short_name ();
+
+ // format: " [backend:ext]"
+ tag_width = (int) strlen (backend) + (int) strlen (ext) + 4;
+ file_width -= tag_width;
+ if (file_width < 1)
+ file_width = 1;
+ tty_print_string (str_fit_to_term (file_label, file_width, J_LEFT_FIT));
+ tty_printf (" [%s:%s]", backend, ext);
+ }
+ else
+ tty_print_string (str_fit_to_term (file_label, file_width, J_LEFT_FIT));
+ }
else
tty_print_string (str_fit_to_term (file_label, r->cols - 5, J_LEFT_FIT));
- if (r->cols > 26)
+ if (r->cols > 26 && !view->mode_flags.syntax)
mcview_display_percent (view, view->mode_flags.hex ? view->hex_cursor : view->dpy_end);
+ else if (view->mode_flags.syntax)
+ {
+ // percent was drawn earlier; park cursor at end of status bar
+ widget_gotoyx (view, r->y, r->x + r->cols - 1);
+ }
}
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/growbuf.c b/src/viewer/growbuf.c
index e843f8afcb..9288002d40 100644
--- a/src/viewer/growbuf.c
+++ b/src/viewer/growbuf.c
@@ -182,17 +182,23 @@ mcview_growbuf_read_until (WView *view, off_t ofs)
*/
view->pipe_first_err_msg = FALSE;
- mcview_show_error (view, NULL, sp->err.buf);
-
- /* when switch from parse to raw mode and back,
- * do not close the already closed pipe (see call to mcview_growbuf_done below).
- * return from here since (sp == view->ds_stdio_pipe) would now be invalid.
- * NOTE: this check was removed by ticket #4103 but the above call to
- * mcview_show_error triggers the stdio pipe handle to be closed:
- * mcview_close_datasource -> mcview_growbuf_done
- */
- if (view->ds_stdio_pipe == NULL)
- return;
+ // In syntax mode, don't show error dialog here — mcview_load() handles
+ // the fallback message after detecting empty output. For non-syntax pipes,
+ // show the raw stderr message as before.
+ if (!view->mode_flags.syntax)
+ {
+ mcview_show_error (view, NULL, sp->err.buf);
+
+ /* when switch from parse to raw mode and back,
+ * do not close the already closed pipe (see call to mcview_growbuf_done below).
+ * return from here since (sp == view->ds_stdio_pipe) would now be invalid.
+ * NOTE: this check was removed by ticket #4103 but the above call to
+ * mcview_show_error triggers the stdio pipe handle to be closed:
+ * mcview_close_datasource -> mcview_growbuf_done
+ */
+ if (view->ds_stdio_pipe == NULL)
+ return;
+ }
}
if (sp->out.len > 0)
diff --git a/src/viewer/internal.h b/src/viewer/internal.h
index 676dc00be4..3440fc3e53 100644
--- a/src/viewer/internal.h
+++ b/src/viewer/internal.h
@@ -157,6 +157,9 @@ struct WView
int cursor_col; // Cursor column
int cursor_row; // Cursor row
int syntax_fill_color; // Last drawn char color, for filling empty lines in syntax mode
+ off_t syntax_file_size; // Cached st_size for syntax status bar (0 = not yet cached)
+ off_t syntax_content_cache_end; // dpy_end used for cached content bytes (-1 = invalid)
+ off_t syntax_content_cache_bytes; // Cached result of content byte scan
struct hexedit_change_node *change_list; // Linked list of changes
WRect status_area; // Where the status line is displayed
WRect ruler_area; // Where the ruler is displayed
@@ -290,6 +293,7 @@ void mcview_enqueue_change (struct hexedit_change_node **head, struct hexedit_ch
void mcview_toggle_magic_mode (WView *view);
void mcview_toggle_wrap_mode (WView *view);
void mcview_toggle_nroff_mode (WView *view);
+void mcview_toggle_syntax_mode (WView *view);
void mcview_toggle_hex_mode (WView *view);
void mcview_init (WView *view);
void mcview_done (WView *view);
diff --git a/src/viewer/lib.c b/src/viewer/lib.c
index 5de9313c96..5d780a731e 100644
--- a/src/viewer/lib.c
+++ b/src/viewer/lib.c
@@ -49,6 +49,7 @@
#include "src/util.h" // file_error_message()
#include "internal.h"
+#include "syntax.h"
/*** global variables ****************************************************************************/
@@ -119,6 +120,77 @@ mcview_toggle_nroff_mode (WView *view)
/* --------------------------------------------------------------------------------------------- */
+void
+mcview_toggle_syntax_mode (WView *view)
+{
+ char *filename;
+ dir_list *dir;
+ int *dir_idx;
+ off_t saved_line, saved_col;
+
+ // save position as line number (byte offsets differ between file and pipe)
+ mcview_offset_to_coord (view, &saved_line, &saved_col, view->dpy_start);
+
+ view->mode_flags.syntax = !view->mode_flags.syntax;
+ mcview_altered_flags.syntax = TRUE;
+
+ // syntax and nroff are mutually exclusive
+ if (view->mode_flags.syntax && view->mode_flags.nroff)
+ {
+ view->mode_flags.nroff = FALSE;
+ mcview_altered_flags.nroff = TRUE;
+ }
+
+ // reinit view
+ filename = g_strdup (vfs_path_as_str (view->filename_vpath));
+ dir = view->dir;
+ dir_idx = view->dir_idx;
+ view->dir = NULL;
+ view->dir_idx = NULL;
+ mcview_done (view);
+ mcview_init (view);
+
+ if (view->mode_flags.syntax && filename != NULL && filename[0] != '\0')
+ {
+ const char *cmd_template;
+ char *syntax_cmd;
+
+ cmd_template =
+ mcview_syntax_command != NULL ? mcview_syntax_command : MCVIEW_SYNTAX_DEFAULT_CMD;
+ syntax_cmd = mcview_syntax_build_command (cmd_template, filename);
+ if (syntax_cmd != NULL)
+ {
+ mcview_load (view, syntax_cmd, filename, 0, 0, 0);
+ g_free (syntax_cmd);
+
+ // highlighter failed (stderr or no output) -- fall back to plain view
+ if (view->datasource == DS_NONE)
+ {
+ view->mode_flags.syntax = FALSE;
+ mcview_load (view, NULL, filename, 0, 0, 0);
+ }
+ }
+ else
+ mcview_load (view, NULL, filename, 0, 0, 0);
+ }
+ else
+ mcview_load (view, NULL, filename, 0, 0, 0);
+
+ // restore position by line number
+ mcview_coord_to_offset (view, &view->dpy_start, saved_line, 0);
+ view->dpy_start = mcview_bol (view, view->dpy_start, 0);
+ view->dpy_wrap_dirty = TRUE;
+
+ view->dir = dir;
+ view->dir_idx = dir_idx;
+ g_free (filename);
+
+ view->dpy_bbar_dirty = TRUE;
+ view->dirty++;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
void
mcview_toggle_hex_mode (WView *view)
{
@@ -175,6 +247,9 @@ mcview_init (WView *view)
view->cursor_col = 0;
view->cursor_row = 0;
view->change_list = NULL;
+ view->syntax_file_size = 0;
+ view->syntax_content_cache_end = -1;
+ view->syntax_content_cache_bytes = 0;
// {status,ruler,data}_area are left uninitialized
diff --git a/src/viewer/mcviewer.c b/src/viewer/mcviewer.c
index bef8977bc0..c0f69728bb 100644
--- a/src/viewer/mcviewer.c
+++ b/src/viewer/mcviewer.c
@@ -46,15 +46,16 @@
#include "src/filemanager/filemanager.h" // the_menubar
#include "internal.h"
+#include "syntax.h"
/*** global variables ****************************************************************************/
mcview_mode_flags_t mcview_global_flags = {
- .wrap = TRUE, .hex = FALSE, .magic = TRUE, .nroff = FALSE
+ .wrap = TRUE, .hex = FALSE, .magic = TRUE, .nroff = FALSE, .syntax = FALSE
};
mcview_mode_flags_t mcview_altered_flags = {
- .wrap = FALSE, .hex = FALSE, .magic = FALSE, .nroff = FALSE
+ .wrap = FALSE, .hex = FALSE, .magic = FALSE, .nroff = FALSE, .syntax = FALSE
};
gboolean mcview_remember_file_position = FALSE;
@@ -80,6 +81,42 @@ char *mcview_show_eof = NULL;
/*** file scope functions ************************************************************************/
/* --------------------------------------------------------------------------------------------- */
+/**
+ * Drain a syntax highlighter pipe so the total size is known and percentage works.
+ * Highlighters process a finite file, so this always terminates.
+ */
+static void
+mcview_drain_pipe (WView *view)
+{
+ off_t ofs;
+
+ if (!view->growbuf_in_use || view->growbuf_finished)
+ return;
+
+ ofs = mcview_get_filesize (view);
+ while (!view->growbuf_finished)
+ {
+ ofs += 8192;
+ mcview_growbuf_read_until (view, ofs);
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Show "falling back to plain view" message and disable syntax mode.
+ */
+static void
+mcview_syntax_fallback (WView *view)
+{
+ message (D_NORMAL, _ ("Syntax Highlighting"), "%s",
+ _ ("Syntax highlighter could not process this file.\n"
+ "Falling back to plain view."));
+ view->mode_flags.syntax = FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
static void
mcview_mouse_callback (Widget *w, mouse_msg_t msg, mouse_event_t *event)
{
@@ -219,14 +256,25 @@ mcview_new (const WRect *r, gboolean is_panel)
mcview_init (view);
- if (mcview_global_flags.hex)
- mcview_toggle_hex_mode (view);
- if (mcview_global_flags.nroff)
- mcview_toggle_nroff_mode (view);
- if (mcview_global_flags.wrap)
- mcview_toggle_wrap_mode (view);
- if (mcview_global_flags.magic)
- mcview_toggle_magic_mode (view);
+ // save global flags — toggle functions (especially mcview_toggle_magic_mode)
+ // call mcview_done() internally, which overwrites mcview_global_flags
+ // with the view's current mode_flags before all flags have been applied
+ {
+ mcview_mode_flags_t saved_global = mcview_global_flags;
+
+ if (saved_global.hex)
+ mcview_toggle_hex_mode (view);
+ if (saved_global.nroff)
+ mcview_toggle_nroff_mode (view);
+ if (saved_global.wrap)
+ mcview_toggle_wrap_mode (view);
+ if (saved_global.magic)
+ mcview_toggle_magic_mode (view);
+ if (saved_global.syntax)
+ view->mode_flags.syntax = TRUE;
+
+ mcview_global_flags = saved_global;
+ }
return view;
}
@@ -287,6 +335,7 @@ mcview_load (WView *view, const char *command, const char *file, int start_line,
{
gboolean retval = FALSE;
vfs_path_t *vpath = NULL;
+ char *syntax_cmd = NULL;
g_assert (view->bytes_per_line != 0);
@@ -323,9 +372,65 @@ mcview_load (WView *view, const char *command, const char *file, int start_line,
mcview_set_codeset (view);
- if (command != NULL && (view->mode_flags.magic || file == NULL || file[0] == '\0'))
+ // build syntax highlight command if syntax mode is on and no explicit command given
+ if (command == NULL && view->mode_flags.syntax && file != NULL && file[0] != '\0')
+ {
+ const char *cmd_template;
+
+ cmd_template =
+ mcview_syntax_command != NULL ? mcview_syntax_command : MCVIEW_SYNTAX_DEFAULT_CMD;
+ syntax_cmd = mcview_syntax_build_command (cmd_template, file);
+ }
+
+ if (syntax_cmd != NULL)
+ {
+ gboolean cmd_ok;
+
+ cmd_ok = mcview_load_command_output (view, syntax_cmd);
+ g_free (syntax_cmd);
+
+ if (view->datasource != DS_NONE)
+ {
+ mcview_drain_pipe (view);
+
+ // If highlighter produced output, use it; otherwise fall back to plain view.
+ if (view->growbuf_in_use && mcview_growbuf_filesize (view) > 0)
+ retval = TRUE;
+ else
+ {
+ mcview_syntax_fallback (view);
+ mcview_close_datasource (view);
+ }
+ }
+ else if (cmd_ok)
+ {
+ // Pipe opened but produced no output (e.g. chroma --fail on unrecognized file).
+ mcview_syntax_fallback (view);
+ }
+ else
+ {
+ // Pipe failed to open -- mcview_load_command_output already showed error.
+ view->mode_flags.syntax = FALSE;
+ }
+ }
+
+ if (!retval && command != NULL
+ && (view->mode_flags.magic || view->mode_flags.syntax || file == NULL || file[0] == '\0'))
+ {
retval = mcview_load_command_output (view, command);
- else if (file != NULL && file[0] != '\0')
+
+ // If syntax command produced no output, show message and fall back to plain view.
+ if (retval && view->mode_flags.syntax && view->datasource == DS_NONE)
+ {
+ mcview_syntax_fallback (view);
+ retval = FALSE;
+ }
+
+ if (retval && view->mode_flags.syntax)
+ mcview_drain_pipe (view);
+ }
+
+ if (!retval && file != NULL && file[0] != '\0')
{
int fd;
char tmp[BUF_MEDIUM];
diff --git a/src/viewer/syntax.c b/src/viewer/syntax.c
new file mode 100644
index 0000000000..e9b11e137c
--- /dev/null
+++ b/src/viewer/syntax.c
@@ -0,0 +1,372 @@
+/*
+ Internal file viewer for the Midnight Commander
+ Syntax highlighting via configurable external command
+
+ 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 .
+ */
+
+/** \file syntax.c
+ * \brief Source: syntax highlighting via external command for mcview
+ */
+
+#include
+
+#include // strstr(), strlen()
+
+#include "lib/global.h"
+#include "lib/widget.h" // quick_dialog(), message()
+
+#include "syntax.h"
+
+/*** global variables ****************************************************************************/
+
+char *mcview_syntax_command = NULL;
+
+/*** file scope macro definitions ****************************************************************/
+
+/*** file scope type declarations ****************************************************************/
+
+typedef struct
+{
+ const char *name; // display name for dialog
+ const char *short_name; // short name for status bar (e.g. "bat", "srchi")
+ const char *binary; // binary to search in PATH
+ const char *cmd; // command template (%s = filename)
+} mcview_syntax_backend_t;
+
+/*** forward declarations (file scope functions) *************************************************/
+
+/*** file scope variables ************************************************************************/
+
+// Supported syntax highlighting backends, in alphabetical order.
+static const mcview_syntax_backend_t syntax_backends[] = {
+ { "bat", "bat", "bat", "bat --color=always --style=plain --paging=never %s" },
+ { "chroma", "chrm", "chroma", "chroma --formatter terminal256 --fail %s" },
+ { "highlight", "hi", "highlight", "highlight -O xterm256 --force %s" },
+ { "pygmentize", "pyg", "pygmentize", "pygmentize -f terminal256 -O stripnl=False %s" },
+ { "source-highlight", "src-hl", "source-highlight",
+ "source-highlight --failsafe --out-format=esc -i %s" },
+};
+
+static const size_t syntax_backends_num = G_N_ELEMENTS (syntax_backends);
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+mcview_syntax_binary_available (const char *binary)
+{
+ char *path;
+
+ if (binary == NULL || binary[0] == '\0')
+ return FALSE;
+
+ if (binary[0] == '/')
+ return g_file_test (binary, G_FILE_TEST_IS_EXECUTABLE);
+
+ path = g_find_program_in_path (binary);
+ if (path == NULL)
+ return FALSE;
+
+ g_free (path);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+// return the effective syntax command (user-configured or default)
+static const char *
+mcview_syntax_effective_command (void)
+{
+ if (mcview_syntax_command != NULL && mcview_syntax_command[0] != '\0')
+ return mcview_syntax_command;
+ return MCVIEW_SYNTAX_DEFAULT_CMD;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+// find the backend index matching the current mcview_syntax_command, or -1
+static int
+mcview_syntax_find_current_backend (void)
+{
+ const char *current;
+ size_t i;
+
+ current = mcview_syntax_effective_command ();
+
+ for (i = 0; i < syntax_backends_num; i++)
+ if (strcmp (current, syntax_backends[i].cmd) == 0)
+ return (int) i;
+
+ return -1;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+char *
+mcview_syntax_extract_binary (const char *cmd_template)
+{
+ const char *p;
+ size_t len;
+
+ if (cmd_template == NULL || cmd_template[0] == '\0')
+ return NULL;
+
+ // find the first space — everything before it is the binary
+ p = strchr (cmd_template, ' ');
+ if (p != NULL)
+ len = (size_t) (p - cmd_template);
+ else
+ len = strlen (cmd_template);
+
+ return g_strndup (cmd_template, len);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+char *
+mcview_syntax_build_command (const char *cmd_template, const char *filename)
+{
+ const char *p;
+ char *quoted_filename;
+ GString *result;
+
+ if (cmd_template == NULL || filename == NULL)
+ return NULL;
+
+ // check at least one %s exists
+ if (strstr (cmd_template, "%s") == NULL)
+ return NULL;
+
+ quoted_filename = g_shell_quote (filename);
+ result = g_string_new ("");
+
+ // substitute all %s occurrences with the shell-escaped filename
+ p = cmd_template;
+ while (*p != '\0')
+ {
+ const char *placeholder;
+
+ placeholder = strstr (p, "%s");
+ if (placeholder == NULL)
+ {
+ g_string_append (result, p);
+ break;
+ }
+
+ g_string_append_len (result, p, placeholder - p);
+ g_string_append (result, quoted_filename);
+ p = placeholder + 2;
+ }
+
+ g_free (quoted_filename);
+
+ return g_string_free (result, FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+gboolean
+mcview_syntax_command_available (void)
+{
+ char *binary;
+ gboolean available;
+
+ binary = mcview_syntax_extract_binary (mcview_syntax_effective_command ());
+ if (binary == NULL)
+ return FALSE;
+
+ available = mcview_syntax_binary_available (binary);
+ g_free (binary);
+
+ return available;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+const char *
+mcview_syntax_get_short_name (void)
+{
+ int idx;
+
+ idx = mcview_syntax_find_current_backend ();
+ if (idx >= 0)
+ return syntax_backends[idx].short_name;
+
+ return "ext";
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+gboolean
+mcview_syntax_options_dialog (void)
+{
+ const char **radio_items;
+ int *backend_map;
+ int total_items = 0;
+ int installed_count = 0;
+ int custom_radio_idx;
+ int selected = 0;
+ int current_idx;
+ int qd_result;
+ gboolean ret = FALSE;
+ size_t i;
+
+ // count installed backends
+ for (i = 0; i < syntax_backends_num; i++)
+ if (mcview_syntax_binary_available (syntax_backends[i].binary))
+ installed_count++;
+
+ // total = installed backends + "Custom..." entry
+ total_items = installed_count + 1;
+ custom_radio_idx = installed_count;
+
+ // build radio items: installed backends + "Custom..."
+ radio_items = g_new (const char *, total_items);
+ backend_map = g_new (int, total_items);
+
+ current_idx = mcview_syntax_find_current_backend ();
+
+ {
+ int j = 0;
+
+ for (i = 0; i < syntax_backends_num; i++)
+ {
+ if (!mcview_syntax_binary_available (syntax_backends[i].binary))
+ continue;
+
+ radio_items[j] = syntax_backends[i].name;
+ backend_map[j] = (int) i;
+
+ if ((int) i == current_idx)
+ selected = j;
+
+ j++;
+ }
+
+ // add "Custom..." entry
+ radio_items[j] = _ ("Custom...");
+ backend_map[j] = -1;
+
+ // select "Custom..." if current command doesn't match any backend
+ if (current_idx < 0 && mcview_syntax_command != NULL && mcview_syntax_command[0] != '\0')
+ selected = j;
+ }
+
+ {
+ quick_widget_t quick_widgets[] = {
+ // clang-format off
+ QUICK_RADIO (total_items, radio_items, &selected, NULL),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ // clang-format on
+ };
+
+ WRect r = { -1, -1, 0, 46 };
+
+ quick_dialog_t qdlg = {
+ .rect = r,
+ .title = _ ("Syntax Backend"),
+ .help = "[Internal File Viewer]",
+ .widgets = quick_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ qd_result = quick_dialog (&qdlg);
+ }
+
+ if (qd_result != B_ENTER)
+ goto cleanup;
+
+ if (selected == custom_radio_idx)
+ {
+ // "Custom..." selected -- prompt for command template
+ char *custom_cmd = NULL;
+ const char *initial;
+
+ initial = (mcview_syntax_command != NULL) ? mcview_syntax_command : "";
+
+ {
+ quick_widget_t custom_widgets[] = {
+ // clang-format off
+ QUICK_LABELED_INPUT (_ ("Command (%s = filename):"), input_label_above,
+ initial, "syntax-custom-cmd", &custom_cmd,
+ NULL, FALSE, FALSE, INPUT_COMPLETE_FILENAMES),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ // clang-format on
+ };
+
+ WRect cr = { -1, -1, 0, 60 };
+
+ quick_dialog_t custom_dlg = {
+ .rect = cr,
+ .title = _ ("Custom Syntax Command"),
+ .help = "[Internal File Viewer]",
+ .widgets = custom_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ qd_result = quick_dialog (&custom_dlg);
+ }
+
+ if (qd_result != B_ENTER || custom_cmd == NULL || custom_cmd[0] == '\0')
+ {
+ g_free (custom_cmd);
+ goto cleanup;
+ }
+
+ if (strstr (custom_cmd, "%s") == NULL)
+ {
+ message (D_ERROR, _ ("Syntax Highlighting"), "%s",
+ _ ("Command must contain %%s placeholder\n"
+ "for the filename."));
+ g_free (custom_cmd);
+ goto cleanup;
+ }
+
+ g_free (mcview_syntax_command);
+ mcview_syntax_command = custom_cmd;
+ ret = TRUE;
+ }
+ else
+ {
+ int chosen;
+
+ g_assert (selected >= 0 && selected < total_items);
+ chosen = backend_map[selected];
+ g_assert (chosen >= 0 && (size_t) chosen < syntax_backends_num);
+
+ g_free (mcview_syntax_command);
+ mcview_syntax_command = g_strdup (syntax_backends[chosen].cmd);
+ ret = TRUE;
+ }
+
+cleanup:
+ g_free (radio_items);
+ g_free (backend_map);
+ return ret;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/syntax.h b/src/viewer/syntax.h
new file mode 100644
index 0000000000..55d83a44b8
--- /dev/null
+++ b/src/viewer/syntax.h
@@ -0,0 +1,47 @@
+/** \file syntax.h
+ * \brief Header: syntax highlighting via external command for mcview
+ */
+
+#ifndef MC__VIEWER_SYNTAX_H
+#define MC__VIEWER_SYNTAX_H
+
+#include "lib/global.h"
+
+/*** typedefs(not structures) and defined constants **********************************************/
+
+/** Default syntax highlighting command */
+#define MCVIEW_SYNTAX_DEFAULT_CMD "source-highlight --failsafe --out-format=esc -i %s"
+
+/*** enums ***************************************************************************************/
+
+/*** structures declarations (and typedefs of structures)*****************************************/
+
+/*** global variables defined in .c file *********************************************************/
+
+extern char *mcview_syntax_command;
+
+/*** declarations of public functions ************************************************************/
+
+/** Extract the binary name (first word) from a command template.
+ * Returns a newly allocated string, or NULL if template is empty/NULL. */
+char *mcview_syntax_extract_binary (const char *cmd_template);
+
+/** Build a shell command by substituting %s with the shell-escaped filename.
+ * Returns a newly allocated string, or NULL on error (no %s, NULL args). */
+char *mcview_syntax_build_command (const char *cmd_template, const char *filename);
+
+/** Check if the configured syntax highlighter binary is available.
+ * Returns TRUE if found in PATH (or is an absolute path that exists). */
+gboolean mcview_syntax_command_available (void);
+
+/** Get the short name of the current backend (e.g. "bat", "srchi").
+ * Returns a static string, never NULL. */
+const char *mcview_syntax_get_short_name (void);
+
+/** Show a dialog to choose the syntax highlighting backend.
+ * Only installed backends are shown. Returns TRUE if selection changed. */
+gboolean mcview_syntax_options_dialog (void);
+
+/*** inline functions ****************************************************************************/
+
+#endif /* MC__VIEWER_SYNTAX_H */
diff --git a/tests/src/viewer/Makefile.am b/tests/src/viewer/Makefile.am
index e6a185c59c..b36a1f2ab5 100644
--- a/tests/src/viewer/Makefile.am
+++ b/tests/src/viewer/Makefile.am
@@ -14,9 +14,13 @@ LIBS += $(GLIB_LIBS)
endif
TESTS = \
- ansi_parser
+ ansi_parser \
+ syntax_cmd
check_PROGRAMS = $(TESTS)
ansi_parser_SOURCES = \
ansi_parser.c
+
+syntax_cmd_SOURCES = \
+ syntax_cmd.c
diff --git a/tests/src/viewer/syntax_cmd.c b/tests/src/viewer/syntax_cmd.c
new file mode 100644
index 0000000000..b4e9b04b39
--- /dev/null
+++ b/tests/src/viewer/syntax_cmd.c
@@ -0,0 +1,263 @@
+/*
+ src/viewer - syntax highlighting command tests
+
+ 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 "src/viewer/syntax.h"
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary from simple command */
+
+START_TEST (test_syntax_extract_binary_simple)
+{
+ // given
+ const char *cmd = "source-highlight --failsafe --out-format=esc -i %s";
+ char *binary;
+
+ // when
+ binary = mcview_syntax_extract_binary (cmd);
+
+ // then
+ mctest_assert_str_eq (binary, "source-highlight");
+ g_free (binary);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary from command with absolute path */
+
+START_TEST (test_syntax_extract_binary_absolute_path)
+{
+ // given
+ const char *cmd = "/usr/bin/source-highlight --failsafe -i %s";
+ char *binary;
+
+ // when
+ binary = mcview_syntax_extract_binary (cmd);
+
+ // then
+ mctest_assert_str_eq (binary, "/usr/bin/source-highlight");
+ g_free (binary);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary from command with only binary name */
+
+START_TEST (test_syntax_extract_binary_name_only)
+{
+ // given
+ const char *cmd = "pygmentize";
+ char *binary;
+
+ // when
+ binary = mcview_syntax_extract_binary (cmd);
+
+ // then
+ mctest_assert_str_eq (binary, "pygmentize");
+ g_free (binary);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary returns NULL for empty string */
+
+START_TEST (test_syntax_extract_binary_empty)
+{
+ // given / when
+ char *binary = mcview_syntax_extract_binary ("");
+
+ // then
+ ck_assert_ptr_eq (binary, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary returns NULL for NULL */
+
+START_TEST (test_syntax_extract_binary_null)
+{
+ // given / when
+ char *binary = mcview_syntax_extract_binary (NULL);
+
+ // then
+ ck_assert_ptr_eq (binary, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command with simple filename substitution */
+
+START_TEST (test_syntax_build_command_simple)
+{
+ // given
+ const char *tmpl = "source-highlight --failsafe --out-format=esc -i %s";
+ const char *filename = "test.c";
+ char *cmd;
+
+ // when
+ cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ mctest_assert_str_eq (cmd, "source-highlight --failsafe --out-format=esc -i 'test.c'");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command shell-escapes filename with spaces */
+
+START_TEST (test_syntax_build_command_spaces_in_filename)
+{
+ // given
+ const char *tmpl = "source-highlight -i %s";
+ const char *filename = "my file.c";
+ char *cmd;
+
+ // when
+ cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ mctest_assert_str_eq (cmd, "source-highlight -i 'my file.c'");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command shell-escapes filename with quotes */
+
+START_TEST (test_syntax_build_command_quotes_in_filename)
+{
+ // given
+ const char *tmpl = "highlight -O xterm256 %s";
+ const char *filename = "it's a test.c";
+ char *cmd;
+
+ // when
+ cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ mctest_assert_str_eq (cmd, "highlight -O xterm256 'it'\\''s a test.c'");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL when template has no %s */
+
+START_TEST (test_syntax_build_command_no_placeholder)
+{
+ // given
+ const char *tmpl = "source-highlight --failsafe";
+ const char *filename = "test.c";
+
+ // when
+ char *cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ ck_assert_ptr_eq (cmd, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL for NULL template */
+
+START_TEST (test_syntax_build_command_null_template)
+{
+ // given / when
+ char *cmd = mcview_syntax_build_command (NULL, "test.c");
+
+ // then
+ ck_assert_ptr_eq (cmd, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL for NULL filename */
+
+START_TEST (test_syntax_build_command_null_filename)
+{
+ // given / when
+ char *cmd = mcview_syntax_build_command ("highlight %s", NULL);
+
+ // then
+ ck_assert_ptr_eq (cmd, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL for empty filename */
+
+START_TEST (test_syntax_build_command_empty_filename)
+{
+ // given / when
+ char *cmd = mcview_syntax_build_command ("highlight %s", "");
+
+ // then — empty filename is valid, produces empty quoted string
+ ck_assert_ptr_ne (cmd, NULL);
+ mctest_assert_str_eq (cmd, "highlight ''");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ Suite *s;
+ SRunner *sr;
+ TCase *tc_extract;
+ TCase *tc_build;
+ int number_failed;
+
+ tc_extract = tcase_create ("syntax-extract-binary");
+ tcase_add_test (tc_extract, test_syntax_extract_binary_simple);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_absolute_path);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_name_only);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_empty);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_null);
+
+ tc_build = tcase_create ("syntax-build-command");
+ tcase_add_test (tc_build, test_syntax_build_command_simple);
+ tcase_add_test (tc_build, test_syntax_build_command_spaces_in_filename);
+ tcase_add_test (tc_build, test_syntax_build_command_quotes_in_filename);
+ tcase_add_test (tc_build, test_syntax_build_command_no_placeholder);
+ tcase_add_test (tc_build, test_syntax_build_command_null_template);
+ tcase_add_test (tc_build, test_syntax_build_command_null_filename);
+ tcase_add_test (tc_build, test_syntax_build_command_empty_filename);
+
+ s = suite_create (TEST_SUITE_NAME);
+ suite_add_tcase (s, tc_extract);
+ suite_add_tcase (s, tc_build);
+ sr = srunner_create (s);
+ srunner_set_log (sr, "-");
+ srunner_run_all (sr, CK_ENV);
+ number_failed = srunner_ntests_failed (sr);
+ srunner_free (sr);
+
+ return (number_failed == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
+}
From ba2a3b36ab8f73dd4c8834ba8e8eaa3eb8987ff3 Mon Sep 17 00:00:00 2001
From: noctuum <25441068+noctuum@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:27:48 +0700
Subject: [PATCH 4/6] doc: add syntax highlighting documentation.
Add user-facing documentation for the new syntax highlighting feature
in the internal file viewer:
- mc.1.in: document the feature in the Internal File Viewer section,
including supported backends (bat, chroma, highlight, pygmentize,
source-highlight), key bindings (s to toggle, S for backend chooser),
Custom command option, status bar format, and persistence behavior
- mc.hint: add a random hint about the s/Shift-S keys
Signed-off-by: noctuum <25441068+noctuum@users.noreply.github.com>
---
doc/hints/mc.hint | 2 ++
doc/man/mc.1.in | 36 ++++++++++++++++++++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/doc/hints/mc.hint b/doc/hints/mc.hint
index 079bc1e3b4..7affa8b995 100644
--- a/doc/hints/mc.hint
+++ b/doc/hints/mc.hint
@@ -62,6 +62,8 @@ Hint: To look at the output of a command in the viewer, use M-!
Hint: F13 (or Shift-F3) invokes the viewer in raw mode.
+Hint: Press 's' in the viewer for syntax highlighting. Shift-S chooses the backend.
+
Hint: You may specify the editor for F4 with the shell variable EDITOR.
Hint: You may specify the external viewer with the shell vars VIEWER or PAGER.
diff --git a/doc/man/mc.1.in b/doc/man/mc.1.in
index c57c3f22ca..1fd48bf655 100644
--- a/doc/man/mc.1.in
+++ b/doc/man/mc.1.in
@@ -2933,6 +2933,34 @@ Some character sequences, which appear most often in preformatted manual
pages, are displayed bold and underlined, thus making a pretty display
of your files.
.PP
+The viewer supports syntax highlighting via an external highlighter.
+Press
+.B s
+to toggle syntax highlighting (or
+.B Shift\-S
+in vim keymap). Press
+.B S
+(or
+.B Alt\-s
+in vim keymap) to choose the highlighting backend.
+Five backends are supported:
+.BR bat ,
+.BR chroma ,
+.BR highlight ,
+.BR pygmentize ,
+and
+.BR source\-highlight .
+A custom command can also be configured via the backend chooser dialog.
+The command template must contain
+.B %s
+as a placeholder for the filename.
+The viewer pipes the file through the chosen highlighter and renders
+the ANSI\-colored output. The backend and file extension are shown in
+the status bar, e.g.
+.IR [bat:py] .
+The syntax mode and backend choice are persisted in
+.IR ~/.config/mc/ini .
+.PP
When in hex mode, the search function accepts text in quotes and
constant numbers. Text in quotes is matched exactly after removing
the quotes. Each number matches one byte. You can mix quoted text
@@ -3025,6 +3053,14 @@ Jump to the next file.
.B C\-b
Jump to the previous file.
.TP
+.B s
+Toggle syntax highlighting. Requires an external highlighter
+.RB ( bat ", " chroma ", " highlight ", " pygmentize ", or " source\-highlight ).
+.TP
+.B S
+Open the syntax highlighting backend chooser dialog. Installed
+backends and a "Custom..." option for arbitrary commands are shown.
+.TP
.B Alt\-r
Toggle the ruler.
.TP
From 061c4379aecfd5c0446a7f2292b4b972376facbc Mon Sep 17 00:00:00 2001
From: noctuum <25441068+noctuum@users.noreply.github.com>
Date: Fri, 13 Mar 2026 21:20:58 +0700
Subject: [PATCH 5/6] mcviewer (ansi.c): harden SGR parser per review feedback.
Address reviewer feedback on the ANSI SGR parser:
Parser improvements:
- Add italic (3), blink (5), reverse (7) attribute support
- Add individual off codes: 23, 25, 27
- Map SGR 21 (double underline) to regular underline
- Fix empty parameter bug: \e[1;;3m now correctly treats
empty param as 0 (reset)
- Add colon ':' sub-separator with proper nested semantics:
\e[38:5:4:7m does NOT set reverse (all sub-params belong
to the color group), while \e[38;5;4;7m correctly does
- Support de jure truecolor: \e[38:2:CS:R:G:Bm
Large file protection:
- Skip syntax highlighting for files > 2 MB to avoid long
processing times from external highlighters
12 new tests (35 total for ANSI parser).
Signed-off-by: noctuum <25441068+noctuum@users.noreply.github.com>
---
src/viewer/ansi.c | 79 +++++++++--
src/viewer/ansi.h | 5 +
src/viewer/ascii.c | 40 +++---
src/viewer/mcviewer.c | 19 ++-
src/viewer/syntax.h | 5 +
tests/src/viewer/ansi_parser.c | 244 +++++++++++++++++++++++++++++++++
6 files changed, 359 insertions(+), 33 deletions(-)
diff --git a/src/viewer/ansi.c b/src/viewer/ansi.c
index 293c93af79..1679502418 100644
--- a/src/viewer/ansi.c
+++ b/src/viewer/ansi.c
@@ -108,14 +108,16 @@ mcview_ansi_rgb_to_256 (int r, int g, int b)
static void
mcview_ansi_csi_finalize_param (mcview_ansi_state_t *state)
{
- if (state->has_current_param && state->param_count < MCVIEW_ANSI_MAX_PARAMS)
+ if (state->param_count < MCVIEW_ANSI_MAX_PARAMS)
{
state->params[state->param_count] = state->current_param;
+ state->is_colon_sep[state->param_count] = state->next_is_colon;
state->param_count++;
}
state->current_param = 0;
state->has_current_param = FALSE;
+ state->next_is_colon = FALSE;
}
/* --------------------------------------------------------------------------------------------- */
@@ -141,27 +143,53 @@ mcview_ansi_apply_one_sgr_param (mcview_ansi_state_t *state, int idx)
state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
state->bold = FALSE;
+ state->italic = FALSE;
state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
}
else if (code == 1)
state->bold = TRUE;
+ else if (code == 3)
+ state->italic = TRUE;
else if (code == 4)
state->underline = TRUE;
+ else if (code == 5)
+ state->blink = TRUE;
+ else if (code == 7)
+ state->reverse = TRUE;
+ else if (code == 21)
+ // double underline — map to regular underline (ncurses/slang limitation)
+ state->underline = TRUE;
else if (code == 22)
state->bold = FALSE;
+ else if (code == 23)
+ state->italic = FALSE;
else if (code == 24)
state->underline = FALSE;
+ else if (code == 25)
+ state->blink = FALSE;
+ else if (code == 27)
+ state->reverse = FALSE;
else if (code >= 30 && code <= 37)
state->fg = code - 30;
else if (code == 38)
{
if (idx + 2 < state->param_count && state->params[idx + 1] == 5)
- // extended foreground 256-color: 38;5;N
+ // extended foreground 256-color: 38;5;N or 38:5:N
state->fg = state->params[idx + 2];
else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
- // truecolor foreground: 38;2;R;G;B → approximate to 256-color
- state->fg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
- state->params[idx + 4]);
+ {
+ // truecolor foreground → approximate to 256-color
+ if (idx + 5 < state->param_count && state->is_colon_sep[idx + 1])
+ // de jure colon notation: 38:2:CS:R:G:B — skip color space
+ state->fg = mcview_ansi_rgb_to_256 (state->params[idx + 3], state->params[idx + 4],
+ state->params[idx + 5]);
+ else
+ // de facto semicolon notation: 38;2;R;G;B
+ state->fg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
+ state->params[idx + 4]);
+ }
}
else if (code == 39)
state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
@@ -170,12 +198,20 @@ mcview_ansi_apply_one_sgr_param (mcview_ansi_state_t *state, int idx)
else if (code == 48)
{
if (idx + 2 < state->param_count && state->params[idx + 1] == 5)
- // extended background 256-color: 48;5;N
+ // extended background 256-color: 48;5;N or 48:5:N
state->bg = state->params[idx + 2];
else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
- // truecolor background: 48;2;R;G;B → approximate to 256-color
- state->bg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
- state->params[idx + 4]);
+ {
+ // truecolor background → approximate to 256-color
+ if (idx + 5 < state->param_count && state->is_colon_sep[idx + 1])
+ // de jure colon notation: 48:2:CS:R:G:B — skip color space
+ state->bg = mcview_ansi_rgb_to_256 (state->params[idx + 3], state->params[idx + 4],
+ state->params[idx + 5]);
+ else
+ // de facto semicolon notation: 48;2;R;G;B
+ state->bg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
+ state->params[idx + 4]);
+ }
}
else if (code == 49)
state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
@@ -202,7 +238,10 @@ mcview_ansi_apply_sgr (mcview_ansi_state_t *state)
state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
state->bold = FALSE;
+ state->italic = FALSE;
state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
return;
}
@@ -212,12 +251,22 @@ mcview_ansi_apply_sgr (mcview_ansi_state_t *state)
code = state->params[i];
+ // skip colon-separated sub-parameters (they belong to the preceding parameter)
+ if (state->is_colon_sep[i])
+ continue;
+
// skip sub-parameters consumed by extended color sequences
if (code == 38 || code == 48)
{
mcview_ansi_apply_one_sgr_param (state, i);
- if (i + 2 < state->param_count && state->params[i + 1] == 5)
+ if (i + 1 < state->param_count && state->is_colon_sep[i + 1])
+ {
+ // colon notation: skip all colon-separated sub-params
+ while (i + 1 < state->param_count && state->is_colon_sep[i + 1])
+ i++;
+ }
+ else if (i + 2 < state->param_count && state->params[i + 1] == 5)
i += 2; // 256-color: 38;5;N — skip 2
else if (i + 4 < state->param_count && state->params[i + 1] == 2)
i += 4; // truecolor: 38;2;R;G;B — skip 4
@@ -239,12 +288,16 @@ mcview_ansi_state_init (mcview_ansi_state_t *state)
state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
state->bold = FALSE;
+ state->italic = FALSE;
state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
state->in_escape = FALSE;
state->in_csi = FALSE;
state->param_count = 0;
state->current_param = 0;
state->has_current_param = FALSE;
+ state->next_is_colon = FALSE;
}
/* --------------------------------------------------------------------------------------------- */
@@ -283,10 +336,12 @@ mcview_ansi_parse_char (mcview_ansi_state_t *state, int ch)
return ANSI_RESULT_CONSUMED;
}
- if (ch == ';')
+ if (ch == ';' || ch == ':')
{
- // parameter separator
+ // parameter separator (; is standard, : is sub-parameter separator)
mcview_ansi_csi_finalize_param (state);
+ if (ch == ':')
+ state->next_is_colon = TRUE;
return ANSI_RESULT_CONSUMED;
}
diff --git a/src/viewer/ansi.h b/src/viewer/ansi.h
index 06d22d2ab8..446784559d 100644
--- a/src/viewer/ansi.h
+++ b/src/viewer/ansi.h
@@ -33,15 +33,20 @@ typedef struct
int fg; /**< foreground: 0-7 base, 8-15 bright, -1 default */
int bg; /**< background: 0-7 base, 8-15 bright, -1 default */
gboolean bold;
+ gboolean italic;
gboolean underline;
+ gboolean blink;
+ gboolean reverse;
/* --- internal parser state --- */
gboolean in_escape; /**< seen ESC, waiting for '[' */
gboolean in_csi; /**< inside CSI sequence (ESC[...) */
int params[MCVIEW_ANSI_MAX_PARAMS];
+ gboolean is_colon_sep[MCVIEW_ANSI_MAX_PARAMS]; /**< TRUE if preceded by ':' */
int param_count;
int current_param; /**< parameter being accumulated */
gboolean has_current_param; /**< whether current_param has digits */
+ gboolean next_is_colon; /**< next param preceded by ':' */
} mcview_ansi_state_t;
/*** declarations of public functions ************************************************************/
diff --git a/src/viewer/ascii.c b/src/viewer/ascii.c
index 07c813978c..7ff0c93db7 100644
--- a/src/viewer/ascii.c
+++ b/src/viewer/ascii.c
@@ -348,17 +348,21 @@ mcview_ansi_get_color (const mcview_ansi_state_t *ansi)
{
tty_color_pair_t color;
tty_color_pair_t *viewer_skin;
- char fg_buf[16], bg_buf[16], attr_buf[32];
+ char fg_buf[16], bg_buf[16], attr_buf[64];
const char *fg_name;
const char *bg_name;
+ gboolean has_attrs;
+
+ has_attrs = ansi->bold || ansi->italic || ansi->underline || ansi->blink || ansi->reverse;
// all defaults → use the skin's normal viewer color
if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT
- && !ansi->bold && !ansi->underline)
+ && !has_attrs)
return VIEWER_NORMAL_COLOR;
- // bold-only and underline-only map to existing skin colors
- if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT)
+ // bold-only and underline-only map to existing skin colors (no other attrs active)
+ if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT
+ && !ansi->italic && !ansi->blink && !ansi->reverse)
{
if (ansi->bold && ansi->underline)
return VIEWER_BOLD_UNDERLINED_COLOR;
@@ -393,20 +397,22 @@ mcview_ansi_get_color (const mcview_ansi_state_t *ansi)
else
color.bg = (viewer_skin != NULL) ? viewer_skin->bg : NULL;
- // build attributes
- if (ansi->bold && ansi->underline)
- {
- g_strlcpy (attr_buf, "bold+underline", sizeof (attr_buf));
- color.attrs = attr_buf;
- }
- else if (ansi->bold)
+ // build attributes string dynamically
+ if (has_attrs)
{
- g_strlcpy (attr_buf, "bold", sizeof (attr_buf));
- color.attrs = attr_buf;
- }
- else if (ansi->underline)
- {
- g_strlcpy (attr_buf, "underline", sizeof (attr_buf));
+ attr_buf[0] = '\0';
+ if (ansi->bold)
+ g_strlcat (attr_buf, "bold+", sizeof (attr_buf));
+ if (ansi->italic)
+ g_strlcat (attr_buf, "italic+", sizeof (attr_buf));
+ if (ansi->underline)
+ g_strlcat (attr_buf, "underline+", sizeof (attr_buf));
+ if (ansi->blink)
+ g_strlcat (attr_buf, "blink+", sizeof (attr_buf));
+ if (ansi->reverse)
+ g_strlcat (attr_buf, "reverse+", sizeof (attr_buf));
+ // remove trailing '+'
+ attr_buf[strlen (attr_buf) - 1] = '\0';
color.attrs = attr_buf;
}
else
diff --git a/src/viewer/mcviewer.c b/src/viewer/mcviewer.c
index c0f69728bb..9f63108cf9 100644
--- a/src/viewer/mcviewer.c
+++ b/src/viewer/mcviewer.c
@@ -375,11 +375,22 @@ mcview_load (WView *view, const char *command, const char *file, int start_line,
// build syntax highlight command if syntax mode is on and no explicit command given
if (command == NULL && view->mode_flags.syntax && file != NULL && file[0] != '\0')
{
- const char *cmd_template;
+ struct stat syntax_st;
- cmd_template =
- mcview_syntax_command != NULL ? mcview_syntax_command : MCVIEW_SYNTAX_DEFAULT_CMD;
- syntax_cmd = mcview_syntax_build_command (cmd_template, file);
+ // skip syntax highlighting for files larger than the threshold
+ if (mc_stat (view->filename_vpath, &syntax_st) == 0
+ && syntax_st.st_size > MCVIEW_SYNTAX_MAX_FILE_SIZE)
+ {
+ view->mode_flags.syntax = FALSE;
+ }
+ else
+ {
+ const char *cmd_template;
+
+ cmd_template =
+ mcview_syntax_command != NULL ? mcview_syntax_command : MCVIEW_SYNTAX_DEFAULT_CMD;
+ syntax_cmd = mcview_syntax_build_command (cmd_template, file);
+ }
}
if (syntax_cmd != NULL)
diff --git a/src/viewer/syntax.h b/src/viewer/syntax.h
index 55d83a44b8..96d17902f2 100644
--- a/src/viewer/syntax.h
+++ b/src/viewer/syntax.h
@@ -12,6 +12,11 @@
/** Default syntax highlighting command */
#define MCVIEW_SYNTAX_DEFAULT_CMD "source-highlight --failsafe --out-format=esc -i %s"
+/** Maximum file size (in bytes) for syntax highlighting.
+ * Files larger than this are displayed without highlighting to avoid
+ * long processing times from external highlighters. */
+#define MCVIEW_SYNTAX_MAX_FILE_SIZE (2 * 1024 * 1024)
+
/*** enums ***************************************************************************************/
/*** structures declarations (and typedefs of structures)*****************************************/
diff --git a/tests/src/viewer/ansi_parser.c b/tests/src/viewer/ansi_parser.c
index dc69d03aa7..3b6283db5a 100644
--- a/tests/src/viewer/ansi_parser.c
+++ b/tests/src/viewer/ansi_parser.c
@@ -64,7 +64,10 @@ START_TEST (test_ansi_init_defaults)
ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
mctest_assert_false (state.bold);
+ mctest_assert_false (state.italic);
mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
mctest_assert_false (state.in_escape);
mctest_assert_false (state.in_csi);
}
@@ -130,7 +133,10 @@ START_TEST (test_ansi_explicit_reset)
ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
mctest_assert_false (state.bold);
+ mctest_assert_false (state.italic);
mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
}
END_TEST
@@ -528,6 +534,233 @@ START_TEST (test_ansi_truecolor_no_misparse)
}
END_TEST
+/* --------------------------------------------------------------------------------------------- */
+/* Test: italic SGR code 3 */
+
+START_TEST (test_ansi_italic)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[3m = italic
+ g_string_free (parse_and_collect (&state, "\033[3m"), TRUE);
+
+ // then
+ mctest_assert_true (state.italic);
+ mctest_assert_false (state.bold);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: blink SGR code 5 */
+
+START_TEST (test_ansi_blink)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[5m = blink
+ g_string_free (parse_and_collect (&state, "\033[5m"), TRUE);
+
+ // then
+ mctest_assert_true (state.blink);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: reverse SGR code 7 */
+
+START_TEST (test_ansi_reverse)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[7m = reverse
+ g_string_free (parse_and_collect (&state, "\033[7m"), TRUE);
+
+ // then
+ mctest_assert_true (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: individual off codes 23, 25, 27 */
+
+START_TEST (test_ansi_individual_off_codes)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — enable italic, blink, reverse then disable each individually
+ g_string_free (parse_and_collect (&state, "\033[3;5;7m"), TRUE);
+ mctest_assert_true (state.italic);
+ mctest_assert_true (state.blink);
+ mctest_assert_true (state.reverse);
+
+ g_string_free (parse_and_collect (&state, "\033[23m"), TRUE);
+ mctest_assert_false (state.italic);
+ mctest_assert_true (state.blink);
+
+ g_string_free (parse_and_collect (&state, "\033[25m"), TRUE);
+ mctest_assert_false (state.blink);
+ mctest_assert_true (state.reverse);
+
+ g_string_free (parse_and_collect (&state, "\033[27m"), TRUE);
+ mctest_assert_false (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: SGR 21 = double underline → mapped to regular underline */
+
+START_TEST (test_ansi_double_underline)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[21m = double underline
+ g_string_free (parse_and_collect (&state, "\033[21m"), TRUE);
+
+ // then — mapped to regular underline
+ mctest_assert_true (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: empty parameter treated as 0 (reset): ESC[1;;3m = bold, reset, italic */
+
+START_TEST (test_ansi_empty_param_is_zero)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[1;;3m → 1=bold, empty=0=reset all, 3=italic
+ g_string_free (parse_and_collect (&state, "\033[1;;3m"), TRUE);
+
+ // then — bold was reset by the 0, italic is on
+ mctest_assert_false (state.bold);
+ mctest_assert_true (state.italic);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: colon notation for 256-color: ESC[38:5:196m */
+
+START_TEST (test_ansi_colon_256_color)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — colon notation
+ g_string_free (parse_and_collect (&state, "\033[38:5:196m"), TRUE);
+
+ // then — fg = 196
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: colon notation does NOT leak into SGR: ESC[38:5:4:7m → 7 is NOT reverse */
+
+START_TEST (test_ansi_colon_nested_no_leak)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — all colon-separated, 7 belongs to the color group
+ g_string_free (parse_and_collect (&state, "\033[38:5:4:7m"), TRUE);
+
+ // then — fg = 4 (from 38:5:4), reverse must NOT be set
+ ck_assert_int_eq (state.fg, 4);
+ mctest_assert_false (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: semicolon flat notation: ESC[38;5;4;7m → color 4 + reverse */
+
+START_TEST (test_ansi_semicolon_flat_with_reverse)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — semicolon notation, 7 is a separate SGR param
+ g_string_free (parse_and_collect (&state, "\033[38;5;4;7m"), TRUE);
+
+ // then — fg = 4, reverse IS set
+ ck_assert_int_eq (state.fg, 4);
+ mctest_assert_true (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: de jure truecolor with colon and color space: ESC[38:2::255:0:0m */
+
+START_TEST (test_ansi_colon_truecolor_with_colorspace)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — de jure: 38:2:CS:R:G:B with empty CS (=0)
+ // cube: r=5 g=0 b=0 → 16 + 180 + 0 + 0 = 196
+ g_string_free (parse_and_collect (&state, "\033[38:2::255:0:0m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: reset clears all new attributes (italic, blink, reverse) */
+
+START_TEST (test_ansi_reset_clears_all_attrs)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set everything, then reset
+ g_string_free (parse_and_collect (&state, "\033[1;3;4;5;7;31;42m"), TRUE);
+ mctest_assert_true (state.bold);
+ mctest_assert_true (state.italic);
+ mctest_assert_true (state.underline);
+ mctest_assert_true (state.blink);
+ mctest_assert_true (state.reverse);
+
+ g_string_free (parse_and_collect (&state, "\033[0m"), TRUE);
+
+ // then — all cleared
+ mctest_assert_false (state.bold);
+ mctest_assert_false (state.italic);
+ mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+}
+END_TEST
+
/* --------------------------------------------------------------------------------------------- */
int
@@ -561,6 +794,17 @@ main (void)
tcase_add_test (tc_core, test_ansi_truecolor_background);
tcase_add_test (tc_core, test_ansi_truecolor_combined);
tcase_add_test (tc_core, test_ansi_truecolor_no_misparse);
+ tcase_add_test (tc_core, test_ansi_italic);
+ tcase_add_test (tc_core, test_ansi_blink);
+ tcase_add_test (tc_core, test_ansi_reverse);
+ tcase_add_test (tc_core, test_ansi_individual_off_codes);
+ tcase_add_test (tc_core, test_ansi_double_underline);
+ tcase_add_test (tc_core, test_ansi_empty_param_is_zero);
+ tcase_add_test (tc_core, test_ansi_colon_256_color);
+ tcase_add_test (tc_core, test_ansi_colon_nested_no_leak);
+ tcase_add_test (tc_core, test_ansi_semicolon_flat_with_reverse);
+ tcase_add_test (tc_core, test_ansi_colon_truecolor_with_colorspace);
+ tcase_add_test (tc_core, test_ansi_reset_clears_all_attrs);
// ***********************************
return mctest_run_all (tc_core);
From f40a086d6aaa38bab581a96df5a68c02cebc4d09 Mon Sep 17 00:00:00 2001
From: noctuum <25441068+noctuum@users.noreply.github.com>
Date: Fri, 13 Mar 2026 21:29:32 +0700
Subject: [PATCH 6/6] mcviewer: remap syntax keybindings to F8/Shift+F8
paradigm.
Per reviewer feedback, align syntax highlighting shortcuts with
the existing F8 (Parse/Raw) paradigm:
- Shift+F8 toggles syntax highlighting (was 's')
- 's' opens backend chooser dialog (was 'Shift-S')
Updated in all three keymaps (default, emacs, vim),
hint file, and man page.
Signed-off-by: noctuum <25441068+noctuum@users.noreply.github.com>
---
doc/hints/mc.hint | 2 +-
doc/man/mc.1.in | 14 +++++---------
misc/mc.default.keymap | 4 ++--
misc/mc.emacs.keymap | 4 ++--
misc/mc.vim.keymap | 4 ++--
5 files changed, 12 insertions(+), 16 deletions(-)
diff --git a/doc/hints/mc.hint b/doc/hints/mc.hint
index 7affa8b995..142b93a17a 100644
--- a/doc/hints/mc.hint
+++ b/doc/hints/mc.hint
@@ -62,7 +62,7 @@ Hint: To look at the output of a command in the viewer, use M-!
Hint: F13 (or Shift-F3) invokes the viewer in raw mode.
-Hint: Press 's' in the viewer for syntax highlighting. Shift-S chooses the backend.
+Hint: Press Shift-F8 in the viewer for syntax highlighting. 's' chooses the backend.
Hint: You may specify the editor for F4 with the shell variable EDITOR.
diff --git a/doc/man/mc.1.in b/doc/man/mc.1.in
index 1fd48bf655..1689a1b5ec 100644
--- a/doc/man/mc.1.in
+++ b/doc/man/mc.1.in
@@ -2935,14 +2935,10 @@ of your files.
.PP
The viewer supports syntax highlighting via an external highlighter.
Press
+.B Shift\-F8
+to toggle syntax highlighting. Press
.B s
-to toggle syntax highlighting (or
-.B Shift\-S
-in vim keymap). Press
-.B S
-(or
-.B Alt\-s
-in vim keymap) to choose the highlighting backend.
+to choose the highlighting backend.
Five backends are supported:
.BR bat ,
.BR chroma ,
@@ -3053,11 +3049,11 @@ Jump to the next file.
.B C\-b
Jump to the previous file.
.TP
-.B s
+.B Shift\-F8
Toggle syntax highlighting. Requires an external highlighter
.RB ( bat ", " chroma ", " highlight ", " pygmentize ", or " source\-highlight ).
.TP
-.B S
+.B s
Open the syntax highlighting backend chooser dialog. Installed
backends and a "Custom..." option for arbitrary commands are shown.
.TP
diff --git a/misc/mc.default.keymap b/misc/mc.default.keymap
index 3f3f9dd5d2..d262ba5944 100644
--- a/misc/mc.default.keymap
+++ b/misc/mc.default.keymap
@@ -419,8 +419,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
-SyntaxMode = s
-SyntaxOptions = shift-s
+SyntaxMode = f18
+SyntaxOptions = s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/misc/mc.emacs.keymap b/misc/mc.emacs.keymap
index e291254348..494aca90f3 100644
--- a/misc/mc.emacs.keymap
+++ b/misc/mc.emacs.keymap
@@ -421,8 +421,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
-SyntaxMode = s
-SyntaxOptions = shift-s
+SyntaxMode = f18
+SyntaxOptions = s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/misc/mc.vim.keymap b/misc/mc.vim.keymap
index 7df1944ceb..f2cd9c599f 100644
--- a/misc/mc.vim.keymap
+++ b/misc/mc.vim.keymap
@@ -288,8 +288,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
-SyntaxMode = shift-s
-SyntaxOptions = alt-s
+SyntaxMode = f18
+SyntaxOptions = s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f