diff --git a/Backend/Settings.py b/Backend/Settings.py index a68d9e3..3fb5d3c 100644 --- a/Backend/Settings.py +++ b/Backend/Settings.py @@ -45,6 +45,9 @@ DefaultSettings: dict[str, Any] = { "ConfirmBeforeDelete": True, "VisibleInBlame": True, "ShowBlameAvatars": False, + # "Live": Markdown is edited where it's shown rendered (Obsidian-style + # live preview). "Separate": rendered View and raw Edit are two modes. + "MarkdownEditing": "Live", } DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes" diff --git a/Client/Browser.py b/Client/Browser.py index 2deadf5..661142d 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -11,20 +11,12 @@ from typing import Callable, Optional import flet as ft from App import App -from Core import ChipButtonStyle, HumanSize, OpenExternally, Palette +from Core import ChipButtonStyle, FileIcons, HumanSize, OpenExternally, Palette from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError from Viewer import FileView DoubleClickWindow = 0.4 # seconds -FileIcons = { - "markdown": ft.Icons.DESCRIPTION_OUTLINED, - "text": ft.Icons.ARTICLE_OUTLINED, - "config": ft.Icons.SETTINGS_OUTLINED, - "html": ft.Icons.CODE, - "image": ft.Icons.IMAGE_OUTLINED, - "video": ft.Icons.MOVIE_OUTLINED, -} TypeLabels = {"markdown": "Markdown", "text": "Text", "config": "Config", "html": "HTML", "image": "Image", "video": "Video"} @@ -366,6 +358,17 @@ class Browser(ft.Column): self.EntryRows[RowName].update() self.SelectedName = Name + def OpenFile(self, Root: str, RelativePath: str) -> None: + """Opens a file from anywhere (the sidebar tree): goes to its directory + first, so Back and Esc land in the list it's in.""" + if self.OpenView is not None and self.OpenView.IsDirty: + self.OpenView.RequestClose(Then=lambda: self.OpenFile(Root, RelativePath)) + return + Directory = ParentPath(RelativePath) + if (Root, Directory) != (self.Root, self.Path) or self.OpenView is not None: + self.NavigateTo(Root, Directory) + self.OpenEntry(BaseName(RelativePath), InApp=True) + def OpenEntry(self, Name: str, InApp: Optional[bool] = None) -> None: Item = self._Find(Name) if Item is None: @@ -388,11 +391,13 @@ class Browser(ft.Column): ) self.FileArea.content = self.OpenView self.FileArea.update() + self.OnPathChanged(Root, RelativePath, False) # highlights the file in the tree def _CloseFile(self) -> None: self.OpenView = None self.ShortcutsPaused = False self._ShowList() + self.OnPathChanged(self.Root, self.Path, False) def _OpenExternally(self, Root: str, RelativePath: str) -> None: try: @@ -618,7 +623,8 @@ class Browser(ft.Column): View.ToggleEdit() return if Event.key == "Escape" and View is not None: - View.RequestClose() + if not View.HandleEscape(): + View.RequestClose() return if self.ShortcutsPaused: return diff --git a/Client/Core.py b/Client/Core.py index 047b9f2..34669af 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -61,6 +61,16 @@ DataHome = Home / ".local" / "share" BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) AssetsDir = BundleDir / "assets" +# Shared by the file list and the sidebar tree. +FileIcons = { + "markdown": ft.Icons.DESCRIPTION_OUTLINED, + "text": ft.Icons.ARTICLE_OUTLINED, + "config": ft.Icons.SETTINGS_OUTLINED, + "html": ft.Icons.CODE, + "image": ft.Icons.IMAGE_OUTLINED, + "video": ft.Icons.MOVIE_OUTLINED, +} + _ThemeRoleMap = { "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", @@ -151,7 +161,12 @@ class AppConfig: ConfirmDelete: bool = True ShowSidebar: bool = True SidebarWidth: int = 240 + ShowFilesInTree: bool = True # False: the sidebar tree shows directories only FontSize: int = 14 # file list and viewer text + # "Separate": Markdown opens rendered, with Edit/View to switch to the raw + # text. "Live": rendered, and clicking a block shows its Markdown to edit + # in place (like Obsidian's live preview). Only affects Markdown. + MarkdownEditing: str = "Live" # 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 @@ -360,6 +375,68 @@ def CodeStyleSheet() -> ft.MarkdownStyleSheet: return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily)) +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 + bold/italic/code, a Purple bar on quotes.""" + + def Heading(Colour: str, Extra: int) -> ft.TextStyle: + return ft.TextStyle(size=FontSize + Extra, color=Colour, weight=ft.FontWeight.BOLD) + + return ft.MarkdownStyleSheet( + p_text_style=ft.TextStyle(size=FontSize, color=Palette.Text), + h1_text_style=Heading(Palette.Mauve, 10), + h2_text_style=Heading(Palette.Blue, 7), + h3_text_style=Heading(Palette.Green, 4), + h4_text_style=Heading(Palette.Yellow, 2), + h5_text_style=Heading(Palette.Peach, 1), + h6_text_style=Heading(Palette.Red, 0), + strong_text_style=ft.TextStyle(color=Palette.Peach, weight=ft.FontWeight.BOLD), + em_text_style=ft.TextStyle(color=Palette.Yellow, italic=True), + del_text_style=ft.TextStyle(color=Palette.Overlay0, decoration=ft.TextDecoration.LINE_THROUGH), + a_text_style=ft.TextStyle(color=Palette.Blue, decoration=ft.TextDecoration.UNDERLINE), + code_text_style=ft.TextStyle(font_family=ActiveFontFamily, size=FontSize, color=Palette.Green, bgcolor=Palette.Surface0), + blockquote_text_style=ft.TextStyle(size=FontSize, color=Palette.Overlay0), + blockquote_decoration=ft.BoxDecoration( + bgcolor=Palette.Mantle, border=ft.Border(left=ft.BorderSide(3, Palette.Mauve)), + ), + blockquote_padding=ft.Padding.only(left=12, top=6, right=8, bottom=6), + list_bullet_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve), + checkbox_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve), + table_head_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve, weight=ft.FontWeight.BOLD), + table_body_text_style=ft.TextStyle(size=FontSize, color=Palette.Text), + table_cells_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle), + codeblock_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle, border_radius=4), + horizontal_rule_decoration=ft.BoxDecoration(border=ft.Border(top=ft.BorderSide(1, Palette.Surface1))), + ) + + +def MarkdownCodeTheme() -> ft.MarkdownCustomCodeTheme: + """Syntax colours for code blocks from the palette roles (Catppuccin's + own mapping), in place of a fixed highlight.js theme that ignored the + active SaraSuite theme.""" + + def Style(Colour: str, **Extra) -> ft.TextStyle: + return ft.TextStyle(color=Colour, **Extra) + + return ft.MarkdownCustomCodeTheme( + root=ft.TextStyle(color=Palette.Text, bgcolor=Palette.Mantle, font_family=ActiveFontFamily), + keyword=Style(Palette.Mauve), meta_keyword=Style(Palette.Mauve), doctag=Style(Palette.Mauve), + section=Style(Palette.Mauve, weight=ft.FontWeight.BOLD), bullet=Style(Palette.Mauve), tag=Style(Palette.Mauve), + string=Style(Palette.Green), meta_string=Style(Palette.Green), addition=Style(Palette.Green), + number=Style(Palette.Peach), literal=Style(Palette.Peach), regexp=Style(Palette.Peach), meta=Style(Palette.Peach), + built_in=Style(Palette.Yellow), builtin_name=Style(Palette.Yellow), type=Style(Palette.Yellow), + class_name=Style(Palette.Yellow), attr=Style(Palette.Yellow), attribute=Style(Palette.Yellow), + selector_class=Style(Palette.Yellow), + function=Style(Palette.Blue), title=Style(Palette.Blue), name=Style(Palette.Blue), link=Style(Palette.Blue), + selector_tag=Style(Palette.Blue), selector_id=Style(Palette.Blue), + variable=Style(Palette.Red), template_variable=Style(Palette.Red), symbol=Style(Palette.Red), + deletion=Style(Palette.Red), + comment=Style(Palette.Overlay0, italic=True), quote=Style(Palette.Overlay0, italic=True), + emphasis=ft.TextStyle(italic=True), strong=ft.TextStyle(weight=ft.FontWeight.BOLD), + ) + + def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton: return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled) diff --git a/Client/Filetree.py b/Client/Filetree.py index f9e23bc..e6eaa22 100644 --- a/Client/Filetree.py +++ b/Client/Filetree.py @@ -1,35 +1,68 @@ -"""The sidebar's directory tree, built like SaraFem's Filetree: one node per -root, children loaded lazily on expand (every expand on a server is a -request). Clicking a directory opens it in the browser.""" +"""The sidebar's tree, built like SaraFem's Filetree: one node per root, +children loaded lazily on expand (every expand on a server is a request). +Clicking a directory opens it in the browser; clicking a file opens it in +the viewer. Files can be hidden from the tree in Settings > General.""" -from typing import Callable, Optional +from typing import Callable, Optional, Union import flet as ft from App import App -from Core import Palette +from Core import FileIcons, Palette from Sources import JoinPath, SourceError Location = tuple[str, str] # (Root, Path) +def _TreeLabel(Name: str, OnTap: Callable[[], None]) -> tuple[ft.Text, ft.Container]: + Label = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1) + Holder = ft.Container(content=ft.GestureDetector(content=Label, on_tap=lambda e: OnTap()), padding=4, border_radius=4) + return Label, Holder + + +def _SetHighlight(Label: ft.Text, Holder: ft.Container, IsSelected: bool) -> None: + Holder.bgcolor = Palette.Surface1 if IsSelected else None + Label.color = Palette.Mauve if IsSelected else Palette.Text + Holder.update() + + +class _FileLeaf: + """A file in the tree: a plain row (no expand arrow) indented like a + child tile, so names line up with the directories beside it.""" + + def __init__(self, Root: str, RelativePath: str, FileType: Optional[str], + OnOpenFile: Callable[[str, str], None], Depth: int): + Name = RelativePath.rsplit("/", 1)[-1] + self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: OnOpenFile(Root, RelativePath)) + self.Tile = ft.Container( + content=ft.Row( + [ft.Icon(FileIcons.get(FileType or "", ft.Icons.INSERT_DRIVE_FILE_OUTLINED), size=16, color=Palette.Overlay0), + ft.Container(self.NameContainer, expand=True)], + spacing=12, + ), + # Icon and name line up with a directory tile's at the same depth. + padding=ft.Padding.only(left=8 + Depth * 14, right=8, top=2, bottom=2), + ) + + def SetSelected(self, IsSelected: bool) -> None: + _SetHighlight(self.NameLabel, self.NameContainer, IsSelected) + + class _FiletreeNode: - def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None], Depth: int): + def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None], + OnOpenFile: Callable[[str, str], None], Depth: int): self.TheApp = TheApp self.Root = Root self.RelativePath = RelativePath self.OnNavigate = OnNavigate + self.OnOpenFile = OnOpenFile self.Depth = Depth self.ChildrenLoaded = False self.ChildNodes: dict[str, "_FiletreeNode"] = {} + self.FileLeaves: dict[str, _FileLeaf] = {} Name = RelativePath.rsplit("/", 1)[-1] if RelativePath else Root - self.NameLabel = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1) - self.NameContainer = ft.Container( - content=ft.GestureDetector(content=self.NameLabel, on_tap=lambda e: self.OnNavigate(self.Root, self.RelativePath)), - padding=4, - border_radius=4, - ) + self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: self.OnNavigate(self.Root, self.RelativePath)) self.Tile = ft.ExpansionTile( title=self.NameContainer, leading=ft.Icon(ft.Icons.STORAGE_OUTLINED if not RelativePath else ft.Icons.FOLDER_OUTLINED, @@ -50,30 +83,35 @@ class _FiletreeNode: def LoadChildren(self, Update: bool = True) -> None: try: - Directories = [Item.Name for Item in self.TheApp.Source.List(self.Root, self.RelativePath) if Item.IsDirectory] + Items = self.TheApp.Source.List(self.Root, self.RelativePath) except SourceError: - Directories = [] # locked, gone or unreachable: just no children + Items = [] # locked, gone or unreachable: just no children self.ChildNodes = {} - for Name in Directories: - ChildPath = JoinPath(self.RelativePath, Name) - self.ChildNodes[Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate, self.Depth + 1) - self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()] + self.FileLeaves = {} + for Item in Items: + 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) + 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. + self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()] + [Leaf.Tile for Leaf in self.FileLeaves.values()] self.ChildrenLoaded = True if Update: self.Tile.update() def SetSelected(self, IsSelected: bool) -> None: - self.NameContainer.bgcolor = Palette.Surface1 if IsSelected else None - self.NameLabel.color = Palette.Mauve if IsSelected else Palette.Text - self.NameContainer.update() + _SetHighlight(self.NameLabel, self.NameContainer, IsSelected) class Filetree(ft.Container): - def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None]): + def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None], OnOpenFile: Callable[[str, str], None]): self.TheApp = TheApp self.OnNavigate = OnNavigate + self.OnOpenFile = OnOpenFile self.RootNodes: dict[str, _FiletreeNode] = {} - self.SelectedNode: Optional[_FiletreeNode] = None + self.SelectedNode: Optional[Union[_FiletreeNode, _FileLeaf]] = None self.TreeList = ft.ListView(expand=True) self.SourceLabel = ft.Text("", size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1) super().__init__( @@ -87,7 +125,7 @@ class Filetree(ft.Container): def Reload(self) -> None: """Rebuilds the tree from the current source (after connecting, - logging in or out, or a change that moved directories around).""" + logging in or out, or a change that moved things around).""" Source = self.TheApp.Source self.SourceLabel.value = "Not connected" if self.TheApp.ConnectError else Source.Describe() self.SelectedNode = None @@ -98,7 +136,7 @@ class Filetree(ft.Container): except SourceError: Roots = [] Expanded = {Root: Node.Tile.expanded for Root, Node in self.RootNodes.items()} - self.RootNodes = {Root: _FiletreeNode(self.TheApp, Root, "", self.OnNavigate, 0) for Root in Roots} + self.RootNodes = {Root: _FiletreeNode(self.TheApp, Root, "", self.OnNavigate, self.OnOpenFile, 0) for Root in Roots} for Root, Node in self.RootNodes.items(): # One root (always the case locally) starts open; otherwise keep what was open. if Expanded.get(Root, len(Roots) == 1): @@ -108,21 +146,28 @@ class Filetree(ft.Container): self.update() def RevealPath(self, Root: Optional[str], RelativePath: str) -> None: - """Expand and highlight the tree down to a directory, loading as needed.""" + """Expand and highlight the tree down to a directory or file, loading + as needed.""" if self.SelectedNode: self.SelectedNode.SetSelected(False) self.SelectedNode = None Node = self.RootNodes.get(Root or "") if Node is None: return - for Part in [Part for Part in RelativePath.split("/") if Part]: + Target: Union[_FiletreeNode, _FileLeaf] = Node + Parts = [Part for Part in RelativePath.split("/") if Part] + for Index, Part in enumerate(Parts): if not Node.ChildrenLoaded: Node.LoadChildren() NextNode = Node.ChildNodes.get(Part) - if NextNode is None: - break # hidden or not a directory: highlight the closest parent + Leaf = Node.FileLeaves.get(Part) if Index == len(Parts) - 1 else None + if NextNode is None and Leaf is None: + break # hidden: highlight the closest parent Node.Tile.expanded = True Node.Tile.update() - Node = NextNode - Node.SetSelected(True) - self.SelectedNode = Node + if Leaf is not None: + Target = Leaf + break + Node = Target = NextNode + Target.SetSelected(True) + self.SelectedNode = Target diff --git a/Client/LiveMarkdown.py b/Client/LiveMarkdown.py new file mode 100644 index 0000000..e912f61 --- /dev/null +++ b/Client/LiveMarkdown.py @@ -0,0 +1,268 @@ +"""Live Markdown editing, like Obsidian's live preview: the file is shown +rendered, block by block, and clicking a block swaps it for its Markdown +(a heading shows its #s, a list its -s) to edit in place. Esc, or clicking +another block, puts it back rendered. + +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.""" + +import re +from dataclasses import dataclass +from typing import Callable, Optional + +import flet as ft + +import Core +from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette + +FenceStart = re.compile(r"^ {0,3}(`{3,}|~{3,})") +HeadingLine = re.compile(r"^ {0,3}(#{1,6})(\s|$)") +RuleLine = re.compile(r"^ {0,3}([-*_])( *\1){2,} *$") +IndentedLine = re.compile(r"^( {2,}|\t)\S") + + +@dataclass +class Block: + Start: int # offsets into the document; Text = Document[Start:End] + End: int + Kind: str # "Heading", "Code", "Rule" or "Text" + StartLine: int + Level: int = 0 # heading level + + +def SplitBlocks(Document: str) -> list[Block]: + Lines = Document.split("\n") + Offsets: list[int] = [] + Position = 0 + for Line in Lines: + Offsets.append(Position) + Position += len(Line) + 1 + + def LineEnd(Index: int) -> int: + return Offsets[Index] + len(Lines[Index]) + + Blocks: list[Block] = [] + Current: Optional[int] = None # first 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 + FenceFrom = 0 + + def CloseCurrent() -> None: + nonlocal Current + if Current is not None: + Blocks.append(Block(Offsets[Current], LineEnd(LastLine), "Text", Current)) + Current = None + + for Index, Line in enumerate(Lines): + 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(): + Blocks.append(Block(Offsets[FenceFrom], LineEnd(Index), "Code", FenceFrom)) + Fence = None + continue + if not Line.strip(): + CloseCurrent() + continue + Match = FenceStart.match(Line) + if Match: + CloseCurrent() + Fence = (Match.group(1)[0], len(Match.group(1))) + FenceFrom = Index + continue + Match = HeadingLine.match(Line) + if Match: + CloseCurrent() + Blocks.append(Block(Offsets[Index], LineEnd(Index), "Heading", Index, len(Match.group(1)))) + continue + Match = RuleLine.match(Line) + # "---" right under text is a setext heading's underline, not a rule. + if Match and (Current is None or Match.group(1) != "-"): + CloseCurrent() + Blocks.append(Block(Offsets[Index], LineEnd(Index), "Rule", Index)) + continue + if Current is None: + Previous = Blocks[-1] if Blocks else None + # An indented line after blank lines continues a loose list + # (or a list item's second paragraph) rather than starting a code block. + if Previous is not None and Previous.Kind == "Text" and IndentedLine.match(Line): + Blocks.pop() + Current = Previous.StartLine + else: + Current = Index + LastLine = Index + CloseCurrent() + if Fence is not None: # never closed: runs to the last non-blank line + Last = max((I for I in range(FenceFrom, len(Lines)) if Lines[I].strip()), default=FenceFrom) + Blocks.append(Block(Offsets[FenceFrom], LineEnd(Last), "Code", FenceFrom)) + return Blocks + + +class LiveMarkdown(ft.Column): + def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None], + OnFocusChange: Callable[[bool], None], OnTapLink: Callable): + super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH) + self.Document = Document + self.FontSize = FontSize + self.OnChange = OnChange + self.OnFocusChange = OnFocusChange + self.OnTapLink = OnTapLink + self.Blocks: list[Block] = [] + 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.EditingIndex: Optional[int] = None # len(Blocks) is the new block at the end + self.Field: Optional[ft.TextField] = None + self._Layout(Update=False) + + # --- Text ------------------------------------------------------------ + + @property + def Text(self) -> str: + """The whole document, including what's being typed in the open block.""" + if self.EditingIndex is None or self.Field is None: + return self.Document + return self._Spliced(self.Field.value or "") + + def SetText(self, Document: str, Update: bool = True) -> None: + self.EditingIndex = None + self.Field = None + self.Document = Document + self._Layout(Update) + + def _Spliced(self, NewText: str) -> str: + """The document with the open block replaced by NewText.""" + Index = self.EditingIndex + Document = self.Document + if Index is None: + return Document + if Index == len(self.Blocks): # a new block after the last one + if not NewText.strip(): + return Document + Base = Document + if Base and not Base.endswith("\n"): + Base += "\n" + if Base and not Base.endswith("\n\n"): + Base += "\n" + return Base + NewText.rstrip("\n") + "\n" + Current = self.Blocks[Index] + if NewText.strip(): + return Document[:Current.Start] + NewText + Document[Current.End:] + # Emptied: take its blank lines with it, so no gap is left behind. + if Index + 1 < len(self.Blocks): + return Document[:Current.Start] + Document[self.Blocks[Index + 1].Start:] + if Index > 0: + return Document[:self.Blocks[Index - 1].End] + Document[Current.End:] + return Document[:Current.Start] + Document[Current.End:] + + # --- Editing --------------------------------------------------------- + + @property + def IsEditing(self) -> bool: + return self.EditingIndex is not None + + def Commit(self) -> None: + """Puts the open block back rendered (re-splitting the document, since + the edit may have made one block into several or merged it with the next).""" + if self.EditingIndex is None: + return + self.Document = self.Text + self.EditingIndex = None + self.Field = None + self.OnFocusChange(False) + self._Layout() + + def _StartEditing(self, Index: int) -> None: + self.EditingIndex = Index + Item = self.Blocks[Index] if Index < len(self.Blocks) else None + Value = self.Document[Item.Start:Item.End] if Item else "" + Style = ft.TextStyle(size=self.FontSize, font_family=Core.ActiveFontFamily, color=Palette.Text) + if Item is not None and Item.Kind == "Heading": + # The heading stays its size and colour while its #s are showing. + Style = getattr(MarkdownSheet(self.FontSize), f"h{Item.Level}_text_style") + self.Field = ft.TextField( + value=Value, + multiline=True, + min_lines=1, + autofocus=True, + border=ft.InputBorder.NONE, + filled=True, + fill_color=Palette.Mantle, + content_padding=ft.Padding.symmetric(horizontal=8, vertical=6), + text_style=Style, + cursor_color=Palette.Mauve, + on_change=lambda e: self.OnChange(), + on_focus=lambda e: self.OnFocusChange(True), + ) + self.OnFocusChange(True) + self._Layout() + + def _OnBlockClicked(self, Control: ft.Control) -> None: + if self.EditingIndex is not None: + self.Commit() # Flutter doesn't unfocus a field when something else is clicked + # Find the block again in the (possibly rebuilt) layout. + for Index, Controls in enumerate(self.BlockControls): + if Controls is Control: + self._StartEditing(Index) + return + + def _OnEndClicked(self) -> None: + if self.EditingIndex is not None: + self.Commit() + self._StartEditing(len(self.Blocks)) + + # --- Rendering ------------------------------------------------------- + + def _Render(self, Text: str) -> ft.Control: + Rendered = ft.Container( + content=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, + ) + Rendered.on_click = lambda e, C=Rendered: self._OnBlockClicked(C) + Rendered.on_hover = lambda e, C=Rendered: self._OnHover(C, e) + return Rendered + + @staticmethod + def _OnHover(Control: ft.Container, Event) -> None: + Control.bgcolor = Palette.Mantle if str(Event.data).lower() == "true" else None + Control.update() + + def _Layout(self, Update: bool = True) -> None: + self.Blocks = SplitBlocks(self.Document) + Cache = self.RenderCache + self.RenderCache = {} + self.BlockControls = [] + for Index, Item in enumerate(self.Blocks): + if Index == self.EditingIndex: + 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) + Control.bgcolor = None + self.BlockControls.append(Control) + self.RenderCache.setdefault(Text, []).append(Control) + Controls = list(self.BlockControls) + if self.EditingIndex == len(self.Blocks): + Controls.append(self.Field) + else: + Hint = "Click to start writing." if not self.Blocks else "Click here to add more." + Controls.append(ft.Container( + content=ft.Text(Hint, color=Palette.Overlay0, italic=True, size=self.FontSize - 2), + padding=ft.Padding.symmetric(horizontal=8, vertical=12), + on_click=lambda e: self._OnEndClicked(), + )) + self.controls = Controls + if Update: + self.update() diff --git a/Client/Main.py b/Client/Main.py index 52733d8..8c2e895 100644 --- a/Client/Main.py +++ b/Client/Main.py @@ -67,7 +67,9 @@ def Main(Page: ft.Page) -> None: TheApp.Connect() # its listeners reload the tree and the browser else: Body.update() + Tree.Reload() # "Show files in the tree" may have changed TheBrowser.Refresh() + Tree.RevealPath(TheBrowser.Root, TheBrowser.Path) def ToggleSidebar() -> None: Config.ShowSidebar = not Config.ShowSidebar @@ -85,7 +87,7 @@ def Main(Page: ft.Page) -> None: TheBrowser = Browser(TheApp, OnPathChanged, lambda: ShowSettings("Connection" if TheApp.ConnectError else "General"), ToggleSidebar) - Tree = Filetree(TheApp, TheBrowser.NavigateTo) + Tree = Filetree(TheApp, TheBrowser.NavigateTo, TheBrowser.OpenFile) Divider = ResizeHandle(OnDrag, OnDragEnd) Tree.visible = Divider.visible = Config.ShowSidebar # STRETCH so the divider gets the full height to be dragged along. diff --git a/Client/Settings.py b/Client/Settings.py index 65047ef..4434966 100644 --- a/Client/Settings.py +++ b/Client/Settings.py @@ -99,6 +99,14 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego value=Config.OpenFilesInApp) 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) + MarkdownEditingPicker = ft.RadioGroup( + ft.Column([ + ft.Radio(value="Live", label="Live: click a part of the rendered file to edit its Markdown"), + ft.Radio(value="Separate", label="Separate: View and Edit are two modes"), + ], spacing=0), + value=Config.MarkdownEditing, + ) FontSizeField = ft.TextField(label="Text size", value=str(Config.FontSize), keyboard_type=ft.KeyboardType.NUMBER, width=160) def ToggleMenuEntry(e) -> None: @@ -190,7 +198,10 @@ 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, FontSizeField, MenuButton, + OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, TreeFilesSwitch, FontSizeField, MenuButton, + ft.Divider(height=1, color=Palette.Surface1), + ft.Text("Markdown editing"), + MarkdownEditingPicker, ft.Divider(height=1, color=Palette.Surface1), OwnAppearance, ], spacing=12, scroll=ft.ScrollMode.AUTO), @@ -227,6 +238,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego Config.OpenFilesInApp = OpenInAppSwitch.value Config.ConfirmDelete = ConfirmDeleteSwitch.value Config.ShowSidebar = SidebarSwitch.value + Config.ShowFilesInTree = TreeFilesSwitch.value + Config.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing Config.FontSize = _ReadInt(FontSizeField, Config.FontSize, 8, 32) Config.Mode = ModePicker.value or Config.Mode Config.LocalRoot = (LocalRootField.value or "").strip() or "~" diff --git a/Client/Viewer.py b/Client/Viewer.py index 1587d92..08c7b05 100644 --- a/Client/Viewer.py +++ b/Client/Viewer.py @@ -4,6 +4,9 @@ EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar). - Markdown is rendered in View; the other text types are shown as highlighted code. 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. + Edit/View becomes Source/Live there. - Images are shown fitted to the window. - Video and other files get their details and Open Externally (a server file is downloaded to ~/.cache/SaraView first). Flet has no built-in @@ -16,7 +19,8 @@ from typing import Callable, Optional import flet as ft from App import App -from Core import ChipButton, CodeStyleSheet, HumanSize, Palette +from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette +from LiveMarkdown import LiveMarkdown from Sources import Entry, SourceError, TextFileTypes LanguageByExtension = { @@ -56,6 +60,9 @@ class FileView(ft.Container): self.OnClose = OnClose self.OnFocusChange = OnFocusChange self.IsText = self.FileType in TextFileTypes + self.IsLive = (self.FileType == "markdown" and TheApp.Config.MarkdownEditing == "Live" + and TheApp.Source.CanWrite) + self.Live: Optional[LiveMarkdown] = None self.IsEditing = False self.IsDirty = False self.Content = "" @@ -63,6 +70,9 @@ class FileView(ft.Container): self.StatusText = ft.Text("", color=Palette.Overlay0, size=12) self.ToggleButton = ChipButton("Edit", self._ToggleEdit, Icon=ft.Icons.EDIT_OUTLINED) + if self.IsLive: + self.ToggleButton.content = "Source" + self.ToggleButton.icon = ft.Icons.CODE self.SaveButton = ChipButton("Save", lambda e: self.Save(), Icon=ft.Icons.SAVE_OUTLINED, Disabled=True) CanWrite = TheApp.Source.CanWrite if not CanWrite: @@ -109,6 +119,10 @@ class FileView(ft.Container): if self.IsText: self.Content = Source.ReadText(self.Root, self.RelativePath) self.TextField.value = self.Content + if self.IsLive: + self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged, + self.OnFocusChange, self._OnTapLink) + return self.Live return self._RenderedView() if self.FileType == "image": return ft.Image(src=Source.ReadBytes(self.Root, self.RelativePath), fit=ft.BoxFit.CONTAIN, expand=True) @@ -124,7 +138,8 @@ class FileView(ft.Container): value=Text, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, - code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK, + md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize), + code_theme=MarkdownCodeTheme(), code_style_sheet=CodeStyleSheet(), on_tap_link=self._OnTapLink, ) @@ -134,7 +149,8 @@ class FileView(ft.Container): value=FencedBlock(Text, LanguageByExtension.get(Extension, "")), selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, - code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK, + md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize), + code_theme=MarkdownCodeTheme(), code_style_sheet=CodeStyleSheet(), ) else: @@ -186,7 +202,7 @@ class FileView(ft.Container): def CanSave(self) -> bool: return self.IsText and self.IsDirty - def _OnTextChanged(self, e) -> None: + def _OnTextChanged(self, e=None) -> None: if not self.IsDirty: self.IsDirty = True self.SaveButton.disabled = False @@ -194,7 +210,17 @@ class FileView(ft.Container): self.SaveButton.update() self.StatusText.update() + @property + def Text(self) -> str: + """What's being edited, in whichever view has it.""" + if self.Live is not None and not self.IsEditing: + return self.Live.Text + return self.TextField.value or "" + def _ToggleEdit(self, e=None) -> None: + if self.Live is not None: + self._ToggleSource() + return self.IsEditing = not self.IsEditing if self.IsEditing: self.Body.content = self.TextField @@ -210,6 +236,32 @@ class FileView(ft.Container): self.Body.update() self.ToggleButton.update() + def _ToggleSource(self) -> None: + """Live mode's toggle: the whole file as plain text, and back.""" + self.IsEditing = not self.IsEditing + if self.IsEditing: + self.TextField.value = self.Live.Text + self.Live.EditingIndex = None # its open block's text is in the field now + self.Body.content = self.TextField + self.ToggleButton.content = "Live" + self.ToggleButton.icon = ft.Icons.VISIBILITY_OUTLINED + self.OnFocusChange(True) + else: + self.Live.SetText(self.TextField.value or "", Update=False) # not on the page yet + self.Body.content = self.Live + self.ToggleButton.content = "Source" + self.ToggleButton.icon = ft.Icons.CODE + self.OnFocusChange(False) + self.Body.update() + self.ToggleButton.update() + + def HandleEscape(self) -> bool: + """Esc first closes an open block in live mode; True if it did.""" + if self.Live is not None and not self.IsEditing and self.Live.IsEditing: + self.Live.Commit() + return True + return False + def ToggleEdit(self) -> None: if self.IsText and not self.ToggleButton.disabled: self._ToggleEdit() @@ -218,11 +270,11 @@ class FileView(ft.Container): if not self.CanSave: return try: - self.TheApp.Source.WriteText(self.Root, self.RelativePath, self.TextField.value or "") + self.TheApp.Source.WriteText(self.Root, self.RelativePath, self.Text) except SourceError as Error: self.TheApp.HandleError(Error, f"Couldn't save {self.Item.Name}") return - self.Content = self.TextField.value or "" + self.Content = self.Text self.IsDirty = False self.SaveButton.disabled = True self.StatusText.value = "Saved." diff --git a/Frontend/package.json b/Frontend/package.json index 88b8ceb..ebd47f4 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -13,6 +13,7 @@ "react-dom": "^18.3.1", "@uiw/react-codemirror": "^4.23.0", "@codemirror/view": "^6.34.1", + "@codemirror/language": "^6.10.3", "@codemirror/lang-markdown": "^6.2.5", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 03a1ed3..a4eabb1 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -387,6 +387,7 @@ export default function App() { ShowGitBlame={Settings ? Settings.ShowGitBlame : false} ShowBlameAvatars={Settings ? Settings.ShowBlameAvatars : false} StickyToolbar={Settings ? Settings.StickyToolbar : true} + MarkdownEditing={Settings ? Settings.MarkdownEditing : "Live"} IsGuest={IsGuest} /> )} diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index 43ef66e..725f986 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { gutter, GutterMarker } from "@codemirror/view"; -import { markdown } from "@codemirror/lang-markdown"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { html } from "@codemirror/lang-html"; import { json } from "@codemirror/lang-json"; import MarkdownIt from "markdown-it"; @@ -9,6 +9,8 @@ import MarkdownItTaskLists from "markdown-it-task-lists"; import MarkdownItMark from "markdown-it-mark"; import MarkdownItFootnote from "markdown-it-footnote"; +import { LivePreview } from "../LivePreview.js"; + import { FetchBlame, FetchFile, @@ -171,11 +173,13 @@ export default function PreviewPane({ ShowGitBlame, ShowBlameAvatars, StickyToolbar, + MarkdownEditing, IsGuest, }) { const [Content, SetContent] = useState(""); const [ResolvedContent, SetResolvedContent] = useState(""); const [IsEditing, SetIsEditing] = useState(false); + const [IsSourceMode, SetIsSourceMode] = useState(false); const [IsSaving, SetIsSaving] = useState(false); const [SaveError, SetSaveError] = useState(null); const [BlameMap, SetBlameMap] = useState({}); @@ -188,6 +192,7 @@ export default function PreviewPane({ useEffect(() => { SetIsEditing(false); + SetIsSourceMode(false); SetSaveError(null); SetBlameMap({}); @@ -281,6 +286,43 @@ export default function PreviewPane({ ); } + // Live: one editor that shows the Markdown rendered and reveals the syntax + // where the cursor is (LivePreview.js). Source shows all of it, plain. + if (FileType === "markdown" && MarkdownEditing === "Live" && !IsGuest) { + return ( +