Files
SaraView/Client/LiveMarkdown.py
T

323 lines
14 KiB
Python

"""Live Markdown editing, like Obsidian's live preview: the file is shown
rendered, block by block, and clicking a block swaps it for its Markdown
(a heading shows its #s, a list its -s) to edit in place. Esc, or clicking
another block, puts it back rendered.
A block is what Markdown itself separates: a heading line, a fenced code
block, a horizontal rule, or a run of lines up to a blank line (a paragraph,
list, table or quote). The text between blocks (blank lines) is kept
byte for byte, so editing one block never reformats the rest of the file.
A ```mermaid block shows as its diagram when MakeDiagram gives one (Diagrams.py),
and the properties block at the top as its panel (Properties.py). Checkboxes
(tasks and true/false properties) tick without opening their block. With
GetSpans, text and headings show their variables' results (Variables.py);
opening a block shows what's written."""
import re
from dataclasses import dataclass
from typing import Callable, Optional
import flet as ft
import Core
from Core import CodeStyleSheet, HardLineBreaks, MarkdownCodeTheme, MarkdownSheet, Palette
from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes
from Variables import Substitute
FenceStart = re.compile(r"^ {0,3}(`{3,}|~{3,})")
HeadingLine = re.compile(r"^ {0,3}(#{1,6})(\s|$)")
RuleLine = re.compile(r"^ {0,3}([-*_])( *\1){2,} *$")
IndentedLine = re.compile(r"^( {2,}|\t)\S")
@dataclass
class Block:
Start: int # offsets into the document; Text = Document[Start:End]
End: int
Kind: str # "Properties", "Heading", "Code", "Rule" or "Text"
StartLine: int
Level: int = 0 # heading level
def SplitBlocks(Document: str) -> list[Block]:
Lines = Document.split("\n")
Offsets: list[int] = []
Position = 0
for Line in Lines:
Offsets.append(Position)
Position += len(Line) + 1
def LineEnd(Index: int) -> int:
return Offsets[Index] + len(Lines[Index])
Blocks: list[Block] = []
Current: Optional[int] = None # first line of the open Text block
LastLine = -1 # last non-blank line of the open Text block
Fence: Optional[tuple[str, int]] = None # (character, length) of an open code fence
FenceFrom = 0
Found = ParseProperties(Document)
SkipUntil = -1
if Found is not None:
Blocks.append(Block(0, LineEnd(Found.EndLine), "Properties", 0))
SkipUntil = Found.EndLine
def CloseCurrent() -> None:
nonlocal Current
if Current is not None:
Blocks.append(Block(Offsets[Current], LineEnd(LastLine), "Text", Current))
Current = None
for Index, Line in enumerate(Lines):
if Index <= SkipUntil:
continue
if Fence is not None:
Match = FenceStart.match(Line)
if Match and Match.group(1)[0] == Fence[0] and len(Match.group(1)) >= Fence[1] and not Line.strip()[len(Match.group(1)):].strip():
Blocks.append(Block(Offsets[FenceFrom], LineEnd(Index), "Code", FenceFrom))
Fence = None
continue
if not Line.strip():
CloseCurrent()
continue
Match = FenceStart.match(Line)
if Match:
CloseCurrent()
Fence = (Match.group(1)[0], len(Match.group(1)))
FenceFrom = Index
continue
Match = HeadingLine.match(Line)
if Match:
CloseCurrent()
Blocks.append(Block(Offsets[Index], LineEnd(Index), "Heading", Index, len(Match.group(1))))
continue
Match = RuleLine.match(Line)
# "---" right under text is a setext heading's underline, not a rule.
if Match and (Current is None or Match.group(1) != "-"):
CloseCurrent()
Blocks.append(Block(Offsets[Index], LineEnd(Index), "Rule", Index))
continue
if Current is None:
Previous = Blocks[-1] if Blocks else None
# An indented line after blank lines continues a loose list
# (or a list item's second paragraph) rather than starting a code block.
if Previous is not None and Previous.Kind == "Text" and IndentedLine.match(Line):
Blocks.pop()
Current = Previous.StartLine
else:
Current = Index
LastLine = Index
CloseCurrent()
if Fence is not None: # never closed: runs to the last non-blank line
Last = max((I for I in range(FenceFrom, len(Lines)) if Lines[I].strip()), default=FenceFrom)
Blocks.append(Block(Offsets[FenceFrom], LineEnd(Last), "Code", FenceFrom))
return Blocks
class LiveMarkdown(ft.Column):
def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None],
OnFocusChange: Callable[[bool], None], OnTapLink: Callable,
MakeDiagram: Optional[Callable[[str, int], Optional[ft.Control]]] = None,
GetSpans: Optional[Callable[[str], list[dict]]] = None,
MakeImage: Optional[Callable[[str, str], Optional[ft.Control]]] = None):
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.Document = Document
self.FontSize = FontSize
self.OnChange = OnChange
self.OnFocusChange = OnFocusChange
self.OnTapLink = OnTapLink
self.MakeDiagram = MakeDiagram
self.GetSpans = GetSpans
self.MakeImage = MakeImage
self.SpansFor: tuple[str, list[dict]] = ("", []) # (document, its spans)
self.Blocks: list[Block] = []
self.BlockControls: list[ft.Control] = []
# Rendered blocks by their text, reused when the layout is rebuilt so
# only the edited part is rendered again.
self.RenderCache: dict[tuple[str, str], list[ft.Control]] = {} # by (kind, text as shown)
self.EditingIndex: Optional[int] = None # len(Blocks) is the new block at the end
self.Field: Optional[ft.TextField] = None
self._Layout(Update=False)
# --- Text ------------------------------------------------------------
@property
def Text(self) -> str:
"""The whole document, including what's being typed in the open block."""
if self.EditingIndex is None or self.Field is None:
return self.Document
return self._Spliced(self.Field.value or "")
def SetText(self, Document: str, Update: bool = True) -> None:
self.EditingIndex = None
self.Field = None
self.Document = Document
self._Layout(Update)
def _Spliced(self, NewText: str) -> str:
"""The document with the open block replaced by NewText."""
Index = self.EditingIndex
Document = self.Document
if Index is None:
return Document
if Index == len(self.Blocks): # a new block after the last one
if not NewText.strip():
return Document
Base = Document
if Base and not Base.endswith("\n"):
Base += "\n"
if Base and not Base.endswith("\n\n"):
Base += "\n"
return Base + NewText.rstrip("\n") + "\n"
Current = self.Blocks[Index]
if NewText.strip():
return Document[:Current.Start] + NewText + Document[Current.End:]
# Emptied: take its blank lines with it, so no gap is left behind.
if Index + 1 < len(self.Blocks):
return Document[:Current.Start] + Document[self.Blocks[Index + 1].Start:]
if Index > 0:
return Document[:self.Blocks[Index - 1].End] + Document[Current.End:]
return Document[:Current.Start] + Document[Current.End:]
# --- Editing ---------------------------------------------------------
@property
def IsEditing(self) -> bool:
return self.EditingIndex is not None
def Commit(self) -> None:
"""Puts the open block back rendered (re-splitting the document, since
the edit may have made one block into several or merged it with the next)."""
if self.EditingIndex is None:
return
self.Document = self.Text
self.EditingIndex = None
self.Field = None
self.OnFocusChange(False)
self._Layout()
def _StartEditing(self, Index: int) -> None:
self.EditingIndex = Index
Item = self.Blocks[Index] if Index < len(self.Blocks) else None
Value = self.Document[Item.Start:Item.End] if Item else ""
Style = ft.TextStyle(size=self.FontSize, font_family=Core.ActiveFontFamily, color=Palette.Text)
if Item is not None and Item.Kind == "Heading":
# The heading stays its size and colour while its #s are showing.
Style = getattr(MarkdownSheet(self.FontSize), f"h{Item.Level}_text_style")
self.Field = ft.TextField(
value=Value,
multiline=True,
min_lines=1,
autofocus=True,
border=ft.InputBorder.NONE,
filled=True,
fill_color=Palette.Mantle,
content_padding=ft.Padding.symmetric(horizontal=8, vertical=6),
text_style=Style,
cursor_color=Palette.Mauve,
on_change=lambda e: self.OnChange(),
on_focus=lambda e: self.OnFocusChange(True),
)
self.OnFocusChange(True)
self._Layout()
def _OnBlockClicked(self, Control: ft.Control) -> None:
if self.EditingIndex is not None:
self.Commit() # Flutter doesn't unfocus a field when something else is clicked
# Find the block again in the (possibly rebuilt) layout.
for Index, Controls in enumerate(self.BlockControls):
if Controls is Control:
self._StartEditing(Index)
return
def _OnEndClicked(self) -> None:
if self.EditingIndex is not None:
self.Commit()
self._StartEditing(len(self.Blocks))
# --- Rendering -------------------------------------------------------
def _Markdown(self, Text: str) -> ft.Control:
return ft.Markdown(
value=HardLineBreaks(Text),
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
on_tap_link=self.OnTapLink,
)
def _Spans(self) -> list[dict]:
if self.GetSpans is None:
return []
if self.SpansFor[0] != self.Document:
self.SpansFor = (self.Document, self.GetSpans(self.Document))
return self.SpansFor[1]
def _Render(self, Text: str, Kind: str, Shown: Optional[str] = None, LineMap: Optional[list] = None) -> ft.Control:
Rendered = ft.Container(padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4)
# Lines are counted from the block's start, found again when ticked:
# a rendered block is reused wherever its text ends up (RenderCache).
Toggle = lambda Line, C=Rendered: self._OnToggle(C, Line)
Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram and Kind == "Code" else None
if Diagram is not None:
Rendered.content = Diagram
elif Kind == "Properties" and (Found := ParseProperties(Text)) is not None:
Rendered.content = PropertiesView(Found, self.FontSize, self._Markdown, Toggle)
elif Kind == "Text":
Parts = WithCheckboxes(Shown if Shown is not None else Text, 0, self._Markdown, Toggle, self.FontSize, LineMap,
self.MakeImage)
Rendered.content = Parts[0] if len(Parts) == 1 else ft.Column(Parts, spacing=0, tight=True)
else:
Rendered.content = self._Markdown(Shown if Shown is not None and Kind == "Heading" else Text)
Rendered.on_click = lambda e, C=Rendered: self._OnBlockClicked(C)
Rendered.on_hover = lambda e, C=Rendered: self._OnHover(C, e)
return Rendered
def _OnToggle(self, Control: ft.Control, Line: int) -> None:
"""A checkbox in a rendered block: flip its line, leave the block shut."""
if self.EditingIndex is not None:
self.Commit()
for Index, Controls in enumerate(self.BlockControls):
if Controls is Control:
self.Document = ToggleLine(self.Document, self.Blocks[Index].StartLine + Line)
self.OnChange()
self._Layout()
return
@staticmethod
def _OnHover(Control: ft.Container, Event) -> None:
Control.bgcolor = Palette.Mantle if str(Event.data).lower() == "true" else None
Control.update()
def _Layout(self, Update: bool = True) -> None:
self.Blocks = SplitBlocks(self.Document)
Spans = self._Spans()
Cache = self.RenderCache
self.RenderCache = {}
self.BlockControls = []
for Index, Item in enumerate(self.Blocks):
if Index == self.EditingIndex:
self.BlockControls.append(self.Field)
continue
Text = self.Document[Item.Start:Item.End]
Shown, LineMap = Substitute(Text, Item.Start, Spans) if Item.Kind in ("Text", "Heading") else (Text, None)
Reused = Cache.get((Item.Kind, Shown))
Control = Reused.pop() if Reused else self._Render(Text, Item.Kind, Shown, LineMap)
Control.bgcolor = None
self.BlockControls.append(Control)
self.RenderCache.setdefault((Item.Kind, Shown), []).append(Control)
Controls = list(self.BlockControls)
if self.EditingIndex == len(self.Blocks):
Controls.append(self.Field)
else:
Hint = "Click to start writing." if not self.Blocks else "Click here to add more."
Controls.append(ft.Container(
content=ft.Text(Hint, color=Palette.Overlay0, italic=True, size=self.FontSize - 2),
padding=ft.Padding.symmetric(horizontal=8, vertical=12),
on_click=lambda e: self._OnEndClicked(),
))
self.controls = Controls
if Update:
self.update()