Properties, and ticking checkboxes without editing (client)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 15:25:45 +02:00
1 parent c8d29ad6f8
commit ca37409e37
4 files changed
+245 -29

No files matched your search

+7 -5
View File
@@ -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
+51 -19
View File
@@ -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)
+156
View File
@@ -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)]
+31 -5
View File
@@ -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