"""
This module defines an urwid Widget that renders a codeblock
"""
import itertools
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 re
import urwid
from lookatme.widgets.smart_attr_spec import SmartAttrSpec
from lookatme.widgets.line_fill import LineFill
import lookatme.utils as utils
import lookatme.utils.colors as colors
AVAILABLE_LEXERS = set()
[docs]def guess_lang(content: str) -> str:
if content.startswith("#!"):
lexer = pygments.lexers.guess_lexer(content)
if "text" not in lexer.aliases: # type: ignore
return lexer.aliases[0] # type: ignore
lang = "text"
curly_braces = re.search(r"[\{\}]", content) is not None
variable_assign = re.search(r"[a-zA-Z_0-9]+\s*=\s*[^=$]", content) is not None
pointers = re.search(r"[a-zA-Z_0-9]->[a-zA-Z0-9_]", content) is not None
def_functions = re.search(r"\s*def [a-zA-Z0-9_]+", content) is not None
function_calls = (
re.search(r"[a-zA-Z0-9_]+\s*([^)]+)", content, re.MULTILINE | re.DOTALL)
is not None
)
if curly_braces or variable_assign or function_calls:
lang = "javascript"
if pointers:
lang = "c++"
elif def_functions:
lang = "ruby"
return lang
[docs]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] = {}
[docs]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
[docs]def supported_styles() -> Mapping[str, str]:
return pygments.styles.STYLE_MAP
[docs]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)
hl_color = colors.get_highlight_color(bg_color)
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
[docs] 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
[docs] 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"]
colors.ensure_contrast(spec)
spec.preserve_spaces = True
return spec
[docs]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] = {}
[docs] 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]
[docs] 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()
[docs] 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
[docs]def clear_cache():
global STYLE_CACHE
STYLE_CACHE = None
LEXER_CACHE.clear()
[docs]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
[docs]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
[docs]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
[docs]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