Skip to content

Instantly share code, notes, and snippets.

@benoit-pierre
Created February 11, 2022 23:00
Show Gist options
  • Save benoit-pierre/5cebb8bf5210b838ddc45f07da32943a to your computer and use it in GitHub Desktop.
Save benoit-pierre/5cebb8bf5210b838ddc45f07da32943a to your computer and use it in GitHub Desktop.
# -*- coding: utf-8 -*-
# pylint: disable=consider-using-f-string,invalid-name,line-too-long,super-with-arguments
'''
Ranger color-scheme using `$LS_COLORS` / `dircolors`.
Originally based on: https://github.com/ranger/colorschemes/raw/a250fe866200940eb06d877a274333a2a54c34f3/ls_colors.py
Usage: copy this file to `~/.config/ranger/colorschemes'. The base color-scheme
used for non file system entries / the unfocused pane is `default`. To change it,
rename this file to `ls_colors_BASE_SCHEME_NAME.py` (e.g. change it to
`ls_colors_snow.py` to use the `snow` color-scheme as base).
'''
import curses
import fnmatch
import importlib
import inspect
import os
import re
import shlex
import subprocess
import sys
import ranger.gui.color as STYLE
import ranger.gui.widgets.browsercolumn
from ranger.gui.colorscheme import ColorScheme, ColorSchemeError
from ranger.gui.context import CONTEXT_KEYS, Context
PY2 = sys.version_info[0] == 2
SPECIAL_PATTERNS = {
'bd': 'device' , # block device
'ca': '' , # cap
'cd': 'device' , # char device
'cl': '' , # clear end of line
'di': 'directory' , # directory
'do': '' , # Solaris door
'ec': '' , # end color, unused
'ex': 'executable', # executable
'fi': 'file' , # file
'fl': '' , # file, default
'lc': '' , # left, unused
'ln': 'link' , # symlink
'mh': '' , # multi hardlink
'mi': '' , # missing file
'no': '' , # normal
'or': 'orphan' , # orphaned symlink
'ow': '' , # other-writable
'pi': 'fifo' , # pipe
'rc': '' , # right, unused
'rs': '' , # reset
'sg': '' , # setgid
'so': 'socket' , # socket
'st': '' , # sticky
'su': '' , # setuid
'tw': '' , # ow with sticky
}
STYLE_ATTRIBUTES = {
1: STYLE.bold,
2: STYLE.dim,
# Italic, not always supported, and
# not exported by `ranger.gui.color`.
3: getattr(curses, 'A_ITALIC', 0),
4: STYLE.underline,
5: STYLE.blink,
# Rapid blink.
6: STYLE.blink,
7: STYLE.reverse,
8: STYLE.invisible,
}
def parse_terminal_attributes(attribute_list):
'''
Parse a list of ECMA-48 SGR sequence
parameters to sets display attributes.
'''
fg, bg, return_attr = STYLE.default_colors
attribute_iter = iter(attribute_list)
for attr in attribute_iter:
if attr == 0:
# Reset to default.
fg, bg, return_attr = STYLE.default_colors
elif attr in STYLE_ATTRIBUTES:
return_attr |= STYLE_ATTRIBUTES[attr]
# Foreground: basic colours.
elif 30 <= attr <= 37:
fg = attr - 30
# Foreground: 256 colors.
elif attr == 38:
attr = next(attribute_iter)
assert attr == 5
fg = next(attribute_iter)
# Background: Basic colours.
elif 40 <= attr <= 47:
bg = attr - 40
# Background: 256 colors.
elif attr == 48:
attr = next(attribute_iter)
assert attr == 5
bg = next(attribute_iter)
# Foreground: bright version of basic colours.
elif 90 <= attr <= 97:
fg = attr - 90 + STYLE.BRIGHT
# Background: bright version of basic colours.
elif 100 <= attr <= 107:
bg = attr - 100 + STYLE.BRIGHT
else:
raise ValueError('invalid/unsupported terminal attribute: {}'.format(attr))
return fg, bg, return_attr
def get_ls_colors():
'''
Return `$LS_COLORS` or call `dircolors`
to get the list colors to used.
'''
spec = os.getenv('LS_COLORS')
if spec is None:
try:
spec = subprocess.check_output(('dircolors', '--csh'))
except (subprocess.CalledProcessError, FileNotFoundError):
pass
else:
spec = shlex.split(spec.decode())
assert len(spec) == 3 and spec[0:2] == ['setenv', 'LS_COLORS'], spec
spec = spec[-1]
if spec is None:
spec = ''
return spec
def parse_ls_colors(spec):
'''
Parse a `$LS_COLORS` spec.
'''
ls_colors = []
for entry in spec.strip(':').split(':'):
if not entry:
continue
pattern, t_attributes = entry.split('=', 1)
if pattern != 'ln' or t_attributes != 'target':
t_attributes = list(map(int, t_attributes.split(';')))
t_attributes = parse_terminal_attributes(t_attributes)
ls_colors.append((pattern, t_attributes))
return ls_colors
def get_base_colorscheme_name():
'''
Find out the name of the base color-scheme to use.
'''
basename = __name__.rsplit('.', 1)[-1]
if basename.startswith('ls_colors_'):
return basename[10:]
return 'default'
def get_colorscheme_class(name):
'''
Return a color-scheme class by (module)
name, checking for a relative one first.
'''
for module_name in (
(__package__ + '.' if __package__ else '') + name,
'ranger.colorschemes.' + name
):
try:
scheme_module = importlib.import_module(module_name)
except ImportError:
continue
for var in scheme_module.__dict__.values():
if var != ColorScheme and \
inspect.isclass(var) and \
issubclass(var, ColorScheme):
return var
break
raise ColorSchemeError("Cannot locate colorscheme `{}'".format(name))
class LsColors(get_colorscheme_class(get_base_colorscheme_name())):
__doc__ = __doc__
def __init__(self):
super(LsColors, self).__init__()
rx_parts = []
self.ls_colors = []
self.pattern_ls_color = []
self.special_ls_color = {}
for n, (pattern, t_attributes) in enumerate(parse_ls_colors(get_ls_colors())):
self.ls_colors.append(t_attributes)
target = SPECIAL_PATTERNS.get(pattern)
if target is not None:
# Special directives.
self.special_ls_color[target] = n
continue
# Other pattern.
pattern = fnmatch.translate(pattern)
if PY2:
assert pattern.endswith(r'\Z(?ms)')
pattern = pattern[:-7]
else:
assert pattern.startswith('(?s:') and pattern.endswith(r')\Z')
pattern = pattern[4:-3]
rx_parts.append('(' + pattern + ')')
self.pattern_ls_color.append(n)
if PY2:
# “sorry, but this version only supports 100 named groups”…
rx_batch_size = 99
else:
rx_batch_size = None
self.pattern_rx_list = []
ls_color_offset = -1
while rx_parts:
rx_batch = rx_parts[:rx_batch_size]
rx_parts = rx_parts[len(rx_batch):]
pattern_rx = re.compile('^(?:' + '|'.join(rx_batch) + ')$',
re.DOTALL | re.IGNORECASE)
self.pattern_rx_list.append((pattern_rx, ls_color_offset))
ls_color_offset += len(rx_batch)
self.old_hook_before_drawing = ranger.gui.widgets.browsercolumn.hook_before_drawing
ranger.gui.widgets.browsercolumn.hook_before_drawing = self.new_hook_before_drawing
def new_hook_before_drawing(self, fsobject, color_list):
'''
Add an `ls_colorN` tag to `color_list`
if there's an applicable LS colors entry.
'''
color_set = frozenset(color_list)
ls_color = None
for kind in (
# Check for symlinks first.
'link',
# Then special files.
'device',
'fifo',
'socket',
# Directories before executables (execute bit).
'directory',
'executable',
# And finally standard files.
'file',
):
if kind not in color_set:
continue
ls_color = self.special_ls_color.get(kind)
if kind == 'link':
# Orphaned link?
if 'bad' in color_set:
ls_color = self.special_ls_color.get('orphan', ls_color)
# Should we look at the link target instead?
if ls_color is not None and self.ls_colors[ls_color] == 'target':
ls_color = None
elif kind == 'file':
# Check for a matching pattern.
for pattern_rx, ls_color_offset in self.pattern_rx_list:
m = pattern_rx.match(fsobject.basename)
if m is not None:
ls_color = self.pattern_ls_color[m.lastindex + ls_color_offset]
break
if ls_color is not None:
color_list.append('ls_color' + str(ls_color))
break
return self.old_hook_before_drawing(fsobject, color_list)
BASE_IGNORED_TAGS = frozenset((
'audio',
'container',
'document',
'image',
'media',
'video',
))
BASE_DECORATION_TAGS = frozenset((
'copied',
'cut',
'line_number',
'marked',
'tag_marker',
) + tuple(k for k in CONTEXT_KEYS if k.startswith('vcs')))
def use(self, context):
'''
Return a `(fg, bg, attr)` tuple for colorizing according
to `context`: if there's an `ls_colorN` attribute, use that,
otherwise, use the base color-scheme.
'''
tags = set()
style = None
for k, v in context.__dict__.items():
if k.startswith('ls_color'):
style = self.ls_colors[int(k[8:])]
elif v and k not in self.BASE_IGNORED_TAGS:
tags.add(k)
if style is None:
# No LS color, use base scheme.
return super(LsColors, self).use(context)
if not context.in_browser or context.inactive_pane or \
not context.selected and tags & self.BASE_DECORATION_TAGS:
# Inactive, or (unselected) decorations, use simplified base scheme.
tags -= self.BASE_IGNORED_TAGS
return super(LsColors, self).use(Context(tags))
# LS color style, unselected.
if 'selected' not in tags:
return style
# LS color style, selected.
fg, bg, attr = style
if attr & STYLE.reverse:
attr &= ~STYLE.reverse
else:
attr |= STYLE.reverse
if 'main_column' in tags:
attr |= STYLE.bold
return fg, bg, attr
def test():
'''
Test the color-scheme: create a temporary directory
with a bunch of entries matching the LS colors spec
and open ranger on it.
'''
# pylint: disable=import-outside-toplevel,too-many-locals
import random
import shutil
import socket
import stat
import tempfile
# Randomize a string case.
def randcase(s):
nchars = len(s)
randbits = random.getrandbits(nchars)
return ''.join(
c.upper() if b == '1' else c.lower()
for c, b in zip(s, format(randbits, '0{}b'.format(nchars)))
)
def mknod(path, kind):
os.mknod(path, kind | 0o500)
def touch(path, mode=0o644):
os.close(os.open(path, os.O_CREAT, mode))
def mklink(path, target):
os.symlink(target, path)
def mksocket(path):
so = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
so.bind(path)
finally:
so.close()
tmpdir = tempfile.mkdtemp()
try:
entry_list = [
(os.mkdir, 'directory'),
(os.mkdir, '.hidden_directory'),
(os.mkdir, '.git'),
(os.mkdir, 'ceci.n\'est.pas.un.jpeg'),
(os.mkfifo, 'fifo'),
(mksocket, 'socket'),
(mklink, 'symlink_to_dir', 'directory'),
(mklink, 'symlink_to_executable', 'executable'),
(mklink, 'symlink_to_file', 'file'),
(mklink, 'symlink_to_missing', 'missing'),
(touch, 'executable', 0o755),
(touch, 'python_script.py', 0o755),
(touch, 'shell_script.sh', 0o755),
(touch, '.hidden_file'),
(touch, '.hidden.jpeg'),
(touch, 'file'),
]
if os.getuid() == 0:
entry_list.extend((
(mknod, 'block_device', stat.S_IFBLK),
(mknod, 'char_device', stat.S_IFCHR),
))
for entry in filter(None, get_ls_colors().split(':')):
pattern = entry.split('=', 1)[0]
if pattern not in SPECIAL_PATTERNS:
entry_list.append((touch, pattern))
for args in entry_list:
fn, name = args[0:2]
args = args[2:]
fn(os.path.join(tmpdir, name), *args)
for unused_retry in range(3):
alt_name = randcase(name)
if alt_name != name:
fn(os.path.join(tmpdir, alt_name), *args)
break
subprocess.call((sys.executable, '-c', '__import__("ranger").main()', tmpdir))
finally:
shutil.rmtree(tmpdir)
if __name__ == '__main__':
test()
@eggbean
Copy link

