diff --git a/matrix/buffer.py b/matrix/buffer.py index 24382f4..0091ae6 100644 --- a/matrix/buffer.py +++ b/matrix/buffer.py @@ -54,7 +54,12 @@ from .colors import Formatted from .config import RedactType from .globals import SCRIPT_NAME, SERVERS, W, TYPING_NOTICE_TIMEOUT from .utf import utf8_decode -from .utils import server_ts_to_weechat, shorten_sender, string_strikethrough +from .utils import ( + server_ts_to_weechat, + shorten_sender, + string_strikethrough, + color_pair, +) @attr.s @@ -1445,7 +1450,9 @@ class RoomBuffer(object): "message{del_color}>{ncolor}").format( del_color=W.color("chat_delimiters"), ncolor=W.color("reset"), - error_color=W.color(G.CONFIG.color.error_message)) + error_color=W.color(color_pair( + G.CONFIG.color.error_message_fg, + G.CONFIG.color.error_message_bg))) last_line.message = message diff --git a/matrix/colors.py b/matrix/colors.py index 96a4c24..3eeabb8 100644 --- a/matrix/colors.py +++ b/matrix/colors.py @@ -30,12 +30,16 @@ from typing import List import webcolors from pygments import highlight from pygments.formatter import Formatter, get_style_by_name -from pygments.lexers import get_lexer_by_name, guess_lexer +from pygments.lexers import get_lexer_by_name from pygments.util import ClassNotFound from . import globals as G from .globals import W -from .utils import string_strikethrough +from .utils import (string_strikethrough, + string_color_and_reset, + color_pair, + text_block, + colored_text_block) try: from HTMLParser import HTMLParser @@ -59,10 +63,12 @@ class Formatted(object): @property def textwrapper(self): + quote_pair = color_pair(G.CONFIG.color.quote_fg, + G.CONFIG.color.quote_bg) return textwrap.TextWrapper( width=67, - initial_indent="{}> ".format(W.color(G.CONFIG.color.quote)), - subsequent_indent="{}> ".format(W.color(G.CONFIG.color.quote)), + initial_indent="{}> ".format(W.color(quote_pair)), + subsequent_indent="{}> ".format(W.color(quote_pair)), ) def is_formatted(self): @@ -271,7 +277,7 @@ class Formatted(object): def to_weechat(self): # TODO BG COLOR - def add_attribute(string, name, value): + def add_attribute(string, name, value, attributes): if not value: return string elif name == "bold": @@ -299,20 +305,49 @@ class Formatted(object): W.string_remove_color(string.replace("\n", ""), "") ) elif name == "code": - try: - lexer = get_lexer_by_name(value) - except ClassNotFound: - lexer = guess_lexer(string) + code_color_pair = color_pair( + G.CONFIG.color.untagged_code_fg, + G.CONFIG.color.untagged_code_bg + ) - try: - style = get_style_by_name(G.CONFIG.look.pygments_style) - except ClassNotFound: - style = "native" + margin = G.CONFIG.look.code_block_margin - # highlight adds a newline to the end of the string, remove it - # from the output - return highlight(string, lexer, - WeechatFormatter(style=style))[:-1] + if attributes["preformatted"]: + # code block + + try: + lexer = get_lexer_by_name(value) + except ClassNotFound: + if G.CONFIG.look.code_blocks: + return colored_text_block( + string, + margin=margin, + color_pair=code_color_pair) + else: + return string_color_and_reset(string, + code_color_pair) + + try: + style = get_style_by_name(G.CONFIG.look.pygments_style) + except ClassNotFound: + style = "native" + + if G.CONFIG.look.code_blocks: + code_block = text_block(string, margin=margin) + else: + code_block = string + + # highlight adds a newline to the end of the string, remove + # it from the output + highlighted_code = highlight( + code_block, + lexer, + WeechatFormatter(style=style) + ).rstrip() + + return highlighted_code + else: + return string_color_and_reset(string, code_color_pair) elif name == "fgcolor": return "{color_on}{text}{color_off}".format( color_on=W.color(value), @@ -337,7 +372,10 @@ class Formatted(object): # terminal, but doing it the other way around results in garbage. if "strikethrough" in attributes: text = add_attribute( - text, "strikethrough", attributes["strikethrough"] + text, + "strikethrough", + attributes["strikethrough"], + attributes ) attributes.pop("strikethrough") @@ -345,19 +383,26 @@ class Formatted(object): return prefix + text.replace("\n", "\n{}".format(prefix)) for key, value in attributes.items(): - # Don't use textwrap to quote the code - if attributes["code"] and key == "quote" and value: + if not value: continue - text = add_attribute(text, key, value) + # Don't use textwrap to quote the code + if key == "quote" and attributes["code"]: + continue + + # Reflow inline code blocks + if key == "code" and not attributes["preformatted"]: + text = text.strip().replace('\n', ' ') + + text = add_attribute(text, key, value, attributes) # If we're quoted code add quotation marks now. - if attributes["quote"] and key == "code" and value: + if key == "code" and attributes["quote"]: + fg = G.CONFIG.color.quote_fg + bg = G.CONFIG.color.quote_bg text = indent( text, - "{}>{} ".format( - W.color(G.CONFIG.color.quote), W.color("reset") - ), + string_color_and_reset(">", color_pair(fg, bg)) + " ", ) # If we're code don't remove multiple newlines blindly @@ -382,6 +427,7 @@ DEFAULT_ATTRIBUTES = { "italic": False, "underline": False, "strikethrough": False, + "preformatted": False, "quote": False, "code": None, "fgcolor": None, @@ -430,6 +476,8 @@ class MatrixHtmlParser(HTMLParser): self._toggle_attribute("strikethrough") elif tag == "blockquote": self._toggle_attribute("quote") + elif tag == "pre": + self._toggle_attribute("preformatted") elif tag == "code": lang = None @@ -480,6 +528,8 @@ class MatrixHtmlParser(HTMLParser): self._toggle_attribute("underline") elif tag == "del": self._toggle_attribute("strikethrough") + elif tag == "pre": + self._toggle_attribute("preformatted") elif tag == "code": if self.text: self.add_substring(self.text, self.attributes.copy()) @@ -624,20 +674,20 @@ def color_line_to_weechat(color_string): return line_colors[color_string] -# The functions colour_dist_sq(), colour_to_6cube(), and colour_find_rgb +# The functions color_dist_sq(), color_to_6cube(), and color_find_rgb # are python ports of the same named functions from the tmux # source, they are under the copyright of Nicholas Marriott, and Avi Halachmi # under the ISC license. # More info: https://github.com/tmux/tmux/blob/master/colour.c -def colour_dist_sq(R, G, B, r, g, b): +def color_dist_sq(R, G, B, r, g, b): # pylint: disable=invalid-name,too-many-arguments # type: (int, int, int, int, int, int) -> int return (R - r) * (R - r) + (G - g) * (G - g) + (B - b) * (B - b) -def colour_to_6cube(v): +def color_to_6cube(v): # pylint: disable=invalid-name # type: (int) -> int if v < 48: @@ -647,15 +697,15 @@ def colour_to_6cube(v): return (v - 35) // 40 -def colour_find_rgb(r, g, b): +def color_find_rgb(r, g, b): # type: (int, int, int) -> int - """Convert an RGB triplet to the xterm(1) 256 colour palette. + """Convert an RGB triplet to the xterm(1) 256 color palette. - xterm provides a 6x6x6 colour cube (16 - 231) and 24 greys (232 - 255). - We map our RGB colour to the closest in the cube, also work out the + xterm provides a 6x6x6 color cube (16 - 231) and 24 greys (232 - 255). + We map our RGB color to the closest in the cube, also work out the closest grey, and use the nearest of the two. - Note that the xterm has much lower resolution for darker colours (they + Note that the xterm has much lower resolution for darker colors (they are not evenly spread out), so our 6 levels are not evenly spread: 0x0, 0x5f (95), 0x87 (135), 0xaf (175), 0xd7 (215) and 0xff (255). Greys are more evenly spread (8, 18, 28 ... 238). @@ -664,15 +714,15 @@ def colour_find_rgb(r, g, b): q2c = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] # Map RGB to 6x6x6 cube. - qr = colour_to_6cube(r) - qg = colour_to_6cube(g) - qb = colour_to_6cube(b) + qr = color_to_6cube(r) + qg = color_to_6cube(g) + qb = color_to_6cube(b) cr = q2c[qr] cg = q2c[qg] cb = q2c[qb] - # If we have hit the colour exactly, return early. + # If we have hit the color exactly, return early. if cr == r and cg == g and cb == b: return 16 + (36 * qr) + (6 * qg) + qb @@ -686,10 +736,10 @@ def colour_find_rgb(r, g, b): grey = 8 + (10 * grey_idx) - # Is grey or 6x6x6 colour closest? - d = colour_dist_sq(cr, cg, cb, r, g, b) + # Is grey or 6x6x6 color closest? + d = color_dist_sq(cr, cg, cb, r, g, b) - if colour_dist_sq(grey, grey, grey, r, g, b) < d: + if color_dist_sq(grey, grey, grey, r, g, b) < d: idx = 232 + grey_idx else: idx = 16 + (36 * qr) + (6 * qg) + qb @@ -728,7 +778,7 @@ def color_html_to_weechat(color): if rgb_color in weechat_basic_colors: return weechat_basic_colors[rgb_color] - return str(colour_find_rgb(*rgb_color)) + return str(color_find_rgb(*rgb_color)) def color_weechat_to_html(color): diff --git a/matrix/config.py b/matrix/config.py index 9d2aeb1..f90b8b0 100644 --- a/matrix/config.py +++ b/matrix/config.py @@ -497,6 +497,27 @@ class MatrixConfig(WeechatConfig): "native", "Pygments style to use for highlighting source code blocks", ), + Option( + "code_blocks", + "boolean", + "", + 0, + 0, + "on", + ("Display preformatted code blocks as rectangular areas by " + "padding them with whitespace up to the length of the longest " + "line (with optional margin)"), + ), + Option( + "code_block_margin", + "integer", + "", + 0, + 100, + "2", + ("Number of spaces to add as a margin around around a code " + "block"), + ), ] network_options = [ @@ -643,34 +664,80 @@ class MatrixConfig(WeechatConfig): color_options = [ Option( - "quote", + "quote_fg", "color", "", 0, 0, "lightgreen", - ("Color for matrix style blockquotes"), + "Foreground color for matrix style blockquotes", ), Option( - "error_message", + "quote_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of quote_fg", + ), + Option( + "error_message_fg", "color", "", 0, 0, "darkgray", - ("Color for error messages that appear inside a room buffer (" - "e.g. when a message errors out when sending or when a " - "message is redacted)"), + ("Foreground color for error messages that appear inside a " + "room buffer (e.g. when a message errors out when sending or " + "when a message is redacted)"), ), Option( - "unconfirmed_message", + "error_message_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of error_message_fg.", + ), + Option( + "unconfirmed_message_fg", "color", "", 0, 0, "darkgray", - ("Color for messages that are printed out but the server " - "hasn't confirmed the that he received them."), + ("Foreground color for messages that are printed out but the " + "server hasn't confirmed the that he received them."), + ), + Option( + "unconfirmed_message_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of unconfirmed_message_fg." + ), + Option( + "untagged_code_fg", + "color", + "", + 0, + 0, + "blue", + ("Foreground color for code without a language specifier. " + "Also used for `inline code`."), + ), + Option( + "untagged_code_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of untagged_code_fg", ), ] diff --git a/matrix/server.py b/matrix/server.py index 016c5bd..f6cfd2f 100644 --- a/matrix/server.py +++ b/matrix/server.py @@ -885,7 +885,8 @@ class MatrixServer(object): plain_message = message.formatted_message.to_weechat() plain_message = W.string_remove_color(plain_message, "") attributes = DEFAULT_ATTRIBUTES.copy() - attributes["fgcolor"] = G.CONFIG.color.unconfirmed_message + attributes["fgcolor"] = G.CONFIG.color.unconfirmed_message_fg + attributes["bgcolor"] = G.CONFIG.color.unconfirmed_message_bg new_formatted = Formatted([FormattedString( plain_message, attributes diff --git a/matrix/utils.py b/matrix/utils.py index 060fd14..91d122d 100644 --- a/matrix/utils.py +++ b/matrix/utils.py @@ -14,7 +14,7 @@ # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -from __future__ import unicode_literals +from __future__ import unicode_literals, division import time from typing import Any, Dict, List @@ -105,3 +105,63 @@ def shorten_sender(sender): def string_strikethrough(string): return "".join(["{}\u0336".format(c) for c in string]) + + +def string_color_and_reset(string, color): + """Color string with color, then reset all attributes.""" + + lines = string.split('\n') + lines = ("{}{}{}".format(W.color(color), line, W.color("reset")) + for line in lines) + return "\n".join(lines) + + +def string_color(string, color): + """Color string with color, then reset the color attribute.""" + + lines = string.split('\n') + lines = ("{}{}{}".format(W.color(color), line, W.color("resetcolor")) + for line in lines) + return "\n".join(lines) + + +def color_pair(color_fg, color_bg): + """Make a color pair from a pair of colors.""" + + if color_bg: + return "{},{}".format(color_fg, color_bg) + else: + return color_fg + + +def text_block(text, margin=0): + """ + Pad block of text with whitespace to form a regular block, optionally + adding a margin. + """ + + # add vertical margin + vertical_margin = margin // 2 + text = "{}{}{}".format( + "\n" * vertical_margin, + text, + "\n" * vertical_margin + ) + + lines = text.split("\n") + longest_len = max(len(l) for l in lines) + margin + + # pad block and add horizontal margin + text = "\n".join( + "{pre}{line}{post}".format( + pre=" " * margin, + line=l, + post=" " * (longest_len - len(l))) + for l in lines) + + return text + + +def colored_text_block(text, margin=0, color_pair=""): + """ Like text_block, but also colors it.""" + return string_color_and_reset(text_block(text, margin=margin), color_pair)