From dff686090b2e71624da943969fdcea507d43ac13 Mon Sep 17 00:00:00 2001 From: rdbende Date: Tue, 18 Jul 2023 19:44:50 +0200 Subject: [PATCH 1/2] first committed version --- porcupine/plugins/preview/__init__.py | 29 +++ porcupine/plugins/preview/gfm.py | 281 ++++++++++++++++++++++++++ porcupine/plugins/preview/renderer.py | 270 +++++++++++++++++++++++++ porcupine/plugins/preview/widget.py | 103 ++++++++++ 4 files changed, 683 insertions(+) create mode 100644 porcupine/plugins/preview/__init__.py create mode 100644 porcupine/plugins/preview/gfm.py create mode 100644 porcupine/plugins/preview/renderer.py create mode 100644 porcupine/plugins/preview/widget.py diff --git a/porcupine/plugins/preview/__init__.py b/porcupine/plugins/preview/__init__.py new file mode 100644 index 000000000..d8d790a10 --- /dev/null +++ b/porcupine/plugins/preview/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from porcupine import get_tab_manager, settings, tabs + +from .widget import MarkupPreview + + +def on_new_filetab(tab: tabs.FileTab) -> None: + if not tab.path: + return + + if "filetype_name" in tab.settings._options: + file_type = tab.settings.get("filetype_name") + elif "filetype_name" in tab.settings.get_state(): + file_type = tab.settings.get_state()["filetype_name"].value + else: + return + + if file_type != "Markdown": + # FIXME: filetype can change + return + + preview = MarkupPreview(tab.panedwindow, editor=tab.textwidget, path=tab.path) + tab.panedwindow.add(preview, stretch="never") + settings.remember_pane_size(tab.panedwindow, preview, "preview_width", 350) + + +def setup(): + get_tab_manager().add_filetab_callback(on_new_filetab) diff --git a/porcupine/plugins/preview/gfm.py b/porcupine/plugins/preview/gfm.py new file mode 100644 index 000000000..0f63bd731 --- /dev/null +++ b/porcupine/plugins/preview/gfm.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import itertools +import re + +from marko import MarkoExtension, block, helpers, inline, patterns + + +class Document(block.Document): + def __init__(self): + super().__init__() + self.footnotes = {} + + +class FootnoteDef(block.BlockElement): + pattern = re.compile(r" {,3}\[\^([^\]]+)\]:[^\n\S]*(?=\S| {4})") + priority = 6 + + def __init__(self, match): + self.label = helpers.normalize_label(match.group(1)) + self._prefix = re.escape(match.group()) + self._second_prefix = r" {1,4}" + + @classmethod + def match(cls, source): + return source.expect_re(cls.pattern) + + @classmethod + def parse(cls, source): + state = cls(source.match) + with source.under_state(state): + state.children = source.parser.parse_source(source) + source.root.footnotes[state.label] = state + return state + + +class FootnoteRef(inline.InlineElement): + pattern = re.compile(r"\[\^([^\]]+)\]") + priority = 6 + + def __init__(self, match): + self.label = helpers.normalize_label(match.group(1)) + + @classmethod + def find(cls, text, *, source): + for match in super().find(text, source=source): + label = helpers.normalize_label(match.group(1)) + if label in source.root.footnotes: + yield match + + +class Paragraph(block.Paragraph): + _task_list_item_pattern = re.compile(r"(\[[\sxX]\])\s+\S") + override = True + + def __init__(self, lines): + super().__init__(lines) + m = self._task_list_item_pattern.match(self.inline_body) + if m: + self.checked = m.group(1)[1:-1].lower() == "x" + self.inline_body = self.inline_body[m.end(1) :] + + +class InlineHTML(inline.InlineHTML): + pattern = re.compile( + r"(<%s(?:%s)* */?>" # open tag + r"|" # closing tag + r"|)" # HTML comment + r"|<\?[\s\S]*?\?>" # processing instruction + r"|" # declaration + r"|)" # CDATA section + % (patterns.tag_name, patterns.attribute, patterns.tag_name) + ) + + +class Strikethrough(inline.InlineElement): + pattern = re.compile(r"(? link_text.count("("): + shift = link_text.count(")") - link_text.count("(") + match = _MatchObj(match, end_shift=-shift) + else: + m = re.search(r"&[a-zA-Z]+;$", link_text) + if m: + match = _MatchObj(match, end_shift=-len(m.group())) + yield match + + +class ListItem(block.ListItem): + pattern = re.compile(r" {,3}(\d{1,9}[.)]|[*\-+])[ \t\n\r\f]") + override = True + + +class Table(block.BlockElement): + """A table element.""" + + _num_of_cols = None + _prefix = "" + + @classmethod + def match(cls, source): + source.anchor() + if TableRow.match(source) and not TableRow._is_delimiter: + if not TableRow.splitter.search(source.next_line()): + return False + source.pos = source.match.end() + num_of_cols = len(TableRow._cells) + if ( + TableRow.match(source) + and TableRow._is_delimiter + and num_of_cols == len(TableRow._cells) + ): + cls._num_of_cols = num_of_cols + source.reset() + return True + source.reset() + return False + + @classmethod + def parse(cls, source): + rv = cls() + rv._num_of_cols = cls._num_of_cols + rv.children = [] + with source.under_state(rv): + TableRow.match(source) + header = TableRow(TableRow.parse(source)) + rv.children.append(header) + TableRow.match(source) + delimiters = TableRow._cells + source.consume() + for d, th in zip(delimiters, header.children): + stripped_d = d.strip() + th.header = True + if stripped_d[0] == ":" and stripped_d[-1] == ":": + th.align = "center" + elif stripped_d[0] == ":": + th.align = "left" + elif stripped_d[-1] == ":": + th.align = "right" + while not source.exhausted: + for e in source.parser._build_block_element_list(): + if issubclass(e, (Table, block.Paragraph)): + continue + if e.match(source): + break + else: + if TableRow.match(source): + rv.children.append(TableRow(TableRow.parse(source))) + continue + break + return rv + + +class TableRow(block.BlockElement): + """A table row element.""" + + splitter = re.compile(r"\s*(? parent._num_of_cols: + cells = cells[: parent._num_of_cols] + cells = [TableCell(cell) for cell in cells] + if parent.children: + for head, cell in zip(parent.children[0].children, cells): + cell.align = head.align + return cells + + +class TableCell(block.BlockElement): + """A table cell element.""" + + virtual = True + + def __init__(self, text): + self.inline_body = text.strip().replace("\\|", "|") + self.header = False + self.align = None + + +GithubFlavoredMarkdown = MarkoExtension( + elements=[ + Document, + FootnoteDef, + FootnoteRef, + InlineHTML, + ListItem, + Paragraph, + Strikethrough, + Table, + TableCell, + TableRow, + Url, + ] +) diff --git a/porcupine/plugins/preview/renderer.py b/porcupine/plugins/preview/renderer.py new file mode 100644 index 000000000..ab6bd626f --- /dev/null +++ b/porcupine/plugins/preview/renderer.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import re, sys, os, subprocess +import tempfile +import tkinter +import urllib.request +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Generator +import webbrowser + +import marko +from marko import block, inline + +if TYPE_CHECKING: + from .widget import MarkdownPreviewWidget + +INDENT_SPACES = 4 + + +class Reference: + def __init__(self, dest: str) -> None: + self.dest = dest + + +class URL(Reference): + def open(self, widget): + webbrowser.open(self.dest) + + +class File(Reference): + def open(self, widget): + path = widget.path.parent / self.dest + + if not path.exists(): + return + if not path.is_dir(): + # TODO: maybe show a warning, because this can execute files? + ... + + if sys.platform == "win32": + os.startfile(path) + elif sys.platform == "darwin": + subprocess.Popen(["open", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + subprocess.Popen(["xdg-open", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +class Anchor(Reference): + def open(self, widget): + widget.tag_remove("highlight", "0.0", "end") + for tag in tuple(f"heading_{i}" for i in range(1, 7)): + ranges = widget.tag_ranges(tag) + for start, end in zip(ranges[0::2], ranges[1::2]): + if self.dest[1:].lower() in widget.get(start, end).replace(" ", "-").lower(): + widget.tag_add("highlight", start, end) + widget.see(start) + return + + +class Footnote(Reference): + def open(self, widget): + ... + + +class MarkupPreviewRenderer: + current_tags: set[str] = set() + images: dict[str, tkinter.PhotoImage] = {} + + def __init__(self, widget: MarkdownPreviewWidget): + self.indent = "" + self.widget = widget + self.footnotes = {} + self.links: dict[str, Reference] = {} + + @contextmanager + def indented(self, spaces: int) -> Generator[None, None, None]: + old_indent = self.indent + self.indent += " " * spaces + yield + self.indent = old_indent + + @contextmanager + def tagged(self, *tags: str) -> Generator[None, None, None]: + self.current_tags.update(tags) + yield + self.current_tags.difference_update(tags) + + def clear(self): + self.links.clear() + self.footnotes.clear() + + def insert(self, string: str) -> None: + tags = tuple(self.current_tags) + if string == "\n": + tags = tags + ("nospacing",) + self.widget.insert("end - 1 char", string, tags) + + # Renderer methods + + def render(self, element: Element) -> Any: + if hasattr(element, "get_type"): + func_name = "render_" + element.get_type(snake_case=True) + render_func = getattr(self, func_name, None) + if render_func is not None: + return render_func(element) + return self.render_children(element) + + def render_children(self, element: Any) -> Any: + for child in element.children: + self.render(child) # type: ignore + + def render_document(self, node): + self.render_children(node) + with self.tagged("footnote"): + for i in self.footnotes.values(): + self.insert(f"{i.label}. ") + self.render_children(i) + + def render_heading(self, element: marko.block.Heading | marko.block.SetextHeading) -> None: + with self.tagged(f"heading_{element.level}"): + self.render_children(element) + + self.insert("\n") + + render_setext_heading = render_heading + + def render_paragraph(self, element: marko.block.Paragraph) -> None: + self.render_children(element) + self.insert("\n") + + def render_literal(self, element: marko.inline.Literal) -> None: + assert isinstance(element.children, str) + self.insert(element.children) + + def render_raw_text(self, element: marko.inline.RawText) -> None: + self.insert(element.children) + + def render_line_break(self, element: marko.inline.LineBreak) -> None: + self.insert(" " if element.soft else "\n") + + def font_measure(self, string: str) -> int: + # because I won't create a tkinter.font.Font just for this + return int(self.widget.tk.call("font", "measure", self.widget.cget("font"), string)) + + def render_list(self, element: marko.block.List) -> None: + bullet = "\u2022" if len(self.indent) < INDENT_SPACES else "\u25E6" + tag = f"list_item_{len(self.indent) + INDENT_SPACES}" + + with self.indented(INDENT_SPACES), self.tagged(tag): + indent = self.font_measure(f"{self.indent}{bullet} ") + self.widget.tag_config(tag, spacing1=2, spacing3=2, lmargin2=indent + 1) + + if element.ordered: + for num, child in enumerate(element.children, start=element.start): + self.insert(f"{self.indent}{num}. ") + self.render(child) + else: + for child in element.children: + self.insert(f"{self.indent}{bullet} ") + self.render(child) + + def render_list_item(self, element: marko.block.ListItem) -> None: + self.render_children(element) + + def render_footnote_ref(self, element: marko.block.ListItem) -> None: + with self.tagged("upper_index", "link"): + self.links[self.widget.index("end - 1 char")] = Footnote(element.label) + self.insert(f"[{element.label}]") + + def render_footnote_def(self, element: marko.block.ListItem) -> None: + self.footnotes[self.widget.index("end - 1 char")] = element + + def render_quote(self, element: marko.block.Quote) -> None: + with self.tagged("quote"): + self.render_children(element) + + def render_code_span(self, element: marko.inline.CodeSpan) -> None: + assert isinstance(element.children, str) + with self.tagged("code_span"): + self.insert(element.children) + + def render_code_block(self, element: marko.block.CodeBlock | marko.block.FencedCode) -> None: + with self.tagged("code_block"): + self.render_children(element) + + render_fenced_code = render_code_block + + def render_emphasis(self, element: marko.inline.Emphasis) -> None: + with self.tagged("italic"): + self.render_children(element) + + def render_strong_emphasis(self, element: marko.inline.StrongEmphasis) -> None: + with self.tagged("bold"): + self.render_children(element) + + def render_strikethrough(self, element: Strikethrough) -> None: + with self.tagged("overstrike"): + self.render_children(element) + + def download_image(self, url: str) -> Path: + # TODO: run this in another thread? + path = Path(tempfile.mkstemp()[1]) + + request = urllib.request.Request(url=url, headers={"User-Agent": "Mozilla/6.0"}) + response = urllib.request.urlopen(request) + + path.write_bytes(response.read()) + return path + + def render_image(self, element: marko.inline.Image) -> None: + source = element.dest + + if source in self.images: + image = self.images[source] + else: + if re.match("^(http|https)://", source): + path = self.download_image(source) + elif (self.widget.path.parent / source).is_file(): + path = self.widget.path.parent / source + else: + self.render_children(element) + self.insert("\n") + return + + try: + image = tkinter.PhotoImage(file=path) + except tkinter.TclError: + # Unsupported file, or smth + return + + self.images[source] = image + + self.widget.image_create("end", image=image) + self.insert("\n") + + def get_link(self, url): + if re.match("^(http|https|ftp)://", url): + return URL(url) + elif url.startswith("#"): + return Anchor(url) + else: + return File(url) + + def render_link(self, element: marko.inline.Link) -> None: + with self.tagged("link"): + self.links[self.widget.index("end - 1 char")] = self.get_link(element.dest) + self.render_children(element) + + def render_auto_link(self, element: marko.inline.AutoLink) -> None: + with self.tagged("link"): + self.links[self.widget.index("end - 1 char")] = self.get_link(element.dest) + self.insert(element.dest) + + def render_url(self, element: marko.inline.AutoLink) -> None: + with self.tagged("link"): + self.links[self.widget.index("end - 1 char")] = URL(element.dest) + self.insert(element.dest) + + def render_thematic_break(self, element: marko.block.ThematicBreak) -> None: + # TODO: how to insert a horizontal separator into the text widget? + pass + + def render_html_block(self, element: marko.block.HTMLBlock) -> None: + with self.tagged("nospacing"): + self.insert(element.body) + + def render_inline_html(self, element: marko.inline.InlineHTML) -> None: + assert isinstance(element.children, str) + self.insert(element.children) diff --git a/porcupine/plugins/preview/widget.py b/porcupine/plugins/preview/widget.py new file mode 100644 index 000000000..08b246f7a --- /dev/null +++ b/porcupine/plugins/preview/widget.py @@ -0,0 +1,103 @@ +import tkinter +from pathlib import Path + +import marko + +from porcupine.utils import add_scroll_command, mix_colors + +from .gfm import GithubFlavoredMarkdown +from .renderer import MarkupPreviewRenderer + + +class MarkupPreview(tkinter.Text): + def __init__(self, master: tkinter.Widget, *, editor: tkinter.Text, path: Path) -> None: + super().__init__( + master, + font=("Segoe UI Variable Static Text", -15), + highlightthickness=0, + borderwidth=1, + relief="solid", + spacing1=10, + spacing3=6, + padx=10, + wrap="word", + cursor="arrow", + ) + self.path = path + self.editor = editor + self.parser = marko.Markdown(extensions=[GithubFlavoredMarkdown]) + self.renderer = MarkupPreviewRenderer(self) + + add_scroll_command(editor, "yscrollcommand", self._scroll_with_editor) + editor.bind("<>", self.update_preview, add=True) + + self.tag_bind("link", "", lambda e: self.config(cursor="hand2")) + self.tag_bind("link", "", lambda e: self.config(cursor="arrow")) + self.tag_bind("link", "", self.handle_link_open) + + self.bind("<>", self.update_tags) + self.update_tags() + self.update_preview() + + def _scroll_with_editor(self) -> None: + # FIXTHIS + + length = int(self.editor.index("end").split(".")[0]) + first = int(self.editor.index("@0,0 linestart").split(".")[0]) + last = int(self.editor.index("@0,100000 linestart").split(".")[0]) + length -= last - first + + round_ = lambda x: round(x / 0.05) * 0.05 + + self.yview_moveto(round_(first / length)) + + def update_preview(self, *junk: object) -> None: + cur = self.editor.index("insert") + self.config(state="normal") + self.renderer.clear() + tree = self.parser.parse(self.editor.get("0.0", "end")) + self.delete("0.0", "end") + self.renderer.render(tree) + self.config(state="disabled") + self.see(cur) + + def update_tags(self, *_) -> None: + bg = self.cget("background") + fg = self.cget("foreground") + font_family = self.tk.splitlist(self.cget("font"))[0] + + code_options = {"background": mix_colors("#7777aa", bg, 0.3), "font": "TkFixedFont"} + small_spacing = {"spacing1": 2, "spacing3": 2} + bold_font = ("-family", font_family, "-weight", "bold") + + self.tag_configure("nospacing", wrap="none", **small_spacing) + self.tag_configure("highlight", background=mix_colors("#ffff00", bg, 0.5)) + self.tag_configure("link", foreground=mix_colors("#0077ff", fg, 0.6), underline=True) + self.tag_configure("code_span", lmargincolor=bg, wrap="char", **code_options) + self.tag_configure("code_block", wrap="none", lmargin1=10, **small_spacing, **code_options) + self.tag_configure( + "quote", + lmargincolor=mix_colors(bg, fg, 0.4), + foreground=mix_colors(bg, fg, 0.3), + lmargin1=4, + lmargin2=4, + **small_spacing, + ) + self.tag_configure("bold", font=bold_font) + self.tag_configure("italic", font=("-family", font_family, "-slant", "italic")) + self.tag_configure("overstrike", font=("-family", font_family, "-overstrike", "1")) + self.tag_configure("upper_index", font=("-family", font_family, "-size", -10), offset=5) + self.tag_configure("footnote", foreground=mix_colors(bg, fg, 0.3)) + + for level, size, pad in zip(range(1, 7), range(34, 13, -4), (13, 12, 11, 10, 9, 8)): + self.tag_configure( + f"heading_{level}", font=bold_font + ("-size", -size), spacing1=pad, spacing3=2 + ) + + def handle_link_open(self, event: tkinter.Event) -> None: + index = self.index(f"@{event.x},{event.y}") + ranges = self.tag_ranges("link") + for start, end in zip(ranges[0::2], ranges[1::2]): + if self.compare(start, "<=", index) and self.compare(index, "<=", end): + self.renderer.links[str(start)].open(self) + break From 4f9e1932b51b1c85b2492ee3cb4bcf7a7f769294 Mon Sep 17 00:00:00 2001 From: rdbende Date: Tue, 18 Jul 2023 17:47:12 +0000 Subject: [PATCH 2/2] Run pycln, pyupgrade, black and isort --- porcupine/plugins/preview/renderer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/porcupine/plugins/preview/renderer.py b/porcupine/plugins/preview/renderer.py index ab6bd626f..b88eff781 100644 --- a/porcupine/plugins/preview/renderer.py +++ b/porcupine/plugins/preview/renderer.py @@ -1,16 +1,18 @@ from __future__ import annotations -import re, sys, os, subprocess +import os +import re +import subprocess +import sys import tempfile import tkinter import urllib.request +import webbrowser from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Generator -import webbrowser import marko -from marko import block, inline if TYPE_CHECKING: from .widget import MarkdownPreviewWidget