eggbean commented Feb 17, 2022

Hi. How do I use this? I have copied this file to ~/.config/ranger/colorschemes, but not sure what to do next. ~/.config/ranger$ python colourschemes/ls_colors.py doesn't seem to make any difference to the files I view, including files which I have coloured in my dircolors file.

@benoit-pierre
Copy link
Author

Copy the file in ~/.config/ranger/colorschemes. If you want to use another base colorscheme, instead of default, rename the file accordingly. For example, if you normally use solarized theme, and want to add ls_colors support to it, rename the file to ls_colors_solarized.py. Then change you config to use the theme in question: set colorscheme ls_colors_solarized.

Running the file directly with Python is for testing how your current ranger render different file types: it wont change your current theme, just create a temporary directory with a bunch of file and open it in ranger.

@eggbean
Copy link

eggbean commented Feb 17, 2022

I stupidly had named the directory colourscheme with a u. Working fine now. Just what I was looking for. Excellent, thanks.

@Tokariew
Copy link

Hi… i tried to use your colorscheme with generated colors from https://github.com/sharkdp/vivid but it failing, when i tried to use true-color (24bit) mode of terminal.
Is it possible to support it in ranger?

@benoit-pierre
Copy link
Author

@Tokariew: ranger themes don't support true-color (see ranger/ranger#690), so no, you'll have to use vivid 8-bit colors mode (vivid -m 8-bit …).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment