Client: links and images open, line breaks like the web, directory notes setting

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 18:22:51 +02:00
1 parent 55ee8e2f23
commit 0513d94c6a
8 files changed
+134 -23

No files matched your search

+43 -3
View File
@@ -12,7 +12,7 @@ import flet as ft
from App import App
from Core import ChipButtonStyle, FileIcons, HumanDuration, HumanSize, OpenExternally, Palette
from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError
from Sources import BaseName, ClassifyFile, Entry, JoinPath, MoveDestination, ParentPath, SourceError
from Graph import GraphView
from Viewer import FileView
@@ -438,24 +438,64 @@ class Browser(ft.Column):
return
Root, RelativePath = self._EntryPath(Name)
if Item.IsDirectory:
self.NavigateTo(Root, RelativePath)
self.OpenDirectory(Root, RelativePath, Item.Note)
return
if InApp is None:
InApp = self.Config.OpenFilesInApp
if not InApp:
self._OpenExternally(Root, RelativePath)
return
self._ShowFile(Root, RelativePath, Item)
def _ShowFile(self, Root: str, RelativePath: str, Item: Entry) -> None:
"""The file view, for a file in the current directory (it may be one
the list hides, like a directory's .sv_ note)."""
self.OpenView = FileView(
self.TheApp, Root, RelativePath, Item,
OnClose=self._CloseFile,
OnOpenExternally=lambda: self._OpenExternally(Root, RelativePath),
OnDownload=(lambda: self._Download(Name)) if self.TheApp.Server else None,
OnDownload=(lambda: self._Download(Item.Name)) if self.TheApp.Server else None,
OnFocusChange=self._SetShortcutsPaused,
OnOpenLink=self.OpenLink,
)
self.FileArea.content = self.OpenView
self.FileArea.update()
self.OnPathChanged(Root, RelativePath, False) # highlights the file in the tree
def OpenDirectory(self, Root: str, RelativePath: str, Note: Optional[dict] = None) -> None:
"""A directory clicked (in the list or the tree): go there, and with
Config.NavigateIntoDirectories open its .sv_ note too."""
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.OpenDirectory(Root, RelativePath, Note))
return
self.NavigateTo(Root, RelativePath)
if self.Config.NavigateIntoDirectories and Note and RelativePath:
self._ShowFile(Root, JoinPath(RelativePath, Note["Name"]),
Entry(Note["Name"], False, Note.get("FileType") or ClassifyFile(Note["Name"])))
def OpenLink(self, Root: str, RelativePath: str) -> None:
"""A link in a note (or an image in one) clicked: open what it points at."""
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.OpenLink(Root, RelativePath))
return
Directory, Name = ParentPath(RelativePath), BaseName(RelativePath)
if not Name:
self.NavigateTo(Root, "")
return
try:
Listed = {Item.Name: Item for Item in self.TheApp.Source.List(Root, Directory)}
except SourceError as Error:
self.TheApp.Notify(f"Couldn't open {RelativePath}: {Error}", "error")
return
Item = Listed.get(Name)
if Item is not None and Item.IsDirectory:
self.OpenDirectory(Root, RelativePath, Item.Note)
return
if (Root, Directory) != (self.Root, self.Path) or self.OpenView is not None:
self.NavigateTo(Root, Directory)
# Not listed may still be there (a hidden .sv_ note); the view says if not.
self._ShowFile(Root, RelativePath, Item or Entry(Name, False, ClassifyFile(Name)))
def _CloseFile(self) -> None:
self.OpenView = None
self.ShortcutsPaused = False
+18
View File
@@ -193,6 +193,9 @@ class AppConfig:
# Double-click on a file: True opens it in SaraView's own viewer, False
# hands it to the system's default program. The menu always offers the other.
OpenFilesInApp: bool = True
# Clicking a directory (in the tree or the list) also opens its .sv_ note,
# like the web view's "Open directory file on click".
NavigateIntoDirectories: bool = False
# Draw ```mermaid diagrams in local folders (LocalDiagrams.py); off removes
# what it downloaded. On a server, the server draws them either way.
LocalDiagrams: bool = True
@@ -438,6 +441,21 @@ def CodeStyleSheet() -> ft.MarkdownStyleSheet:
return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily))
def HardLineBreaks(Text: str) -> str:
"""Every line break shows, like the web view's Markdown (breaks: true):
Flet's joins a paragraph's lines into one. Code blocks stay as they are."""
Lines = Text.split("\n")
InFence = False
for Index, Line in enumerate(Lines[:-1]):
if Line.lstrip().startswith(("```", "~~~")):
InFence = not InFence
continue
if InFence or not Line.strip() or not Lines[Index + 1].strip() or Line.endswith((" ", "\\")):
continue
Lines[Index] = Line + " "
return "\n".join(Lines)
def MarkdownSheet(FontSize: int) -> ft.MarkdownStyleSheet:
"""Rendered Markdown's colours, the same roles as the web view's
.MarkdownBody (Frontend/src/Styles/Theme.css): rainbow headings, coloured
+5 -4
View File
@@ -55,8 +55,8 @@ class _FileLeaf:
class _FiletreeNode:
def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None],
OnOpenFile: Callable[[str, str], None], Depth: int):
def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[..., None],
OnOpenFile: Callable[[str, str], None], Depth: int, Note: Optional[dict] = None):
self.TheApp = TheApp
self.Root = Root
self.RelativePath = RelativePath
@@ -68,7 +68,8 @@ class _FiletreeNode:
self.FileLeaves: dict[str, _FileLeaf] = {}
Name = RelativePath.rsplit("/", 1)[-1] if RelativePath else Root
self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: self.OnNavigate(self.Root, self.RelativePath))
# OnNavigate(Root, Path, Note): the browser's OpenDirectory, which may open the .sv_ note too.
self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: self.OnNavigate(self.Root, self.RelativePath, Note))
self.Tile = ft.ExpansionTile(
title=self.NameContainer,
leading=ft.Icon(ft.Icons.STORAGE_OUTLINED if not RelativePath else ft.Icons.FOLDER_OUTLINED,
@@ -98,7 +99,7 @@ class _FiletreeNode:
ChildPath = JoinPath(self.RelativePath, Item.Name)
if Item.IsDirectory:
self.ChildNodes[Item.Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate,
self.OnOpenFile, self.Depth + 1)
self.OnOpenFile, self.Depth + 1, Item.Note)
elif self.TheApp.Config.ShowFilesInTree:
self.FileLeaves[Item.Name] = _FileLeaf(self.Root, ChildPath, Item.FileType, self.OnOpenFile, self.Depth + 1)
# Directories first, then files, like the file list.
+7 -4
View File
@@ -20,7 +20,7 @@ from typing import Callable, Optional
import flet as ft
import Core
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
from Core import CodeStyleSheet, HardLineBreaks, MarkdownCodeTheme, MarkdownSheet, Palette
from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes
from Variables import Substitute
@@ -117,7 +117,8 @@ 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,
GetSpans: Optional[Callable[[str], list[dict]]] = None):
GetSpans: Optional[Callable[[str], list[dict]]] = None,
MakeImage: Optional[Callable[[str, str], Optional[ft.Control]]] = None):
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.Document = Document
@@ -127,6 +128,7 @@ class LiveMarkdown(ft.Column):
self.OnTapLink = OnTapLink
self.MakeDiagram = MakeDiagram
self.GetSpans = GetSpans
self.MakeImage = MakeImage
self.SpansFor: tuple[str, list[dict]] = ("", []) # (document, its spans)
self.Blocks: list[Block] = []
self.BlockControls: list[ft.Control] = []
@@ -237,7 +239,7 @@ class LiveMarkdown(ft.Column):
def _Markdown(self, Text: str) -> ft.Control:
return ft.Markdown(
value=Text,
value=HardLineBreaks(Text),
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.FontSize),
code_theme=MarkdownCodeTheme(),
@@ -263,7 +265,8 @@ class LiveMarkdown(ft.Column):
elif Kind == "Properties" and (Found := ParseProperties(Text)) is not None:
Rendered.content = PropertiesView(Found, self.FontSize, self._Markdown, Toggle)
elif Kind == "Text":
Parts = WithCheckboxes(Shown if Shown is not None else Text, 0, self._Markdown, Toggle, self.FontSize, LineMap)
Parts = WithCheckboxes(Shown if Shown is not None else Text, 0, self._Markdown, Toggle, self.FontSize, LineMap,
self.MakeImage)
Rendered.content = Parts[0] if len(Parts) == 1 else ft.Column(Parts, spacing=0, tight=True)
else:
Rendered.content = self._Markdown(Shown if Shown is not None and Kind == "Heading" else Text)
+1 -1
View File
@@ -92,7 +92,7 @@ def Main(Page: ft.Page) -> None:
TheBrowser = Browser(TheApp, OnPathChanged,
lambda Category=None: ShowSettings(Category or ("Connection" if TheApp.ConnectError else "General")),
ToggleSidebar)
Tree = Filetree(TheApp, TheBrowser.NavigateTo, TheBrowser.OpenFile)
Tree = Filetree(TheApp, TheBrowser.OpenDirectory, TheBrowser.OpenFile)
Tree.SetEntryButtons(TheBrowser.EntryButtons, Config.NewEntryButtonsAtTop, Update=False)
Divider = ResizeHandle(OnDrag, OnDragEnd)
Tree.visible = Divider.visible = Config.ShowSidebar
+13 -3
View File
@@ -22,6 +22,7 @@ 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)\]")
ImageLine = re.compile(r"^\s*!\[([^\]]*)\]\(<?([^)>]+?)>?\)\s*$")
BooleanValue = re.compile(r"^(true|false)$", re.IGNORECASE)
Quoted = re.compile(r"""^(["']).*\1$""")
IntegerValue = re.compile(r"^[+-]?\d+$")
@@ -169,9 +170,10 @@ def PropertiesView(Found: Parsed, FontSize: int, MakeMarkdown: Callable[[str], f
def WithCheckboxes(Text: str, FirstLine: int, MakeMarkdown: Callable[[str], ft.Control],
Toggle: OnToggle, FontSize: int, LineMap: Optional[list[Optional[int]]] = None) -> 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; LineMap
Toggle: OnToggle, FontSize: int, LineMap: Optional[list[Optional[int]]] = None,
MakeImage: Optional[Callable[[str, str], Optional[ft.Control]]] = None) -> list[ft.Control]:
"""Markdown with its task lines as real checkboxes, and lines that are
only an image as the image (MakeImage): the lines between render as usual. FirstLine is where Text starts in the note; LineMap
(Variables.Substitute) says which of its lines each shown line is, None
for one from a variable's result (its checkbox can't be ticked)."""
Controls: list[ft.Control] = []
@@ -186,6 +188,14 @@ def WithCheckboxes(Text: str, FirstLine: int, MakeMarkdown: Callable[[str], ft.C
for Offset, Line in enumerate(Text.split("\n")):
if re.match(r"^ {0,3}(`{3,}|~{3,})", Line):
InFence = not InFence
# A line that's only an image: shown and clickable (MakeImage), as
# Flet's Markdown has no click for images.
Picture = ImageLine.match(Line) if MakeImage is not None and not InFence else None
Shown = MakeImage(Picture.group(2), Picture.group(1)) if Picture else None
if Shown is not None:
Flush()
Controls.append(Shown)
continue
Task = None if InFence else TaskLine.match(Line)
if Task is None:
Pending.append(Line)
+5 -1
View File
@@ -103,6 +103,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
ConfirmDeleteSwitch = ft.Switch(label="Ask before deleting", value=Config.ConfirmDelete)
SidebarSwitch = ft.Switch(label="Show the directory tree (Ctrl+B)", value=Config.ShowSidebar)
TreeFilesSwitch = ft.Switch(label="Show files in the tree (off: directories only)", value=Config.ShowFilesInTree)
DirectoryNotesSwitch = ft.Switch(label="Clicking a directory also opens its .sv_ note",
value=Config.NavigateIntoDirectories)
ButtonsAtTopSwitch = ft.Switch(label="New File, New Directory and Upload above the tree (off: below)",
value=Config.NewEntryButtonsAtTop)
MarkdownEditingPicker = ft.RadioGroup(
@@ -228,7 +230,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
ft.Row([ChipButton("Export", OnExport), ChipButton("Import", OnImport)]),
], spacing=12, scroll=ft.ScrollMode.AUTO),
"General": ft.Column([
OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, TreeFilesSwitch, ButtonsAtTopSwitch, FontSizeField, MenuButton,
OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, TreeFilesSwitch, DirectoryNotesSwitch, ButtonsAtTopSwitch,
FontSizeField, MenuButton,
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Markdown editing"),
MarkdownEditingPicker,
@@ -304,6 +307,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
Config.ShowSidebar = SidebarSwitch.value
Config.ShowFilesInTree = TreeFilesSwitch.value
Config.NewEntryButtonsAtTop = ButtonsAtTopSwitch.value
Config.NavigateIntoDirectories = DirectoryNotesSwitch.value
Config.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing
RemoveDiagramTools = Config.LocalDiagrams and not LocalDiagramsSwitch.value
Config.LocalDiagrams = LocalDiagramsSwitch.value
+42 -7
View File
@@ -18,17 +18,18 @@ EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
- SaraTag tags (from the server's Books) show as chips under the toolbar."""
import re
from urllib.parse import unquote
from typing import Callable, Optional
import flet as ft
from App import App
from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
from Core import ChipButton, CodeStyleSheet, HardLineBreaks, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
from Diagrams import MaybeDiagram, WithDiagrams
from Properties import ParseProperties, PropertiesView, ToggleLine, WithCheckboxes
from Variables import FindSpans, Substitute
from LiveMarkdown import LiveMarkdown
from Sources import Entry, SourceError, TextFileTypes
from Sources import Entry, NormalizeRelativePath, ParentPath, SourceError, TextFileTypes
LanguageByExtension = {
".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", ".cfg": "ini", ".ini": "ini",
@@ -58,6 +59,7 @@ class FileView(ft.Container):
OnOpenExternally: Callable[[], None],
OnDownload: Optional[Callable[[], None]],
OnFocusChange: Callable[[bool], None],
OnOpenLink: Optional[Callable[[str, str], None]] = None,
):
self.TheApp = TheApp
self.Root = Root
@@ -66,6 +68,8 @@ class FileView(ft.Container):
self.FileType = Item.FileType or "unknown"
self.OnClose = OnClose
self.OnFocusChange = OnFocusChange
self.OnOpenLink = OnOpenLink # (Root, RelativePath): a link to another note, or an image, clicked
self.Pictures: dict[str, Optional[bytes]] = {} # images by path, read once
self.IsText = self.FileType in TextFileTypes
self.IsLive = (self.FileType == "markdown" and TheApp.Config.MarkdownEditing == "Live"
and TheApp.Source.CanWrite)
@@ -130,7 +134,8 @@ class FileView(ft.Container):
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
self.OnFocusChange, self._OnTapLink,
MakeDiagram=lambda Text, Size: MaybeDiagram(self.TheApp, Text, Size),
GetSpans=lambda Document: FindSpans(self.TheApp, self.Root, self.RelativePath, Document))
GetSpans=lambda Document: FindSpans(self.TheApp, self.Root, self.RelativePath, Document),
MakeImage=self._Image)
return self.Live
return self._RenderedView()
if self.FileType == "image":
@@ -152,7 +157,7 @@ class FileView(ft.Container):
def MakeMarkdown(Part: str) -> ft.Control:
return ft.Markdown(
value=Part,
value=HardLineBreaks(Part),
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(FontSize),
@@ -164,7 +169,7 @@ class FileView(ft.Container):
def MakeWithCheckboxes(Part: str, Start: int) -> ft.Control:
FirstLine = Text[:BodyStart + Start].count("\n")
Shown, LineMap = Substitute(Part, BodyStart + Start, Spans) # variables as their results
Parts = WithCheckboxes(Shown, FirstLine, MakeMarkdown, Toggle, FontSize, LineMap)
Parts = WithCheckboxes(Shown, FirstLine, MakeMarkdown, Toggle, FontSize, LineMap, self._Image)
return Parts[0] if len(Parts) == 1 else ft.Column(Parts, spacing=0, tight=True)
Views = WithDiagrams(self.TheApp, Text[BodyStart:], FontSize, MakeWithCheckboxes)
@@ -220,9 +225,39 @@ class FileView(ft.Container):
return ft.Row(Chips, wrap=True, spacing=4, run_spacing=4)
def _OnTapLink(self, e) -> None:
Target = str(e.data or "")
if Target.startswith(("http://", "https://", "mailto:")):
self.OpenTarget(str(e.data or ""))
def OpenTarget(self, Target: str) -> None:
"""A clicked link or image: web addresses in the browser, a note (or
directory, or image) relative to this one in SaraView."""
if not Target or Target.startswith("#"):
return
if re.match(r"^[a-z][a-z0-9+.-]*:", Target, re.IGNORECASE):
self.TheApp.Page.run_task(ft.UrlLauncher().launch_url, Target)
elif self.OnOpenLink is not None:
Path = NormalizeRelativePath(ParentPath(self.RelativePath), unquote(Target.split("#", 1)[0]))
self.OnOpenLink(self.Root, Path)
def _Image(self, Src: str, Alt: str) -> Optional[ft.Control]:
"""A line that's only an image (Properties.WithCheckboxes): the image,
opened when clicked. None if it can't be read: then it shows as written."""
if re.match(r"^https?://", Src):
Picture: ft.Image = ft.Image(src=Src, fit=ft.BoxFit.CONTAIN, tooltip=Alt or None)
else:
Path = NormalizeRelativePath(ParentPath(self.RelativePath), unquote(Src))
if Path not in self.Pictures:
try:
self.Pictures[Path] = self.TheApp.Source.ReadBytes(self.Root, Path)
except SourceError:
self.Pictures[Path] = None
if self.Pictures[Path] is None:
return None
Picture = ft.Image(src=self.Pictures[Path], fit=ft.BoxFit.CONTAIN, tooltip=Alt or None)
return ft.Container(
ft.GestureDetector(content=Picture, on_tap=lambda e: self.OpenTarget(Src), mouse_cursor=ft.MouseCursor.CLICK),
alignment=ft.Alignment.CENTER_LEFT,
padding=ft.Padding.symmetric(vertical=4),
)
def _ToggleAndSave(self, Line: int) -> None:
"""A checkbox ticked in View: flip its line and save, like the web view."""