sapling/eden/scm/edenscm/color.py

435 lines
14 KiB
Python
Raw Normal View History

# Portions Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2.
# 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 absolute_import
import re
from typing import Dict, List, Optional, Pattern, Union
import bindings
from . import encoding, pycompat, util
from .i18n import _
from .pycompat import decodeutf8, encodeutf8
try:
import curses
curses.COLOR_BLACK
except (ImportError, AttributeError):
curses = None
# start and stop parameters for effects
_defaulteffects = {
"none": 0,
"black": 30,
"red": 31,
"green": 32,
"yellow": 33,
"blue": 34,
"magenta": 35,
"cyan": 36,
"white": 37,
"bold": 1,
"italic": 3,
"underline": 4,
"inverse": 7,
"dim": 2,
"black_background": 40,
"red_background": 41,
"green_background": 42,
"yellow_background": 43,
"blue_background": 44,
"purple_background": 45,
"cyan_background": 46,
"white_background": 47,
}
_effects: Dict[str, int] = {}
_defaultstyles = {
"grep.match": "red bold",
"grep.linenumber": "green",
"grep.rev": "green",
"grep.change": "green",
"grep.sep": "cyan",
"grep.filename": "magenta",
"grep.user": "magenta",
"grep.date": "magenta",
"blackbox.timestamp": "green",
"blackbox.session.0": "yellow",
"blackbox.session.1": "cyan",
"blackbox.session.2": "magenta",
"blackbox.session.3": "brightblue:blue",
"blame.age.1hour": "#ffe:color231:bold",
"blame.age.1day": "#eea:color230:bold",
"blame.age.7day": "#dd5:color229:brightyellow:yellow",
"blame.age.30day": "#cc3:color228:brightyellow:yellow",
"blame.age.60day": "#aa2:color185:yellow",
"blame.age.180day": "#881:color142:yellow",
"blame.age.360day": "#661:color100:yellow",
"blame.age.old": "#440:color58:brightblack:yellow",
"bookmarks.active": "green",
"branches.active": "none",
"branches.closed": "black bold",
"branches.current": "green",
"branches.inactive": "none",
"diff.changed": "white",
"diff.deleted": "color160:brightred:red",
"diff.deleted.changed": "color196:brightred:red",
"diff.deleted.unchanged": "color124:red",
"diff.diffline": "bold",
"diff.extended": "cyan bold",
"diff.file_a": "red bold",
"diff.file_b": "green bold",
"diff.hunk": "magenta",
"diff.inserted": "color40:brightgreen:green",
"diff.inserted.changed": "color40:brightgreen:green",
"diff.inserted.unchanged": "color28:green",
"diff.tab": "",
"diff.trailingwhitespace": "bold red_background",
"changeset.public": "yellow",
"changeset.draft": "brightyellow:yellow bold",
"changeset.secret": "brightyellow:yellow",
"diffstat.deleted": "red",
"diffstat.inserted": "green",
"formatvariant.name.mismatchconfig": "red",
"formatvariant.name.mismatchdefault": "yellow",
"formatvariant.name.uptodate": "green",
"formatvariant.repo.mismatchconfig": "red",
"formatvariant.repo.mismatchdefault": "yellow",
"formatvariant.repo.uptodate": "green",
"formatvariant.config.special": "yellow",
"formatvariant.config.default": "green",
"formatvariant.default": "",
"histedit.remaining": "red bold",
"log.changeset": "",
"processtree.descendants": "green",
"processtree.selected": "green bold",
"progress.fancy.bar.background": "",
"progress.fancy.bar.indeterminate": "yellow_background",
"progress.fancy.bar.normal": "green_background",
"progress.fancy.bar.spinner": "cyan_background",
"progress.fancy.count": "bold",
"progress.fancy.item": "",
"progress.fancy.topic": "bold",
"ui.prompt": "yellow",
"rebase.rebased": "blue",
"rebase.remaining": "red bold",
"resolve.resolved": "green bold",
"resolve.unresolved": "red bold",
"shelve.age": "cyan",
"shelve.newest": "green bold",
"shelve.name": "blue bold",
"status.added": "green bold",
"ui.metrics": "#777:color242:dim",
"ui.prefix.component": "cyan",
"ui.prefix.error": "brightred:red",
"ui.prefix.notice": "yellow",
"testing.divider": "brightblack:none",
"testing.lineloc": "brightblue:blue",
"testing.source": "none",
"testing.exceeded": "cyan",
}
def loadcolortable(ui, extname, colortable) -> None:
_defaultstyles.update(colortable)
def setup(ui) -> None:
"""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 != "debug":
configstyles(ui)
def _modesetup(ui) -> Optional[str]:
if ui.config("ui", "color") == "debug" and not ui.plain("color"):
return "debug"
elif bindings.io.shouldcolor(ui._rcfg):
return "ansi"
else:
return None
def normalizestyle(ui, style):
"""choose a fallback from a list of labels"""
# colorname1:colorname2:colorname3 means:
# use colorname1 if supported, fallback to colorname2, then
# fallback to colorname3.
for e in style.split(":"):
if valideffect(ui, e):
return e
class truecoloreffects(dict):
def makecolor(self, c):
n = 38
if c.endswith("_background"):
c = c[:-11]
n = 48
if len(c) == 4:
r, g, b = c[1] + c[1], c[2] + c[2], c[3] + c[3]
else:
r, g, b = c[1:3], c[3:5], c[5:7]
return ";".join(map(str, [n, 2, int(r, 16), int(g, 16), int(b, 16)]))
def get(self, key, default=None):
if _truecolorre.match(key):
return self.makecolor(key)
else:
return super(truecoloreffects, self).get(key, default)
def __getitem__(self, key):
if _truecolorre.match(key):
return self.makecolor(key)
else:
return super(truecoloreffects, self).__getitem__(key)
def _extendcolors(colors) -> None:
# see https://en.wikipedia.org/wiki/ANSI_escape_code
global _effects
_effects = _defaulteffects.copy()
if colors >= 16:
_effects.update(
{
"brightblack": 90,
"brightred": 91,
"brightgreen": 92,
"brightyellow": 93,
"brightblue": 94,
"brightmagenta": 95,
"brightcyan": 96,
"brightwhite": 97,
}
)
if colors >= 256:
for i in range(256):
# pyre-fixme[6]: For 2nd param expected `int` but got `str`.
_effects["color%s" % i] = "38;5;%s" % i
# pyre-fixme[6]: For 2nd param expected `int` but got `str`.
_effects["color%s_background" % i] = "48;5;%s" % i
if colors >= 16777216:
_effects = truecoloreffects(_effects)
def configstyles(ui) -> None:
if ui._colormode == "ansi":
_extendcolors(supportedcolors(ui))
ui._styles.update(_defaultstyles)
for status, cfgeffects in ui.configitems("color"):
if "." not in status or status.startswith("color."):
continue
cfgeffects = ui.configlist("color", status)
if cfgeffects:
good = []
for e in cfgeffects:
n = normalizestyle(ui, e)
if n:
good.append(n)
else:
ui.warn(
_(
"ignoring unknown color/effect %r "
"(configured in color.%s)\n"
)
% (e, status)
)
ui._styles[status] = " ".join(good)
def _activeeffects(ui):
"""Return the effects map for the color mode set on the ui."""
if ui._colormode is not None:
return _effects
return {}
def valideffect(ui, effect) -> bool:
"Determine if the effect is valid or not."
return all(
(isinstance(_activeeffects(ui), truecoloreffects) and _truecolorre.match(e))
or (e in _activeeffects(ui))
for e in effect.split("+")
)
def _mergeeffects(
text: "Union[str, bytes]", start: str, stop: str, usebytes: bool = False
) -> "Union[str, bytes]":
"""Insert start sequence at every occurrence of stop sequence
>>> s = _mergeeffects('cyan', '[C]', '|')
>>> s = _mergeeffects(s + 'yellow', '[Y]', '|')
>>> s = _mergeeffects('ma' + s + 'genta', '[M]', '|')
>>> s = _mergeeffects('red' + s, '[R]', '|')
>>> s
'[R]red[M]ma[Y][C]cyan|[R][M][Y]yellow|[R][M]genta|'
"""
parts = []
if usebytes:
assert isinstance(text, bytes)
for t in text.split(encodeutf8(stop)):
if not t:
continue
parts.extend([encodeutf8(start), t, encodeutf8(stop)])
return b"".join(parts)
else:
assert isinstance(text, str)
for t in text.split(stop):
if not t:
continue
parts.extend([start, t, stop])
return "".join(parts)
def _render_effects(ui, text, effects: List[str], usebytes: bool = False):
"Wrap text in commands to turn on each effect."
if not text:
return text
activeeffects = _activeeffects(ui)
# pyre-fixme[16]: `List` has no attribute `split`.
effects = ["none"] + [e for effect in effects.split() for e in effect.split("+")]
start = [pycompat.bytestr(activeeffects[e]) for e in effects]
start = "\033[" + ";".join(start) + "m"
stop = "\033[" + pycompat.bytestr(activeeffects["none"]) + "m"
return _mergeeffects(text, start, stop, usebytes=usebytes)
_ansieffectre: Pattern[str] = re.compile(r"\x1b\[[0-9;]*m")
_truecolorre: Pattern[str] = re.compile(r"#([0-9A-Fa-f]{3}){1,2}(_background)?")
def stripeffects(text):
"""Strip ANSI control codes which could be inserted by colorlabel()"""
return _ansieffectre.sub("", text)
def colorlabel(ui, msg, label, usebytes: bool = False) -> Union[bytes, str]:
"""add color control code according to the mode"""
if ui._colormode == "debug":
if label and msg:
if msg[-1] == "\n":
if usebytes:
msg = b"[%s|%s]\n" % (encodeutf8(label), msg[:-1])
else:
msg = "[%s|%s]\n" % (label, msg[:-1])
else:
if usebytes:
msg = b"[%s|%s]" % (encodeutf8(label), msg)
else:
msg = "[%s|%s]" % (label, msg)
elif ui._colormode is not None:
if ui.configbool("color", "use-rust", default=True):
if not ui._styler:
ui._styler = bindings.io.styler(supportedcolors(ui))
style = " ".join(ui._styles.get(l, l) for l in label.split())
if usebytes:
msg = decodeutf8(msg)
styled = ui._styler.renderbytes(style, msg)
if not usebytes:
styled = styled.decode()
return styled
effects = []
for l in label.split():
s = ui._styles.get(l, "")
if ":" in s:
s = normalizestyle(ui, s)
if s:
effects.append(s)
elif valideffect(ui, l):
effects.append(l)
effects = " ".join(effects)
if effects:
if usebytes:
msg = b"\n".join(
[
# pyre-fixme[6]: For 3rd param expected `List[str]` but got
# `str`.
_render_effects(ui, line, effects, usebytes=True)
for line in msg.split(b"\n")
]
)
else:
msg = "\n".join(
# pyre-fixme[6]: For 3rd param expected `List[str]` but got `str`.
[_render_effects(ui, line, effects) for line in msg.split("\n")]
)
return msg
def supportedcolors(ui):
"""Return the number of colors likely supported by the terminal
Usually it's one of 8, 16, 256.
"""
# HGCOLORS can override the decision
env = encoding.environ
if "HGCOLORS" in env:
colors = 8
try:
colors = int(env["HGCOLORS"])
except Exception:
pass
return colors
# Colors reported by terminfo. Might be smaller than the real value.
ticolors = 8
if curses:
try:
curses.setupterm()
ticolors = curses.tigetnum("colors")
except Exception:
pass
# Guess the real number of colors supported.
# ConEmu normalizes 256 colors incorrectly. Limit it to 16 colors.
if "ConEmuPID" in env:
realcolors = 16
# Emacs has issues with 16 or 256 colors.
if env.get("INSIDE_EMACS"):
realcolors = 8
# Detecting Terminal features is hard. "infocmp" seems to be a "standard"
# way to do it. But it can often miss real terminal capabilities.
#
# Tested on real tmux (2.2), mosh (1.3.0), screen (4.04) from Linux (xfce)
# and OS X Terminal.app and iTerm 2. Every terminal support 256 colors
# except for "screen". "screen" also uses underline for "dim".
#
# screen can actually support 256 colors if it's started with TERM set to
# "xterm-256color". In that case, screen will set TERM to
# "screen.xterm-256color". Tmux sets TERM to "screen" by default. But it
# also sets TMUX.
elif env.get("TERM") == "screen" and "TMUX" not in env:
realcolors = 16
# If COLORTERM is set to indicate a truecolor terminal, believe it.
elif env.get("COLORTERM") in ("truecolor", "24bit"):
realcolors = 16777216
# XXX: The gitbash pager doesn't support more than 8 colors, remove once
# we switched over to our embedded less pager.
elif pycompat.iswindows and ui.pageractive:
realcolors = 8
# Otherwise, pretend to support 256 colors.
else:
realcolors = 256
# terminfo can override "realcolors" upwards.
return max([realcolors, ticolors])