diff --git a/lib/keybind.c b/lib/keybind.c
index a208b2dc3..dda5cd2d8 100644
--- a/lib/keybind.c
+++ b/lib/keybind.c
@@ -351,6 +351,7 @@ static name_keymap_t command_names[] = {
ADD_KEYMAP_NAME_DESC (HexMode, N_ ("Toggle hex mode")),
ADD_KEYMAP_NAME_DESC (MagicMode, N_ ("Toggle magic mode")),
ADD_KEYMAP_NAME_DESC (NroffMode, N_ ("Toggle nroff mode")),
+ ADD_KEYMAP_NAME_DESC (AnsiMode, N_ ("Toggle ANSI color mode")),
ADD_KEYMAP_NAME_DESC (BookmarkGoto, N_ ("Go to bookmark")),
ADD_KEYMAP_NAME_DESC (Ruler, N_ ("Toggle ruler")),
ADD_KEYMAP_NAME_DESC (SearchForward, N_ ("Search forward")),
diff --git a/lib/keybind.h b/lib/keybind.h
index 1bfa91c89..bb7ea295d 100644
--- a/lib/keybind.h
+++ b/lib/keybind.h
@@ -328,6 +328,7 @@ enum
CK_WrapMode = 600L,
CK_MagicMode,
CK_NroffMode,
+ CK_AnsiMode,
CK_HexMode,
CK_HexEditMode,
CK_BookmarkGoto,
diff --git a/misc/ext.d/misc.sh.in b/misc/ext.d/misc.sh.in
index adf7bc5d4..52a0c71ff 100644
--- a/misc/ext.d/misc.sh.in
+++ b/misc/ext.d/misc.sh.in
@@ -55,9 +55,14 @@ do_view_action() {
@EXTFSHELPERSDIR@/torrent list "${MC_EXT_FILENAME}" 2>/dev/null
;;
javaclass)
- procyon "${MC_EXT_FILENAME}" 2>/dev/null || \
- jad -p "${MC_EXT_FILENAME}" 2>/dev/null || \
- (file -b "${MC_EXT_FILENAME}"; javap -private "${MC_EXT_FILENAME}" 2>/dev/null)
+ if command -v procyon >/dev/null 2>&1; then
+ script -q /dev/null -c "procyon \"${MC_EXT_FILENAME}\" 2>/dev/null"
+ elif command -v jad >/dev/null 2>&1; then
+ jad -p "${MC_EXT_FILENAME}" 2>/dev/null
+ else
+ file -b "${MC_EXT_FILENAME}"
+ javap -private "${MC_EXT_FILENAME}" 2>/dev/null
+ fi
;;
*)
;;
diff --git a/misc/mc.default.keymap b/misc/mc.default.keymap
index 6a69b9a8b..dc17ae0ea 100644
--- a/misc/mc.default.keymap
+++ b/misc/mc.default.keymap
@@ -406,6 +406,7 @@ SearchBackwardContinue = ctrl-r
SearchOppositeContinue = shift-n
MagicMode = f8
NroffMode = f9
+AnsiMode = shift-f9
Home = ctrl-a
End = ctrl-e
Left = h; left
diff --git a/misc/mc.emacs.keymap b/misc/mc.emacs.keymap
index b03fc8e68..be74d4e69 100644
--- a/misc/mc.emacs.keymap
+++ b/misc/mc.emacs.keymap
@@ -407,6 +407,7 @@ SearchBackwardContinue = ctrl-r
SearchOppositeContinue = shift-n
MagicMode = f8
NroffMode = f9
+AnsiMode = shift-f9
Home = ctrl-a
End = ctrl-e
Left = h; left
diff --git a/misc/mc.vim.keymap b/misc/mc.vim.keymap
index e790b3c6c..b73446477 100644
--- a/misc/mc.vim.keymap
+++ b/misc/mc.vim.keymap
@@ -274,6 +274,7 @@ SearchBackwardContinue = ctrl-r
SearchOppositeContinue = shift-n
MagicMode = f8
NroffMode = f9
+AnsiMode = shift-f9
Home = ctrl-a
End = ctrl-e
Left = h; left
diff --git a/src/viewer/Makefile.am b/src/viewer/Makefile.am
index 9bf164840..72a321dea 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/actions_cmd.c b/src/viewer/actions_cmd.c
index 6e12bbb7c..21ec2b2df 100644
--- a/src/viewer/actions_cmd.c
+++ b/src/viewer/actions_cmd.c
@@ -466,6 +466,9 @@ mcview_execute_cmd (WView *view, long command)
case CK_NroffMode:
mcview_toggle_nroff_mode (view);
break;
+ case CK_AnsiMode:
+ mcview_toggle_ansi_mode (view);
+ break;
case CK_Home:
mcview_moveto_bol (view);
break;
diff --git a/src/viewer/ansi.c b/src/viewer/ansi.c
new file mode 100644
index 000000000..167950241
--- /dev/null
+++ b/src/viewer/ansi.c
@@ -0,0 +1,376 @@
+/*
+ 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->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;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * 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->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 or 38:5:N
+ state->fg = state->params[idx + 2];
+ else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
+ {
+ // 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;
+ 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 or 48:5:N
+ state->bg = state->params[idx + 2];
+ else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
+ {
+ // 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;
+ 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->italic = FALSE;
+ state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
+ return;
+ }
+
+ for (i = 0; i < state->param_count; i++)
+ {
+ int code;
+
+ 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 + 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
+ }
+ 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->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;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+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 == ';' || ch == ':')
+ {
+ // 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;
+ }
+
+ // 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 000000000..446784559
--- /dev/null
+++ b/src/viewer/ansi.h
@@ -0,0 +1,64 @@
+/** \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 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 ************************************************************/
+
+/** 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/ascii.c b/src/viewer/ascii.c
index 43b5d00da..847946037 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,137 @@ 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[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
+ && !has_attrs)
+ return VIEWER_NORMAL_COLOR;
+
+ // 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;
+ 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 string dynamically
+ if (has_attrs)
+ {
+ 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
+ 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 +496,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;
@@ -536,6 +668,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.
@@ -568,6 +725,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;
@@ -600,6 +758,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;
@@ -610,8 +769,20 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
if (cs[0] == '\n')
{
- // New line: reset all formatting state for the next paragraph.
+ // 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);
+
+ // Reset positional and nroff state for the new line, but preserve
+ // ANSI SGR state: in terminals, color/attribute spans legally cross
+ // newlines and must remain active until an explicit ESC[m reset.
+ mcview_ansi_state_t saved_ansi = state->ansi;
mcview_state_machine_init (state, state->offset);
+ state->ansi = saved_ansi;
+
if (linewidth != NULL)
*linewidth = col;
return 1;
@@ -709,6 +880,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;
@@ -1036,6 +1210,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 7358ba105..3300f5bd3 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 d376f8617..dd89d3ab0 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 **********************************************/
@@ -87,6 +88,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;
@@ -164,6 +166,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
@@ -304,6 +307,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_ansi_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 23543377b..c5904edc0 100644
--- a/src/viewer/lib.c
+++ b/src/viewer/lib.c
@@ -119,6 +119,18 @@ mcview_toggle_nroff_mode (WView *view)
/* --------------------------------------------------------------------------------------------- */
+void
+mcview_toggle_ansi_mode (WView *view)
+{
+ view->mode_flags.syntax = !view->mode_flags.syntax;
+ mcview_altered_flags.syntax = TRUE;
+ view->dpy_wrap_dirty = TRUE;
+ view->dpy_bbar_dirty = TRUE;
+ view->dirty++;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
void
mcview_toggle_hex_mode (WView *view)
{
diff --git a/src/viewer/mcviewer.c b/src/viewer/mcviewer.c
index 46f421154..efa3f9ee2 100644
--- a/src/viewer/mcviewer.c
+++ b/src/viewer/mcviewer.c
@@ -50,11 +50,11 @@
/*** 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;
@@ -227,6 +227,8 @@ mcview_new (const WRect *r, gboolean is_panel)
mcview_toggle_wrap_mode (view);
if (mcview_global_flags.magic)
mcview_toggle_magic_mode (view);
+ if (mcview_global_flags.syntax)
+ mcview_toggle_ansi_mode (view);
return view;
}
diff --git a/src/viewer/mcviewer.h b/src/viewer/mcviewer.h
index 9f1a17397..f2c381d0b 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 *********************************************************/
diff --git a/tests/src/viewer/Makefile.am b/tests/src/viewer/Makefile.am
index 91549b42b..b88cbfec2 100644
--- a/tests/src/viewer/Makefile.am
+++ b/tests/src/viewer/Makefile.am
@@ -17,7 +17,8 @@ endif
TESTS = \
mcview__load_zip_magic \
- mcview__stream
+ mcview__stream \
+ ansi_parser
check_PROGRAMS = $(TESTS)
@@ -26,3 +27,6 @@ mcview__load_zip_magic_SOURCES = \
mcview__stream_SOURCES = \
mcview__stream.c
+
+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 000000000..71b7effbe
--- /dev/null
+++ b/tests/src/viewer/ansi_parser.c
@@ -0,0 +1,835 @@
+/*
+ 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.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);
+}
+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.italic);
+ mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
+}
+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
+
+/* --------------------------------------------------------------------------------------------- */
+/* 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
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ANSI color state persists across a newline character */
+
+START_TEST (test_ansi_state_persists_across_newline)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set red fg, emit text, then a newline, then more text without reset
+ result = parse_and_collect (&state, "\033[31mfoo\nbar");
+
+ // then — both segments are visible and fg is still red after the newline
+ mctest_assert_str_eq (result->str, "foo\nbar");
+ ck_assert_int_eq (state.fg, 1);
+ g_string_free (result, TRUE);
+}
+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);
+ 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);
+ tcase_add_test (tc_core, test_ansi_state_persists_across_newline);
+ // ***********************************
+
+ return mctest_run_all (tc_core);
+}
+
+/* --------------------------------------------------------------------------------------------- */