"""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.""" import re from dataclasses import dataclass from typing import Callable, Optional import flet as ft import Core from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes 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): 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[tuple[str, 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 _Markdown(self, Text: str) -> ft.Control: return 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, ) def _Render(self, Text: str, Kind: str) -> 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(Text, 0, self._Markdown, Toggle, self.FontSize) Rendered.content = Parts[0] if len(Parts) == 1 else ft.Column(Parts, spacing=0, tight=True) else: Rendered.content = self._Markdown(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) 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((Item.Kind, Text)) Control = Reused.pop() if Reused else self._Render(Text, Item.Kind) Control.bgcolor = None self.BlockControls.append(Control) self.RenderCache.setdefault((Item.Kind, 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()