diff --git a/Client/Diagrams.py b/Client/Diagrams.py index 9a5d114..e82ccbd 100644 --- a/Client/Diagrams.py +++ b/Client/Diagrams.py @@ -146,10 +146,12 @@ def MaybeDiagram(TheApp, BlockText: str, FontSize: int) -> Optional[ft.Control]: return DiagramBlock(TheApp, Source, FontSize) -def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> list[ft.Control]: - """The rendered view's controls: the Markdown, split around its diagrams.""" +def WithDiagrams(TheApp, Document: str, FontSize: int, + MakeMarkdown: Callable[[str, int], ft.Control]) -> list[ft.Control]: + """The rendered view's controls: the Markdown, split around its diagrams. + MakeMarkdown(Part, Start) gets each piece and where it starts in Document.""" if not CanDraw(TheApp) or not any(DiagramFence.match(Line) for Line in Document.split("\n")): - return [MakeMarkdown(Document)] + return [MakeMarkdown(Document, 0)] from LiveMarkdown import SplitBlocks # LiveMarkdown imports this module Controls: list[ft.Control] = [] @@ -159,9 +161,9 @@ def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[s if Source is None: continue if Document[Position:Block.Start].strip(): - Controls.append(MakeMarkdown(Document[Position:Block.Start])) + Controls.append(MakeMarkdown(Document[Position:Block.Start], Position)) Controls.append(DiagramBlock(TheApp, Source, FontSize)) Position = Block.End if Document[Position:].strip(): - Controls.append(MakeMarkdown(Document[Position:])) + Controls.append(MakeMarkdown(Document[Position:], Position)) return Controls diff --git a/Client/LiveMarkdown.py b/Client/LiveMarkdown.py index 83a86e9..08545d0 100644 --- a/Client/LiveMarkdown.py +++ b/Client/LiveMarkdown.py @@ -7,7 +7,9 @@ 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).""" +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 @@ -17,6 +19,7 @@ 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|$)") @@ -28,7 +31,7 @@ IndentedLine = re.compile(r"^( {2,}|\t)\S") class Block: Start: int # offsets into the document; Text = Document[Start:End] End: int - Kind: str # "Heading", "Code", "Rule" or "Text" + Kind: str # "Properties", "Heading", "Code", "Rule" or "Text" StartLine: int Level: int = 0 # heading level @@ -49,6 +52,11 @@ def SplitBlocks(Document: str) -> list[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 @@ -57,6 +65,8 @@ def SplitBlocks(Document: str) -> list[Block]: 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(): @@ -116,7 +126,7 @@ class LiveMarkdown(ft.Column): 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.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) @@ -219,24 +229,46 @@ class LiveMarkdown(ft.Column): # --- 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, + 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 @@ -252,11 +284,11 @@ class LiveMarkdown(ft.Column): 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) + 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(Text, []).append(Control) + self.RenderCache.setdefault((Item.Kind, Text), []).append(Control) Controls = list(self.BlockControls) if self.EditingIndex == len(self.Blocks): Controls.append(self.Field) diff --git a/Client/Properties.py b/Client/Properties.py new file mode 100644 index 0000000..774fa47 --- /dev/null +++ b/Client/Properties.py @@ -0,0 +1,156 @@ +"""Properties, like Obsidian's (and the web view's Frontend/src/Properties.js): +a block at the very top of a note, + + --- + Key: Value + Done: true + --- + +shown as a Properties panel instead of text. true/false values are +checkboxes, and so are task list items (- [ ]): both can be ticked without +editing the note. The parsing follows Properties.js so a note reads the same +in both.""" + +import re +from dataclasses import dataclass, field +from typing import Callable, Optional + +import flet as ft + +from Core import Palette + +OpenOrClose = re.compile(r"^---\s*$") +PropertyLine = re.compile(r"^([^:\s][^:]*?)\s*:\s*(.*)$") +TaskLine = re.compile(r"^(\s*(?:[-*+]|\d+[.)])\s+)\[( |x|X)\]") +BooleanValue = re.compile(r"^(true|false)$", re.IGNORECASE) + +# Ticks a box: called with the line (0-based, in the whole note) to flip. +OnToggle = Optional[Callable[[int], None]] + + +@dataclass +class Property: + Key: str + Value: str + Line: int + List: list[str] = field(default_factory=list) + + +@dataclass +class Parsed: + Properties: list[Property] + EndLine: int # the closing --- + EndOffset: int # just after it + + +def ParseProperties(Text: str) -> Optional[Parsed]: + """The block at the top, or None (also when it isn't all Key: Value + lines: then it's just a note that starts with a --- rule).""" + Lines = Text.split("\n") + if not OpenOrClose.match(Lines[0]): + return None + EndLine = next((Index for Index in range(1, len(Lines)) if OpenOrClose.match(Lines[Index])), None) + if EndLine is None: + return None + Found: list[Property] = [] + for Index in range(1, EndLine): + Line = Lines[Index] + Match = PropertyLine.match(Line) + if Match: + Found.append(Property(Match.group(1), Match.group(2).strip(), Index)) + elif Line.strip().startswith("- ") and Found: + Found[-1].List.append(Line.strip()[2:].strip()) # a list under the previous key + elif Line.strip(): + return None + return Parsed(Found, EndLine, len("\n".join(Lines[:EndLine + 1]))) + + +def ToggleLine(Text: str, LineIndex: int) -> str: + """Flips a task's [ ]/[x], or a true/false property, on one line.""" + Lines = Text.split("\n") + if not 0 <= LineIndex < len(Lines): + return Text + Line = Lines[LineIndex] + Task = TaskLine.match(Line) + if Task: + Mark = "x" if Task.group(2) == " " else " " + Lines[LineIndex] = f"{Task.group(1)}[{Mark}]{Line[Task.end():]}" + else: + Lines[LineIndex] = re.sub(r"(:\s*)(true|false)(\s*)$", + lambda M: M.group(1) + ("false" if M.group(2).lower() == "true" else "true") + M.group(3), + Line, flags=re.IGNORECASE) + return "\n".join(Lines) + + +def _Checkbox(Checked: bool, Line: int, Toggle: OnToggle) -> ft.Checkbox: + return ft.Checkbox( + value=Checked, + disabled=Toggle is None, + active_color=Palette.Mauve, + check_color=Palette.Base, + visual_density=ft.VisualDensity.COMPACT, + on_change=(lambda e, L=Line: Toggle(L)) if Toggle else None, + ) + + +def PropertiesView(Found: Parsed, FontSize: int, MakeMarkdown: Callable[[str], ft.Control], + Toggle: OnToggle) -> ft.Control: + """The panel. MakeMarkdown renders a value (links, bold).""" + Rows: list[ft.Control] = [] + for Item in Found.Properties: + if BooleanValue.match(Item.Value): + Shown: ft.Control = _Checkbox(Item.Value.lower() == "true", Item.Line, Toggle) + elif Item.List: + Shown = ft.Row([ft.Container(ft.Text(Entry, color=Palette.Mauve, size=FontSize - 1), bgcolor=Palette.Surface0, + border_radius=4, padding=ft.Padding.symmetric(horizontal=6, vertical=1)) + for Entry in Item.List], wrap=True, spacing=4, run_spacing=4) + elif Item.Value: + Shown = MakeMarkdown(Item.Value) + else: + Shown = ft.Text("Empty", color=Palette.Overlay0, italic=True, size=FontSize - 1) + Rows.append(ft.Row([ + ft.Container(ft.Text(Item.Key, color=Palette.Overlay0, size=FontSize - 1, max_lines=1, + overflow=ft.TextOverflow.ELLIPSIS), expand=3), + ft.Container(Shown, expand=7, alignment=ft.Alignment.CENTER_LEFT), + ], spacing=12, vertical_alignment=ft.CrossAxisAlignment.CENTER)) + return ft.Container( + ft.Column([ft.Text("Properties", color=Palette.Overlay0, size=FontSize - 1), *Rows], spacing=2, tight=True), + bgcolor=Palette.Mantle, + border=ft.Border.all(1, Palette.Surface1), + border_radius=4, + padding=ft.Padding.symmetric(horizontal=12, vertical=8), + ) + + +def WithCheckboxes(Text: str, FirstLine: int, MakeMarkdown: Callable[[str], ft.Control], + Toggle: OnToggle, FontSize: int) -> list[ft.Control]: + """Markdown with its task lines as real checkboxes: the lines between + them render as usual. FirstLine is where Text starts in the note.""" + Controls: list[ft.Control] = [] + Pending: list[str] = [] + InFence = False + + def Flush() -> None: + if "".join(Pending).strip(): + Controls.append(MakeMarkdown("\n".join(Pending))) + Pending.clear() + + for Offset, Line in enumerate(Text.split("\n")): + if re.match(r"^ {0,3}(`{3,}|~{3,})", Line): + InFence = not InFence + Task = None if InFence else TaskLine.match(Line) + if Task is None: + Pending.append(Line) + continue + Flush() + Indent = len(Task.group(1)) - len(Task.group(1).lstrip()) + Controls.append(ft.Row( + [_Checkbox(Task.group(2) != " ", FirstLine + Offset, Toggle), + ft.Container(MakeMarkdown(Line[Task.end():].strip() or " "), expand=True)], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + )) + if Indent: + Controls[-1] = ft.Container(Controls[-1], padding=ft.Padding.only(left=Indent * 8)) + Flush() + return Controls or [MakeMarkdown(Text)] diff --git a/Client/Viewer.py b/Client/Viewer.py index 5668e43..6f97617 100644 --- a/Client/Viewer.py +++ b/Client/Viewer.py @@ -2,7 +2,10 @@ EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar). - Markdown is rendered in View; the other text types are shown as - highlighted code. On a server, ```mermaid blocks show as diagrams (Diagrams.py). Edit is a plain monospace field with an explicit Save + highlighted code. On a server, ```mermaid blocks show as diagrams (Diagrams.py), + and the properties block at the top as its panel (Properties.py). + Checkboxes (tasks, true/false properties) tick from View and save at once, + like the web view; in Live they leave the note unsaved, like typing. Edit is a plain monospace field with an explicit Save (Ctrl+S). On a server, saving records line blame for you, like the web view. - With Markdown editing set to Live (Settings > General), Markdown opens in LiveMarkdown instead: rendered, and click a block to edit it in place. @@ -21,6 +24,7 @@ import flet as ft from App import App from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette from Diagrams import MaybeDiagram, WithDiagrams +from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes from LiveMarkdown import LiveMarkdown from Sources import Entry, SourceError, TextFileTypes @@ -137,18 +141,30 @@ class FileView(ft.Container): Text = self.TextField.value or "" Views: Optional[list[ft.Control]] = None if self.FileType == "markdown" and len(Text) <= HighlightCharLimit: + FontSize = self.TheApp.Config.FontSize + Toggle = self._ToggleAndSave if self.TheApp.Source.CanWrite else None + Found = ParseProperties(Text) + BodyStart = Found.EndOffset if Found else 0 + def MakeMarkdown(Part: str) -> ft.Control: return ft.Markdown( value=Part, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, - md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize), + md_style_sheet=MarkdownSheet(FontSize), code_theme=MarkdownCodeTheme(), code_style_sheet=CodeStyleSheet(), on_tap_link=self._OnTapLink, ) - Views = WithDiagrams(self.TheApp, Text, self.TheApp.Config.FontSize, MakeMarkdown) + def MakeWithCheckboxes(Part: str, Start: int) -> ft.Control: + FirstLine = Text[:BodyStart + Start].count("\n") + Parts = WithCheckboxes(Part, FirstLine, MakeMarkdown, Toggle, FontSize) + return Parts[0] if len(Parts) == 1 else ft.Column(Parts, spacing=0, tight=True) + + Views = WithDiagrams(self.TheApp, Text[BodyStart:], FontSize, MakeWithCheckboxes) + if Found and Found.Properties: + Views.insert(0, PropertiesView(Found, FontSize, MakeMarkdown, Toggle)) View: ft.Control = Views[0] elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit: Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower() @@ -203,6 +219,14 @@ class FileView(ft.Container): if Target.startswith(("http://", "https://", "mailto:")): self.TheApp.Page.run_task(ft.UrlLauncher().launch_url, Target) + def _ToggleAndSave(self, Line: int) -> None: + """A checkbox ticked in View: flip its line and save, like the web view.""" + self.TextField.value = ToggleLine(self.TextField.value or "", Line) + self._OnTextChanged() + self.Save(Quiet=True) + self.Body.content = self._RenderedView() + self.Body.update() + # --- Editing ---------------------------------------------------------- @property @@ -273,7 +297,8 @@ class FileView(ft.Container): if self.IsText and not self.ToggleButton.disabled: self._ToggleEdit() - def Save(self) -> None: + def Save(self, Quiet: bool = False) -> None: + """Quiet: no "Saved" notice (a ticked checkbox saves itself).""" if not self.CanSave: return try: @@ -287,7 +312,8 @@ class FileView(ft.Container): self.StatusText.value = "Saved." self.SaveButton.update() self.StatusText.update() - self.TheApp.Notify(f"Saved {self.Item.Name}.", "success") + if not Quiet: + self.TheApp.Notify(f"Saved {self.Item.Name}.", "success") def RequestClose(self, Then: Optional[Callable[[], None]] = None) -> None: """Closes, asking first if there are unsaved changes. Then runs after