Commit ad781585 authored by Bram Schoenmakers's avatar Bram Schoenmakers

Show colors in the console

This is achieved using abstract colors for projects, contexts, etc.. The
Color filter for the pretty printer has no knowledge of colors, it just
marks a certain text as 'project color'. All this information is stored
in the TopydoString. The CLI will convert the colored parts in the
string with the corresponding ANSI codes, the column UI will convert the
string to an appropriate markup for the Text widget.

Also, palette items are now specified using an enum-like class
(PaletteItem) to reduce hidden dependencies of the palette item names.

Moreover, ConsoleWidget is now a urwid.Pile with Text widgets, instead
of one single Text widget with all text. This also allows for doing
operations on lines in the output in the future (mark a line and hit
complete, for instance).
parent ddec35c2
...@@ -22,6 +22,7 @@ I/O on the command-line. ...@@ -22,6 +22,7 @@ I/O on the command-line.
import getopt import getopt
import sys import sys
from topydo.lib.Color import AbstractColor, Color
from topydo.lib.TopydoString import TopydoString from topydo.lib.TopydoString import TopydoString
MAIN_OPTS = "ac:C:d:ht:v" MAIN_OPTS = "ac:C:d:ht:v"
...@@ -80,14 +81,42 @@ def write(p_file, p_string): ...@@ -80,14 +81,42 @@ def write(p_file, p_string):
p_file.write(p_string + "\n") p_file.write(p_string + "\n")
def output(p_string): def lookup_color(p_color):
ansi = lambda c: c.as_ansi() """
Converts an AbstractColor to a normal Color. Returns the Color itself
when a normal color is passed.
"""
if not lookup_color.colors:
lookup_color.colors[AbstractColor.NEUTRAL] = Color('NEUTRAL')
lookup_color.colors[AbstractColor.PROJECT] = config().project_color()
lookup_color.colors[AbstractColor.CONTEXT] = config().context_color()
lookup_color.colors[AbstractColor.META] = config().metadata_color()
lookup_color.colors[AbstractColor.LINK] = config().link_color()
try:
return lookup_color.colors[p_color]
except KeyError:
return p_color
lookup_color.colors = {}
def insert_ansi(p_string):
""" Returns a string with color information at the right positions. """
result = p_string.data
for pos, color in sorted(p_string.colors.items(), reverse=True):
color = lookup_color(color)
result = result[:pos] + color.as_ansi() + result[pos:]
return result
def output(p_string):
if isinstance(p_string, list): if isinstance(p_string, list):
p_string = "\n".join([s.with_colors(ansi) for s in p_string]) p_string = "\n".join([insert_ansi(s) for s in p_string])
elif isinstance(p_string, TopydoString): elif isinstance(p_string, TopydoString):
# convert color codes to ANSI # convert color codes to ANSI
p_string = p_string.with_colors(ansi) p_string = insert_ansi(p_string)
write(sys.stdout, p_string) write(sys.stdout, p_string)
......
...@@ -17,6 +17,14 @@ ...@@ -17,6 +17,14 @@
""" This module provides a class that represents a color. """ """ This module provides a class that represents a color. """
class AbstractColor:
NEUTRAL = 0
PROJECT = 1
CONTEXT = 2
META = 3
LINK = 4
class Color: class Color:
color_names_dict = { color_names_dict = {
'black': 0, 'black': 0,
......
...@@ -25,17 +25,25 @@ class TopydoString(collections.UserString): ...@@ -25,17 +25,25 @@ class TopydoString(collections.UserString):
where a certain color should start. where a certain color should start.
""" """
def __init__(self, p_content): def __init__(self, p_content, p_metadata=None):
if isinstance(p_content, TopydoString): if isinstance(p_content, TopydoString):
# don't nest topydostrings # don't nest topydostrings
self.colors = p_content.colors self.colors = p_content.colors
self.metadata = p_content.metadata
super().__init__(p_content.data) super().__init__(p_content.data)
else: else:
self.colors = {} self.colors = {}
super().__init__(p_content) super().__init__(p_content)
# allows clients to pass arbitrary data with this string (e.g. a Todo
# object)
self.metadata = p_metadata
def append(self, p_string, p_color): def append(self, p_string, p_color):
""" Append a string with the given color. """ """
Append a string with the given color (normal Color or an
AbstractColor).
"""
self.colors[len(self.data)] = p_color self.colors[len(self.data)] = p_color
self.data += p_string self.data += p_string
...@@ -43,15 +51,3 @@ class TopydoString(collections.UserString): ...@@ -43,15 +51,3 @@ class TopydoString(collections.UserString):
""" Start using a color at the given position. """ """ Start using a color at the given position. """
self.colors[p_pos] = p_color self.colors[p_pos] = p_color
def with_colors(self, p_transform_fn):
"""
Returns a string with color information at the right positions.
p_transform_fn is a function that takes a Color object and returns a
string representing the color (e.g. "#ff0000").
"""
result = self.data
for pos, color in sorted(self.colors.items(), reverse=True):
result = result[:pos] + p_transform_fn(color) + result[pos:]
return result
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
import re import re
from topydo.lib.Color import Color from topydo.lib.Color import AbstractColor
from topydo.lib.Config import config from topydo.lib.Config import config
from topydo.lib.PrettyPrinterFilter import PrettyPrinterFilter from topydo.lib.PrettyPrinterFilter import PrettyPrinterFilter
from topydo.lib.TopydoString import TopydoString from topydo.lib.TopydoString import TopydoString
...@@ -34,16 +34,15 @@ class PrettyPrinterColorFilter(PrettyPrinterFilter): ...@@ -34,16 +34,15 @@ class PrettyPrinterColorFilter(PrettyPrinterFilter):
def filter(self, p_todo_str, p_todo): def filter(self, p_todo_str, p_todo):
""" Applies the colors. """ """ Applies the colors. """
if config().colors(): if config().colors():
p_todo_str = TopydoString(p_todo_str) p_todo_str = TopydoString(p_todo_str, p_todo)
priority_color = config().priority_color(p_todo.priority()) priority_color = config().priority_color(p_todo.priority())
neutral_color = Color('NEUTRAL')
colors = [ colors = [
(r'\B@(\S*\w)', config().context_color()), (r'\B@(\S*\w)', AbstractColor.CONTEXT),
(r'\B\+(\S*\w)', config().project_color()), (r'\B\+(\S*\w)', AbstractColor.PROJECT),
(r'\b\S+:[^/\s]\S*\b', config().metadata_color()), (r'\b\S+:[^/\s]\S*\b', AbstractColor.META),
(r'(^|\s)(\w+:){1}(//\S+)', config().link_color()), (r'(^|\s)(\w+:){1}(//\S+)', AbstractColor.LINK),
] ]
for pattern, color in colors: for pattern, color in colors:
...@@ -51,7 +50,7 @@ class PrettyPrinterColorFilter(PrettyPrinterFilter): ...@@ -51,7 +50,7 @@ class PrettyPrinterColorFilter(PrettyPrinterFilter):
p_todo_str.set_color(match.start(), color) p_todo_str.set_color(match.start(), color)
p_todo_str.set_color(match.end(), priority_color) p_todo_str.set_color(match.end(), priority_color)
p_todo_str.append('', neutral_color) p_todo_str.append('', AbstractColor.NEUTRAL)
# color by priority # color by priority
p_todo_str.set_color(0, priority_color) p_todo_str.set_color(0, priority_color)
......
...@@ -16,15 +16,61 @@ ...@@ -16,15 +16,61 @@
import urwid import urwid
from topydo.lib.Color import AbstractColor
from topydo.lib.Todo import Todo
from topydo.lib.TopydoString import TopydoString
PALETTE_LOOKUP = {
# omitting AbstractColor.NEUTRAL on purpose, so a text without any
# attribute will be added to the markup
AbstractColor.PROJECT: 'project',
AbstractColor.CONTEXT: 'context',
AbstractColor.META: 'metadata',
AbstractColor.LINK: 'link',
}
def topydostringToMarkup(p_string):
markup = []
color_positions = sorted(p_string.colors.items())
for i, (start_pos, color) in enumerate(color_positions):
# color starts at indicated position
start = start_pos
# color ends at next color indication. if missing, run until the end of
# the string
try:
end = color_positions[i+1][0]
except IndexError:
end = len(str(p_string))
text = str(p_string)[start:end]
if color in PALETTE_LOOKUP:
markup.append((PALETTE_LOOKUP[color], text))
else:
# a plain text without any attribute set (including
# AbstractColor.NEUTRAL)
markup.append(text)
color_at_start = color_positions and color_positions[0][0] == 0
# priority color should appear at the start if present, build a nesting
# markup
if color_at_start and isinstance(p_string.metadata, Todo):
priority = p_string.metadata.priority()
markup = ('pri_' + priority, markup)
return markup
class ConsoleWidget(urwid.LineBox): class ConsoleWidget(urwid.LineBox):
def __init__(self, p_text=""): def __init__(self, p_text=""):
urwid.register_signal(ConsoleWidget, ['close']) urwid.register_signal(ConsoleWidget, ['close'])
self.text = urwid.Text(p_text)
self.width = 0 self.width = 0
self.pile = urwid.Pile([])
super().__init__(self.text) super().__init__(self.pile)
def keypress(self, p_size, p_key): def keypress(self, p_size, p_key):
if p_key == 'enter' or p_key == 'q' or p_key == 'esc': if p_key == 'enter' or p_key == 'q' or p_key == 'esc':
...@@ -47,10 +93,20 @@ class ConsoleWidget(urwid.LineBox): ...@@ -47,10 +93,20 @@ class ConsoleWidget(urwid.LineBox):
return True return True
def print_text(self, p_text): def print_text(self, p_text):
self.text.set_text(self.text.text + p_text) if isinstance(p_text, list):
for text in p_text:
self.print_text(text)
return
elif isinstance(p_text, TopydoString):
text = urwid.Text(topydostringToMarkup(p_text))
else:
text = urwid.Text(p_text)
self.pile.contents.append((text, ('pack', None)))
def clear(self): def clear(self):
self.text.set_text("") self.pile.contents = []
def console_width(self): def console_width(self):
# return the last known width (last render) # return the last known width (last render)
......
...@@ -28,7 +28,7 @@ from topydo.ui.CommandLineWidget import CommandLineWidget ...@@ -28,7 +28,7 @@ from topydo.ui.CommandLineWidget import CommandLineWidget
from topydo.ui.ConsoleWidget import ConsoleWidget from topydo.ui.ConsoleWidget import ConsoleWidget
from topydo.ui.KeystateWidget import KeystateWidget from topydo.ui.KeystateWidget import KeystateWidget
from topydo.ui.TodoListWidget import TodoListWidget from topydo.ui.TodoListWidget import TodoListWidget
from topydo.ui.TodoWidget import _to_urwid_color from topydo.ui.Utils import PaletteItem, to_urwid_color
from topydo.ui.ViewWidget import ViewWidget from topydo.ui.ViewWidget import ViewWidget
from topydo.ui.ColumnLayout import columns from topydo.ui.ColumnLayout import columns
from topydo.lib.Config import config, ConfigError from topydo.lib.Config import config, ConfigError
...@@ -94,7 +94,6 @@ class UIApplication(CLIApplicationBase): ...@@ -94,7 +94,6 @@ class UIApplication(CLIApplicationBase):
super().__init__() super().__init__()
self._process_flags() self._process_flags()
config(p_overrides={('topydo', 'colors'): '0'}) # disable color in output
self.todofile = TodoFile.TodoFile(config().todotxt()) self.todofile = TodoFile.TodoFile(config().todotxt())
self.todolist = TodoList.TodoList(self.todofile.read()) self.todolist = TodoList.TodoList(self.todofile.read())
...@@ -164,28 +163,28 @@ class UIApplication(CLIApplicationBase): ...@@ -164,28 +163,28 @@ class UIApplication(CLIApplicationBase):
self._set_alarm_for_next_midnight_update() self._set_alarm_for_next_midnight_update()
def _create_color_palette(self): def _create_color_palette(self):
project_color = _to_urwid_color(config().project_color()) project_color = to_urwid_color(config().project_color())
context_color = _to_urwid_color(config().context_color()) context_color = to_urwid_color(config().context_color())
metadata_color = _to_urwid_color(config().metadata_color()) metadata_color = to_urwid_color(config().metadata_color())
link_color = _to_urwid_color(config().link_color()) link_color = to_urwid_color(config().link_color())
palette = [ palette = [
('project', '', '', '', project_color, ''), (PaletteItem.PROJECT, '', '', '', project_color, ''),
('project_focus', '', 'light gray', '', project_color, None), (PaletteItem.PROJECT_FOCUS, '', 'light gray', '', project_color, None),
('context', '', '', '', context_color, ''), (PaletteItem.CONTEXT, '', '', '', context_color, ''),
('context_focus', '', 'light gray', '', context_color, None), (PaletteItem.CONTEXT_FOCUS, '', 'light gray', '', context_color, None),
('metadata', '', '', '', metadata_color, ''), (PaletteItem.METADATA, '', '', '', metadata_color, ''),
('metadata_focus', '', 'light gray', '', metadata_color, None), (PaletteItem.METADATA_FOCUS, '', 'light gray', '', metadata_color, None),
('link', '', '', '', link_color, ''), (PaletteItem.LINK, '', '', '', link_color, ''),
('link_focus', '', 'light gray', '', link_color, None), (PaletteItem.LINK_FOCUS, '', 'light gray', '', link_color, None),
('default_focus', 'black', 'light gray'), (PaletteItem.DEFAULT_FOCUS, 'black', 'light gray'),
('marked', '', 'light blue'), (PaletteItem.MARKED, '', 'light blue'),
] ]
for C in ascii_uppercase: for C in ascii_uppercase:
pri_color_cfg = config().priority_color(C) pri_color_cfg = config().priority_color(C)
pri_color = _to_urwid_color(pri_color_cfg) pri_color = to_urwid_color(pri_color_cfg)
pri_color_focus = pri_color if not pri_color_cfg.is_neutral() else 'black' pri_color_focus = pri_color if not pri_color_cfg.is_neutral() else 'black'
palette.append(( palette.append((
...@@ -209,7 +208,7 @@ class UIApplication(CLIApplicationBase): ...@@ -209,7 +208,7 @@ class UIApplication(CLIApplicationBase):
self.mainloop.set_alarm_at(time.mktime(tomorrow.timetuple()), callback) self.mainloop.set_alarm_at(time.mktime(tomorrow.timetuple()), callback)
def _output(self, p_text): def _output(self, p_text):
self._print_to_console(p_text + "\n") self._print_to_console(p_text)
def _execute_handler(self, p_command, p_todo_id=None, p_output=None): def _execute_handler(self, p_command, p_todo_id=None, p_output=None):
""" """
......
...@@ -14,11 +14,12 @@ ...@@ -14,11 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
import urwid
from topydo.lib.ListFormat import ListFormatParser from topydo.lib.ListFormat import ListFormatParser
from topydo.lib.ProgressColor import progress_color from topydo.lib.ProgressColor import progress_color
from topydo.ui.Utils import PaletteItem, to_urwid_color
import urwid
import re
# pass a None todo list, since we won't use %i or %I here # pass a None todo list, since we won't use %i or %I here
PRIO_FORMATTER = ListFormatParser(None, "%{(}p{)}") PRIO_FORMATTER = ListFormatParser(None, "%{(}p{)}")
...@@ -29,35 +30,23 @@ TAG_PATTERN = r'\b\S+:[^/\s]\S*\b' ...@@ -29,35 +30,23 @@ TAG_PATTERN = r'\b\S+:[^/\s]\S*\b'
URL_PATTERN = r'(?:^|\s)(?:\w+:){1}(?://\S+)' URL_PATTERN = r'(?:^|\s)(?:\w+:){1}(?://\S+)'
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)
def _markup(p_todo, p_focus): def _markup(p_todo, p_focus):
""" """
Returns an attribute spec for the colors that correspond to the given todo Returns an attribute spec for the colors that correspond to the given todo
item. item.
""" """
pri = p_todo.priority() pri = p_todo.priority()
pri = 'pri_' + pri if pri else 'default' pri = 'pri_' + pri if pri else PaletteItem.DEFAULT
if not p_focus: if not p_focus:
attr_dict = {None: pri} attr_dict = {None: pri}
else: else:
# use '_focus' palette entries instead of standard ones # use '_focus' palette entries instead of standard ones
attr_dict = {None: pri + '_focus'} attr_dict = {None: pri + '_focus'}
attr_dict['project'] = 'project_focus' attr_dict[PaletteItem.PROJECT] = PaletteItem.PROJECT_FOCUS
attr_dict['context'] = 'context_focus' attr_dict[PaletteItem.CONTEXT] = PaletteItem.CONTEXT_FOCUS
attr_dict['metadata'] = 'metadata_focus' attr_dict[PaletteItem.METADATA] = PaletteItem.METADATA_FOCUS
attr_dict['link'] = 'link_focus' attr_dict[PaletteItem.LINK] = PaletteItem.LINK_FOCUS
return attr_dict return attr_dict
...@@ -82,14 +71,14 @@ class TodoWidget(urwid.WidgetWrap): ...@@ -82,14 +71,14 @@ class TodoWidget(urwid.WidgetWrap):
if not substring: if not substring:
continue continue
if re.match(TAG_PATTERN, substring): if re.match(TAG_PATTERN, substring):
txt_markup.append(('metadata', substring)) txt_markup.append((PaletteItem.METADATA, substring))
elif re.match(URL_PATTERN, substring): elif re.match(URL_PATTERN, substring):
txt_markup.append(('link', substring)) txt_markup.append((PaletteItem.LINK, substring))
elif re.match(PRJ_CON_PATTERN, substring): elif re.match(PRJ_CON_PATTERN, substring):
if substring.startswith('+'): if substring.startswith('+'):
txt_markup.append(('project', substring)) txt_markup.append((PaletteItem.PROJECT, substring))
else: else:
txt_markup.append(('context', substring)) txt_markup.append((PaletteItem.CONTEXT, substring))
else: else:
txt_markup.append(substring) txt_markup.append(substring)
...@@ -97,11 +86,11 @@ class TodoWidget(urwid.WidgetWrap): ...@@ -97,11 +86,11 @@ class TodoWidget(urwid.WidgetWrap):
priority_widget = urwid.Text(priority_text) priority_widget = urwid.Text(priority_text)
self.text_widget = urwid.Text(txt_markup) self.text_widget = urwid.Text(txt_markup)
progress = _to_urwid_color(progress_color(p_todo)) progress = to_urwid_color(progress_color(p_todo))
progress_bar = urwid.AttrMap( progress_bar = urwid.AttrMap(
urwid.SolidFill(' '), urwid.SolidFill(' '),
urwid.AttrSpec('default', progress, 256), urwid.AttrSpec(PaletteItem.DEFAULT, progress, 256),
urwid.AttrSpec('default', progress, 256), urwid.AttrSpec(PaletteItem.DEFAULT, progress, 256),
) )
self.columns = urwid.Columns( self.columns = urwid.Columns(
...@@ -136,11 +125,11 @@ class TodoWidget(urwid.WidgetWrap): ...@@ -136,11 +125,11 @@ class TodoWidget(urwid.WidgetWrap):
def mark(self): def mark(self):
attr_map = { attr_map = {
None: 'marked', None: PaletteItem.MARKED,
'link': 'marked', PaletteItem.LINK: PaletteItem.MARKED,
'context': 'marked', PaletteItem.CONTEXT: PaletteItem.MARKED,
'project': 'marked', PaletteItem.PROJECT: PaletteItem.MARKED,
'metadata': 'marked', PaletteItem.METADATA: PaletteItem.MARKED,
} }
self.widget.set_attr_map(attr_map) self.widget.set_attr_map(attr_map)
......
# 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/>.
class PaletteItem:
PROJECT = 'project'
PROJECT_FOCUS = 'project_focus'
CONTEXT = 'context_focus'
CONTEXT_FOCUS = 'context_focus'
METADATA = 'metadata'
METADATA_FOCUS = 'metadata_focus'
LINK = 'link'
LINK_FOCUS = 'link_focus'
DEFAULT = 'default'
DEFAULT_FOCUS = 'default_focus'
MARKED = 'marked'
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)
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