Commit 163fdb91 authored by Bram Schoenmakers's avatar Bram Schoenmakers

Introduce a Color class

This color class abstracts away all the possible representations of a
color in various UIs. These are color objects:

Color(9)              # red
Color('red')          # red
Color(196)            # red
Color('NEUTRAL')      # neutral (resets attributes)
Color()               # no effect

The configuration stores Color objects now, code that requires colors
doesn't have to worry about integer representations of colors.

One semantic change: when an empty color is passed as a priority color,
it will resort to an empty color instead of the neutral color. In
practice this should have the same visual effect. Test case
test_priority_color4 is adapted for this.

This commit is the result after an initial refactoring of the color code
(squashed its history into this one). @mruwek also contributed to this
refactoring, thanks for the help and the ideas to get to this point.
parent 5e746b2a
This diff is collapsed.
......@@ -122,23 +122,19 @@ class ConfigTest(TopydoTest):
def test_config20(self):
""" No project color value. """
self.assertEqual(config("test/data/ConfigTest5.conf").project_color(),
config().defaults["colorscheme"]["project_color"])
self.assertEqual(config("test/data/ConfigTest5.conf").project_color().color, 1)
def test_config21(self):
""" No context color value. """
self.assertEqual(config("test/data/ConfigTest5.conf").context_color(),
config().defaults["colorscheme"]["context_color"])
self.assertEqual(config("test/data/ConfigTest5.conf").context_color().color, 5)
def test_config22(self):
""" No metadata color value. """
self.assertEqual(config("test/data/ConfigTest5.conf").metadata_color(),
config().defaults["colorscheme"]["metadata_color"])
self.assertEqual(config("test/data/ConfigTest5.conf").metadata_color().color, 2)
def test_config23(self):
""" No link color value. """
self.assertEqual(config("test/data/ConfigTest5.conf").link_color(),
config().defaults["colorscheme"]["link_color"])
self.assertEqual(config("test/data/ConfigTest5.conf").link_color().color, 6)
if __name__ == '__main__':
unittest.main()
......@@ -679,7 +679,7 @@ C -
self.assertEqual(self.output, result)
@mock.patch('topydo.lib.ListFormat.get_terminal_size')
def test_list_format44(self, mock_terminal_size):
def test_list_format45(self, mock_terminal_size):
""" Colorblocks should not affect truncating or right_alignment. """
self.maxDiff = None
mock_terminal_size.return_value = self.terminal_size(100, 25)
......
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2016 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program 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.
#
# This program 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 <http://www.gnu.org/licenses/>.
""" This module provides a class that represents a color. """
class Color:
color_names_dict = {
'black': 0,
'red': 1,
'green': 2,
'yellow': 3,
'blue': 4,
'magenta': 5,
'cyan': 6,
'gray': 7,
'darkgray': 8,
'light-red': 9,
'light-green': 10,
'light-yellow': 11,
'light-blue': 12,
'light-magenta': 13,
'light-cyan': 14,
'white': 15,
}
def __init__(self, p_value=None):
""" p_value is user input, be it a word color or an xterm code """
self._value = None
self.color = p_value
@property
def color(self):
return self._value
@color.setter
def color(self, p_value):
try:
if not p_value:
self._value = None
elif p_value in Color.color_names_dict:
self._value = Color.color_names_dict[p_value]
else:
self._value = int(p_value)
# values not in the 256 range are normalized to be neutral
if not 0 <= self._value < 256:
raise ValueError
except ValueError:
# garbage was entered, make it neutral, so at least some
# highlighting may take place
self._value = -1
def is_neutral(self):
"""
A neutral color is the default color on the shell, setting this color
will reset all other attributes (background, foreground, decoration).
"""
return self._value == -1
def is_valid(self):
"""
Whether the color is a valid color.
"""
return self._value is not None
def as_ansi(self, p_decoration='normal', p_background=False):
if not self.is_valid():
return ''
elif self.is_neutral():
return '\033[0m'
is_low_color = 0 <= self._value < 8
is_high_color = 8 <= self._value < 16
is_256 = 16 <= self._value < 255
decoration_dict = {
'normal': '0',
'bold': '1',
'faint': '2',
'italic': '3',
'underline': '4',
}
decoration = decoration_dict[p_decoration]
base = 40 if p_background else 30
if is_high_color:
color = '1;{}'.format(base + self._value - 8)
elif is_256:
color = '{};5;{}'.format(base + 8, self._value)
elif is_low_color:
color = str(base + self._value)
else:
color = ''
return '\033[{};{}m'.format(
decoration,
color
)
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2014 - 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program 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.
#
# This program 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 <http://www.gnu.org/licenses/>.
""" This module serves for managing output colors. """
from topydo.lib.Config import config
NEUTRAL_COLOR = '\033[0m'
def int_to_ansi(p_int, p_decorator='normal', p_safe=True, p_background=''):
"""
Returns ansi code for color based on xterm color id (0-255) and
decoration, where decoration can be one of: normal, bold, faint,
italic, or underline. When p_safe is True, resulting ansi code is
constructed in most compatible way, but with support for only base 16
colors.
"""
decoration_dict = {
'normal': '0',
'bold': '1',
'faint': '2',
'italic': '3',
'underline': '4'
}
decoration = decoration_dict[p_decorator]
try:
if p_safe:
if p_background:
p_background = ';4{}'.format(p_background)
if 8 > int(p_int) >= 0:
return '\033[{};3{}{}m'.format(decoration, str(p_int), p_background)
elif 16 > int(p_int):
p_int = int(p_int) - 8
return '\033[{};1;3{}{}m'.format(decoration, str(p_int), p_background)
if 256 > int(p_int) >= 0:
if p_background:
p_background = ';48;5;{}'.format(str(p_int))
return '\033[{};38;5;{}{}m'.format(decoration, str(p_int), p_background)
else:
return NEUTRAL_COLOR
except ValueError:
return None
def _name_to_int(p_color_name):
""" Returns xterm color id from color name. """
color_names_dict = {
'black': 0,
'red': 1,
'green': 2,
'yellow': 3,
'blue': 4,
'magenta': 5,
'cyan': 6,
'gray': 7,
'darkgray': 8,
'light-red': 9,
'light-green': 10,
'light-yellow': 11,
'light-blue': 12,
'light-magenta': 13,
'light-cyan': 14,
'white': 15,
}
try:
return color_names_dict[p_color_name]
except KeyError:
return 404
def _name_to_ansi(p_color_name, p_decorator):
""" Returns ansi color code from color name. """
number = _name_to_int(p_color_name)
return int_to_ansi(number, p_decorator)
def _get_ansi(p_color, p_decorator):
""" Returns ansi color code from color name or xterm color id. """
if p_color == '':
ansi = ''
else:
ansi = int_to_ansi(p_color, p_decorator, False)
if not ansi:
ansi = _name_to_ansi(p_color, p_decorator)
return ansi
def _get_priority_colors():
pri_ansi_colors = dict()
pri_colors = config().priority_colors()
for pri in pri_colors:
color = _get_ansi(pri_colors[pri], 'normal')
if color == '':
color = NEUTRAL_COLOR
pri_ansi_colors[pri] = color
return pri_ansi_colors
class Colors(object):
def __init__(self):
self.priority_colors = _get_priority_colors()
self.project_color = config().project_color()
self.context_color = config().context_color()
self.metadata_color = config().metadata_color()
self.link_color = config().link_color()
def get_project_color(self):
return _get_ansi(self.project_color, 'bold')
def get_context_color(self):
return _get_ansi(self.context_color, 'bold')
def get_metadata_color(self):
return _get_ansi(self.metadata_color, 'bold')
def get_link_color(self):
return _get_ansi(self.link_color, 'underline')
def get_priority_color(self, p_priority):
try:
priority_color = self.priority_colors[p_priority]
except KeyError:
priority_color = NEUTRAL_COLOR
return priority_color
......@@ -18,6 +18,8 @@ import configparser
import os
import shlex
from topydo.lib.Color import Color
def home_config_path(p_filename):
return os.path.join(os.path.expanduser('~'), p_filename)
......@@ -237,53 +239,53 @@ class _Config:
return [] if hidden_tags == '' else [tag.strip() for tag in
hidden_tags.split(',')]
def priority_colors(self):
def priority_color(self, p_priority):
"""
Returns a dict with priorities as keys and color numbers as value.
"""
pri_colors_str = self.cp.get('colorscheme', 'priority_colors')
def _str_to_dict(p_string):
pri_colors_dict = dict()
for pri_color in p_string.split(','):
pri, color = pri_color.split(':')
pri_colors_dict[pri] = color
pri_colors_dict[pri] = Color(color)
return pri_colors_dict
try:
pri_colors_str = self.cp.get('colorscheme', 'priority_colors')
if pri_colors_str == '':
pri_colors_dict = {'A': '', 'B': '', 'C': ''}
pri_colors_dict = _str_to_dict('A:-1,B:-1,C:-1')
else:
pri_colors_dict = _str_to_dict(pri_colors_str)
except ValueError:
pri_colors_dict = _str_to_dict(self.defaults['colorscheme']['priority_colors'])
return pri_colors_dict
return pri_colors_dict[p_priority] if p_priority in pri_colors_dict else Color('NEUTRAL')
def project_color(self):
try:
return self.cp.get('colorscheme', 'project_color')
return Color(self.cp.getint('colorscheme', 'project_color'))
except ValueError:
return int(self.defaults['colorscheme']['project_color'])
return Color(self.cp.get('colorscheme', 'project_color'))
def context_color(self):
try:
return self.cp.get('colorscheme', 'context_color')
return Color(self.cp.getint('colorscheme', 'context_color'))
except ValueError:
return int(self.defaults['colorscheme']['context_color'])
return Color(self.cp.get('colorscheme', 'context_color'))
def metadata_color(self):
try:
return self.cp.get('colorscheme', 'metadata_color')
return Color(self.cp.getint('colorscheme', 'metadata_color'))
except ValueError:
return int(self.defaults['colorscheme']['metadata_color'])
return Color(self.cp.get('colorscheme', 'metadata_color'))
def link_color(self):
try:
return self.cp.get('colorscheme', 'link_color')
return Color(self.cp.getint('colorscheme', 'link_color'))
except ValueError:
return int(self.defaults['colorscheme']['link_color'])
return Color(self.cp.get('colorscheme', 'link_color'))
def auto_creation_date(self):
try:
......
......@@ -20,7 +20,7 @@ import arrow
import re
from topydo.lib.Config import config
from topydo.lib.Colorblock import color_block
from topydo.lib.ProgressColor import progress_color
from topydo.lib.Utils import get_terminal_size, escape_ansi
MAIN_PATTERN = (r'^({{(?P<before>.+?)}})?'
......@@ -129,6 +129,12 @@ def _right_align(p_str):
return p_str
def color_block(p_todo, p_256_color):
return '{} {}'.format(
progress_color(p_todo, p_256_color).as_ansi(p_background=True),
config().priority_color(p_todo.priority()).as_ansi(),
)
class ListFormatParser(object):
""" Parser of format string. """
def __init__(self, p_todolist, p_format=None):
......@@ -195,9 +201,9 @@ class ListFormatParser(object):
# relative completion date
'X': lambda t: 'x ' + humanize_date(t.completion_date()) if t.is_completed() else '',
'z': lambda t: color_block(t) if config().colors() else ' ',
'z': lambda t: color_block(t, p_256_color=False) if config().colors() else ' ',
'Z': lambda t: color_block(t, p_safe=False) if config().colors() else ' ',
'Z': lambda t: color_block(t, p_256_color=True) if config().colors() else ' ',
}
self.format_list = self._preprocess_format()
......
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2014 - 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
# Copyright (C) 2016 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
......@@ -16,22 +16,22 @@
import re
from topydo.lib.Colors import int_to_ansi, Colors
from topydo.lib.Color import Color
from topydo.lib.Recurrence import relative_date_to_date
COLOR16_RANGE = [
10, # light green
2, # green
3, # yellow
1, # red
]
def progress_color(p_todo, p_256color=False):
color16_range = [
10, # light green
2, # green
3, # yellow
1, # red
]
# https://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg
# a gradient from green to yellow to red
COLOR256_RANGE = \
[22, 28, 34, 40, 46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196]
# https://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg
# a gradient from green to yellow to red
color256_range = \
[22, 28, 34, 40, 46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196]
def progress_color_code(p_todo, p_safe=True):
def get_length():
"""
Returns the length of the p_todo item in days, based on the recurrence
......@@ -76,26 +76,15 @@ def progress_color_code(p_todo, p_safe=True):
else:
return 0
def progress_to_color():
color_range = COLOR16_RANGE if p_safe else COLOR256_RANGE
progress = get_progress()
# TODO: remove linear scale to exponential scale
if progress > 1:
# overdue, return the last color
return color_range[-1]
else:
# not overdue, calculate position over color range excl. due date
# color
pos = round(progress * (len(color_range) - 2))
return color_range[pos]
return progress_to_color()
def color_block(p_todo, p_safe=True):
color_code = progress_color_code(p_todo, p_safe)
ansi_code = int_to_ansi(color_code, p_safe=p_safe, p_background=color_code)
priority_color = Colors().get_priority_color(p_todo.priority())
return '{} {}'.format(ansi_code, priority_color)
color_range = color256_range if p_256color else color16_range
progress = get_progress()
# TODO: remove linear scale to exponential scale
if progress > 1:
# overdue, return the last color
return Color(color_range[-1])
else:
# not overdue, calculate position over color range excl. due date
# color
pos = round(progress * (len(color_range) - 2))
return Color(color_range[pos])
......@@ -18,7 +18,7 @@
import re
from topydo.lib.Colors import NEUTRAL_COLOR, Colors
from topydo.lib.Color import Color
from topydo.lib.Config import config
from topydo.lib.PrettyPrinterFilter import PrettyPrinterFilter
......@@ -33,35 +33,35 @@ class PrettyPrinterColorFilter(PrettyPrinterFilter):
def filter(self, p_todo_str, p_todo):
""" Applies the colors. """
if config().colors():
colorscheme = Colors()
priority_color = colorscheme.get_priority_color(p_todo.priority())
project_color = colorscheme.get_project_color()
context_color = colorscheme.get_context_color()
metadata_color = colorscheme.get_metadata_color()
link_color = colorscheme.get_link_color()
priority_color = config().priority_color(p_todo.priority())
project_color = config().project_color()
context_color = config().context_color()
metadata_color = config().metadata_color()
link_color = config().link_color()
neutral_color = Color('NEUTRAL')
# color projects / contexts
p_todo_str = re.sub(
r'\B(\+|@)(\S*\w)',
lambda m: (
context_color if m.group(0)[0] == "@"
else project_color) + m.group(0) + priority_color,
context_color.as_ansi() if m.group(0)[0] == "@"
else project_color.as_ansi()) + m.group(0) + priority_color.as_ansi(),
p_todo_str)
# tags
p_todo_str = re.sub(r'\b\S+:[^/\s]\S*\b',
metadata_color + r'\g<0>' + priority_color,
metadata_color.as_ansi() + r'\g<0>' + priority_color.as_ansi(),
p_todo_str)
# add link_color to any valid URL specified outside of the tag.
p_todo_str = re.sub(r'(^|\s)(\w+:){1}(//\S+)',
r'\1' + link_color + r'\2\3' + priority_color,
r'\1' + link_color.as_ansi() + r'\2\3' + priority_color.as_ansi(),
p_todo_str)
p_todo_str += NEUTRAL_COLOR
p_todo_str += neutral_color.as_ansi()
# color by priority
p_todo_str = priority_color + p_todo_str
p_todo_str = priority_color.as_ansi() + p_todo_str
return p_todo_str
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program 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.
#
# This program 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 <http://www.gnu.org/licenses/>.
""" Provides color support suitable for urwid. """
COLOR_MAP = {
'black': 'black',
'red': 'dark red',
'green': 'dark green',
'yellow': 'brown',
'blue': 'dark blue',
'magenta': 'dark magenta',
'cyan': 'dark cyan',
'gray': 'light gray',
'darkgray': 'dark gray',
'light-red': 'light red',
'light-green': 'light green',
'light-yellow': 'yellow',
'light-blue': 'light blue',
'light-magenta': 'light magenta',
'light-cyan': 'light cyan',
'white': 'white',
}
# Add 256 urwid-like colors
for i in range(256):
COLOR_MAP[str(i)] = 'h' + str(i)
......@@ -16,7 +16,6 @@
from topydo.lib.Config import config
from topydo.lib.ListFormat import ListFormatParser
from topydo.ui.Colors import COLOR_MAP
import urwid
......@@ -30,16 +29,21 @@ def _markup(p_todo, p_focus):
item.
"""
priority_colors = config().priority_colors()
try:
# retrieve the assigned value in the config file
fg_color = priority_colors[p_todo.priority()]
# convert to a color that urwid understands
fg_color = COLOR_MAP[fg_color]
except KeyError:
fg_color = 'black' if p_focus else 'default'
def to_urwid_color(p_color):
"""
Given a Color object, transform it to a color that urwid understands.
"""
if not p_color.is_valid():
return 'black'
elif p_color.is_neutral():
return 'default'
else:
return 'h{}'.format(p_color.color)
# retrieve the assigned value in the config file
fg_color = config().priority_color(p_todo.priority())
fg_color = 'black' if p_focus and fg_color.is_neutral() else to_urwid_color(
fg_color)
bg_color = 'light gray' if p_focus else 'default'
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment