diff --git a/lookatme/__main__.py b/lookatme/__main__.py index 0adeb13..a502195 100644 --- a/lookatme/__main__.py +++ b/lookatme/__main__.py @@ -50,13 +50,7 @@ "--theme", "theme", type=click.Choice(["dark", "light"]), - default="dark", -) -@click.option( - "--style", - "code_style", default=None, - type=click.Choice(list(pygments.styles.get_all_styles())), ) @click.option( "--dump-styles", @@ -122,7 +116,6 @@ def main( threads, log_path, theme, - code_style, dump_styles, input_files, live_reload, @@ -151,11 +144,12 @@ def main( else: tutors = [x.strip() for x in tutorial.split(",")] + if theme is None: + theme = "dark" theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme]) lookatme.config.set_global_style_with_precedence( theme_mod, {}, - code_style, ) tutorial_md = lookatme.tutorial.get_tutorial_md(tutors) if tutorial_md is None: @@ -169,7 +163,6 @@ def main( pres = Presentation( input_files[0], theme, - code_style, live_reload=live_reload, single_slide=single_slide, preload_extensions=preload_exts, diff --git a/lookatme/config.py b/lookatme/config.py index b19dba1..dcb0355 100644 --- a/lookatme/config.py +++ b/lookatme/config.py @@ -28,7 +28,8 @@ def get_style() -> Dict: def get_style_with_precedence( - theme_mod: ModuleType, direct_overrides: Dict, style_override: str + theme_mod: ModuleType, + direct_overrides: Dict, ) -> Dict[str, Any]: """Return the resulting style dict from the provided override values.""" # style override order: @@ -37,20 +38,20 @@ def get_style_with_precedence( # 2. inline styles from the presentation dict_deep_update(styles, direct_overrides) # 3. CLI style overrides - if style_override is not None: - styles["style"] = style_override # type: ignore + # TODO return styles def set_global_style_with_precedence( - theme_mod, direct_overrides, style_override + theme_mod, + direct_overrides, ) -> Dict[str, Any]: """Set the lookatme.config.STYLE value based on the provided override values """ global STYLE - STYLE = get_style_with_precedence(theme_mod, direct_overrides, style_override) + STYLE = get_style_with_precedence(theme_mod, direct_overrides) return STYLE diff --git a/lookatme/pres.py b/lookatme/pres.py index 281d0c8..3d53997 100644 --- a/lookatme/pres.py +++ b/lookatme/pres.py @@ -42,7 +42,6 @@ def __init__( self, input_stream, theme, - style_override=None, live_reload=False, single_slide=False, preload_extensions=None, @@ -62,7 +61,6 @@ def __init__( lookatme.config.SLIDE_SOURCE_DIR = os.path.dirname(input_stream.name) self.input_filename = input_stream.name - self.style_override = style_override self.live_reload = live_reload self.tui = None self.single_slide = single_slide @@ -71,8 +69,7 @@ def __init__( self.ignore_ext_failure = ignore_ext_failure self.initial_load_complete = False self.no_threads = no_threads - - self.theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme]) + self.cli_theme = theme if self.live_reload: self.reload_thread = threading.Thread(target=self.reload_watcher) @@ -133,10 +130,12 @@ def reload(self, data=None): self.ignore_ext_failure, ) + theme = self.meta.get("theme", self.cli_theme or "dark") + self.theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme]) + self.styles = lookatme.config.set_global_style_with_precedence( self.theme_mod, self.meta.get("styles", {}), - self.style_override, ) self.initial_load_complete = True diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py index 8aca9c2..67e4e8e 100644 --- a/lookatme/render/markdown_block.py +++ b/lookatme/render/markdown_block.py @@ -7,19 +7,20 @@ import copy import pygments import pygments.styles +import pygments.lexers import re import sys -from typing import Dict, List, Tuple, Optional, Union +from typing import Any, Dict, List, Tuple, Optional, Union import urwid import lookatme.config as config import lookatme.render.markdown_inline as markdown_inline -import lookatme.render.pygments as pygments_render from lookatme.contrib import contrib_first from lookatme.render.context import Context from lookatme.tutorial import tutor from lookatme.widgets.fancy_box import FancyBox +import lookatme.widgets.codeblock as codeblock import lookatme.utils as utils THIS_MOD = sys.modules[__name__] @@ -412,6 +413,122 @@ def render_blockquote_close(token: Dict, ctx: Context): ctx.container_pop() +def _parse_hl_lines(values) -> List: + """Parse comma-separated lists of line ranges to highlight""" + res = [] + matches = re.finditer( + r""" + ,?\s* + ( + (?P[0-9]+) + (\.\.|-) + (?P[0-9]+) + | + (?P[0-9]+) + ) + \s*""", + values, + re.VERBOSE, + ) + + for match in matches: + info = match.groupdict() + if info["singleLine"]: + val = int(info["singleLine"]) + res.append(range(val, val + 1)) + elif info["rangeStart"]: + res.append( + range( + int(info["rangeStart"]), + int(info["rangeEnd"]) + 1, + ) + ) + + return res + + +def _parse_curly_extra(data: str) -> Dict[str, Any]: + res = {} + + matches = re.finditer( + r"""\s* + ( + (?P[a-zA-Z-_]+) + \s*=\s* + ( + "(?P[^"]*)" + | + '(?P[^']*)' + | + (?P[a-zA-Z0-9-_,]+) + ) + | + (?P\.)? + (?P\.)? + (?P[a-zA-Z0-9-_]+) + ) + \s* + """, + data, + re.VERBOSE, + ) + + for match in matches: + info = match.groupdict() + + if info["classOrIdName"]: + val = info["classOrIdName"].lower() + if val in codeblock.supported_langs(): + res["lang"] = info["classOrIdName"] + elif val in ( + "numberlines", + "number_lines", + "numbers", + "line_numbers", + "linenumbers", + "line_numbers", + ): + res["line_numbers"] = True + elif info["attr"]: + attr = info["attr"].lower() + val = info["plainVal"] or info["doubleQuoteVal"] or info["singleQuoteVal"] + if attr in ("startfrom", "start_from", "line_numberstart", "startlineno"): + res["start_line_number"] = int(val) + elif attr in ("hl_lines", "hllines", "highlight", "highlight_lines"): + res["hl_lines"] = _parse_hl_lines(val) + + return res + + +@tutor( + "markdown block", + "code blocks - extra attributes", + r""" + Code blocks can also have additional attributes defined by using curly braces. + Values within the curly brace are either css class names or ids (start with a `.` + or `#`), or have the form `key=value`. + + The attributes below have specific meanings - all other attributes will be + ignored: + + * `.language` - use `language` as the syntax highlighting language + * `.numberLines` - add line numbers + * `startFrom=X` - start the line numbers from the line `X` + * `hllines=ranges` - highlight the line ranges. This should be a comma separated + list of either single line numbers, or a line range (e.g. `4-5`). + + + ```{.python .numberLines hllines=4-5,7 startFrom="3"} + def hello_world(): + print("Hello, world!\n") + print("Hello, world!\n") + print("Hello, world!\n") + print("Hello, world!\n") + print("Hello, world!\n") + ``` + + """, +) @tutor( "markdown block", "code blocks", @@ -419,7 +536,8 @@ def render_blockquote_close(token: Dict, ctx: Context): Multi-line code blocks are either surrounded by "fences" (three in a row of either `\`` or `~`), or are lines of text indented at least four spaces. - Fenced codeblocks let you specify the language of the code: + Fenced codeblocks let you specify the language of the code. (See the next + slide about additional attributes) ```python @@ -431,16 +549,15 @@ def hello_world(): ## Style The syntax highlighting style used to highlight the code block can be - specified in the markdown metadata: + specified in the markdown metadata, as well as an override for the + background color, and the language to use for inline code. - style + code Valid values for the `style` field come directly from pygments. In the version of pygments being used as you read this, the list of valid values is: {pygments_values} - - > **NOTE** This style name is confusing and will be renamed in lookatme v3.0+ """.format( pygments_values=" ".join(pygments.styles.get_all_styles()), ), @@ -455,12 +572,60 @@ def render_fence(token: Dict, ctx: Context): ctx.ensure_new_block() info = token.get("info", None) or "text" - lang = info.split()[0] - # TODO support line highlighting, etc? - text = token["content"] - res = pygments_render.render_text(text, lang=lang) - ctx.widget_add(res) + match = re.match(r"^(?P[^{\s]+)?\s*(\{(?P[^{]+)\})?", info) + lang = "text" + line_numbers = False + start_line_number = 1 + hl_lines = [] + + if match is not None: + full_info = match.groupdict() + if full_info["lang"] is not None: + lang = full_info["lang"] + if full_info["curly_extra"] is not None: + curly_extra = _parse_curly_extra(full_info["curly_extra"]) + lang = curly_extra.get("lang", lang) + line_numbers = curly_extra.get("line_numbers", line_numbers) + start_line_number = curly_extra.get("start_line_number", start_line_number) + hl_lines = curly_extra.get("hl_lines", []) + + curr_spec = ctx.spec_text + default_fg = "default" + bg_override = config.get_style()["code"]["bg_override"] + if curr_spec: + default_fg = ( + default_fg + or utils.overwrite_style({"fg": curr_spec.foreground}, {"fg": default_fg})[ + "fg" + ] + ) + + code = codeblock.CodeBlock( + source=token["content"], + lang=lang, + style_name=config.get_style()["code"]["style"], + line_numbers=line_numbers, + start_line_number=start_line_number, + hl_lines=hl_lines, + default_fg=default_fg, + bg_override=bg_override, + ) + + ctx.widget_add(code) + + +# +# text = token["content"] +# res = pygments_render.render_text( +# text, +# lang=lang, +# line_numbers=line_numbers, +# start_line_number=start_line_number, +# hl_lines=hl_lines, +# ) +# +# ctx.widget_add(res) class TableTokenExtractor: diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py index fd9e2ad..fe71c06 100644 --- a/lookatme/render/markdown_inline.py +++ b/lookatme/render/markdown_inline.py @@ -11,13 +11,14 @@ import urwid import lookatme.config as config -import lookatme.render.pygments as pygments_render import lookatme.utils as utils from lookatme.contrib import contrib_first from lookatme.render.context import Context from lookatme.render.markdown_html import Tag from lookatme.tutorial import tutor from lookatme.widgets.clickable_text import LinkIndicatorSpec +import lookatme.widgets.codeblock as codeblock + THIS_MOD = sys.modules[__name__] @@ -260,15 +261,47 @@ def render_softbreak(_, ctx: Context): The `OddOne` class accepts `Table` instances, converts them to raw pointers, forces garbage collection to run. + + ## Style + + The default language used for syntax highlighting of inline code blocks + can be controlled through the markdown metadata. See the + `styles.code.inline_lang` option: + + code """, ) @contrib_first def render_code_inline(token, ctx: Context): - # TODO: add a style for the default programming language for inline text - # blocks - spec, text = pygments_render.render_text(token["content"], plain=True)[0] - with ctx.use_spec(spec): - ctx.inline_push((ctx.spec_text, text)) + inline_lang = config.get_style()["code"]["inline_lang"] + code_style = config.get_style()["code"]["style"] + + default_fg = "default" + bg_override = config.get_style()["code"]["bg_override"] + curr_spec = ctx.spec_text + if curr_spec: + default_fg = ( + default_fg + or utils.overwrite_style({"fg": curr_spec.foreground}, {"fg": default_fg})[ + "fg" + ] + ) + + lexer = codeblock.get_lexer(inline_lang) + style = codeblock.get_style_cache( + default_fg=default_fg, bg_override=bg_override + ).get_style(code_style) + + tokens = list(lexer.get_tokens(token["content"])) + # split the tokens into distinct lines, and only keep the first line + # of tokens + tokens = codeblock.tokens_to_lines(tokens)[0] + + markups = codeblock.tokens_to_markup(tokens, style) # type: ignore + + for spec, text in markups: + spec = ctx.spec_text_with(spec) + ctx.inline_push((spec, text)) @tutor( diff --git a/lookatme/render/pygments.py b/lookatme/render/pygments.py deleted file mode 100644 index bace04a..0000000 --- a/lookatme/render/pygments.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Pygments related rendering -""" - - -import time - -import pygments -import pygments.lexers -import pygments.styles -import pygments.util -import urwid -from pygments.formatter import Formatter - -import lookatme.config as config - -LEXER_CACHE = {} -STYLE_CACHE = {} -FORMATTER_CACHE = {} - - -def get_formatter(style_name): - style = get_style(style_name) - - formatter, style_bg = FORMATTER_CACHE.get(style_name, (None, None)) - if formatter is None: - style_bg = UrwidFormatter.findclosest(style.background_color.replace("#", "")) - formatter = UrwidFormatter( - style=style, - usebg=(style_bg is not None), - ) - FORMATTER_CACHE[style_name] = (formatter, style_bg) - return formatter, style_bg - - -def get_lexer(lang, default="text"): - lexer = LEXER_CACHE.get(lang, None) - if lexer is None: - try: - lexer = pygments.lexers.get_lexer_by_name(lang) - except pygments.util.ClassNotFound: - lexer = pygments.lexers.get_lexer_by_name(default) - LEXER_CACHE[lang] = lexer - return lexer - - -def get_style(style_name): - style = STYLE_CACHE.get(style_name, None) - if style is None: - style = pygments.styles.get_style_by_name(style_name) - STYLE_CACHE[style_name] = style - return style - - -def render_text(text, lang="text", style_name=None, plain=False): - """Render the provided text with the pygments renderer""" - if style_name is None: - style_name = config.get_style()["style"] - - lexer = get_lexer(lang) - formatter, style_bg = get_formatter(style_name) - - start = time.time() - code_tokens = lexer.get_tokens(text) - config.get_log().debug(f"Took {time.time()-start}s to render {len(text)} bytes") - - markup = [] - for x in formatter.formatgenerator(code_tokens): - if style_bg: - x[0].background = style_bg - markup.append(x) - - if markup[-1][1] == "\n": - markup = markup[:-1] - - if len(markup) == 0: - markup = [(None, "")] - elif markup[-1][1].endswith("\n"): - markup[-1] = (markup[-1][0], markup[-1][1][:-1]) - - if plain: - return markup - else: - return urwid.AttrMap(urwid.Text(markup), urwid.AttrSpec("default", style_bg)) - - -class UrwidFormatter(Formatter): - """Formatter that returns [(text,attrspec), ...], - where text is a piece of text, and attrspec is an urwid.AttrSpec""" - - def __init__(self, **options): - """Extra arguments: - - usebold: if false, bold will be ignored and always off - default: True - usebg: if false, background color will always be 'default' - default: True - colors: number of colors to use (16, 88, or 256) - default: 256""" - self.usebold = options.get("usebold", True) - self.usebg = options.get("usebg", True) - self.style_attrs = {} - Formatter.__init__(self, **options) - - @property - def style(self): - return self._style - - @style.setter - def style(self, newstyle): - self._style = newstyle - self._setup_styles() - - @staticmethod - def _distance(col1, col2): - r1, g1, b1 = col1 - r2, g2, b2 = col2 - - rd = r1 - r2 - gd = g1 - g2 - bd = b1 - b2 - - return rd * rd + gd * gd + bd * bd - - @classmethod - def findclosest(cls, colstr, colors=256): - """Takes a hex string and finds the nearest color to it. - - Returns a string urwid will recognize.""" - - rgb = int(colstr, 16) - r = (rgb >> 16) & 0xFF - g = (rgb >> 8) & 0xFF - b = rgb & 0xFF - - dist = 257 * 257 * 3 - bestcol = urwid.AttrSpec("h0", "default") - - for i in range(colors): - curcol = urwid.AttrSpec("h%d" % i, "default", colors=colors) - currgb = curcol.get_rgb_values()[:3] - curdist = cls._distance((r, g, b), currgb) - if curdist < dist: - dist = curdist - bestcol = curcol - - return bestcol.foreground - - def findclosestattr( - self, fgcolstr=None, bgcolstr=None, othersettings="", colors=256 - ): - """Takes two hex colstring (e.g. 'ff00dd') and returns the - nearest urwid style.""" - fg = bg = "default" - if fgcolstr: - fg = self.findclosest(fgcolstr, colors) - if bgcolstr: - bg = self.findclosest(bgcolstr, colors) - if othersettings: - fg = fg + "," + othersettings - return urwid.AttrSpec(fg, bg, colors) - - def _setup_styles(self, colors=256): - """Fills self.style_attrs with urwid.AttrSpec attributes - corresponding to the closest equivalents to the given style.""" - for ttype, ndef in self.style: - fgcolstr = bgcolstr = None - othersettings = "" - if ndef["color"]: - fgcolstr = ndef["color"] - if self.usebg and ndef["bgcolor"]: - bgcolstr = ndef["bgcolor"] - if self.usebold and ndef["bold"]: - othersettings = "bold" - self.style_attrs[str(ttype)] = self.findclosestattr( - fgcolstr, bgcolstr, othersettings, colors - ) - - def formatgenerator(self, tokensource): - """Takes a token source, and generates - (tokenstring, urwid.AttrSpec) pairs""" - for (ttype, tstring) in tokensource: - parts = str(ttype).split(".") - while str(ttype) not in self.style_attrs: - parts = parts[:-1] - ttype = ".".join(parts) - - attr = self.style_attrs[str(ttype)] - yield attr, tstring - - def format(self, tokensource, outfile): - for (attr, tstring) in self.formatgenerator(tokensource): - outfile.write(attr, tstring) diff --git a/lookatme/schemas.py b/lookatme/schemas.py index 1e8c480..d090239 100644 --- a/lookatme/schemas.py +++ b/lookatme/schemas.py @@ -7,6 +7,7 @@ from typing import cast, Dict import pygments.styles +import pygments.lexers import yaml from marshmallow import INCLUDE, RAISE, Schema, fields, validate @@ -398,6 +399,15 @@ class Meta: } +class CodeSchema(Schema): + style = fields.Str( + dump_default="monokai", + validate=validate.OneOf(list(pygments.styles.get_all_styles())), + ) + bg_override = fields.Str(dump_default="") + inline_lang = fields.Str(dump_default="text") + + table_border_default = cast(Dict, BorderBoxSchema().dump(None)) for k, v in table_border_default.items(): table_border_default[k]["fg"] = "bold,#c0c0c0" @@ -468,11 +478,6 @@ class Meta: render_module = YamlRender unknown = RAISE - style = fields.Str( - dump_default="monokai", - validate=validate.OneOf(list(pygments.styles.get_all_styles())), - ) - title = fields.Nested( StyleFieldSchema, dump_default={ @@ -497,8 +502,8 @@ class Meta: slides = fields.Nested( StyleFieldSchema, dump_default={ - "fg": "default", - "bg": "default", + "fg": "#ffffff", + "bg": "#000000", }, ) slide_number = fields.Nested( @@ -526,12 +531,12 @@ class Meta: "right": 10, }, ) - headings = fields.Nested(HeadingsSchema, dump_default=HeadingsSchema().dump(None)) bullets = fields.Nested(BulletsSchema, dump_default=BulletsSchema().dump(None)) numbering = fields.Nested( NumberingSchema, dump_default=NumberingSchema().dump(None) ) + code = fields.Nested(CodeSchema, dump_default=CodeSchema().dump(None)) table = fields.Nested(TableSchema, dump_default=TableSchema().dump(None)) quote = fields.Nested(BlockQuoteSchema, dump_default=BlockQuoteSchema().dump(None)) hrule = fields.Nested(HruleSchema, dump_default=HruleSchema().dump(None)) @@ -578,6 +583,7 @@ class Meta: load_default=datetime.datetime.now().strftime("%Y-%m-%d"), ) author = fields.Str(dump_default="", load_default="") + theme = fields.Str(dump_default="dark", load_default="dark") styles = fields.Nested( StyleSchema, dump_default=StyleSchema().dump(None), diff --git a/lookatme/themes/light.py b/lookatme/themes/light.py index 76c33e4..69c942f 100644 --- a/lookatme/themes/light.py +++ b/lookatme/themes/light.py @@ -13,8 +13,8 @@ "bg": "", }, "slides": { - "fg": "#f20,bold", - "bg": "", + "fg": "#000000", + "bg": "#ffffff", }, "quote": { "style": { diff --git a/lookatme/tui.py b/lookatme/tui.py index 24e33f2..d5c6483 100644 --- a/lookatme/tui.py +++ b/lookatme/tui.py @@ -20,6 +20,7 @@ from lookatme.tutorial import tutor from lookatme.utils import spec_from_style from lookatme.widgets.clickable_text import ClickableText +import lookatme.widgets.codeblock as codeblock def text(style, data, align="left"): @@ -96,7 +97,9 @@ def _cache_slide_render(self, slide: Slide): res = self.do_render(slide, slide.number) self.cache[slide.number] = res except Exception as e: - self._log.error(f"Error occurred rendering slide {slide.number}") + self._log.error( + f"Error occurred rendering slide {slide.number}", exc_info=True + ) try: curr_token = self.ctx.tokens.curr @@ -256,9 +259,7 @@ def __init__(self, pres, start_idx=0, no_threads=False): self.root_paddings = urwid.Padding(self.slide_body, left=10, right=10) self.pres = pres - self.ctx = Context(None) - self.ctx.source_push(self.pres.no_meta_source) - self.ctx.spec_push(spec_from_style(config.get_style()["slides"])) + self.init_ctx() self.root_widget = root_urwid_widget(self.root_margins) self.loop = urwid.MainLoop( @@ -430,8 +431,18 @@ def update(self): self.update_creation() self.update_body() + def init_ctx(self): + self.ctx = Context(None) + self.ctx.source_push(self.pres.no_meta_source) + self.ctx.spec_push(spec_from_style(config.get_style()["slides"])) + def reload(self): """Reload the input, keeping the current slide in focus""" + self.init_ctx() + self.slide_renderer.ctx = self.ctx + + codeblock.clear_cache() + curr_slide_idx = self.curr_slide.number self.slide_renderer.flush_cache() self.pres.reload() diff --git a/lookatme/tutorial.py b/lookatme/tutorial.py index c7bf9b7..579b6f7 100644 --- a/lookatme/tutorial.py +++ b/lookatme/tutorial.py @@ -46,23 +46,23 @@ def __init__( def get_md(self, rendered_example=True) -> str: """Get the tutor's markdown text after resolving any special markup contained in it. - opts. Dict[str, Any] - slides. Current can include `{ """ slides_md = self.slides_md if self.lazy_formatting is not None: slides_md = slides_md.format(**self.lazy_formatting()) tag_handlers = { - "EXAMPLE": lambda contents: self._handle_show_and_render( - contents, rendered_example + "EXAMPLE": lambda contents, attrs: self._handle_show_and_render( + contents, attrs, rendered_example ), "STYLE": self._handle_style_yaml, } res_md = [] last_idx = 0 - regex = "<(?PTUTOR:(?P[A-Z_]+))>(?P.*)" + regex = ( + r"<(?PTUTOR:(?P[A-Z_]+))(?P.*)>(?P.*)" + ) for match in re.finditer(regex, slides_md, re.MULTILINE | re.DOTALL): res_md.append(slides_md[last_idx : match.start()]) match_groups = match.groupdict() @@ -73,7 +73,7 @@ def get_md(self, rendered_example=True) -> str: match_groups["type"] ) ) - res_md.append(handler(match_groups["inner"])) + res_md.append(handler(match_groups["inner"], match_groups["attrs"])) last_idx = match.end() res_md.append(slides_md[last_idx:]) @@ -118,12 +118,14 @@ def _get_source_link(self): ), ) - def _handle_show_and_render(self, contents, rendered_example: bool = True) -> str: + def _handle_show_and_render( + self, contents, attrs: str, rendered_example: bool = True + ) -> str: contents = contents.strip() markdown_example = "\n".join( [ - "~~~markdown", + "~~~markdown{}".format(attrs), contents, "~~~", ] @@ -143,11 +145,12 @@ def _handle_show_and_render(self, contents, rendered_example: bool = True) -> st return "\n\n".join(res) - def _handle_style_yaml(self, contents: str) -> str: + def _handle_style_yaml(self, contents: str, attrs: str) -> str: contents = contents.strip() style = config.get_style()[contents] style = {"styles": {contents: style}} - return "```yaml\n---\n{style_yaml}---\n```".format( + return "```yaml{attrs}\n---\n{style_yaml}---\n```".format( + attrs=attrs, style_yaml=yaml.dump(style).encode().decode("unicode-escape"), ) diff --git a/lookatme/utils.py b/lookatme/utils.py index a802006..45ac7d2 100644 --- a/lookatme/utils.py +++ b/lookatme/utils.py @@ -188,6 +188,14 @@ def get_fg_bg_styles(style): raise ValueError("Unsupported style value {!r}".format(style)) +def extract_hexcolor(spec_style: str) -> str: + for part in spec_style.split(","): + if part.startswith("#"): + return part + # TODO + return "#ffffff" + + def overwrite_style( orig_style: Dict[str, str], new_style: Dict[str, str] ) -> Dict[str, str]: diff --git a/lookatme/widgets/codeblock.py b/lookatme/widgets/codeblock.py new file mode 100644 index 0000000..c843be1 --- /dev/null +++ b/lookatme/widgets/codeblock.py @@ -0,0 +1,428 @@ +""" +This module defines an urwid Widget that renders a codeblock +""" + + +import itertools +import math +import re +from typing import Dict, List, Mapping, Optional, Set, Tuple + + +import pygments +from pygments.lexer import Lexer +import pygments.lexers +import pygments.styles +from pygments.style import StyleMeta +import pygments.util +import pygments.token +import urwid + + +from lookatme.widgets.smart_attr_spec import SmartAttrSpec +from lookatme.widgets.line_fill import LineFill +import lookatme.utils as utils + + +AVAILABLE_LEXERS = set() + + +def supported_langs() -> Set[str]: + global AVAILABLE_LEXERS + + if len(AVAILABLE_LEXERS) == 0: + AVAILABLE_LEXERS = set( + itertools.chain(*[x[1] for x in pygments.lexers.get_all_lexers()]) + ) + + return AVAILABLE_LEXERS + + +LEXER_CACHE: Dict[str, Lexer] = {} + + +def get_lexer(lang, default="text") -> Lexer: + lexer = LEXER_CACHE.get(lang, None) + if lexer is None: + try: + lexer = pygments.lexers.get_lexer_by_name(lang) + except pygments.util.ClassNotFound: + lexer = pygments.lexers.get_lexer_by_name(default) + LEXER_CACHE[lang] = lexer + return lexer + + +def supported_styles() -> Mapping[str, str]: + return pygments.styles.STYLE_MAP + + +class SyntaxHlStyle: + """Stores urwid styles for each token type for a specific pygments syntax + highlighting style. + """ + + def __init__( + self, + name: str, + styles: Dict[str, SmartAttrSpec], + pygments_style: StyleMeta, + default_fg: str, + bg_override: Optional[str] = None, + ): + self.name = name + self.styles = styles + self.pygments_style = pygments_style + self.default_fg = default_fg + self.bg_override = bg_override + + bg_color = self.bg_override or self.pygments_style.background_color + self.bg_spec = SmartAttrSpec(fg="", bg=bg_color) + bg_luminance = self._luminance(bg_color) + if bg_luminance > 0.5: + hl_color = self._increase_brightness(bg_color, -0.15) + else: + hl_color = self._increase_brightness(bg_color, 0.15) + self.highlight_spec = SmartAttrSpec("bold", hl_color) + + if self.bg_override: + self.bg_spec.background = bg_override + + self.line_number_spec = utils.overwrite_spec( + SmartAttrSpec( + fg=self._to_urwid_color( + self.pygments_style.line_number_color, + self.default_fg, + ), + bg=self._to_urwid_color( + self.pygments_style.line_number_background_color, + self.bg_spec.background, # type: ignore + ), + ), + self.get_style_spec("Token.Comment", False), + ) + self.line_number_spec_hl = utils.overwrite_spec( + self.line_number_spec, self.highlight_spec + ) + + def _to_urwid_color(self, val: str, inherit_val: str) -> str: + if val in ("inherit", "transparent", None): + return inherit_val + return val + + def get_line_number_spec(self, do_hl: bool = False) -> SmartAttrSpec: + if do_hl: + return self.line_number_spec_hl + else: + return self.line_number_spec + + def get_style_spec(self, token_type: str, highlight: bool) -> SmartAttrSpec: + """Attempt to find the closest matching style for the provided token + type. + """ + token_type = str(token_type) + parts = token_type.split(".") + spec = None + while len(token_type) > 0: + token_type = ".".join(parts) + existing_style = self.styles.get(token_type, None) + if existing_style is not None: + spec = existing_style + break + parts = parts[:-1] + + if spec is None: + spec = self.styles[token_type] + + if highlight: + spec = utils.overwrite_spec(spec, self.highlight_spec) + else: + spec = utils.overwrite_spec(spec, self.bg_spec) + + if "default" in spec.foreground: + spec.foreground = utils.overwrite_style( + {"fg": spec.foreground}, {"fg": self.default_fg} + )["fg"] + + self._ensure_contrast(spec) + + return spec + + def _ensure_contrast(self, spec: urwid.AttrSpec): + luminance_fg = self._luminance(utils.extract_hexcolor(spec.foreground)) + luminance_bg = self._luminance(utils.extract_hexcolor(spec.background)) + + if luminance_fg > luminance_bg: + contrast_ratio = (luminance_fg + 0.05) / (luminance_bg + 0.05) + else: + contrast_ratio = (luminance_bg + 0.05) / (luminance_fg + 0.05) + + # w3c recommends a contrast >= 4.5, but most coding color schemes don't + # fit this + if contrast_ratio >= 3.0: + return + + if luminance_bg < 0.5: + new_fg = "#ffffff" + else: + new_fg = "#000000" + + tmp_spec = utils.overwrite_style( + {"fg": spec.foreground}, + {"fg": new_fg}, + ) + spec.foreground = tmp_spec["fg"] + + def _luminance(self, color: str) -> float: + color = color.strip("#") + if len(color) != 6: + return 0.0 + + red, green, blue = [int(x, 16) for x in re.findall("..", color)] + red = math.pow(red / 255.0, 2.2) + green = math.pow(green / 255.0, 2.2) + blue = math.pow(blue / 255.0, 2.2) + + return red * 0.2126 + green * 0.7152 + blue * 0.0722 + + def _increase_brightness(self, color: str, percent: float) -> str: + color = color.strip("#") + red, green, blue = [int(x, 16) for x in re.findall("..", color)] + + if percent > 0: + red += (255.0 - red) * percent + green += (255.0 - green) * percent + blue += (255.0 - blue) * percent + else: + red += red * percent + green += green * percent + blue += blue * percent + + if percent < 0: + red = max(0, red) + green = max(0, green) + blue = max(0, blue) + else: + red = min(255, red) + green = min(255, green) + blue = min(255, blue) + + return "#{:02x}{:02x}{:02x}".format(int(red), int(green), int(blue)) + + +class StyleCache: + """Caches the highlight styles for loaded pygments syntax highlighting + styles. + """ + + def __init__( + self, + default_fg: Optional[str] = None, + bg_override: Optional[str] = None, + ): + self.default_fg = default_fg or "default" + self.bg_override = bg_override + self.cache: Dict[str, SyntaxHlStyle] = {} + + def get_style(self, style_name: str) -> SyntaxHlStyle: + """Return the highlight style for the specified pygments style name. If + the style name isn't found, the "text" style will be used instead. + """ + if style_name not in self.cache: + self.cache[style_name] = self.load_style(style_name) + + return self.cache[style_name] + + def is_valid_style(self, style_name: str) -> bool: + """Return whether the style name is a valid pygments style""" + return style_name in supported_styles() + + def load_style(self, style_name: str) -> SyntaxHlStyle: + if not self.is_valid_style(style_name): + style_name = "text" + + pygments_style = pygments.styles.get_style_by_name(style_name) + style_dict = {} + for token_type, style_info in pygments_style: + fg_color = style_info.get("color", None) + fg = "#" + fg_color if fg_color else "default" + + bg_color = style_info.get("bgcolor", None) + bg = "#" + bg_color if bg_color else "default" + + if style_info.get("bold", False): + fg += ",bold" # type: ignore + if style_info.get("italics", False): + fg += ",italics" # type: ignore + if style_info.get("underline", False): + fg += ",underline" # type: ignore + + style_dict[str(token_type)] = SmartAttrSpec(fg, bg) + + return SyntaxHlStyle( + style_name, + style_dict, + pygments_style, + default_fg=self.default_fg, + bg_override=self.bg_override, + ) + + +STYLE_CACHE = None + + +def clear_cache(): + global STYLE_CACHE + + STYLE_CACHE = None + LEXER_CACHE.clear() + + +def get_style_cache( + default_fg: Optional[str] = None, + bg_override: Optional[str] = None, +) -> StyleCache: + global STYLE_CACHE + + if STYLE_CACHE is None: + STYLE_CACHE = StyleCache(default_fg, bg_override) + + return STYLE_CACHE + + +def tokens_to_markup( + line: List[Tuple[str, str]], style: SyntaxHlStyle, do_hl: bool = False +) -> List[Tuple[SmartAttrSpec, str]]: + res = [] + for token_type, token_val in line: + spec = style.get_style_spec(token_type, do_hl) + res.append((spec, token_val)) + if len(res) == 0: + res.append("") + return res + + +def tokens_to_lines(tokens) -> List[List[Tuple[str, str]]]: + lines = [] + curr_line = [] + token_stack = list(reversed([x for x in tokens])) + while len(token_stack) > 0: + ttype, tstring = token_stack.pop() + + if "\n" in tstring and tstring != "\n": + for line_part in reversed(re.split(r"(\n)", tstring)): + if len(line_part) == 0: + continue + token_stack.append((ttype, line_part)) + continue + + if tstring == "\n": + lines.append(curr_line) + curr_line = [] + continue + + curr_line.append((ttype, tstring)) + + return lines + + +class CodeBlock(urwid.Pile): + def __init__( + self, + source: str, + lang: str = "text", + style_name: str = "monokai", + line_numbers: bool = False, + start_line_number: int = 1, + hl_lines: Optional[List[range]] = None, + default_fg: Optional[str] = None, + bg_override: Optional[str] = None, + ): + self.source = source + self.lang = lang + self.line_numbers = line_numbers + self.start_line_number = start_line_number + self.hl_lines = hl_lines or [] + + self.style = get_style_cache( + default_fg=default_fg, + bg_override=bg_override, + ).get_style(style_name) + + contents = self._create_contents() + + super().__init__(contents) + + def _create_contents(self) -> List[urwid.Columns]: + """Create the contents that will be used in the Pile""" + tokens = get_lexer(self.lang).get_tokens(self.source) + res = [] + + lines = tokens_to_lines(tokens) + + max_line_num_width = len(str(self.start_line_number + len(lines))) + line_num_format_str = " {:" + str(max_line_num_width) + "} " + line_num_col_width = len(line_num_format_str.format(0)) + + for idx, line in enumerate(lines): + line_num = idx + self.start_line_number + do_hl = self._should_hl_line(line_num) + columns = [] + box_columns = [] + + if self.line_numbers: + line_num_text = line_num_format_str.format(line_num) + line_num_spec = self.style.get_line_number_spec(do_hl) + columns.append( + (line_num_col_width, urwid.Text((line_num_spec, line_num_text))) + ) + columns.append( + ( + 1, + LineFill( + beg_chars="", + fill_char="│", + end_chars="", + fill_spec=line_num_spec, + orientation=LineFill.VERTICAL, + ), + ) + ) + columns.append( + ( + 1, + LineFill( + beg_chars="", + fill_char=" ", + end_chars="", + fill_spec=line_num_spec, + orientation=LineFill.VERTICAL, + ), + ) + ) + box_columns.append(1) + box_columns.append(2) + + line_text = urwid.Text(tokens_to_markup(line, self.style, do_hl)) + columns.append(line_text) + + row = urwid.Columns(columns, box_columns=box_columns) + + wrap_spec = self.style.highlight_spec if do_hl else self.style.bg_spec + row = urwid.AttrMap(row, {None: wrap_spec}) + + res.append(row) + + return res + + def _make_line_column( + self, format_str: str, line_num: int, do_hl: bool + ) -> urwid.Text: + text = format_str.format(line_num) + spec = self.style.get_line_number_spec(do_hl) + return urwid.Text((spec, text)) + + def _should_hl_line(self, line_num: int) -> bool: + for hl_range in self.hl_lines: + if line_num in hl_range: + return True + return False diff --git a/tests/test_cli.py b/tests/test_cli.py index 8d59ca1..c76b4a6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,7 @@ def _get_dumped_style( tmpdir, theme: Optional[str] = None, md_meta_style: Optional[str] = None, - cli_style: Optional[str] = None, + # cli_style: Optional[str] = None, ) -> str: cli_args = ["--dump-styles"] @@ -47,20 +47,23 @@ def _get_dumped_style( [ "---", "styles:", - " style: {}".format(md_meta_style), + " code:", + " style: {}".format(md_meta_style), "---", ] ) ) cli_args += [str(tmpfile)] - if cli_style is not None: - cli_args += ["--style", cli_style] + + # # TODO once we have better CLI style processing, add this back in! + # if cli_style is not None: + # cli_args += ["--code-style", cli_style] res = run_cmd(*cli_args) assert res.exit_code == 0 yaml_data = yaml.safe_load(res.output) - return yaml_data["style"] + return yaml_data["code"]["style"] def test_style_override_precedence_dark(tmpdir): @@ -68,18 +71,14 @@ def test_style_override_precedence_dark(tmpdir): default_style = _get_dumped_style(tmpdir) themed_style = _get_dumped_style(tmpdir, theme="dark") themed_and_md = _get_dumped_style(tmpdir, theme="dark", md_meta_style="emacs") - themed_and_md_and_cli = _get_dumped_style( - tmpdir, theme="dark", md_meta_style="emacs", cli_style="zenburn" - ) default = lookatme.schemas.MetaSchema().dump(None) - assert default_style == default["styles"]["style"] + assert default_style == default["styles"]["code"]["style"] dark_theme_styles = lookatme.schemas.StyleSchema().dump(dark_theme.theme) - assert themed_style == dark_theme_styles["style"] # type: ignore + assert themed_style == dark_theme_styles["code"]["style"] # type: ignore assert themed_and_md == "emacs" - assert themed_and_md_and_cli == "zenburn" def test_style_override_precedence_light(tmpdir): @@ -87,18 +86,14 @@ def test_style_override_precedence_light(tmpdir): default_style = _get_dumped_style(tmpdir) themed_style = _get_dumped_style(tmpdir, theme="light") themed_and_md = _get_dumped_style(tmpdir, theme="light", md_meta_style="emacs") - themed_and_md_and_cli = _get_dumped_style( - tmpdir, theme="light", md_meta_style="emacs", cli_style="zenburn" - ) default = lookatme.schemas.MetaSchema().dump(None) - assert default_style == default["styles"]["style"] + assert default_style == default["styles"]["code"]["style"] light_theme_styles = lookatme.schemas.StyleSchema().dump(light_theme.theme) - assert themed_style == light_theme_styles["style"] # type: ignore + assert themed_style == light_theme_styles["code"]["style"] # type: ignore assert themed_and_md == "emacs" - assert themed_and_md_and_cli == "zenburn" def test_version(): diff --git a/tests/test_file_loader.py b/tests/test_file_loader.py index b4ceaab..0e71f5d 100644 --- a/tests/test_file_loader.py +++ b/tests/test_file_loader.py @@ -7,7 +7,6 @@ import lookatme.config import lookatme.contrib.file_loader -import lookatme.render.pygments import tests.utils as utils from tests.utils import override_style @@ -50,7 +49,7 @@ def test_file_loader(tmpdir, _): "SSSSSSS", ], styles={ - "S": {"fg": "g93", "bg": "g15"}, + "S": {"fg": "#f8f8f2", "bg": "#272822"}, }, ) @@ -89,7 +88,7 @@ def test_file_loader_with_transform(tmpdir, _): "TTTTTTT", ], styles={ - "T": {"fg": "g93", "bg": "g15"}, + "T": {"fg": "#f8f8f2", "bg": "#272822"}, }, ) @@ -114,7 +113,7 @@ def test_file_loader_relative(tmpdir, _): "SSSSSSS", ], styles={ - "S": {"fg": "g93", "bg": "g15"}, + "S": {"fg": "#f8f8f2", "bg": "#272822"}, }, ) @@ -135,6 +134,6 @@ def test_file_loader_not_found(_): "TTTTTTTTTTTTTT", ], styles={ - "T": {"fg": "g93", "bg": "g15"}, + "T": {"fg": "#f8f8f2", "bg": "#272822"}, }, ) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 1c7b422..66438e8 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -559,7 +559,9 @@ def test_block_quote(style): @override_style( { - "code": "monokai", + "code": { + "style": "monokai", + } } ) def test_code(styles): @@ -586,7 +588,7 @@ def test_code(styles): "RRRRRRR", ], styles={ - "R": {"fg": "#dd8", "bg": "g15"}, + "R": {"fg": "#e6db74", "bg": "#272822"}, " ": {}, }, ) @@ -594,7 +596,53 @@ def test_code(styles): @override_style( { - "code": "monokai", + "code": { + "style": "monokai", + } + } +) +def test_code_with_highlight_and_numbers(styles): + """Test code block rendering""" + utils.validate_render( + md_text=""" + ```python {.numberLines startFrom=3 hllines=3-4,6} + 'Hello' + 'Hello' + 'Hello' + 'Hello' + 'Hello' + ``` + """, + text=[ + " 3 │ 'Hello'", + " 4 │ 'Hello'", + " 5 │ 'Hello'", + " 6 │ 'Hello'", + " 7 │ 'Hello'", + ], + style_mask=[ + "lllllrrrrrrr", # highlighted + "lllllrrrrrrr", # highlighted + "LLLLLRRRRRRR", + "lllllrrrrrrr", # highlighted + "LLLLLRRRRRRR", + ], + styles={ + # normal lines + "R": {"fg": "#e6db74", "bg": "#272822"}, + "L": {"fg": "#75715e", "bg": "#272822"}, + # highlightled lines + "r": {"fg": "#e6db74,bold", "bg": "#474843"}, + "l": {"fg": "#75715e,bold", "bg": "#474843"}, + }, + ) + + +@override_style( + { + "code": { + "style": "monokai", + }, } ) def test_empty_codeblock(style): @@ -613,14 +661,16 @@ def test_empty_codeblock(style): "B", ], styles={ - "B": {"bg": "g15"}, + "B": {"bg": "#272822"}, }, ) @override_style( { - "code": "monokai", + "code": { + "style": "monokai", + } } ) def test_code_preceded_by_text(styles): @@ -644,8 +694,8 @@ def test_code_preceded_by_text(styles): "B______", ], styles={ - "B": {"fg": "g93", "bg": "g15"}, - "_": {"bg": "g15"}, + "B": {"fg": "#f8f8f2", "bg": "#272822"}, + "_": {"bg": "#272822"}, " ": {}, }, ) @@ -653,7 +703,9 @@ def test_code_preceded_by_text(styles): @override_style( { - "code": "monokai", + "code": { + "style": "monokai", + } } ) def test_code_yaml(styles): @@ -686,12 +738,12 @@ def test_code_yaml(styles): "::::VVVVV_____________", ], styles={ - "K": {"fg": "#f06", "bg": "g15"}, - "S": {"fg": "#dd8", "bg": "g15"}, - "V": {"fg": "#a8f", "bg": "g15"}, - ":": {"fg": "g93", "bg": "g15"}, - "-": {"bg": "g15"}, - "_": {"bg": "g15"}, + "K": {"fg": "#f92672", "bg": "#272822"}, + "S": {"fg": "#e6db74", "bg": "#272822"}, + "V": {"fg": "#ae81ff", "bg": "#272822"}, + ":": {"fg": "#f8f8f2", "bg": "#272822"}, + "-": {"bg": "#272822"}, + "_": {"bg": "#272822"}, }, ) diff --git a/tests/test_tutorial.py b/tests/test_tutorial.py index 6f401d0..4fc289a 100644 --- a/tests/test_tutorial.py +++ b/tests/test_tutorial.py @@ -44,7 +44,7 @@ def test_tutor(mocker): "group", "\n".join( [ - "contents", + "contents", "test", ] ), @@ -54,7 +54,7 @@ def test_tutor(mocker): md_text = tutor.get_md() - md_example = "> ~~~markdown\n> contents\n> ~~~" + md_example = "> ~~~markdown {hllines=1}\n> contents\n> ~~~" assert md_example in md_text md_rendered = "\ncontents"