From 4c45cd9557bce6eabd6fbfeb0534bec1d8ffca14 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/7] 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/viewer/Makefile.am | 6 +- tests/src/viewer/ansi_parser.c | 569 +++++++++++++++++++++++++++++++++ 7 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 src/viewer/ansi.c create mode 100644 src/viewer/ansi.h create mode 100644 tests/src/viewer/ansi_parser.c diff --git a/configure.ac b/configure.ac index 30f37014e..d336c82db 100644 --- a/configure.ac +++ b/configure.ac @@ -865,6 +865,7 @@ tests/src/filemanager/Makefile tests/src/viewer/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 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/ansi.c b/src/viewer/ansi.c new file mode 100644 index 000000000..293c93af7 --- /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 000000000..06d22d2ab --- /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 d376f8617..1be06f074 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; 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..dc69d03aa --- /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 67dc010d53c61b84162f8a1546f211b45e4f7e07 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/7] 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 43b5d00da..51c9e2515 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; @@ -536,6 +662,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 +719,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 +752,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,6 +763,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) @@ -709,6 +869,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 +1199,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 1be06f074..d2c418d01 100644 --- a/src/viewer/internal.h +++ b/src/viewer/internal.h @@ -166,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 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 *********************************************************/ From 07111dbd0f573df07914de782a56cd7e2c973867 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 3/7] 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 +++--- tests/src/viewer/ansi_parser.c | 244 +++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 29 deletions(-) diff --git a/src/viewer/ansi.c b/src/viewer/ansi.c index 293c93af7..167950241 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 06d22d2ab..446784559 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 51c9e2515..8bd206dd0 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/tests/src/viewer/ansi_parser.c b/tests/src/viewer/ansi_parser.c index dc69d03aa..3b6283db5 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 cadc734afc019e4fd615d09b25ebb4988913adea Mon Sep 17 00:00:00 2001 From: Ilia Maslakov Date: Sun, 12 Apr 2026 14:44:43 +0200 Subject: [PATCH 4/7] fix: remove duplicate tests/src/viewer/Makefile in configure.ac Signed-off-by: Ilia Maslakov --- configure.ac | 1 - 1 file changed, 1 deletion(-) diff --git a/configure.ac b/configure.ac index d336c82db..30f37014e 100644 --- a/configure.ac +++ b/configure.ac @@ -865,7 +865,6 @@ tests/src/filemanager/Makefile tests/src/viewer/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 From 670887d19d5f7cbfbe448a893b11e7856884fd8f Mon Sep 17 00:00:00 2001 From: Ilia Maslakov Date: Sun, 12 Apr 2026 15:28:56 +0200 Subject: [PATCH 5/7] viewer: add CK_AnsiMode toggle (Shift+F9) to activate ANSI color rendering Add mcview_toggle_ansi_mode() and CK_AnsiMode key command (bound to Shift+F9) to allow the user to enable or disable ANSI SGR color rendering in the viewer. The ANSI SGR parser and rendering pipeline were written by noctuum as part of MidnightCommander/mc PR #5065. This commit wires the toggle mechanism so the feature is accessible at runtime. Adapted-by: Ilia Maslakov Signed-off-by: Ilia Maslakov --- lib/keybind.c | 1 + lib/keybind.h | 1 + misc/mc.default.keymap | 1 + misc/mc.emacs.keymap | 1 + misc/mc.vim.keymap | 1 + src/viewer/actions_cmd.c | 3 +++ src/viewer/internal.h | 1 + src/viewer/lib.c | 12 ++++++++++++ src/viewer/mcviewer.c | 6 ++++-- 9 files changed, 25 insertions(+), 2 deletions(-) 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/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/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/internal.h b/src/viewer/internal.h index d2c418d01..dd89d3ab0 100644 --- a/src/viewer/internal.h +++ b/src/viewer/internal.h @@ -307,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; } From eae61d37d0e496ca4bc6b62b2365ac272930028e Mon Sep 17 00:00:00 2001 From: Ilia Maslakov Date: Sun, 12 Apr 2026 15:29:00 +0200 Subject: [PATCH 6/7] ext.d: preserve procyon ANSI colors when viewing .class files procyon detects whether stdout is a TTY and emits ANSI color codes only in that case. When mc pipes the output through the viewer, stdout is not a TTY, so colors are stripped. Use script(1) to run procyon inside a pseudo-TTY, preserving its ANSI output so the viewer can render it with AnsiMode enabled. Signed-off-by: Ilia Maslakov --- misc/ext.d/misc.sh.in | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 ;; *) ;; From 0b552f7209fb37ac67a1ea20a70114ce7f090ba3 Mon Sep 17 00:00:00 2001 From: Ilia Maslakov Date: Sun, 12 Apr 2026 15:49:15 +0200 Subject: [PATCH 7/7] viewer: preserve ANSI state across newlines in text renderer ANSI SGR attributes (color, bold, etc.) legally span newlines: a sequence like ESC[31m applied before '\n' must remain active on the next line until an explicit reset. The previous code called mcview_state_machine_init() on every newline, which wiped the ANSI parser state. Fix: save state->ansi before mcview_state_machine_init() and restore it after, so positional/nroff state is reset per line while ANSI attributes persist. Add test_ansi_state_persists_across_newline to ansi_parser.c to cover this invariant at the parser level. Signed-off-by: Ilia Maslakov --- src/viewer/ascii.c | 7 ++++++- tests/src/viewer/ansi_parser.c | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/viewer/ascii.c b/src/viewer/ascii.c index 8bd206dd0..847946037 100644 --- a/src/viewer/ascii.c +++ b/src/viewer/ascii.c @@ -776,8 +776,13 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole mcview_fill_line_remaining (view, row, col, line_fill, dpy_text_column); - // New line: reset all formatting state for the next paragraph. + // 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; diff --git a/tests/src/viewer/ansi_parser.c b/tests/src/viewer/ansi_parser.c index 3b6283db5..71b7effbe 100644 --- a/tests/src/viewer/ansi_parser.c +++ b/tests/src/viewer/ansi_parser.c @@ -761,6 +761,27 @@ START_TEST (test_ansi_reset_clears_all_attrs) } 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 @@ -805,6 +826,7 @@ main (void) 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);