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