mercurial/color.py
author Pierre-Yves David <pierre-yves.david@octobus.net>
Fri, 15 Nov 2024 16:34:50 +0100
branchstable
changeset 52259 074bc2b135f3
parent 51863 f4733654f144
permissions -rw-r--r--
packaging: explain why we purge while building wheels We also make is a bit narrower so that it is less surprising.

# utility for color output for Mercurial commands
#
# Copyright (C) 2007 Kevin Christen <kevin.christen@gmail.com> and other
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

from __future__ import annotations

import re

from .i18n import _

from . import (
    encoding,
    pycompat,
)

from .utils import stringutil

try:
    import curses

    # Mapping from effect name to terminfo attribute name (or raw code) or
    # color number.  This will also force-load the curses module.
    _baseterminfoparams = {
        b'none': (True, b'sgr0', b''),
        b'standout': (True, b'smso', b''),
        b'underline': (True, b'smul', b''),
        b'reverse': (True, b'rev', b''),
        b'inverse': (True, b'rev', b''),
        b'blink': (True, b'blink', b''),
        b'dim': (True, b'dim', b''),
        b'bold': (True, b'bold', b''),
        b'invisible': (True, b'invis', b''),
        b'italic': (True, b'sitm', b''),
        b'black': (False, curses.COLOR_BLACK, b''),
        b'red': (False, curses.COLOR_RED, b''),
        b'green': (False, curses.COLOR_GREEN, b''),
        b'yellow': (False, curses.COLOR_YELLOW, b''),
        b'blue': (False, curses.COLOR_BLUE, b''),
        b'magenta': (False, curses.COLOR_MAGENTA, b''),
        b'cyan': (False, curses.COLOR_CYAN, b''),
        b'white': (False, curses.COLOR_WHITE, b''),
    }
except (ImportError, AttributeError):
    curses = None
    _baseterminfoparams = {}

# start and stop parameters for effects
_effects = {
    b'none': 0,
    b'black': 30,
    b'red': 31,
    b'green': 32,
    b'yellow': 33,
    b'blue': 34,
    b'magenta': 35,
    b'cyan': 36,
    b'white': 37,
    b'bold': 1,
    b'italic': 3,
    b'underline': 4,
    b'inverse': 7,
    b'dim': 2,
    b'black_background': 40,
    b'red_background': 41,
    b'green_background': 42,
    b'yellow_background': 43,
    b'blue_background': 44,
    b'purple_background': 45,
    b'cyan_background': 46,
    b'white_background': 47,
}

_defaultstyles = {
    b'grep.match': b'red bold',
    b'grep.linenumber': b'green',
    b'grep.rev': b'blue',
    b'grep.sep': b'cyan',
    b'grep.filename': b'magenta',
    b'grep.user': b'magenta',
    b'grep.date': b'magenta',
    b'grep.inserted': b'green bold',
    b'grep.deleted': b'red bold',
    b'bookmarks.active': b'green',
    b'branches.active': b'none',
    b'branches.closed': b'black bold',
    b'branches.current': b'green',
    b'branches.inactive': b'none',
    b'diff.changed': b'white',
    b'diff.deleted': b'red',
    b'diff.deleted.changed': b'red bold underline',
    b'diff.deleted.unchanged': b'red',
    b'diff.diffline': b'bold',
    b'diff.extended': b'cyan bold',
    b'diff.file_a': b'red bold',
    b'diff.file_b': b'green bold',
    b'diff.hunk': b'magenta',
    b'diff.inserted': b'green',
    b'diff.inserted.changed': b'green bold underline',
    b'diff.inserted.unchanged': b'green',
    b'diff.tab': b'',
    b'diff.trailingwhitespace': b'bold red_background',
    b'changeset.public': b'',
    b'changeset.draft': b'',
    b'changeset.secret': b'',
    b'diffstat.deleted': b'red',
    b'diffstat.inserted': b'green',
    b'formatvariant.name.mismatchconfig': b'red',
    b'formatvariant.name.mismatchdefault': b'yellow',
    b'formatvariant.name.uptodate': b'green',
    b'formatvariant.repo.mismatchconfig': b'red',
    b'formatvariant.repo.mismatchdefault': b'yellow',
    b'formatvariant.repo.uptodate': b'green',
    b'formatvariant.config.special': b'yellow',
    b'formatvariant.config.default': b'green',
    b'formatvariant.default': b'',
    b'histedit.remaining': b'red bold',
    b'ui.addremove.added': b'green',
    b'ui.addremove.removed': b'red',
    b'ui.error': b'red',
    b'ui.prompt': b'yellow',
    b'log.changeset': b'yellow',
    b'patchbomb.finalsummary': b'',
    b'patchbomb.from': b'magenta',
    b'patchbomb.to': b'cyan',
    b'patchbomb.subject': b'green',
    b'patchbomb.diffstats': b'',
    b'rebase.rebased': b'blue',
    b'rebase.remaining': b'red bold',
    b'resolve.resolved': b'green bold',
    b'resolve.unresolved': b'red bold',
    b'shelve.age': b'cyan',
    b'shelve.newest': b'green bold',
    b'shelve.name': b'blue bold',
    b'status.added': b'green bold',
    b'status.clean': b'none',
    b'status.copied': b'none',
    b'status.deleted': b'cyan bold underline',
    b'status.ignored': b'black bold',
    b'status.modified': b'blue bold',
    b'status.removed': b'red bold',
    b'status.unknown': b'magenta bold underline',
    b'tags.normal': b'green',
    b'tags.local': b'black bold',
    b'upgrade-repo.requirement.preserved': b'cyan',
    b'upgrade-repo.requirement.added': b'green',
    b'upgrade-repo.requirement.removed': b'red',
}


def loadcolortable(ui, extname, colortable):
    _defaultstyles.update(colortable)


def _terminfosetup(ui, mode, formatted):
    '''Initialize terminfo data and the terminal if we're in terminfo mode.'''

    # If we failed to load curses, we go ahead and return.
    if curses is None:
        return
    # Otherwise, see what the config file says.
    if mode not in (b'auto', b'terminfo'):
        return
    ui._terminfoparams.update(_baseterminfoparams)

    for key, val in ui.configitems(b'color'):
        if key.startswith(b'color.'):
            newval = (False, int(val), b'')
            ui._terminfoparams[key[6:]] = newval
        elif key.startswith(b'terminfo.'):
            newval = (True, b'', val.replace(b'\\E', b'\x1b'))
            ui._terminfoparams[key[9:]] = newval
    try:
        curses.setupterm()
    except curses.error:
        ui._terminfoparams.clear()
        return

    for key, (b, e, c) in ui._terminfoparams.copy().items():
        if not b:
            continue
        if not c and not curses.tigetstr(pycompat.sysstr(e)):
            # Most terminals don't support dim, invis, etc, so don't be
            # noisy and use ui.debug().
            ui.debug(b"no terminfo entry for %s\n" % e)
            del ui._terminfoparams[key]
    if not curses.tigetstr('setaf') or not curses.tigetstr('setab'):
        # Only warn about missing terminfo entries if we explicitly asked for
        # terminfo mode and we're in a formatted terminal.
        if mode == b"terminfo" and formatted:
            ui.warn(
                _(
                    b"no terminfo entry for setab/setaf: reverting to "
                    b"ECMA-48 color\n"
                )
            )
        ui._terminfoparams.clear()


def setup(ui):
    """configure color on a ui

    That function both set the colormode for the ui object and read
    the configuration looking for custom colors and effect definitions."""
    mode = _modesetup(ui)
    ui._colormode = mode
    if mode and mode != b'debug':
        configstyles(ui)


