mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
Add live Markdown editing, colours and files in the tree
This commit is contained in:
1 parent
0eecfba224
commit
f851ecd820
15 files changed
+1025
-58
No files matched your search
@@ -0,0 +1,268 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user