metalines: split contents from rendering module
This commit is contained in:
parent
8aee7fdfba
commit
6bc4dbcc5d
|
@ -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!
|
||||
"""
|
||||
|
||||
|
||||
|
|
217
bebop/metalines.py
Normal file
217
bebop/metalines.py
Normal file
|
@ -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])
|
|
@ -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):
|
||||
|
|
Reference in a new issue