def _modesetup(ui):
    if ui.plain(b'color'):
        return None
    config = ui.config(b'ui', b'color')
    if config == b'debug':
        return b'debug'

    auto = config == b'auto'
    always = False
    if not auto and stringutil.parsebool(config):
        # We want the config to behave like a boolean, "on" is actually auto,
        # but "always" value is treated as a special case to reduce confusion.
        if (
            ui.configsource(b'ui', b'color') == b'--color'
            or config == b'always'
        ):
            always = True
        else:
            auto = True

    if not always and not auto:
        return None

    formatted = always or (
        encoding.environ.get(b'TERM') != b'dumb' and ui.formatted()
    )

    mode = ui.config(b'color', b'mode')

    # If pager is active, color.pagermode overrides color.mode.
    if getattr(ui, 'pageractive', False):
        mode = ui.config(b'color', b'pagermode', mode)

    realmode = mode
    if pycompat.iswindows:
        from . import win32

        if mode == b'auto':
            # Since "ansi" could result in terminal gibberish, we error on the
            # side of selecting "win32". However, if w32effects is not defined,
            # we almost certainly don't support "win32", so don't even try.
            # w32effects is not populated when stdout is redirected, so checking
            # it first avoids win32 calls in a state known to error out.
            if not w32effects or win32.enablevtmode():
                realmode = b'ansi'
            else:
                realmode = b'win32'
        # An empty w32effects is a clue that stdout is redirected, and thus
        # cannot enable VT mode.
        elif mode == b'ansi' and w32effects:
            win32.enablevtmode()
    elif mode == b'auto':
        realmode = b'ansi'

    def modewarn():
        # only warn if color.mode was explicitly set and we're in
        # a formatted terminal
        if mode == realmode and formatted:
            ui.warn(_(b'warning: failed to set color mode to %s\n') % mode)

    if realmode == b'win32':
        ui._terminfoparams.clear()
        if not w32effects:
            modewarn()
            return None
    elif realmode == b'ansi':
        ui._terminfoparams.clear()
    elif realmode == b'terminfo':
        _terminfosetup(ui, mode, formatted)
        if not ui._terminfoparams:
            ## FIXME Shouldn't we return None in this case too?
            modewarn()
            realmode = b'ansi'
    else:
        return None

    if always or (auto and formatted):
        return realmode
    return None


def configstyles(ui):
    ui._styles.update(_defaultstyles)
    for status, cfgeffects in ui.configitems(b'color'):
        if b'.' not in status or status.startswith((b'color.', b'terminfo.')):
            continue
        cfgeffects = ui.configlist(b'color', status)
        if cfgeffects:
            good = []
            for e in cfgeffects:
                if valideffect(ui, e):
                    good.append(e)
                else:
                    ui.warn(
                        _(
                            b"ignoring unknown color/effect %s "
                            b"(configured in color.%s)\n"
                        )
                        % (stringutil.pprint(e), status)
                    )
            ui._styles[status] = b' '.join(good)


def _activeeffects(ui):
    '''Return the effects map for the color mode set on the ui.'''
    if ui._colormode == b'win32':
        return w32effects
    elif ui._colormode is not None:
        return _effects
    return {}


def valideffect(ui, effect):
    """Determine if the effect is valid or not."""
    return (not ui._terminfoparams and effect in _activeeffects(ui)) or (
        effect in ui._terminfoparams or effect[:-11] in ui._terminfoparams
    )


def _effect_str(ui, effect):
    '''Helper function for render_effects().'''

    bg = False
    if effect.endswith(b'_background'):
        bg = True
        effect = effect[:-11]
    try:
        attr, val, termcode = ui._terminfoparams[effect]
    except KeyError:
        return b''
    if attr:
        if termcode:
            return termcode
        else:
            return curses.tigetstr(pycompat.sysstr(val))
    elif bg:
        return curses.tparm(curses.tigetstr('setab'), val)
    else:
        return curses.tparm(curses.tigetstr('setaf'), val)


