diff --git a/Client/Browser.py b/Client/Browser.py index b4f4f1d..0214c5b 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -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 diff --git a/Client/Core.py b/Client/Core.py index 8d088d4..c8a78a4 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -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 diff --git a/Client/Filetree.py b/Client/Filetree.py index 6c4dbd8..3fffec4 100644 --- a/Client/Filetree.py +++ b/Client/Filetree.py @@ -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. diff --git a/Client/LiveMarkdown.py b/Client/LiveMarkdown.py index 629a9bb..4c31442 100644 --- a/Client/LiveMarkdown.py +++ b/Client/LiveMarkdown.py @@ -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) diff --git a/Client/Main.py b/Client/Main.py index 7aa0c54..1397c07 100644 --- a/Client/Main.py +++ b/Client/Main.py @@ -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 diff --git a/Client/Properties.py b/Client/Properties.py index 6f22cfb..3766255 100644 --- a/Client/Properties.py +++ b/Client/Properties.py @@ -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) diff --git a/Client/Settings.py b/Client/Settings.py index eb0d5d5..0850747 100644 --- a/Client/Settings.py +++ b/Client/Settings.py @@ -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 diff --git a/Client/Viewer.py b/Client/Viewer.py index 6ac7fb6..6f1324c 100644 --- a/Client/Viewer.py +++ b/Client/Viewer.py @@ -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."""