diff --git a/bebop/bookmarks.py b/bebop/bookmarks.py index 02cb59d..8131350 100644 --- a/bebop/bookmarks.py +++ b/bebop/bookmarks.py @@ -8,7 +8,7 @@ TEMPLATE = """\ Welcome to your bookmark page! This file has been created in "{original_path}" \ and you can edit it as you wish. New bookmarks will be added on a new \ -line at the end. Always keep an empty line at the end! +line at the end, so always keep an empty line there! """ diff --git a/bebop/metalines.py b/bebop/metalines.py new file mode 100644 index 0000000..56e4532 --- /dev/null +++ b/bebop/metalines.py @@ -0,0 +1,217 @@ +"""Metalines generation. + +In Bebop we use a list of elements as produced by our parser. These elements are +converted into so-called "metalines", which are the text lines as they will be +displayed, along with associated meta-data such as its type or a link's URL. +""" + +import string +from enum import IntEnum +from typing import List + +from bebop.gemtext import ( + Blockquote, Link, ListItem, Paragraph, Preformatted, Title) + + +SPLIT_CHARS = " \t-" +JOIN_CHAR = "-" +LIST_ITEM_MARK = "• " + + +class LineType(IntEnum): + """Type of line. + + Keep lines type along with the content for later rendering. + Title type values match the title level to avoid looking it up. + """ + NONE = 0 + TITLE_1 = 1 + TITLE_2 = 2 + TITLE_3 = 3 + PARAGRAPH = 4 + LINK = 5 + PREFORMATTED = 6 + BLOCKQUOTE = 7 + LIST_ITEM = 8 + + +def generate_metalines(elements, width): + """Format elements into a list of lines with metadata. + + The returned list ("metalines") are tuples (meta, line), meta being a + dict of metadata and line a text line to display. Currently the only + metadata keys used are: + - type: one of the Renderer.TYPE constants. + - url: only for links, the URL the link on this line refers to. Note + that this key is present only for the first line of the link, i.e. + long link descriptions wrapped on multiple lines will not have a this + key except for the first line. + - link_id: only alongside "url" key, ID generated for this link. + """ + metalines = [] + context = {"width": width} + separator = ({"type": LineType.NONE}, "") + has_margins = False + thin_type = None + for index, element in enumerate(elements): + previous_had_margins = has_margins + last_thin_type = thin_type + has_margins = False + thin_type = None + if isinstance(element, Title): + element_metalines = format_title(element, context) + has_margins = True + elif isinstance(element, Paragraph): + element_metalines = format_paragraph(element, context) + has_margins = True + elif isinstance(element, Link): + element_metalines = format_link(element, context) + thin_type = LineType.LINK + elif isinstance(element, Preformatted): + element_metalines = format_preformatted(element, context) + has_margins = True + elif isinstance(element, Blockquote): + element_metalines = format_blockquote(element, context) + has_margins = True + elif isinstance(element, ListItem): + element_metalines = format_list_item(element, context) + thin_type = LineType.LIST_ITEM + else: + continue + # If current element requires margins and is not the first elements, + # separate from previous element. Also do it if the current element does + # not require margins but follows an element that required it (e.g. link + # after a paragraph). Also do it if both the current and previous + # elements do not require margins but differ in type. + if ( + (has_margins and index > 0) + or (not has_margins and previous_had_margins) + or (not has_margins and thin_type != last_thin_type) + ): + metalines.append(separator) + # Append the element metalines now. + metalines += element_metalines + return metalines + + +def format_title(title: Title, context: dict): + """Return metalines for this title.""" + width = context["width"] + if title.level == 1: + wrapped = wrap_words(title.text, width) + line_template = f"{{:^{width}}}" + lines = (line_template.format(line) for line in wrapped) + else: + if title.level == 2: + lines = wrap_words(title.text, width, indent=2) + else: + lines = wrap_words(title.text, width) + # Title levels match the type constants of titles. + return [({"type": LineType(title.level)}, line) for line in lines] + + +def format_paragraph(paragraph: Paragraph, context: dict): + """Return metalines for this paragraph.""" + lines = wrap_words(paragraph.text, context["width"]) + return [({"type": LineType.PARAGRAPH}, line) for line in lines] + + +def format_link(link: Link, context: dict): + """Return metalines for this link.""" + # Get a new link and build the "[id]" anchor. + link_anchor = f"[{link.ident}] " + link_text = link.text or link.url + # Wrap lines, indented by the link anchor length. + lines = wrap_words(link_text, context["width"], indent=len(link_anchor)) + first_line_meta = { + "type": LineType.LINK, + "url": link.url, + "link_id": link.ident + } + # Replace first line indentation with the anchor. + first_line_text = link_anchor + lines[0][len(link_anchor):] + first_line = [(first_line_meta, first_line_text)] + other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]] + return first_line + other_lines + + +def format_preformatted(preformatted: Preformatted, context: dict): + """Return metalines for this preformatted block.""" + return [ + ({"type": LineType.PREFORMATTED}, line) + for line in preformatted.lines + ] + + +def format_blockquote(blockquote: Blockquote, context: dict): + """Return metalines for this blockquote.""" + lines = wrap_words(blockquote.text, context["width"]) + return [({"type": LineType.BLOCKQUOTE}, line) for line in lines] + + +def format_list_item(item: ListItem, context: dict): + """Return metalines for this list item.""" + indent = len(LIST_ITEM_MARK) + lines = wrap_words(item.text, context["width"], indent=indent) + first_line = LIST_ITEM_MARK + lines[0][indent:] + lines[0] = first_line + return [({"type": LineType.LIST_ITEM}, line) for line in lines] + + +def wrap_words(text: str, width: int, indent: int =0) -> List[str]: + """Wrap a text in several lines according to the renderer's width.""" + lines = [] + line = " " * indent + words = _explode_words(text) + for word in words: + line_len, word_len = len(line), len(word) + # If adding the new word would overflow the line, use a new line. + if line_len + word_len > width: + # Push only non-empty lines. + if line_len > 0: + lines.append(line) + line = " " * indent + # Force split words that are longer than the width. + while word_len > width: + split_offset = width - 1 - indent + word_line = " " * indent + word[:split_offset] + JOIN_CHAR + lines.append(word_line) + word = word[split_offset:] + word_len = len(word) + word = word.lstrip() + line += word + if line: + lines.append(line) + return lines + + +def _explode_words(text: str) -> List[str]: + """Split a string into a list of words.""" + words = [] + pos = 0 + while True: + sep, sep_index = _find_next_sep(text[pos:]) + if not sep: + words.append(text[pos:]) + return words + word = text[pos : pos + sep_index] + # If the separator is not a space char, append it to the word. + if sep in string.whitespace: + words.append(word) + words.append(sep) + else: + words.append(word + sep) + pos += sep_index + 1 + + +def _find_next_sep(text: str): + """Find the next separator index and return both the separator and index.""" + indices = [] + for sep in SPLIT_CHARS: + try: + indices.append((sep, text.index(sep))) + except ValueError: + pass + if not indices: + return ("", 0) + return min(indices, key=lambda e: e[1]) diff --git a/bebop/rendering.py b/bebop/rendering.py index 7dfffd7..eedd397 100644 --- a/bebop/rendering.py +++ b/bebop/rendering.py @@ -1,224 +1,9 @@ -"""Rendering Gemtext in curses. - -In Bebop we use a list of elements as produced by our parser. These elements are -rendered into so-called "metalines", which are the text lines as they will be -displayed, along with associated meta-data such as its type or a link's URL. -""" +"""Rendering Gemtext in curses.""" import curses -import string -from enum import IntEnum -from typing import List from bebop.colors import ColorPair -from bebop.gemtext import (Blockquote, Link, ListItem, Paragraph, Preformatted, - Title) - - -SPLIT_CHARS = " \t-" -JOIN_CHAR = "-" -LIST_ITEM_MARK = "• " - - -class LineType(IntEnum): - """Type of line. - - Keep lines type along with the content for later rendering. - Title type values match the title level to avoid looking it up. - """ - NONE = 0 - TITLE_1 = 1 - TITLE_2 = 2 - TITLE_3 = 3 - PARAGRAPH = 4 - LINK = 5 - PREFORMATTED = 6 - BLOCKQUOTE = 7 - LIST_ITEM = 8 - - -def generate_metalines(elements, width): - """Format elements into a list of lines with metadata. - - The returned list ("metalines") are tuples (meta, line), meta being a - dict of metadata and line a text line to display. Currently the only - metadata keys used are: - - type: one of the Renderer.TYPE constants. - - url: only for links, the URL the link on this line refers to. Note - that this key is present only for the first line of the link, i.e. - long link descriptions wrapped on multiple lines will not have a this - key except for the first line. - - link_id: only alongside "url" key, ID generated for this link. - """ - metalines = [] - context = {"last_link_id": 0, "width": width} - separator = ({"type": LineType.NONE}, "") - has_margins = False - thin_type = None - for index, element in enumerate(elements): - previous_had_margins = has_margins - last_thin_type = thin_type - has_margins = False - thin_type = None - if isinstance(element, Title): - element_metalines = format_title(element, context) - has_margins = True - elif isinstance(element, Paragraph): - element_metalines = format_paragraph(element, context) - has_margins = True - elif isinstance(element, Link): - element_metalines = format_link(element, context) - thin_type = LineType.LINK - elif isinstance(element, Preformatted): - element_metalines = format_preformatted(element, context) - has_margins = True - elif isinstance(element, Blockquote): - element_metalines = format_blockquote(element, context) - has_margins = True - elif isinstance(element, ListItem): - element_metalines = format_list_item(element, context) - thin_type = LineType.LIST_ITEM - else: - continue - # If current element requires margins and is not the first elements, - # separate from previous element. Also do it if the current element does - # not require margins but follows an element that required it (e.g. link - # after a paragraph). Also do it if both the current and previous - # elements do not require margins but differ in type. - if ( - (has_margins and index > 0) - or (not has_margins and previous_had_margins) - or (not has_margins and thin_type != last_thin_type) - ): - metalines.append(separator) - # Append the element metalines now. - metalines += element_metalines - return metalines - - -def format_title(title: Title, context: dict): - """Return metalines for this title.""" - width = context["width"] - if title.level == 1: - wrapped = wrap_words(title.text, width) - line_template = f"{{:^{width}}}" - lines = (line_template.format(line) for line in wrapped) - else: - if title.level == 2: - lines = wrap_words(title.text, width, indent=2) - else: - lines = wrap_words(title.text, width) - # Title levels match the type constants of titles. - return [({"type": LineType(title.level)}, line) for line in lines] - - -def format_paragraph(paragraph: Paragraph, context: dict): - """Return metalines for this paragraph.""" - lines = wrap_words(paragraph.text, context["width"]) - return [({"type": LineType.PARAGRAPH}, line) for line in lines] - - -def format_link(link: Link, context: dict): - """Return metalines for this link.""" - # Get a new link and build the "[id]" anchor. - link_id = context["last_link_id"] + 1 - context["last_link_id"] = link_id - link_text = link.text or link.url - link_anchor = f"[{link_id}] " - # Wrap lines, indented by the link anchor length. - lines = wrap_words(link_text, context["width"], indent=len(link_anchor)) - first_line_meta = { - "type": LineType.LINK, - "url": link.url, - "link_id": link_id - } - # Replace first line indentation with the anchor. - first_line_text = link_anchor + lines[0][len(link_anchor):] - first_line = [(first_line_meta, first_line_text)] - other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]] - return first_line + other_lines - - -def format_preformatted(preformatted: Preformatted, context: dict): - """Return metalines for this preformatted block.""" - return [ - ({"type": LineType.PREFORMATTED}, line) - for line in preformatted.lines - ] - - -def format_blockquote(blockquote: Blockquote, context: dict): - """Return metalines for this blockquote.""" - lines = wrap_words(blockquote.text, context["width"]) - return [({"type": LineType.BLOCKQUOTE}, line) for line in lines] - - -def format_list_item(item: ListItem, context: dict): - """Return metalines for this list item.""" - indent = len(LIST_ITEM_MARK) - lines = wrap_words(item.text, context["width"], indent=indent) - first_line = LIST_ITEM_MARK + lines[0][indent:] - lines[0] = first_line - return [({"type": LineType.LIST_ITEM}, line) for line in lines] - - -def wrap_words(text: str, width: int, indent: int =0) -> List[str]: - """Wrap a text in several lines according to the renderer's width.""" - lines = [] - line = " " * indent - words = _explode_words(text) - for word in words: - line_len, word_len = len(line), len(word) - # If adding the new word would overflow the line, use a new line. - if line_len + word_len > width: - # Push only non-empty lines. - if line_len > 0: - lines.append(line) - line = " " * indent - # Force split words that are longer than the width. - while word_len > width: - split_offset = width - 1 - indent - word_line = " " * indent + word[:split_offset] + JOIN_CHAR - lines.append(word_line) - word = word[split_offset:] - word_len = len(word) - word = word.lstrip() - line += word - if line: - lines.append(line) - return lines - - -def _explode_words(text: str) -> List[str]: - """Split a string into a list of words.""" - words = [] - pos = 0 - while True: - sep, sep_index = _find_next_sep(text[pos:]) - if not sep: - words.append(text[pos:]) - return words - word = text[pos : pos + sep_index] - # If the separator is not a space char, append it to the word. - if sep in string.whitespace: - words.append(word) - words.append(sep) - else: - words.append(word + sep) - pos += sep_index + 1 - - -def _find_next_sep(text: str): - """Find the next separator index and return both the separator and index.""" - indices = [] - for sep in SPLIT_CHARS: - try: - indices.append((sep, text.index(sep))) - except ValueError: - pass - if not indices: - return ("", 0) - return min(indices, key=lambda e: e[1]) +from bebop.metalines import LineType def render_lines(metalines, window, max_width):