Commit 5e746b2a authored by Bram Schoenmakers's avatar Bram Schoenmakers

Merge branch 'colorblock' into column-ui/colors

Conflicts:
	setup.py
	test/test_list_format.py
parents 2c9ded68 4352ece9
......@@ -122,49 +122,49 @@ class ColorsTest(TopydoTest):
def test_priority_color1(self):
config("test/data/ColorsTest1.conf")
color = Colors().get_priority_colors()
colors = Colors()
self.assertEqual(color['A'], '\033[0;38;5;1m')
self.assertEqual(color['B'], '\033[0;38;5;2m')
self.assertEqual(color['C'], '\033[0;38;5;3m')
self.assertEqual(colors.get_priority_color('A'), '\033[0;38;5;1m')
self.assertEqual(colors.get_priority_color('B'), '\033[0;38;5;2m')
self.assertEqual(colors.get_priority_color('C'), '\033[0;38;5;3m')
def test_priority_color2(self):
config("test/data/ColorsTest2.conf")
color = Colors().get_priority_colors()
colors = Colors()
self.assertEqual(color['A'], '\033[0;35m')
self.assertEqual(color['B'], '\033[0;1;36m')
self.assertEqual(color['C'], '\033[0;37m')
self.assertEqual(colors.get_priority_color('A'), '\033[0;35m')
self.assertEqual(colors.get_priority_color('B'), '\033[0;1;36m')
self.assertEqual(colors.get_priority_color('C'), '\033[0;37m')
def test_priority_color3(self):
config("test/data/ColorsTest3.conf")
color = Colors().get_priority_colors()
colors = Colors()
self.assertEqual(color['A'], '\033[0;35m')
self.assertEqual(color['B'], '\033[0;1;36m')
self.assertEqual(color['Z'], NEUTRAL_COLOR)
self.assertEqual(color['D'], '\033[0;31m')
self.assertEqual(color['C'], '\033[0;38;5;7m')
self.assertEqual(colors.get_priority_color('A'), '\033[0;35m')
self.assertEqual(colors.get_priority_color('B'), '\033[0;1;36m')
self.assertEqual(colors.get_priority_color('Z'), NEUTRAL_COLOR)
self.assertEqual(colors.get_priority_color('D'), '\033[0;31m')
self.assertEqual(colors.get_priority_color('C'), '\033[0;38;5;7m')
def test_priority_color4(self):
config("test/data/ColorsTest4.conf")
color = Colors().get_priority_colors()
colors = Colors()
self.assertEqual(color['A'], NEUTRAL_COLOR)
self.assertEqual(color['B'], NEUTRAL_COLOR)
self.assertEqual(color['C'], NEUTRAL_COLOR)
self.assertEqual(colors.get_priority_color('A'), NEUTRAL_COLOR)
self.assertEqual(colors.get_priority_color('B'), NEUTRAL_COLOR)
self.assertEqual(colors.get_priority_color('C'), NEUTRAL_COLOR)
def test_empty_color_values(self):
config("test/data/ColorsTest5.conf")
pri_color = Colors().get_priority_colors()
project_color = Colors().get_project_color()
context_color = Colors().get_context_color()
link_color = Colors().get_link_color()
metadata_color = Colors().get_metadata_color()
self.assertEqual(pri_color['A'], NEUTRAL_COLOR)
self.assertEqual(pri_color['B'], NEUTRAL_COLOR)
self.assertEqual(pri_color['C'], NEUTRAL_COLOR)
colors = Colors()
project_color = colors.get_project_color()
context_color = colors.get_context_color()
link_color = colors.get_link_color()
metadata_color = colors.get_metadata_color()
self.assertEqual(colors.get_priority_color('A'), NEUTRAL_COLOR)
self.assertEqual(colors.get_priority_color('B'), NEUTRAL_COLOR)
self.assertEqual(colors.get_priority_color('C'), NEUTRAL_COLOR)
self.assertEqual(project_color, '')
self.assertEqual(context_color, '')
self.assertEqual(link_color, '')
......@@ -172,15 +172,15 @@ class ColorsTest(TopydoTest):
def test_empty_colorscheme(self):
config("test/data/config1")
pri_color = Colors().get_priority_colors()
project_color = Colors().get_project_color()
context_color = Colors().get_context_color()
link_color = Colors().get_link_color()
metadata_color = Colors().get_metadata_color()
self.assertEqual(pri_color['A'], '\033[0;36m')
self.assertEqual(pri_color['B'], '\033[0;33m')
self.assertEqual(pri_color['C'], '\033[0;34m')
colors = Colors()
project_color = colors.get_project_color()
context_color = colors.get_context_color()
link_color = colors.get_link_color()
metadata_color = colors.get_metadata_color()
self.assertEqual(colors.get_priority_color('A'), '\033[0;36m')
self.assertEqual(colors.get_priority_color('B'), '\033[0;33m')
self.assertEqual(colors.get_priority_color('C'), '\033[0;34m')
self.assertEqual(project_color, '\033[1;31m')
self.assertEqual(context_color, '\033[1;35m')
self.assertEqual(link_color, '\033[4;36m')
......
......@@ -678,6 +678,28 @@ C -
"""
self.assertEqual(self.output, result)
@mock.patch('topydo.lib.ListFormat.get_terminal_size')
def test_list_format44(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)
config(p_overrides={('ls', 'list_format'): '%Z|%I| %x %p %S %k\\t%{(}h{)}'})
command1 = ListCommand(["-x"], self.todolist, self.out, self.error)
command1.execute()
config(p_overrides={('ls', 'list_format'): '%z|%I| %x %p %S %k\\t%{(}h{)}'})
command2 = ListCommand(["-x"], self.todolist, self.out, self.error)
command2.execute()
result = u""" | 1| D Bar @Context1 +Project2 (due a month ago, started a month ago)
| 2| Z Lorem ipsum dolorem sit amet. Red @fox +jumpe... lazy:bar (due in 2 days, starts in a day)
| 3| C Foo @Context2 Not@Context +Project1 Not+Project
| 4| C Baz @Context1 +Project1 key:value
| 5| Drink beer @ home
| 6| x 2014-12-12 Completed but with date:2014-12-12
"""
self.assertEqual(self.output, result * 2)
if __name__ == '__main__':
unittest.main()
# 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/>.
import re
from topydo.lib.Colors import int_to_ansi, Colors
from topydo.lib.Recurrence import relative_date_to_date
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]
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
period + due date, or the start/due date.
"""
result = 0
def diff_days(p_start, p_end):
if p_start < p_end:
diff = p_end - p_start
return diff.days
return 0
if p_todo.has_tag('rec') and p_todo.due_date():
# add negation, offset is based on due date
recurrence_pattern = p_todo.tag_value('rec')
neg_recurrence_pattern = re.sub('^\+?', '-', recurrence_pattern)
start = relative_date_to_date(
neg_recurrence_pattern, p_todo.due_date())
due = p_todo.due_date()
result = diff_days(start, due)
else:
result = p_todo.length()
return result
def get_progress():
"""
Returns a value from 0 to 1 where we are today in a date range. Returns
a value >1 when a todo item is overdue.
"""
if p_todo.is_overdue():
return 1.1
elif p_todo.due_date():
days_till_due = p_todo.days_till_due()
length = get_length() or 14
return max((length - days_till_due), 0) / length
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)
......@@ -20,113 +20,128 @@ 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 = config().priority_colors()
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 _int_to_ansi(self, p_int, p_decorator='normal', p_safe=True):
"""
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 8 > int(p_int) >= 0:
return '\033[{};3{}m'.format(decoration, str(p_int))
elif 16 > int(p_int):
p_int = int(p_int) - 8
return '\033[{};1;3{}m'.format(decoration, str(p_int))
if 256 > int(p_int) >= 0:
return '\033[{};38;5;{}m'.format(decoration, str(p_int))
else:
return NEUTRAL_COLOR
except ValueError:
return None
def _name_to_int(self, 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(self, p_color_name, p_decorator):
""" Returns ansi color code from color name. """
number = self._name_to_int(p_color_name)
return self._int_to_ansi(number, p_decorator)
def _get_ansi(self, p_color, p_decorator):
""" Returns ansi color code from color name or xterm color id. """
if p_color == '':
ansi = ''
else:
ansi = self._int_to_ansi(p_color, p_decorator, False)
if not ansi:
ansi = self._name_to_ansi(p_color, p_decorator)
return ansi
def get_priority_colors(self):
pri_ansi_colors = dict()
for pri in self.priority_colors:
color = self._get_ansi(self.priority_colors[pri], 'normal')
if color == '':
color = NEUTRAL_COLOR
pri_ansi_colors[pri] = color
return pri_ansi_colors
def get_project_color(self):
return self._get_ansi(self.project_color, 'bold')
return _get_ansi(self.project_color, 'bold')
def get_context_color(self):
return self._get_ansi(self.context_color, 'bold')
return _get_ansi(self.context_color, 'bold')
def get_metadata_color(self):
return self._get_ansi(self.metadata_color, 'bold')
return _get_ansi(self.metadata_color, 'bold')
def get_link_color(self):
return self._get_ansi(self.link_color, 'underline')
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
......@@ -20,7 +20,8 @@ import arrow
import re
from topydo.lib.Config import config
from topydo.lib.Utils import get_terminal_size
from topydo.lib.Colorblock import color_block
from topydo.lib.Utils import get_terminal_size, escape_ansi
MAIN_PATTERN = (r'^({{(?P<before>.+?)}})?'
r'(?P<placeholder>{ph}|\[{ph}\])'
......@@ -107,7 +108,7 @@ def _truncate(p_str, p_repl):
Place of the truncation is calculated depending on p_max_width.
"""
# 4 is for '...' and an extra space at the end
text_lim = _columns() - len(p_str) - 4
text_lim = _columns() - len(escape_ansi(p_str)) - 4
truncated_str = re.sub(re.escape(p_repl), p_repl[:text_lim] + '...', p_str)
return truncated_str
......@@ -119,7 +120,7 @@ def _right_align(p_str):
Right alignment is done using proper number of spaces calculated from
'line_width' attribute.
"""
to_fill = _columns() - len(p_str)
to_fill = _columns() - len(escape_ansi(p_str))
if to_fill > 0:
p_str = re.sub('\t', ' '*to_fill, p_str)
......@@ -193,6 +194,10 @@ 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_safe=False) if config().colors() else ' ',
}
self.format_list = self._preprocess_format()
......@@ -269,7 +274,7 @@ class ListFormatParser(object):
parsed_str = _unescape_percent_sign(''.join(parsed_list))
parsed_str = _remove_redundant_spaces(parsed_str)
if self.one_line and len(parsed_str) >= _columns():
if self.one_line and len(escape_ansi(parsed_str)) >= _columns():
parsed_str = _truncate(parsed_str, repl_trunc)
if re.search('.*\t', parsed_str):
......
......@@ -34,18 +34,12 @@ class PrettyPrinterColorFilter(PrettyPrinterFilter):
""" Applies the colors. """
if config().colors():
colorscheme = Colors()
priority_colors = colorscheme.get_priority_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 = NEUTRAL_COLOR
try:
priority_color = priority_colors[p_todo.priority()]
except KeyError:
pass
# color projects / contexts
p_todo_str = re.sub(
r'\B(\+|@)(\S*\w)',
......
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