Files
SaraFem/Filetree.py
T
2026-09-24 22:03:58 +02:00

160 lines
5.6 KiB
Python

"""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, 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("/")
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