def _mergeeffects(text, start, stop):
    """Insert start sequence at every occurrence of stop sequence

    >>> s = _mergeeffects(b'cyan', b'[C]', b'|')
    >>> s = _mergeeffects(s + b'yellow', b'[Y]', b'|')
    >>> s = _mergeeffects(b'ma' + s + b'genta', b'[M]', b'|')
    >>> s = _mergeeffects(b'red' + s, b'[R]', b'|')
    >>> s
    '[R]red[M]ma[Y][C]cyan|[R][M][Y]yellow|[R][M]genta|'
    """
    parts = []
    for t in text.split(stop):
        if not t:
            continue
        parts.extend([start, t, stop])
    return b''.join(parts)


def _render_effects(ui, text, effects):
    """Wrap text in commands to turn on each effect."""
    if not text:
        return text
    if ui._terminfoparams:
        start = b''.join(
            _effect_str(ui, effect) for effect in [b'none'] + effects.split()
        )
        stop = _effect_str(ui, b'none')
    else:
        activeeffects = _activeeffects(ui)
        start = [
            pycompat.bytestr(activeeffects[e])
            for e in [b'none'] + effects.split()
        ]
        start = b'\033[' + b';'.join(start) + b'm'
        stop = b'\033[' + pycompat.bytestr(activeeffects[b'none']) + b'm'
    return _mergeeffects(text, start, stop)


_ansieffectre = re.compile(br'\x1b\[[0-9;]*m')


def stripeffects(text):
    """Strip ANSI control codes which could be inserted by colorlabel()"""
    return _ansieffectre.sub(b'', text)


def colorlabel(ui, msg, label):
    """add color control code according to the mode"""
    if ui._colormode == b'debug':
        if label and msg:
            if msg.endswith(b'\n'):
                msg = b"[%s|%s]\n" % (label, msg[:-1])
            else:
                msg = b"[%s|%s]" % (label, msg)
    elif ui._colormode is not None:
        effects = []
        for l in label.split():
            s = ui._styles.get(l, b'')
            if s:
                effects.append(s)
            elif valideffect(ui, l):
                effects.append(l)
        effects = b' '.join(effects)
        if effects:
            msg = b'\n'.join(
                [
                    _render_effects(ui, line, effects)
                    for line in msg.split(b'\n')
                ]
            )
    return msg


