Files
SaraView/Client/LiveMarkdown.py
T

269 lines
11 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."""
import re
from dataclasses import dataclass
from typing import Callable, Optional
import flet as ft
import Core
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
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 # "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
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 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):
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.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[str, list[ft.Control]] = {}
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 _Render(self, Text: str) -> ft.Control:
Rendered = ft.Container(
content=ft.Markdown(
value=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,
),
padding=ft.Padding.symmetric(horizontal=8, vertical=6),
border_radius=4,
)
Rendered.on_click = lambda e, C=Rendered: self._OnBlockClicked(C)
Rendered.on_hover = lambda e, C=Rendered: self._OnHover(C, e)
return Rendered
@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)
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]
Reused = Cache.get(Text)
Control = Reused.pop() if Reused else self._Render(Text)
Control.bgcolor = None
self.BlockControls.append(Control)
self.RenderCache.setdefault(Text, []).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()