"""Real hierarchical directory tree for the sidebar, separate from Bookmarks (see the mockup: sidebar splits into a bookmarks section and a filetree section). Children are loaded lazily on expand, since the tree can be rooted at "/". Each node also accepts drops from the Browser's file list, moving the dropped entry into that directory.""" from pathlib import Path from typing import Callable, Optional import flet as ft from Core import AppConfig, DefaultFiletreeRoot, DragGroup, MoveIntoDirectory, NotificationStyle, Palette class _FiletreeNode: def __init__( self, EntryPath: Path, Config: AppConfig, OnNavigate: Callable[[Path], None], Depth: int, OnFilesMoved: Optional[Callable[[], None]] = None, ): self.EntryPath = EntryPath self.Config = Config self.OnNavigate = OnNavigate self.Depth = Depth self.OnFilesMoved = OnFilesMoved self.ChildrenLoaded = False self.ChildNodes: dict[Path, "_FiletreeNode"] = {} self.IsSelected = False self.NameLabel = ft.Text(EntryPath.name or str(EntryPath), size=13, color=Palette.Text) self.NameContainer = ft.Container( content=ft.GestureDetector( content=self.NameLabel, on_tap=lambda e: self.OnNavigate(self.EntryPath), ), padding=4, border_radius=4, ) self.Tile = ft.ExpansionTile( title=ft.DragTarget(group=DragGroup, content=self.NameContainer, on_accept=self._OnEntryDropped), leading=ft.Icon(ft.Icons.FOLDER_OUTLINED, size=16, color=Palette.Overlay0), controls=[], tile_padding=ft.Padding.only(left=8 + Depth * 14, right=8), icon_color=Palette.Overlay0, collapsed_icon_color=Palette.Overlay0, on_change=self._OnExpansionChanged, ) def _OnExpansionChanged(self, e) -> None: if self.Tile.expanded and not self.ChildrenLoaded: self._LoadChildren() def _LoadChildren(self) -> None: try: SubDirs = sorted( (p for p in self.EntryPath.iterdir() if p.is_dir()), key=lambda p: p.name.lower(), ) except (PermissionError, FileNotFoundError, OSError): SubDirs = [] if not self.Config.ShowDotfiles: SubDirs = [p for p in SubDirs if not p.name.startswith(".")] self.ChildNodes = {} Controls = [] for SubDir in SubDirs: Node = _FiletreeNode(SubDir, self.Config, self.OnNavigate, self.Depth + 1, self.OnFilesMoved) self.ChildNodes[SubDir] = Node Controls.append(Node.Tile) self.Tile.controls = Controls self.ChildrenLoaded = True self.Tile.update() def _OnEntryDropped(self, e: ft.DragTargetEvent) -> None: SourcePath = Path(e.src.data) try: MoveIntoDirectory(SourcePath, self.EntryPath) except OSError as Error: self._Notify(str(Error)) return if self.ChildrenLoaded: self._LoadChildren() if self.OnFilesMoved: self.OnFilesMoved() self._Notify(f'Moved "{SourcePath.name}" to {self.EntryPath.name or self.EntryPath}.', Kind="success") def _Notify(self, Message: str, Kind: str = "error") -> None: Page = self.NameContainer.page Background, Accent = NotificationStyle(Kind) Page.show_dialog( ft.SnackBar( content=ft.Text(Message, color=Accent), bgcolor=Background, shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)), open=True, ) ) def SetSelected(self, IsSelected: bool) -> None: self.IsSelected = IsSelected self.NameContainer.bgcolor = Palette.Surface1 if IsSelected else None self.NameLabel.color = Palette.Mauve if IsSelected else Palette.Text self.NameContainer.update() class Filetree(ft.Container): def __init__( self, OnNavigate: Callable[[Path], None], Config: AppConfig, RootPath: Optional[Path] = None, OnFilesMoved: Optional[Callable[[], None]] = None, ): self.OnNavigate = OnNavigate self.Config = Config self.RootPath = RootPath or Path(DefaultFiletreeRoot) self.OnFilesMoved = OnFilesMoved self.SelectedNode: Optional[_FiletreeNode] = None self.RootNode = _FiletreeNode(self.RootPath, Config, OnNavigate, Depth=0, OnFilesMoved=OnFilesMoved) super().__init__( content=ft.ListView(controls=[self.RootNode.Tile], expand=True), expand=True, bgcolor=Palette.Mantle, ) def RevealPath(self, TargetPath: Path) -> None: """Expand + highlight the tree down to TargetPath, lazily loading as needed.""" if self.SelectedNode: self.SelectedNode.SetSelected(False) self.SelectedNode = None try: Relative = TargetPath.relative_to(self.RootPath) except ValueError: return # TargetPath isn't under this tree's root Node = self.RootNode Parts = Relative.parts if str(Relative) != "." else () for Part in Parts: if not Node.ChildrenLoaded: Node._LoadChildren() NextPath = Node.EntryPath / Part NextNode = Node.ChildNodes.get(NextPath) if NextNode is None: return # hidden, permission denied, or not a directory Node.Tile.expanded = True Node.Tile.update() Node = NextNode Node.SetSelected(True) self.SelectedNode = Node