From 5994fd5fe93dd10e6f16d454fe5a27d45f5a321f Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Fri, 25 Mar 2022 21:05:19 +0000 Subject: [PATCH 1/7] Add markdown to formatted text convertor --- examples/print-text/markdown-sample.md | 34 + src/prompt_toolkit/border.py | 33 + src/prompt_toolkit/formatted_text/__init__.py | 4 + src/prompt_toolkit/formatted_text/markdown.py | 589 ++++++++++++++++++ src/prompt_toolkit/formatted_text/utils.py | 317 +++++++++- src/prompt_toolkit/styles/defaults.py | 26 + src/prompt_toolkit/widgets/base.py | 11 +- 7 files changed, 1003 insertions(+), 11 deletions(-) create mode 100644 examples/print-text/markdown-sample.md create mode 100644 src/prompt_toolkit/border.py create mode 100644 src/prompt_toolkit/formatted_text/markdown.py diff --git a/examples/print-text/markdown-sample.md b/examples/print-text/markdown-sample.md new file mode 100644 index 000000000..0b73bcfe7 --- /dev/null +++ b/examples/print-text/markdown-sample.md @@ -0,0 +1,34 @@ +# Heading + +## Sub-heading + +## Long heading with lots of words which will wrap the words onto multiple lines on a narrow screen + +*Italic*, **bold**, `code`, ~~strikethrough~~. + +Inline [hyperlinks](https://python-prompt-toolkit.readthedocs.io/) and images ![](https://python-prompt-toolkit.readthedocs.io/en/master/_static/logo_400px.png) inline too. + +> Quote blocks + +- Lists + - Sublists + +1. Numbered +2. lists + 1. with +3. sublists + +--- + +| **Table** | Header | Row | +|:-------------|:------:|---------:| +| Left | Centre | Right | +| *Formatting* | **in** | `tables` | + + +```python +def code_blocks(): + print("Hello world!") +``` + +Fin. diff --git a/src/prompt_toolkit/border.py b/src/prompt_toolkit/border.py new file mode 100644 index 000000000..6dac8a162 --- /dev/null +++ b/src/prompt_toolkit/border.py @@ -0,0 +1,33 @@ +"""Defines borders.""" + + +class SquareBorder: + """Square thin border.""" + + TOP_LEFT = "┌" + TOP_SPLIT = "┬" + TOP_RIGHT = "┐" + HORIZONTAL = "─" + VERTICAL = "│" + LEFT_SPLIT = "├" + RIGHT_SPLIT = "┤" + CROSS = "┼" + BOTTOM_LEFT = "└" + BOTTOM_SPLIT = "┴" + BOTTOM_RIGHT = "┘" + + +class DoubleBorder: + """Box drawing characters with double lines.""" + + TOP_LEFT = "╔" + TOP_RIGHT = "╗" + VERTICAL = "║" + INNER_VERTICAL = "║" + HORIZONTAL = "═" + INNER_HORIZONTAL = "═" + BOTTOM_LEFT = "╚" + BOTTOM_RIGHT = "╝" + SPLIT_LEFT = "╠" + SPLIT_RIGHT = "╣" + CROSS = "╬" diff --git a/src/prompt_toolkit/formatted_text/__init__.py b/src/prompt_toolkit/formatted_text/__init__.py index f0c92c96f..e5df2ea7e 100644 --- a/src/prompt_toolkit/formatted_text/__init__.py +++ b/src/prompt_toolkit/formatted_text/__init__.py @@ -21,6 +21,8 @@ to_formatted_text, ) from .html import HTML + +from .markdown import Markdown from .pygments import PygmentsTokens from .utils import ( fragment_list_len, @@ -45,6 +47,8 @@ "ANSI", # Pygments. "PygmentsTokens", + # Markdown + "Markdown", # Utils. "fragment_list_len", "fragment_list_width", diff --git a/src/prompt_toolkit/formatted_text/markdown.py b/src/prompt_toolkit/formatted_text/markdown.py new file mode 100644 index 000000000..139a0456b --- /dev/null +++ b/src/prompt_toolkit/formatted_text/markdown.py @@ -0,0 +1,589 @@ +"""Contains a markdown to formatted text parser.""" + +from itertools import zip_longest +from typing import TYPE_CHECKING, Any, Dict, Callable, List, Optional, Tuple +from warnings import warn + +from prompt_toolkit.application.current import get_app_session +from prompt_toolkit.formatted_text.utils import ( + fragment_list_width, + split_lines, + to_formatted_text, + to_plain_text, +) + +from prompt_toolkit.border import DoubleBorder, SquareBorder + +from .utils import ( + FormattedTextAlign, + add_border, + align, + apply_style, + indent, + last_line_length, + lex, + strip, + wrap, +) + +if TYPE_CHECKING: + + from markdown_it.token import Token # type: ignore + from prompt_toolkit.formatted_text.base import StyleAndTextTuples + +# Check for markdown-it-py +markdown_parser: Optional["MarkdownIt"] = None +try: + from markdown_it import MarkdownIt # type: ignore +except ModuleNotFoundError: + warn("The markdown parser requires `markdown-it-py` to be installed") +else: + markdown_parser = ( + MarkdownIt().enable("linkify").enable("table").enable("strikethrough") + ) + +# Check for markdown-it-py plugins +try: + import mdit_py_plugins # type: ignore # noqa F401 +except ModuleNotFoundError: + pass +else: + from mdit_py_plugins.amsmath import amsmath_plugin # type: ignore + from mdit_py_plugins.dollarmath import dollarmath_plugin # type: ignore + from mdit_py_plugins.texmath import texmath_plugin # type: ignore + + if markdown_parser is not None: + markdown_parser.use(texmath_plugin) + markdown_parser.use(dollarmath_plugin) + markdown_parser.use(amsmath_plugin) + + +_SIDES = { + "left": FormattedTextAlign.LEFT, + "right": FormattedTextAlign.RIGHT, + "center": FormattedTextAlign.CENTER, +} + + +def h1(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": + """Format a top-level heading wrapped and centered with a full width double border.""" + ft = wrap(ft, width - 4) + ft = align(FormattedTextAlign.CENTER, ft, width=width - 4) + ft = add_border(ft, width, style="class:md.h1.border", border=DoubleBorder) + ft.append(("", "\n\n")) + return ft + + +def h2(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": + """Format a 2nd-level headding wrapped and centered with a double border.""" + ft = wrap(ft, width=width - 4) + ft = align(FormattedTextAlign.CENTER, ft) + ft = add_border(ft, style="class:md.h2.border", border=SquareBorder) + ft = align(FormattedTextAlign.CENTER, ft, width=width) + ft.append(("", "\n\n")) + return ft + + +def h(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": + """Format headings wrapped and centeredr.""" + ft = wrap(ft, width) + ft = align(FormattedTextAlign.CENTER, ft, width=width) + ft.append(("", "\n\n")) + return ft + + +def p( + ft: "StyleAndTextTuples", width: int, hidden: bool, **kwargs: Any +) -> "StyleAndTextTuples": + """Format paragraphs wrapped.""" + ft = wrap(ft, width) + ft.append(("", "\n" if hidden else "\n\n")) + return ft + + +def ul(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": + """Format unordered lists.""" + ft.append(("", "\n")) + return ft + + +def ol(ft: "StyleAndTextTuples", width: "int", **kwargs: "Any") -> "StyleAndTextTuples": + """Formats ordered lists.""" + ft.append(("", "\n")) + return ft + + +def li( + ft: "StyleAndTextTuples", width: int, attrs: Dict[str, Any], **kwargs: Any +) -> "StyleAndTextTuples": + """Formats list items.""" + ft = strip(ft) + # Determine if this is an ordered or unordered list + if attrs.get("data-list-type") == "ol": + margin_style = "class:md.ol.margin" + else: + margin_style = "class:md.ul.margin" + # Get the margin (potentially contains aligned item numbers) + margin = attrs.get("data-margin", "•") + # We put a speace each side of the margin + ft = indent(ft, margin=" " * (len(margin) + 2), style=margin_style) + ft[0] = (ft[0][0], f" {margin} ") + ft.append(("", "\n")) + return ft + + +def hr(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": + """Format horizontal rules.""" + ft = [ + ("class:md.hr", "─" * width), + ("", "\n\n"), + ] + return ft + + +def br(ft: "StyleAndTextTuples", **kwargs: Any) -> "StyleAndTextTuples": + """Format line breaks.""" + return [("", "\n")] + + +def blockquote( + ft: "StyleAndTextTuples", + width: int, + info: str = "", + block: bool = False, + **kwargs: Any, +) -> "StyleAndTextTuples": + """Format blockquotes with a solid left margin.""" + ft = strip(ft) + ft = indent(ft, margin="▌ ", style="class:md.blockquote.margin") + ft.append(("", "\n\n")) + return ft + + +def code( + ft: "StyleAndTextTuples", + width: int, + info: str = "", + block: bool = False, + **kwargs: Any, +) -> "StyleAndTextTuples": + """Format inline code, and lexes and formats code blocks with a border.""" + if block: + ft = strip(ft, left=False, right=True, char="\n") + ft = lex(ft, lexer_name=info) + ft = align(FormattedTextAlign.LEFT, ft, width - 4) + ft = add_border(ft, width, style="class:md.code.border", border=SquareBorder) + ft.append(("", "\n\n")) + else: + ft = apply_style(ft, style="class:md.code.inline") + return ft + + +def math( + ft: "StyleAndTextTuples", width: int, block: bool, **kwargs: Any +) -> "StyleAndTextTuples": + """Format inline maths, and quotes math blocks.""" + if block: + return blockquote(ft, width - 2, **kwargs) + else: + return ft + + +def a( + ft: "StyleAndTextTuples", attrs: Dict[str, str], **kwargs: Any +) -> "StyleAndTextTuples": + """Format hyperlinks and adds link escape sequences.""" + result: "StyleAndTextTuples" = [] + href = attrs.get("href") + if href: + result.append(("[ZeroWidthEscape]", f"\x1b]8;;{href}\x1b\\")) + result += ft + if href: + result.append(("[ZeroWidthEscape]", "\x1b]8;;\x1b\\")) + return result + + +def img( + ft: "StyleAndTextTuples", + width: int, + attrs: Dict[str, str], + block: bool, + left: int, + border: bool = False, + bounds: Tuple[str, str] = ("", ""), # Semi-circle blocks + **kwargs: Any, +) -> "StyleAndTextTuples": + """Format image titles.""" + if not to_plain_text(ft): + # Add fallback text if there is no image title + title = attrs.get("alt") + # Try getting the filename + if not title and not (src := attrs.get("src", "")).startswith("data:"): + title = src.rsplit("/", 1)[-1] + if not title: + title = "Image" + ft = [("class:md.img", title)] + # Add the sunrise emoji to represent an image. I would use :framed_picture:, but it + # requires multiple code-points and causes breakage in many terminals + result = [("class:md.img", "🌄 "), *ft] + result = apply_style(result, style="class:md.img") + result = [ + ("class:md.img.border", f"{bounds[0]}"), + *result, + ("class:md.img.border", f"{bounds[1]}"), + ] + return result + + +# Maps HTML tag names to formatting functions. Functionality can be extended by +# modifying this dictionary +TAG_RULES: "dict[str, Callable]" = { + "h1": h1, + "h2": h2, + "h3": h, + "h4": h, + "h5": h, + "h6": h, + "p": p, + "ul": ul, + "ol": ol, + "li": li, + "hr": hr, + "br": br, + "blockquote": blockquote, + "code": code, + "math": math, + "a": a, + "img": img, +} + +# Mapping showing how much width the formatting of block elements used. This is used to +# reduce the available width when rendering child elements +TAG_INSETS = { + "li": 3, + "blockquote": 2, +} + + +class Markdown: + """A markdown formatted text renderer. + + Accepts a markdown string and renders it at a given width. + """ + + def __init__( + self, + markup: str, + width: Optional[int] = None, + strip_trailing_lines: bool = True, + ) -> None: + """Initialize the markdown formatter. + + Args: + markup: The markdown text to render + width: The width in characters available for rendering. If :py:const:`None` + the terminal width will be used + strip_trailing_lines: If :py:const:`True`, empty lines at the end of the + rendered output will be removed + + """ + self.markup = markup + self.width = width or get_app_session().output.get_size().columns + self.strip_trailing_lines = strip_trailing_lines + + if markdown_parser is not None: + self.formatted_text = self.render( + tokens=markdown_parser.parse(self.markup), + width=self.width, + ) + else: + self.formatted_text = lex( + to_formatted_text(self.markup), + lexer_name="markdown", + ) + if strip_trailing_lines: + self.formatted_text = strip( + self.formatted_text, + left=False, + char="\n", + ) + + def render( + self, tokens: List["Token"], width: int = 80, left: int = 0 + ) -> "StyleAndTextTuples": + """Render a list of parsed markdown tokens. + + Args: + tokens: The list of parsed tokens to render + width: The width at which to render the tokens + left: The position on the current line at which to render the output - used + to indent subsequent lines when rendering inline blocks like images + + Returns: + Formatted text + + """ + ft = [] + + i = 0 + while i < len(tokens): + token = tokens[i] + + # If this is an inline block, render it's children + if token.type == "inline": + ft += self.render(token.children, width) + i += 1 + + # Otherwise gather the tokens in the current block + else: + nest = 0 + tokens_in_block = 0 + for j, token in enumerate(tokens[i:]): + nest += token.nesting + if nest == 0: + tokens_in_block = j + break + + # If there is a special method for rendering the block, use it + + # Table require a lot of care + if token.tag == "table": + ft += self.render_table( + tokens[i : i + tokens_in_block + 1], + width=width, + left=last_line_length(ft), + ) + + # We need to keep track of item numbers in ordered lists + elif token.tag == "ol": + ft += self.render_ordered_list( + tokens[i : i + tokens_in_block + 1], + width=width, + left=last_line_length(ft), + ) + + # Otherwise all other blocks are rendered in the same way + else: + ft += self.render_block( + tokens[i : i + tokens_in_block + 1], + width=width, + left=last_line_length(ft), + ) + + i += j + 1 + + return ft + + def render_block( + self, + tokens: List["Token"], + width: int, + left: int = 0, + ) -> "StyleAndTextTuples": + """Render a list of parsed markdown tokens representing a block element. + + Args: + tokens: The list of parsed tokens to render + width: The width at which to render the tokens + left: The position on the current line at which to render the output - used + to indent subsequent lines when rendering inline blocks like images + + Returns: + Formatted text + + """ + ft = [] + token = tokens[0] + + # Restrict width if necessary + if inset := TAG_INSETS.get(token.tag): + width -= inset + + style = "class:md" + if token.tag: + style = f"{style}.{token.tag}" + + # Render innards + if len(tokens) > 1: + ft += self.render(tokens[1:-1], width) + ft = apply_style(ft, style) + else: + ft.append((style, token.content)) + + # Apply tag rule + if rule := TAG_RULES.get(token.tag): + ft = rule( + ft, + width=width, + info=token.info, + block=token.block, + attrs=token.attrs, + hidden=token.hidden, + left=left, + ) + + return ft + + def render_ordered_list( + self, + tokens: List["Token"], + width: int, + left: int = 0, + ) -> "StyleAndTextTuples": + """Render an ordered list by adding indices to the child list items.""" + # Find the list item tokens + list_level_tokens = [] + nest = 0 + for token in tokens: + if nest == 1 and token.tag == "li": + list_level_tokens.append(token) + nest += token.nesting + # Assign them a marking + margin_width = len(str(len(list_level_tokens))) + for i, token in enumerate(list_level_tokens, start=1): + token.attrs["data-margin"] = str(i).rjust(margin_width) + "." + token.attrs["data-list-type"] = "ol" + # Now render the tokens as normal + return self.render_block( + tokens, + width=width, + left=left, + ) + + def render_table( + self, + tokens: List["Token"], + width: int, + left: int = 0, + border: "Type[Border]" = SquareBorder, + ) -> "StyleAndTextTuples": + """Render a list of parsed markdown tokens representing a table element. + + Args: + tokens: The list of parsed tokens to render + width: The width at which to render the tokens + left: The position on the current line at which to render the output - used + to indent subsequent lines when rendering inline blocks like images + border: The border style to use to render the table + + Returns: + Formatted text + + """ + ft: "StyleAndTextTuples" = [] + # Stack the tokens in the shape of the table + cell_tokens: List[list["Token"]] = [] + i = 0 + while i < len(tokens): + token = tokens[i] + if token.type == "tr_open": + cell_tokens.append([]) + elif token.type in ("th_open", "td_open"): + for j, token in enumerate(tokens[i:]): + if token.type in ("th_close", "td_close"): + cell_tokens[-1].append(tokens[i : i + j + 1]) + break + i += j + i += 1 + + def _render_token( + tokens: "list[Token]", width: "Optional[int]" = None + ) -> "StyleAndTextTuples": + """Render a token with correct alignment.""" + side = "left" + # Check CSS for text alignment + for style_str in tokens[0].attrs.get("style", "").split(";"): + if ":" in style_str: + key, value = style_str.strip().split(":", 1) + if key.strip() == "text-align": + side = value + # Render with a very long line length if we do not have a width + ft = self.render(tokens, width=width or 999999) + # If we do have a width, wrap and apply the alignment + if width: + ft = wrap(ft, width) + ft = align(_SIDES[side], ft, width) + return ft + + # Find the naive widths of each cell + cell_renders: List[List["StyleAndTextTuples"]] = [] + cell_widths: List[List[int]] = [] + for row in cell_tokens: + cell_widths.append([]) + cell_renders.append([]) + for token in row: + rendered = _render_token(token) + cell_renders[-1].append(rendered) + cell_widths[-1].append(fragment_list_width(rendered)) + + # Calculate row and column widths, accounting for broders + col_widths = [ + max([row[i] for row in cell_widths]) for i in range(len(cell_widths[0])) + ] + + # Adjust widths and potentially re-render cells + # Reduce biggest cells until we fit in width + while sum(col_widths) + 3 * (len(col_widths) - 1) + 4 > width: + idxmax = max(enumerate(col_widths), key=lambda x: x[1])[0] + col_widths[idxmax] -= 1 + # Re-render changed cells + for i, row_widths in enumerate(cell_widths): + for j, new_width in enumerate(col_widths): + if row_widths[j] != new_width: + cell_renders[i][j] = _render_token( + cell_tokens[i][j], width=new_width + ) + + # Justify cell contents + for i, row in enumerate(cell_renders): + for j, cell in enumerate(row): + cell_renders[i][j] = align( + FormattedTextAlign.LEFT, cell, width=col_widths[j] + ) + + # Render table + style = "class:md.table.border" + + def _draw_add_border(left: str, split: str, right: str) -> None: + ft.append((style, left + border.HORIZONTAL)) + for col_width in col_widths: + ft.append((style, border.HORIZONTAL * col_width)) + ft.append((style, border.HORIZONTAL + split + border.HORIZONTAL)) + ft.pop() + ft.append((style, border.HORIZONTAL + right + "\n")) + + # Draw top border + _draw_add_border(border.TOP_LEFT, border.TOP_SPLIT, border.TOP_RIGHT) + # Draw each row + for i, row in enumerate(cell_renders): + for row_lines in zip_longest(*map(split_lines, row)): + # Draw each line in each row + ft.append((style, border.VERTICAL + " ")) + for j, line in enumerate(row_lines): + if line is None: + line = [("", " " * col_widths[j])] + ft += line + ft.append((style, " " + border.VERTICAL + " ")) + ft.pop() + ft.append((style, " " + border.VERTICAL + "\n")) + # Draw border between rows + if i < len(cell_renders) - 1: + _draw_add_border(border.LEFT_SPLIT, border.CROSS, border.RIGHT_SPLIT) + # Draw bottom border + _draw_add_border(border.BOTTOM_LEFT, border.BOTTOM_SPLIT, border.BOTTOM_RIGHT) + + ft.append(("", "\n")) + return ft + + def __pt_formatted_text__(self) -> "StyleAndTextTuples": + """Formatted text magic method.""" + return self.formatted_text + + +if __name__ == "__main__": + import sys + + from prompt_toolkit.shortcuts.utils import print_formatted_text + + with open(sys.argv[1]) as f: + print_formatted_text(Markdown(f.read())) diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py index cda4233e0..8e174c496 100644 --- a/src/prompt_toolkit/formatted_text/utils.py +++ b/src/prompt_toolkit/formatted_text/utils.py @@ -4,7 +4,11 @@ When ``to_formatted_text`` has been called, we get a list of ``(style, text)`` tuples. This file contains functions for manipulating such a list. """ -from typing import Iterable, cast +from enum import Enum +from typing import Iterable, Optional, cast + +from pygments.lexers import get_lexer_by_name # type: ignore +from pygments.util import ClassNotFound # type: ignore from prompt_toolkit.utils import get_cwidth @@ -24,6 +28,14 @@ ] +class FormattedTextAlign(Enum): + """Alignment of formatted text.""" + + LEFT = "LEFT" + RIGHT = "RIGHT" + CENTER = "CENTER" + + def to_plain_text(value: AnyFormattedText) -> str: """ Turn any kind of formatted text back into plain text. @@ -96,3 +108,306 @@ def split_lines(fragments: StyleAndTextTuples) -> Iterable[StyleAndTextTuples]: # line is yielded. (Otherwise, there's no way to differentiate between the # cases where `fragments` does and doesn't end with a newline.) yield line + + +def last_line_length(ft: "StyleAndTextTuples") -> "int": + """Calculate the length of the last line in formatted text.""" + line: "StyleAndTextTuples" = [] + for style, text, *_ in ft[::-1]: + index = text.find("\n") + line.append((style, text[index + 1 :])) + if index > -1: + break + return fragment_list_width(line) + + +def max_line_width(ft: "StyleAndTextTuples") -> "int": + """Calculate the length of the longest line in formatted text.""" + return max(fragment_list_width(line) for line in split_lines(ft)) + + +def fragment_list_to_words( + fragments: "StyleAndTextTuples", +) -> "Iterable[OneStyleAndTextTuple]": + """Split formatted text into word fragments.""" + for style, string, *mouse_handler in fragments: + parts = string.split(" ") + for part in parts[:-1]: + yield cast("OneStyleAndTextTuple", (style, part, *mouse_handler)) + yield cast("OneStyleAndTextTuple", (style, " ", *mouse_handler)) + yield cast("OneStyleAndTextTuple", (style, parts[-1], *mouse_handler)) + + +def apply_style(ft: "StyleAndTextTuples", style: "str") -> "StyleAndTextTuples": + """Apply a style to formatted text.""" + return [ + ( + f"{fragment_style} {style}" + if "[ZeroWidthEscape]" not in fragment_style + else fragment_style, + text, + ) + for (fragment_style, text, *_) in ft + ] + + +def strip( + ft: "StyleAndTextTuples", + left: "bool" = True, + right: "bool" = True, + char: "Optional[str]" = None, +) -> "StyleAndTextTuples": + """Strip whitespace (or a given character) from the ends of formatted text. + + Args: + ft: The formatted text to strip + left: If :py:const:`True`, strip from the left side of the input + right: If :py:const:`True`, strip from the right side of the input + char: The character to strip. If :py:const:`None`, strips whitespace + + Returns: + The stripped formatted text + + """ + result = ft[:] + for toggle, index, strip_func in [(left, 0, str.lstrip), (right, -1, str.rstrip)]: + if toggle: + while result and not (text := strip_func(result[index][1], char)): + del result[index] + if result and "[ZeroWidthEscape]" not in result[index][0]: + result[index] = (result[index][0], text) + return result + + +def truncate( + ft: "StyleAndTextTuples", + width: "int", + style: "str" = "", + placeholder: "str" = "…", +) -> "StyleAndTextTuples": + """Truncates all lines at a given length. + + Args: + ft: The formatted text to truncate + width: The width at which to truncate the text + style: The style to apply to the truncation placeholder. The style of the + truncated text will be used if not provided + placeholder: The string that will appear at the end of a truncated line + + Returns: + The truncated formatted text + + """ + result: "StyleAndTextTuples" = [] + phw = sum(get_cwidth(c) for c in placeholder) + for line in split_lines(ft): + used_width = 0 + for item in line: + fragment_width = sum( + get_cwidth(c) for c in item[1] if "[ZeroWidthEscape]" not in item[0] + ) + if used_width + fragment_width > width - phw: + remaining_width = width - used_width - fragment_width - phw + result.append((item[0], item[1][:remaining_width])) + result.append((style or item[0], placeholder)) + break + else: + result.append(item) + used_width += fragment_width + result.append(("", "\n")) + result.pop() + return result + + +def wrap( + ft: "StyleAndTextTuples", + width: "int", + style: "str" = "", + placeholder: "str" = "…", +) -> "StyleAndTextTuples": + """Wraps formatted text at a given width. + + If words are longer than the given line they will be truncated + + Args: + ft: The formatted text to wrap + width: The width at which to wrap the text + style: The style to apply to the truncation placeholder + placeholder: The string that will appear at the end of a truncated line + + Returns: + The wrapped formatted text + """ + result: "StyleAndTextTuples" = [] + lines = list(split_lines(ft)) + for i, line in enumerate(lines): + if fragment_list_width(line) <= width: + result += line + if i < len(lines) - 1: + result.append(("", "\n")) + else: + used_width = 0 + for item in fragment_list_to_words(line): + fragment_width = sum( + get_cwidth(c) for c in item[1] if "[ZeroWidthEscape]" not in item[0] + ) + # Start a new line we are at the end + if used_width + fragment_width > width and used_width > 0: + # Remove trailing whitespace + result = strip(result, left=False) + result.append(("", "\n")) + used_width = 0 + # Truncate words longer than a line + if fragment_width > width and used_width == 0: + result += truncate([item], width, style, placeholder) + used_width += fragment_width + # Left-strip words at the start of a line + elif used_width == 0: + result += strip([item], right=False) + used_width += fragment_width + # Otherwise just add the word to the line + else: + result.append(item) + used_width += fragment_width + return result + + +def align( + how: "FormattedTextAlign", + ft: "StyleAndTextTuples", + width: "Optional[int]" = None, + style: "str" = "", + placeholder: "str" = "…", +) -> "StyleAndTextTuples": + """Align formatted text at a given width. + + Args: + how: The alignment direction + ft: The formatted text to strip + width: The width to which the output should be padded. If :py:const:`None`, the + length of the longest line is used + style: The style to apply to the padding + placeholder: The string that will appear at the end of a truncated line + + Returns: + The aligned formatted text + + """ + lines = split_lines(ft) + if width is None: + lines = [strip(line) for line in split_lines(ft)] + width = max(fragment_list_width(line) for line in lines) + result: "StyleAndTextTuples" = [] + for line in lines: + line_width = fragment_list_width(line) + # Truncate the line if it is too long + if line_width > width: + result += truncate(line, width, style, placeholder) + else: + pad_left = pad_right = 0 + if how == FormattedTextAlign.CENTER: + pad_left = (width - line_width) // 2 + pad_right = width - line_width - pad_left + elif how == FormattedTextAlign.LEFT: + pad_right = width - line_width + elif how == FormattedTextAlign.RIGHT: + pad_left = width - line_width + if pad_left: + result.append((style, " " * pad_left)) + result += line + if pad_right: + result.append((style, " " * pad_right)) + result.append((style, "\n")) + result.pop() + return result + + +def indent( + ft: "StyleAndTextTuples", + margin: "str" = " ", + style: "str" = "", + skip_first: "bool" = False, +) -> "StyleAndTextTuples": + """Indents formatted text with a given margin. + + Args: + ft: The formatted text to strip + margin: The margin string to add + style: The style to apply to the margin + skip_first: If :py:const:`True`, the first line is skipped + + Returns: + The indented formatted text + + """ + result: "StyleAndTextTuples" = [] + for i, line in enumerate(split_lines(ft)): + if not (i == 0 and skip_first): + result.append((style, margin)) + result += line + result.append(("", "\n")) + result.pop() + return result + + +def add_border( + ft: "StyleAndTextTuples", + width: "Optional[int]" = None, + style: "str" = "", + border: "Optional[Type[B]]" = None, +) -> "StyleAndTextTuples": + """Adds a border around formatted text. + + Args: + ft: The formatted text to enclose with a border + width: The target width of the output including the border + style: The style to apply to the border + border: The border to apply + + Returns: + The indented formatted text + + """ + if border is None: + # See mypy issue #4236 + border = cast("Type[B]", Border) + if width is None: + width = max_line_width(ft) + 4 + + # ft = align(FormattedTextAlign.LEFT, ft, width - 4) + result: "StyleAndTextTuples" = [] + + result.append( + ( + style, + border.TOP_LEFT + border.HORIZONTAL * (width - 2) + border.TOP_RIGHT + "\n", + ) + ) + for line in split_lines(ft): + result += [ + (style, border.VERTICAL), + ("", " "), + *line, + ("", " "), + (style, border.VERTICAL + "\n"), + ] + result.append( + ( + style, + border.BOTTOM_LEFT + border.HORIZONTAL * (width - 2) + border.BOTTOM_RIGHT, + ) + ) + return result + + +def lex(ft: "StyleAndTextTuples", lexer_name: "str") -> "StyleAndTextTuples": + """Format formatted text using a named :py:mod:`pygments` lexer.""" + from prompt_toolkit.lexers.pygments import _token_cache + + text = fragment_list_to_text(ft) + try: + lexer = get_lexer_by_name(lexer_name) + except ClassNotFound: + return ft + else: + return [(_token_cache[t], v) for _, t, v in lexer.get_tokens_unprocessed(text)] diff --git a/src/prompt_toolkit/styles/defaults.py b/src/prompt_toolkit/styles/defaults.py index 4ac554562..57bc1728d 100644 --- a/src/prompt_toolkit/styles/defaults.py +++ b/src/prompt_toolkit/styles/defaults.py @@ -209,6 +209,31 @@ } +MARKDOWN_STYLE = [ + ("md.h1", "bold underline"), + ("md.h1.border", "fg:ansiyellow nounderline"), + ("md.h2", "bold"), + ("md.h2.border", "fg:grey nobold"), + ("md.h3", "bold"), + ("md.h4", "bold italic"), + ("md.h5", "underline"), + ("md.h6", "italic"), + ("md.code.inline", "bg:#333"), + ("md.strong", "bold"), + ("md.em", "italic"), + ("md.hr", "fg:ansired"), + ("md.ul.margin", "fg:ansiyellow"), + ("md.ol.margin", "fg:ansicyan"), + ("md.blockquote", "fg:ansipurple"), + ("md.blockquote.margin", "fg:grey"), + ("md.th", "bold"), + ("md.a", "underline fg:ansibrightblue"), + ("md.s", "strike"), + ("md.img", "bg:cyan fg:black"), + ("md.img.border", "fg:cyan bg:default"), +] + + @memoized() def default_ui_style() -> BaseStyle: """ @@ -219,6 +244,7 @@ def default_ui_style() -> BaseStyle: Style(PROMPT_TOOLKIT_STYLE), Style(COLORS_STYLE), Style(WIDGETS_STYLE), + Style(MARKDOWN_STYLE), ] ) diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py index 885d23a88..cb18d8e08 100644 --- a/src/prompt_toolkit/widgets/base.py +++ b/src/prompt_toolkit/widgets/base.py @@ -17,6 +17,7 @@ from prompt_toolkit.application.current import get_app from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest +from prompt_toolkit.border import SquareBorder as Border from prompt_toolkit.buffer import Buffer, BufferAcceptHandler from prompt_toolkit.completion import Completer, DynamicCompleter from prompt_toolkit.document import Document @@ -96,16 +97,6 @@ E = KeyPressEvent -class Border: - "Box drawing characters. (Thin)" - HORIZONTAL = "\u2500" - VERTICAL = "\u2502" - TOP_LEFT = "\u250c" - TOP_RIGHT = "\u2510" - BOTTOM_LEFT = "\u2514" - BOTTOM_RIGHT = "\u2518" - - class TextArea: """ A simple input field. From ebd58c6d6be3367ab89b723ffeeb2c5707b13293 Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Fri, 25 Mar 2022 23:57:11 +0000 Subject: [PATCH 2/7] Ensure tests pass --- src/prompt_toolkit/border.py | 22 ++- src/prompt_toolkit/formatted_text/markdown.py | 185 ++++++++++++------ src/prompt_toolkit/formatted_text/utils.py | 95 ++++----- src/prompt_toolkit/widgets/menus.py | 3 +- 4 files changed, 193 insertions(+), 112 deletions(-) diff --git a/src/prompt_toolkit/border.py b/src/prompt_toolkit/border.py index 6dac8a162..793d02674 100644 --- a/src/prompt_toolkit/border.py +++ b/src/prompt_toolkit/border.py @@ -1,7 +1,25 @@ """Defines borders.""" +from abc import ABCMeta -class SquareBorder: + +class Border(metaclass=ABCMeta): + """Base border type.""" + + TOP_LEFT: str + TOP_SPLIT: str + TOP_RIGHT: str + HORIZONTAL: str + VERTICAL: str + LEFT_SPLIT: str + RIGHT_SPLIT: str + CROSS: str + BOTTOM_LEFT: str + BOTTOM_SPLIT: str + BOTTOM_RIGHT: str + + +class SquareBorder(Border): """Square thin border.""" TOP_LEFT = "┌" @@ -17,7 +35,7 @@ class SquareBorder: BOTTOM_RIGHT = "┘" -class DoubleBorder: +class DoubleBorder(Border): """Box drawing characters with double lines.""" TOP_LEFT = "╔" diff --git a/src/prompt_toolkit/formatted_text/markdown.py b/src/prompt_toolkit/formatted_text/markdown.py index 139a0456b..7bb5b4083 100644 --- a/src/prompt_toolkit/formatted_text/markdown.py +++ b/src/prompt_toolkit/formatted_text/markdown.py @@ -1,19 +1,28 @@ """Contains a markdown to formatted text parser.""" from itertools import zip_longest -from typing import TYPE_CHECKING, Any, Dict, Callable, List, Optional, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Type, + Union, +) from warnings import warn from prompt_toolkit.application.current import get_app_session +from prompt_toolkit.border import Border, DoubleBorder, SquareBorder +from prompt_toolkit.formatted_text.base import to_formatted_text from prompt_toolkit.formatted_text.utils import ( fragment_list_width, split_lines, - to_formatted_text, to_plain_text, ) -from prompt_toolkit.border import DoubleBorder, SquareBorder - from .utils import ( FormattedTextAlign, add_border, @@ -28,13 +37,14 @@ if TYPE_CHECKING: - from markdown_it.token import Token # type: ignore + from markdown_it.token import Token + from prompt_toolkit.formatted_text.base import StyleAndTextTuples # Check for markdown-it-py markdown_parser: Optional["MarkdownIt"] = None try: - from markdown_it import MarkdownIt # type: ignore + from markdown_it import MarkdownIt except ModuleNotFoundError: warn("The markdown parser requires `markdown-it-py` to be installed") else: @@ -44,13 +54,13 @@ # Check for markdown-it-py plugins try: - import mdit_py_plugins # type: ignore # noqa F401 + import mdit_py_plugins except ModuleNotFoundError: pass else: - from mdit_py_plugins.amsmath import amsmath_plugin # type: ignore - from mdit_py_plugins.dollarmath import dollarmath_plugin # type: ignore - from mdit_py_plugins.texmath import texmath_plugin # type: ignore + from mdit_py_plugins.amsmath.indedx import amsmath_plugin + from mdit_py_plugins.dollarmath.index import dollarmath_plugin + from mdit_py_plugins.texmath.index import texmath_plugin if markdown_parser is not None: markdown_parser.use(texmath_plugin) @@ -65,7 +75,12 @@ } -def h1(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": +def h1( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Format a top-level heading wrapped and centered with a full width double border.""" ft = wrap(ft, width - 4) ft = align(FormattedTextAlign.CENTER, ft, width=width - 4) @@ -74,7 +89,12 @@ def h1(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTupl return ft -def h2(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": +def h2( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Format a 2nd-level headding wrapped and centered with a double border.""" ft = wrap(ft, width=width - 4) ft = align(FormattedTextAlign.CENTER, ft) @@ -84,7 +104,12 @@ def h2(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTupl return ft -def h(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": +def h( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Format headings wrapped and centeredr.""" ft = wrap(ft, width) ft = align(FormattedTextAlign.CENTER, ft, width=width) @@ -93,38 +118,54 @@ def h(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuple def p( - ft: "StyleAndTextTuples", width: int, hidden: bool, **kwargs: Any + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", ) -> "StyleAndTextTuples": """Format paragraphs wrapped.""" ft = wrap(ft, width) - ft.append(("", "\n" if hidden else "\n\n")) + ft.append(("", "\n" if token.hidden else "\n\n")) return ft -def ul(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": +def ul( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Format unordered lists.""" ft.append(("", "\n")) return ft -def ol(ft: "StyleAndTextTuples", width: "int", **kwargs: "Any") -> "StyleAndTextTuples": +def ol( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Formats ordered lists.""" ft.append(("", "\n")) return ft def li( - ft: "StyleAndTextTuples", width: int, attrs: Dict[str, Any], **kwargs: Any + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", ) -> "StyleAndTextTuples": """Formats list items.""" ft = strip(ft) # Determine if this is an ordered or unordered list - if attrs.get("data-list-type") == "ol": + if token.attrs.get("data-list-type") == "ol": margin_style = "class:md.ol.margin" else: margin_style = "class:md.ul.margin" # Get the margin (potentially contains aligned item numbers) - margin = attrs.get("data-margin", "•") + margin = str(token.attrs.get("data-margin", "•")) # We put a speace each side of the margin ft = indent(ft, margin=" " * (len(margin) + 2), style=margin_style) ft[0] = (ft[0][0], f" {margin} ") @@ -132,7 +173,12 @@ def li( return ft -def hr(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTuples": +def hr( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Format horizontal rules.""" ft = [ ("class:md.hr", "─" * width), @@ -141,7 +187,12 @@ def hr(ft: "StyleAndTextTuples", width: int, **kwargs: Any) -> "StyleAndTextTupl return ft -def br(ft: "StyleAndTextTuples", **kwargs: Any) -> "StyleAndTextTuples": +def br( + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", +) -> "StyleAndTextTuples": """Format line breaks.""" return [("", "\n")] @@ -149,9 +200,8 @@ def br(ft: "StyleAndTextTuples", **kwargs: Any) -> "StyleAndTextTuples": def blockquote( ft: "StyleAndTextTuples", width: int, - info: str = "", - block: bool = False, - **kwargs: Any, + left: int, + token: "Token", ) -> "StyleAndTextTuples": """Format blockquotes with a solid left margin.""" ft = strip(ft) @@ -163,14 +213,13 @@ def blockquote( def code( ft: "StyleAndTextTuples", width: int, - info: str = "", - block: bool = False, - **kwargs: Any, + left: int, + token: "Token", ) -> "StyleAndTextTuples": """Format inline code, and lexes and formats code blocks with a border.""" - if block: + if token.block: ft = strip(ft, left=False, right=True, char="\n") - ft = lex(ft, lexer_name=info) + ft = lex(ft, lexer_name=token.info) ft = align(FormattedTextAlign.LEFT, ft, width - 4) ft = add_border(ft, width, style="class:md.code.border", border=SquareBorder) ft.append(("", "\n\n")) @@ -180,21 +229,34 @@ def code( def math( - ft: "StyleAndTextTuples", width: int, block: bool, **kwargs: Any + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", ) -> "StyleAndTextTuples": """Format inline maths, and quotes math blocks.""" - if block: - return blockquote(ft, width - 2, **kwargs) + if token.block: + return blockquote(ft, width - 2, left, token) else: return ft + """ width=width, + info=token.info, + block=token.block, + attrs=token.attrs, + hidden=token.hidden, + left=left,""" + def a( - ft: "StyleAndTextTuples", attrs: Dict[str, str], **kwargs: Any + ft: "StyleAndTextTuples", + width: int, + left: int, + token: "Token", ) -> "StyleAndTextTuples": """Format hyperlinks and adds link escape sequences.""" result: "StyleAndTextTuples" = [] - href = attrs.get("href") + href = token.attrs.get("href") if href: result.append(("[ZeroWidthEscape]", f"\x1b]8;;{href}\x1b\\")) result += ft @@ -206,19 +268,17 @@ def a( def img( ft: "StyleAndTextTuples", width: int, - attrs: Dict[str, str], - block: bool, left: int, - border: bool = False, - bounds: Tuple[str, str] = ("", ""), # Semi-circle blocks - **kwargs: Any, + token: "Token", ) -> "StyleAndTextTuples": """Format image titles.""" + bounds = ("", "") if not to_plain_text(ft): # Add fallback text if there is no image title - title = attrs.get("alt") + title = str(token.attrs.get("alt")) # Try getting the filename - if not title and not (src := attrs.get("src", "")).startswith("data:"): + src = str(token.attrs.get("src", "")) + if not title and not src.startswith("data:"): title = src.rsplit("/", 1)[-1] if not title: title = "Image" @@ -237,7 +297,13 @@ def img( # Maps HTML tag names to formatting functions. Functionality can be extended by # modifying this dictionary -TAG_RULES: "dict[str, Callable]" = { +TAG_RULES: Dict[ + str, + Callable[ + [StyleAndTextTuples, int, int, "Token"], + StyleAndTextTuples, + ], +] = { "h1": h1, "h2": h2, "h3": h, @@ -330,7 +396,7 @@ def render( token = tokens[i] # If this is an inline block, render it's children - if token.type == "inline": + if token.type == "inline" and token.children: ft += self.render(token.children, width) i += 1 @@ -414,12 +480,9 @@ def render_block( if rule := TAG_RULES.get(token.tag): ft = rule( ft, - width=width, - info=token.info, - block=token.block, - attrs=token.attrs, - hidden=token.hidden, - left=left, + width, + left, + token, ) return ft @@ -472,7 +535,7 @@ def render_table( """ ft: "StyleAndTextTuples" = [] # Stack the tokens in the shape of the table - cell_tokens: List[list["Token"]] = [] + cell_tokens: List[List[List["Token"]]] = [] i = 0 while i < len(tokens): token = tokens[i] @@ -487,12 +550,12 @@ def render_table( i += 1 def _render_token( - tokens: "list[Token]", width: "Optional[int]" = None - ) -> "StyleAndTextTuples": + tokens: List[Token], width: Optional[int] = None + ) -> StyleAndTextTuples: """Render a token with correct alignment.""" side = "left" # Check CSS for text alignment - for style_str in tokens[0].attrs.get("style", "").split(";"): + for style_str in str(tokens[0].attrs.get("style", "")).split(";"): if ":" in style_str: key, value = style_str.strip().split(":", 1) if key.strip() == "text-align": @@ -506,13 +569,13 @@ def _render_token( return ft # Find the naive widths of each cell - cell_renders: List[List["StyleAndTextTuples"]] = [] + cell_renders: List[List[StyleAndTextTuples]] = [] cell_widths: List[List[int]] = [] for row in cell_tokens: cell_widths.append([]) cell_renders.append([]) - for token in row: - rendered = _render_token(token) + for each_tokens in row: + rendered = _render_token(each_tokens) cell_renders[-1].append(rendered) cell_widths[-1].append(fragment_list_width(rendered)) @@ -535,8 +598,8 @@ def _render_token( ) # Justify cell contents - for i, row in enumerate(cell_renders): - for j, cell in enumerate(row): + for i, renders_row in enumerate(cell_renders): + for j, cell in enumerate(renders_row): cell_renders[i][j] = align( FormattedTextAlign.LEFT, cell, width=col_widths[j] ) @@ -555,8 +618,8 @@ def _draw_add_border(left: str, split: str, right: str) -> None: # Draw top border _draw_add_border(border.TOP_LEFT, border.TOP_SPLIT, border.TOP_RIGHT) # Draw each row - for i, row in enumerate(cell_renders): - for row_lines in zip_longest(*map(split_lines, row)): + for i, renders_row in enumerate(cell_renders): + for row_lines in zip_longest(*map(split_lines, renders_row)): # Draw each line in each row ft.append((style, border.VERTICAL + " ")) for j, line in enumerate(row_lines): diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py index 8e174c496..8362becd0 100644 --- a/src/prompt_toolkit/formatted_text/utils.py +++ b/src/prompt_toolkit/formatted_text/utils.py @@ -5,11 +5,12 @@ tuples. This file contains functions for manipulating such a list. """ from enum import Enum -from typing import Iterable, Optional, cast +from typing import Iterable, Optional, Type, cast -from pygments.lexers import get_lexer_by_name # type: ignore -from pygments.util import ClassNotFound # type: ignore +from pygments.lexers import get_lexer_by_name +from pygments.util import ClassNotFound +from prompt_toolkit.border import Border, SquareBorder from prompt_toolkit.utils import get_cwidth from .base import ( @@ -110,9 +111,9 @@ def split_lines(fragments: StyleAndTextTuples) -> Iterable[StyleAndTextTuples]: yield line -def last_line_length(ft: "StyleAndTextTuples") -> "int": +def last_line_length(ft: StyleAndTextTuples) -> int: """Calculate the length of the last line in formatted text.""" - line: "StyleAndTextTuples" = [] + line: StyleAndTextTuples = [] for style, text, *_ in ft[::-1]: index = text.find("\n") line.append((style, text[index + 1 :])) @@ -121,13 +122,13 @@ def last_line_length(ft: "StyleAndTextTuples") -> "int": return fragment_list_width(line) -def max_line_width(ft: "StyleAndTextTuples") -> "int": +def max_line_width(ft: StyleAndTextTuples) -> int: """Calculate the length of the longest line in formatted text.""" return max(fragment_list_width(line) for line in split_lines(ft)) def fragment_list_to_words( - fragments: "StyleAndTextTuples", + fragments: StyleAndTextTuples, ) -> "Iterable[OneStyleAndTextTuple]": """Split formatted text into word fragments.""" for style, string, *mouse_handler in fragments: @@ -138,7 +139,7 @@ def fragment_list_to_words( yield cast("OneStyleAndTextTuple", (style, parts[-1], *mouse_handler)) -def apply_style(ft: "StyleAndTextTuples", style: "str") -> "StyleAndTextTuples": +def apply_style(ft: StyleAndTextTuples, style: str) -> StyleAndTextTuples: """Apply a style to formatted text.""" return [ ( @@ -152,11 +153,11 @@ def apply_style(ft: "StyleAndTextTuples", style: "str") -> "StyleAndTextTuples": def strip( - ft: "StyleAndTextTuples", - left: "bool" = True, - right: "bool" = True, - char: "Optional[str]" = None, -) -> "StyleAndTextTuples": + ft: StyleAndTextTuples, + left: bool = True, + right: bool = True, + char: Optional[str] = None, +) -> StyleAndTextTuples: """Strip whitespace (or a given character) from the ends of formatted text. Args: @@ -180,11 +181,11 @@ def strip( def truncate( - ft: "StyleAndTextTuples", - width: "int", - style: "str" = "", - placeholder: "str" = "…", -) -> "StyleAndTextTuples": + ft: StyleAndTextTuples, + width: int, + style: str = "", + placeholder: str = "…", +) -> StyleAndTextTuples: """Truncates all lines at a given length. Args: @@ -198,7 +199,7 @@ def truncate( The truncated formatted text """ - result: "StyleAndTextTuples" = [] + result: StyleAndTextTuples = [] phw = sum(get_cwidth(c) for c in placeholder) for line in split_lines(ft): used_width = 0 @@ -220,11 +221,11 @@ def truncate( def wrap( - ft: "StyleAndTextTuples", - width: "int", - style: "str" = "", - placeholder: "str" = "…", -) -> "StyleAndTextTuples": + ft: StyleAndTextTuples, + width: int, + style: str = "", + placeholder: str = "…", +) -> StyleAndTextTuples: """Wraps formatted text at a given width. If words are longer than the given line they will be truncated @@ -238,7 +239,7 @@ def wrap( Returns: The wrapped formatted text """ - result: "StyleAndTextTuples" = [] + result: StyleAndTextTuples = [] lines = list(split_lines(ft)) for i, line in enumerate(lines): if fragment_list_width(line) <= width: @@ -273,12 +274,12 @@ def wrap( def align( - how: "FormattedTextAlign", - ft: "StyleAndTextTuples", - width: "Optional[int]" = None, - style: "str" = "", - placeholder: "str" = "…", -) -> "StyleAndTextTuples": + how: FormattedTextAlign, + ft: StyleAndTextTuples, + width: Optional[int] = None, + style: str = "", + placeholder: str = "…", +) -> StyleAndTextTuples: """Align formatted text at a given width. Args: @@ -297,7 +298,7 @@ def align( if width is None: lines = [strip(line) for line in split_lines(ft)] width = max(fragment_list_width(line) for line in lines) - result: "StyleAndTextTuples" = [] + result: StyleAndTextTuples = [] for line in lines: line_width = fragment_list_width(line) # Truncate the line if it is too long @@ -323,11 +324,11 @@ def align( def indent( - ft: "StyleAndTextTuples", - margin: "str" = " ", - style: "str" = "", - skip_first: "bool" = False, -) -> "StyleAndTextTuples": + ft: StyleAndTextTuples, + margin: str = " ", + style: str = "", + skip_first: bool = False, +) -> StyleAndTextTuples: """Indents formatted text with a given margin. Args: @@ -340,7 +341,7 @@ def indent( The indented formatted text """ - result: "StyleAndTextTuples" = [] + result: StyleAndTextTuples = [] for i, line in enumerate(split_lines(ft)): if not (i == 0 and skip_first): result.append((style, margin)) @@ -351,11 +352,11 @@ def indent( def add_border( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: "Optional[int]" = None, - style: "str" = "", - border: "Optional[Type[B]]" = None, -) -> "StyleAndTextTuples": + style: str = "", + border: "Type[Border]" = SquareBorder, +) -> StyleAndTextTuples: """Adds a border around formatted text. Args: @@ -368,14 +369,14 @@ def add_border( The indented formatted text """ - if border is None: - # See mypy issue #4236 - border = cast("Type[B]", Border) + # if border is None: + # See mypy issue #4236 + # border = cast("Type[Border]", Border) if width is None: width = max_line_width(ft) + 4 # ft = align(FormattedTextAlign.LEFT, ft, width - 4) - result: "StyleAndTextTuples" = [] + result: StyleAndTextTuples = [] result.append( ( @@ -400,7 +401,7 @@ def add_border( return result -def lex(ft: "StyleAndTextTuples", lexer_name: "str") -> "StyleAndTextTuples": +def lex(ft: StyleAndTextTuples, lexer_name: str) -> StyleAndTextTuples: """Format formatted text using a named :py:mod:`pygments` lexer.""" from prompt_toolkit.lexers.pygments import _token_cache diff --git a/src/prompt_toolkit/widgets/menus.py b/src/prompt_toolkit/widgets/menus.py index 6827ebecc..8290b1f87 100644 --- a/src/prompt_toolkit/widgets/menus.py +++ b/src/prompt_toolkit/widgets/menus.py @@ -19,8 +19,7 @@ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType from prompt_toolkit.utils import get_cwidth from prompt_toolkit.widgets import Shadow - -from .base import Border +from prompt_toolkit.border import SquareBorder as Border __all__ = [ "MenuContainer", From 7de5e4870eb1bc2e0051acd9d0c8bc023ec87dfd Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Sat, 26 Mar 2022 00:04:43 +0000 Subject: [PATCH 3/7] Remove all walruses --- src/prompt_toolkit/formatted_text/markdown.py | 6 ++++-- src/prompt_toolkit/formatted_text/utils.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/prompt_toolkit/formatted_text/markdown.py b/src/prompt_toolkit/formatted_text/markdown.py index 7bb5b4083..c2b15ffec 100644 --- a/src/prompt_toolkit/formatted_text/markdown.py +++ b/src/prompt_toolkit/formatted_text/markdown.py @@ -462,7 +462,8 @@ def render_block( token = tokens[0] # Restrict width if necessary - if inset := TAG_INSETS.get(token.tag): + inset = TAG_INSETS.get(token.tag) + if inset: width -= inset style = "class:md" @@ -477,7 +478,8 @@ def render_block( ft.append((style, token.content)) # Apply tag rule - if rule := TAG_RULES.get(token.tag): + rule = TAG_RULES.get(token.tag) + if rule: ft = rule( ft, width, diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py index 8362becd0..01a6d5d9c 100644 --- a/src/prompt_toolkit/formatted_text/utils.py +++ b/src/prompt_toolkit/formatted_text/utils.py @@ -173,8 +173,10 @@ def strip( result = ft[:] for toggle, index, strip_func in [(left, 0, str.lstrip), (right, -1, str.rstrip)]: if toggle: - while result and not (text := strip_func(result[index][1], char)): + text = strip_func(result[index][1], char) + while result and not text: del result[index] + text = strip_func(result[index][1], char) if result and "[ZeroWidthEscape]" not in result[index][0]: result[index] = (result[index][0], text) return result From 439d9beeb01afc013bc63fa972b94b88bd875626 Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Sat, 26 Mar 2022 00:13:04 +0000 Subject: [PATCH 4/7] Fix imports --- src/prompt_toolkit/formatted_text/__init__.py | 1 - src/prompt_toolkit/formatted_text/markdown.py | 2 +- src/prompt_toolkit/widgets/menus.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/prompt_toolkit/formatted_text/__init__.py b/src/prompt_toolkit/formatted_text/__init__.py index e5df2ea7e..d270e21b4 100644 --- a/src/prompt_toolkit/formatted_text/__init__.py +++ b/src/prompt_toolkit/formatted_text/__init__.py @@ -21,7 +21,6 @@ to_formatted_text, ) from .html import HTML - from .markdown import Markdown from .pygments import PygmentsTokens from .utils import ( diff --git a/src/prompt_toolkit/formatted_text/markdown.py b/src/prompt_toolkit/formatted_text/markdown.py index c2b15ffec..ac43f26c2 100644 --- a/src/prompt_toolkit/formatted_text/markdown.py +++ b/src/prompt_toolkit/formatted_text/markdown.py @@ -23,6 +23,7 @@ to_plain_text, ) +from .base import StyleAndTextTuples from .utils import ( FormattedTextAlign, add_border, @@ -39,7 +40,6 @@ from markdown_it.token import Token - from prompt_toolkit.formatted_text.base import StyleAndTextTuples # Check for markdown-it-py markdown_parser: Optional["MarkdownIt"] = None diff --git a/src/prompt_toolkit/widgets/menus.py b/src/prompt_toolkit/widgets/menus.py index 8290b1f87..82d380c1f 100644 --- a/src/prompt_toolkit/widgets/menus.py +++ b/src/prompt_toolkit/widgets/menus.py @@ -1,6 +1,7 @@ from typing import Callable, Iterable, List, Optional, Sequence, Union from prompt_toolkit.application.current import get_app +from prompt_toolkit.border import SquareBorder as Border from prompt_toolkit.filters import Condition from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple, StyleAndTextTuples from prompt_toolkit.key_binding.key_bindings import KeyBindings, KeyBindingsBase @@ -19,7 +20,6 @@ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType from prompt_toolkit.utils import get_cwidth from prompt_toolkit.widgets import Shadow -from prompt_toolkit.border import SquareBorder as Border __all__ = [ "MenuContainer", From cb890b4a5f986c60c3e7763f72fd31cae429dcfd Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Sat, 26 Mar 2022 01:01:56 +0000 Subject: [PATCH 5/7] Use consistent border attribute naming --- src/prompt_toolkit/border.py | 39 +++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/prompt_toolkit/border.py b/src/prompt_toolkit/border.py index 793d02674..f10a227d0 100644 --- a/src/prompt_toolkit/border.py +++ b/src/prompt_toolkit/border.py @@ -35,17 +35,42 @@ class SquareBorder(Border): BOTTOM_RIGHT = "┘" +class RoundBorder(SquareBorder): + """Thin border with round corners.""" + + TOP_LEFT = "╭" + TOP_RIGHT = "╮" + BOTTOM_LEFT = "╰" + BOTTOM_RIGHT = "╯" + + class DoubleBorder(Border): - """Box drawing characters with double lines.""" + """Square border with double lines.""" TOP_LEFT = "╔" + TOP_SPLIT = "╦" TOP_RIGHT = "╗" - VERTICAL = "║" - INNER_VERTICAL = "║" HORIZONTAL = "═" - INNER_HORIZONTAL = "═" + VERTICAL = "║" + LEFT_SPLIT = "╠" + RIGHT_SPLIT = "╣" + CROSS = "╬" BOTTOM_LEFT = "╚" + BOTTOM_SPLIT = "╩" BOTTOM_RIGHT = "╝" - SPLIT_LEFT = "╠" - SPLIT_RIGHT = "╣" - CROSS = "╬" + + +class ThickBorder(Border): + """Square border with thick lines.""" + + TOP_LEFT = "┏" + TOP_SPLIT = "┳" + TOP_RIGHT = "┓" + HORIZONTAL = "━" + VERTICAL = "┃" + LEFT_SPLIT = "┣" + RIGHT_SPLIT = "┫" + CROSS = "╋" + BOTTOM_LEFT = "┗" + BOTTOM_SPLIT = "┻" + BOTTOM_RIGHT = "┛" From 7405abef624b28906004910e99b1b5e3090d0583 Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Sun, 27 Mar 2022 23:45:32 +0100 Subject: [PATCH 6/7] Fix typing & linting --- src/prompt_toolkit/formatted_text/markdown.py | 87 +++++++++---------- src/prompt_toolkit/formatted_text/utils.py | 16 +++- 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/src/prompt_toolkit/formatted_text/markdown.py b/src/prompt_toolkit/formatted_text/markdown.py index ac43f26c2..37fc066f4 100644 --- a/src/prompt_toolkit/formatted_text/markdown.py +++ b/src/prompt_toolkit/formatted_text/markdown.py @@ -16,23 +16,20 @@ from prompt_toolkit.application.current import get_app_session from prompt_toolkit.border import Border, DoubleBorder, SquareBorder -from prompt_toolkit.formatted_text.base import to_formatted_text -from prompt_toolkit.formatted_text.utils import ( - fragment_list_width, - split_lines, - to_plain_text, -) -from .base import StyleAndTextTuples +from .base import StyleAndTextTuples, to_formatted_text from .utils import ( FormattedTextAlign, add_border, align, apply_style, + fragment_list_width, indent, last_line_length, lex, + split_lines, strip, + to_plain_text, wrap, ) @@ -54,11 +51,11 @@ # Check for markdown-it-py plugins try: - import mdit_py_plugins + import mdit_py_plugins # noqa F401 except ModuleNotFoundError: pass else: - from mdit_py_plugins.amsmath.indedx import amsmath_plugin + from mdit_py_plugins.amsmath import amsmath_plugin from mdit_py_plugins.dollarmath.index import dollarmath_plugin from mdit_py_plugins.texmath.index import texmath_plugin @@ -76,11 +73,11 @@ def h1( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format a top-level heading wrapped and centered with a full width double border.""" ft = wrap(ft, width - 4) ft = align(FormattedTextAlign.CENTER, ft, width=width - 4) @@ -90,11 +87,11 @@ def h1( def h2( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format a 2nd-level headding wrapped and centered with a double border.""" ft = wrap(ft, width=width - 4) ft = align(FormattedTextAlign.CENTER, ft) @@ -105,11 +102,11 @@ def h2( def h( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format headings wrapped and centeredr.""" ft = wrap(ft, width) ft = align(FormattedTextAlign.CENTER, ft, width=width) @@ -118,11 +115,11 @@ def h( def p( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format paragraphs wrapped.""" ft = wrap(ft, width) ft.append(("", "\n" if token.hidden else "\n\n")) @@ -130,33 +127,33 @@ def p( def ul( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format unordered lists.""" ft.append(("", "\n")) return ft def ol( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Formats ordered lists.""" ft.append(("", "\n")) return ft def li( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Formats list items.""" ft = strip(ft) # Determine if this is an ordered or unordered list @@ -174,11 +171,11 @@ def li( def hr( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format horizontal rules.""" ft = [ ("class:md.hr", "─" * width), @@ -188,21 +185,21 @@ def hr( def br( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format line breaks.""" return [("", "\n")] def blockquote( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format blockquotes with a solid left margin.""" ft = strip(ft) ft = indent(ft, margin="▌ ", style="class:md.blockquote.margin") @@ -211,11 +208,11 @@ def blockquote( def code( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format inline code, and lexes and formats code blocks with a border.""" if token.block: ft = strip(ft, left=False, right=True, char="\n") @@ -229,11 +226,11 @@ def code( def math( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format inline maths, and quotes math blocks.""" if token.block: return blockquote(ft, width - 2, left, token) @@ -249,13 +246,13 @@ def math( def a( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format hyperlinks and adds link escape sequences.""" - result: "StyleAndTextTuples" = [] + result: StyleAndTextTuples = [] href = token.attrs.get("href") if href: result.append(("[ZeroWidthEscape]", f"\x1b]8;;{href}\x1b\\")) @@ -266,11 +263,11 @@ def a( def img( - ft: "StyleAndTextTuples", + ft: StyleAndTextTuples, width: int, left: int, token: "Token", -) -> "StyleAndTextTuples": +) -> StyleAndTextTuples: """Format image titles.""" bounds = ("", "") if not to_plain_text(ft): @@ -376,7 +373,7 @@ def __init__( def render( self, tokens: List["Token"], width: int = 80, left: int = 0 - ) -> "StyleAndTextTuples": + ) -> StyleAndTextTuples: """Render a list of parsed markdown tokens. Args: @@ -445,7 +442,7 @@ def render_block( tokens: List["Token"], width: int, left: int = 0, - ) -> "StyleAndTextTuples": + ) -> StyleAndTextTuples: """Render a list of parsed markdown tokens representing a block element. Args: @@ -494,7 +491,7 @@ def render_ordered_list( tokens: List["Token"], width: int, left: int = 0, - ) -> "StyleAndTextTuples": + ) -> StyleAndTextTuples: """Render an ordered list by adding indices to the child list items.""" # Find the list item tokens list_level_tokens = [] @@ -521,7 +518,7 @@ def render_table( width: int, left: int = 0, border: "Type[Border]" = SquareBorder, - ) -> "StyleAndTextTuples": + ) -> StyleAndTextTuples: """Render a list of parsed markdown tokens representing a table element. Args: @@ -535,7 +532,7 @@ def render_table( Formatted text """ - ft: "StyleAndTextTuples" = [] + ft: StyleAndTextTuples = [] # Stack the tokens in the shape of the table cell_tokens: List[List[List["Token"]]] = [] i = 0 @@ -552,7 +549,7 @@ def render_table( i += 1 def _render_token( - tokens: List[Token], width: Optional[int] = None + tokens: List["Token"], width: Optional[int] = None ) -> StyleAndTextTuples: """Render a token with correct alignment.""" side = "left" @@ -640,7 +637,7 @@ def _draw_add_border(left: str, split: str, right: str) -> None: ft.append(("", "\n")) return ft - def __pt_formatted_text__(self) -> "StyleAndTextTuples": + def __pt_formatted_text__(self) -> StyleAndTextTuples: """Formatted text magic method.""" return self.formatted_text diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py index 01a6d5d9c..80fc2f006 100644 --- a/src/prompt_toolkit/formatted_text/utils.py +++ b/src/prompt_toolkit/formatted_text/utils.py @@ -21,11 +21,23 @@ ) __all__ = [ + "FormattedTextAlign", "to_plain_text", "fragment_list_len", "fragment_list_width", "fragment_list_to_text", "split_lines", + "last_line_length", + "max_line_width", + "fragment_list_to_words", + "apply_style", + "strip", + "truncate", + "wrap", + "align", + "indent", + "add_border", + "lex", ] @@ -172,10 +184,12 @@ def strip( """ result = ft[:] for toggle, index, strip_func in [(left, 0, str.lstrip), (right, -1, str.rstrip)]: - if toggle: + if result and toggle: text = strip_func(result[index][1], char) while result and not text: del result[index] + if not result: + break text = strip_func(result[index][1], char) if result and "[ZeroWidthEscape]" not in result[index][0]: result[index] = (result[index][0], text) From d4d5baefff023ef87bbab13e450e2d8bcd43f697 Mon Sep 17 00:00:00 2001 From: Josiah Outram Halstead Date: Sun, 27 Mar 2022 23:45:48 +0100 Subject: [PATCH 7/7] Add blank border --- src/prompt_toolkit/border.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/prompt_toolkit/border.py b/src/prompt_toolkit/border.py index f10a227d0..677bb9c04 100644 --- a/src/prompt_toolkit/border.py +++ b/src/prompt_toolkit/border.py @@ -19,6 +19,23 @@ class Border(metaclass=ABCMeta): BOTTOM_RIGHT: str +class NoBorder(Border): + """Invisible border.""" + + TOP_LEFT = " " + TOP_SPLIT = " " + TOP_RIGHT = " " + HORIZONTAL = " " + INNER_VERTICAL = " " + VERTICAL = " " + LEFT_SPLIT = " " + RIGHT_SPLIT = " " + CROSS = " " + BOTTOM_LEFT = " " + BOTTOM_SPLIT = " " + BOTTOM_RIGHT = " " + + class SquareBorder(Border): """Square thin border."""