w32effects = None
if pycompat.iswindows:
    import ctypes

    _kernel32 = ctypes.windll.kernel32  # pytype: disable=module-attr

    _WORD = ctypes.c_ushort

    _INVALID_HANDLE_VALUE = -1

    class _COORD(ctypes.Structure):
        _fields_ = [('X', ctypes.c_short), ('Y', ctypes.c_short)]

    class _SMALL_RECT(ctypes.Structure):
        _fields_ = [
            ('Left', ctypes.c_short),
            ('Top', ctypes.c_short),
            ('Right', ctypes.c_short),
            ('Bottom', ctypes.c_short),
        ]

    class _CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
        _fields_ = [
            ('dwSize', _COORD),
            ('dwCursorPosition', _COORD),
            ('wAttributes', _WORD),
            ('srWindow', _SMALL_RECT),
            ('dwMaximumWindowSize', _COORD),
        ]

    _STD_OUTPUT_HANDLE = 0xFFFFFFF5  # (DWORD)-11
    _STD_ERROR_HANDLE = 0xFFFFFFF4  # (DWORD)-12

    _FOREGROUND_BLUE = 0x0001
    _FOREGROUND_GREEN = 0x0002
    _FOREGROUND_RED = 0x0004
    _FOREGROUND_INTENSITY = 0x0008

    _BACKGROUND_BLUE = 0x0010
    _BACKGROUND_GREEN = 0x0020
    _BACKGROUND_RED = 0x0040
    _BACKGROUND_INTENSITY = 0x0080

    _COMMON_LVB_REVERSE_VIDEO = 0x4000
    _COMMON_LVB_UNDERSCORE = 0x8000

    # http://msdn.microsoft.com/en-us/library/ms682088%28VS.85%29.aspx
    w32effects = {
        b'none': -1,
        b'black': 0,
        b'red': _FOREGROUND_RED,
        b'green': _FOREGROUND_GREEN,
        b'yellow': _FOREGROUND_RED | _FOREGROUND_GREEN,
        b'blue': _FOREGROUND_BLUE,
        b'magenta': _FOREGROUND_BLUE | _FOREGROUND_RED,
        b'cyan': _FOREGROUND_BLUE | _FOREGROUND_GREEN,
        b'white': _FOREGROUND_RED | _FOREGROUND_GREEN | _FOREGROUND_BLUE,
        b'bold': _FOREGROUND_INTENSITY,
        b'black_background': 0x100,  # unused value > 0x0f
        b'red_background': _BACKGROUND_RED,
        b'green_background': _BACKGROUND_GREEN,
        b'yellow_background': _BACKGROUND_RED | _BACKGROUND_GREEN,
        b'blue_background': _BACKGROUND_BLUE,
        b'purple_background': _BACKGROUND_BLUE | _BACKGROUND_RED,
        b'cyan_background': _BACKGROUND_BLUE | _BACKGROUND_GREEN,
        b'white_background': (
            _BACKGROUND_RED | _BACKGROUND_GREEN | _BACKGROUND_BLUE
        ),
        b'bold_background': _BACKGROUND_INTENSITY,
        b'underline': _COMMON_LVB_UNDERSCORE,  # double-byte charsets only
        b'inverse': _COMMON_LVB_REVERSE_VIDEO,  # double-byte charsets only
    }

    passthrough = {
        _FOREGROUND_INTENSITY,
        _BACKGROUND_INTENSITY,
        _COMMON_LVB_UNDERSCORE,
        _COMMON_LVB_REVERSE_VIDEO,
    }

    stdout = _kernel32.GetStdHandle(
        _STD_OUTPUT_HANDLE
    )  # don't close the handle returned
    if stdout is None or stdout == _INVALID_HANDLE_VALUE:
        w32effects = None
    else:
        csbi = _CONSOLE_SCREEN_BUFFER_INFO()
        if not _kernel32.GetConsoleScreenBufferInfo(stdout, ctypes.byref(csbi)):
            # stdout may not support GetConsoleScreenBufferInfo()
            # when called from subprocess or redirected
            w32effects = None
        else:
            origattr = csbi.wAttributes
            ansire = re.compile(
                br'\033\[([^m]*)m([^\033]*)(.*)', re.MULTILINE | re.DOTALL
            )

    def win32print(ui, writefunc, text, **opts):
        label = opts.get('label', b'')
        attr = origattr

        def mapcolor(val, attr):
            if val == -1:
                return origattr
            elif val in passthrough:
                return attr | val
            elif val > 0x0F:
                return (val & 0x70) | (attr & 0x8F)
            else:
                return (val & 0x07) | (attr & 0xF8)

        # determine console attributes based on labels
        for l in label.split():
            style = ui._styles.get(l, b'')
            for effect in style.split():
                try:
                    attr = mapcolor(w32effects[effect], attr)
                except KeyError:
                    # w32effects could not have certain attributes so we skip
                    # them if not found
                    pass
        # hack to ensure regexp finds data
        if not text.startswith(b'\033['):
            text = b'\033[m' + text

        # Look for ANSI-like codes embedded in text
        m = re.match(ansire, text)

        try:
            while m:
                for sattr in m.group(1).split(b';'):
                    if sattr:
                        attr = mapcolor(int(sattr), attr)
                ui.flush()
                _kernel32.SetConsoleTextAttribute(stdout, attr)
                writefunc(m.group(2))
                m = re.match(ansire, m.group(3))
        finally:
            # Explicitly reset original attributes
            ui.flush()
            _kernel32.SetConsoleTextAttribute(stdout, origattr)