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
+238 -22

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) return DiagramBlock(TheApp, Source, FontSize)
def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> list[ft.Control]: def WithDiagrams(TheApp, Document: str, FontSize: int,
"""The rendered view's controls: the Markdown, split around its diagrams.""" 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")): 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 from LiveMarkdown import SplitBlocks # LiveMarkdown imports this module
Controls: list[ft.Control] = [] Controls: list[ft.Control] = []
@@ -159,9 +161,9 @@ def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[s
if Source is None: if Source is None:
continue continue
if Document[Position:Block.Start].strip(): 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)) Controls.append(DiagramBlock(TheApp, Source, FontSize))
Position = Block.End Position = Block.End
if Document[Position:].strip(): if Document[Position:].strip():
Controls.append(MakeMarkdown(Document[Position:])) Controls.append(MakeMarkdown(Document[Position:], Position))
return Controls return Controls
+45 -13
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, 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 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. 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 import re
from dataclasses import dataclass from dataclasses import dataclass
@@ -17,6 +19,7 @@ import flet as ft
import Core import Core
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes
FenceStart = re.compile(r"^ {0,3}(`{3,}|~{3,})") FenceStart = re.compile(r"^ {0,3}(`{3,}|~{3,})")
HeadingLine = re.compile(r"^ {0,3}(#{1,6})(\s|$)") HeadingLine = re.compile(r"^ {0,3}(#{1,6})(\s|$)")
@@ -28,7 +31,7 @@ IndentedLine = re.compile(r"^( {2,}|\t)\S")
class Block: class Block:
Start: int # offsets into the document; Text = Document[Start:End] Start: int # offsets into the document; Text = Document[Start:End]
End: int End: int
Kind: str # "Heading", "Code", "Rule" or "Text" Kind: str # "Properties", "Heading", "Code", "Rule" or "Text"
StartLine: int StartLine: int
Level: int = 0 # heading level 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 LastLine = -1 # last non-blank line of the open Text block
Fence: Optional[tuple[str, int]] = None # (character, length) of an open code fence Fence: Optional[tuple[str, int]] = None # (character, length) of an open code fence
FenceFrom = 0 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: def CloseCurrent() -> None:
nonlocal Current nonlocal Current
@@ -57,6 +65,8 @@ def SplitBlocks(Document: str) -> list[Block]:
Current = None Current = None
for Index, Line in enumerate(Lines): for Index, Line in enumerate(Lines):
if Index <= SkipUntil:
continue
if Fence is not None: if Fence is not None:
Match = FenceStart.match(Line) 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(): 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] = [] self.BlockControls: list[ft.Control] = []
# Rendered blocks by their text, reused when the layout is rebuilt so # Rendered blocks by their text, reused when the layout is rebuilt so
# only the edited part is rendered again. # 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.EditingIndex: Optional[int] = None # len(Blocks) is the new block at the end
self.Field: Optional[ft.TextField] = None self.Field: Optional[ft.TextField] = None
self._Layout(Update=False) self._Layout(Update=False)
@@ -219,24 +229,46 @@ class LiveMarkdown(ft.Column):
# --- Rendering ------------------------------------------------------- # --- Rendering -------------------------------------------------------
def _Render(self, Text: str) -> ft.Control: def _Markdown(self, Text: str) -> ft.Control:
Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram else None return ft.Markdown(
Rendered = ft.Container(
content=Diagram or ft.Markdown(
value=Text, value=Text,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.FontSize), md_style_sheet=MarkdownSheet(self.FontSize),
code_theme=MarkdownCodeTheme(), code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(), code_style_sheet=CodeStyleSheet(),
on_tap_link=self.OnTapLink, on_tap_link=self.OnTapLink,
),
padding=ft.Padding.symmetric(horizontal=8, vertical=6),
border_radius=4,
) )
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_click = lambda e, C=Rendered: self._OnBlockClicked(C)
Rendered.on_hover = lambda e, C=Rendered: self._OnHover(C, e) Rendered.on_hover = lambda e, C=Rendered: self._OnHover(C, e)
return Rendered 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 @staticmethod
def _OnHover(Control: ft.Container, Event) -> None: def _OnHover(Control: ft.Container, Event) -> None:
Control.bgcolor = Palette.Mantle if str(Event.data).lower() == "true" else 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) self.BlockControls.append(self.Field)
continue continue
Text = self.Document[Item.Start:Item.End] Text = self.Document[Item.Start:Item.End]
Reused = Cache.get(Text) Reused = Cache.get((Item.Kind, Text))
Control = Reused.pop() if Reused else self._Render(Text) Control = Reused.pop() if Reused else self._Render(Text, Item.Kind)
Control.bgcolor = None Control.bgcolor = None
self.BlockControls.append(Control) self.BlockControls.append(Control)
self.RenderCache.setdefault(Text, []).append(Control) self.RenderCache.setdefault((Item.Kind, Text), []).append(Control)
Controls = list(self.BlockControls) Controls = list(self.BlockControls)
if self.EditingIndex == len(self.Blocks): if self.EditingIndex == len(self.Blocks):
Controls.append(self.Field) 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)]
+30 -4
View File
@@ -2,7 +2,10 @@
EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar). EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
- Markdown is rendered in View; the other text types are shown as - 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. (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 - With Markdown editing set to Live (Settings > General), Markdown opens in
LiveMarkdown instead: rendered, and click a block to edit it in place. 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 App import App
from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
from Diagrams import MaybeDiagram, WithDiagrams from Diagrams import MaybeDiagram, WithDiagrams
from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes
from LiveMarkdown import LiveMarkdown from LiveMarkdown import LiveMarkdown
from Sources import Entry, SourceError, TextFileTypes from Sources import Entry, SourceError, TextFileTypes
@@ -137,18 +141,30 @@ class FileView(ft.Container):
Text = self.TextField.value or "" Text = self.TextField.value or ""
Views: Optional[list[ft.Control]] = None Views: Optional[list[ft.Control]] = None
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit: 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: def MakeMarkdown(Part: str) -> ft.Control:
return ft.Markdown( return ft.Markdown(
value=Part, value=Part,
selectable=True, selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize), md_style_sheet=MarkdownSheet(FontSize),
code_theme=MarkdownCodeTheme(), code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(), code_style_sheet=CodeStyleSheet(),
on_tap_link=self._OnTapLink, 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] View: ft.Control = Views[0]
elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit: elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit:
Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower() Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower()
@@ -203,6 +219,14 @@ class FileView(ft.Container):
if Target.startswith(("http://", "https://", "mailto:")): if Target.startswith(("http://", "https://", "mailto:")):
self.TheApp.Page.run_task(ft.UrlLauncher().launch_url, Target) 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 ---------------------------------------------------------- # --- Editing ----------------------------------------------------------
@property @property
@@ -273,7 +297,8 @@ class FileView(ft.Container):
if self.IsText and not self.ToggleButton.disabled: if self.IsText and not self.ToggleButton.disabled:
self._ToggleEdit() 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: if not self.CanSave:
return return
try: try:
@@ -287,6 +312,7 @@ class FileView(ft.Container):
self.StatusText.value = "Saved." self.StatusText.value = "Saved."
self.SaveButton.update() self.SaveButton.update()
self.StatusText.update() self.StatusText.update()
if not Quiet:
self.TheApp.Notify(f"Saved {self.Item.Name}.", "success") self.TheApp.Notify(f"Saved {self.Item.Name}.", "success")
def RequestClose(self, Then: Optional[Callable[[], None]] = None) -> None: def RequestClose(self, Then: Optional[Callable[[], None]] = None) -> None: