mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
273 lines
11 KiB
Python
273 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.
|
|
A ```mermaid block shows as its diagram when MakeDiagram gives one (Diagrams.py)."""
|
|
|
|
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,
|
|
MakeDiagram: Optional[Callable[[str, int], 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.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:
|
|
Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram else None
|
|
Rendered = ft.Container(
|
|
content=Diagram or 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()
|