"""Left-hand panel: Bookmarks (flat, user-curated — Trash is just one of them, and they're drag-reorderable), Devices (removable media via udisks2, Devices.py) and a real Filetree (Filetree.py), each independently shown/ hidden via Config. Bookmarks and Filetree both accept file/directory drops from the Browser's file list to move things into them.""" from pathlib import Path from typing import Callable, Optional import flet as ft from Bookmarks import IconChoices, IconLabelOverrides, LoadBookmarks, SaveBookmarks from Core import ( AppConfig, ChipButtonStyle, DragGroup, MoveIntoDirectory, NotificationStyle, Palette, ResizeHandle, ) from Devices import DevicesPanel from Filetree import Filetree class Sidebar(ft.Container): def __init__( self, OnNavigate: Callable[[Path], None], Config: AppConfig, OnFilesMoved: Optional[Callable[[], None]] = None, ): self.OnNavigate = OnNavigate self.Config = Config self.OnFilesMoved = OnFilesMoved self.LocationTiles: dict[Path, Callable[[bool], None]] = {} self.BookmarksListView: Optional[ft.ReorderableListView] = None self.DevicesView: Optional[DevicesPanel] = None self.FiletreeView: Optional[Filetree] = None Sections: list[ft.Control] = [] SplitEvenly = Config.SplitBookmarksFiletreeEvenly and Config.ShowBookmarks and Config.ShowFiletree # Section boxes a resize-drag can mutate .height on directly — only # built (and only get a drag handle between them) outside SplitEvenly, # which is a 50/50 flex split with no single fixed height to drag. BookmarksBox: Optional[ft.Container] = None DevicesBox: Optional[ft.Container] = None if Config.ShowBookmarks: self.BookmarksListView = ft.ReorderableListView( controls=self._BuildBookmarkTiles(), spacing=0, show_default_drag_handles=False, on_reorder=self._OnBookmarksReordered, ) if SplitEvenly: self.BookmarksListView.expand = 1 Sections.append(self.BookmarksListView) else: BookmarksBox = ft.Container( content=self.BookmarksListView, height=Config.BookmarksMaxHeight or Config.BookmarksMinHeight or None, ) Sections.append(BookmarksBox) if Config.ShowBookmarks and (Config.ShowDevices or Config.ShowFiletree): if BookmarksBox is not None: Sections.append(self._BuildSectionDivider(BookmarksBox, "BookmarksMaxHeight")) else: Sections.append(ft.Divider(height=1, color=Palette.Surface1)) if Config.ShowDevices: self.DevicesView = DevicesPanel(OnNavigate=OnNavigate, Config=Config) DevicesBox = ft.Container( content=self.DevicesView, height=Config.DevicesMaxHeight or Config.DevicesMinHeight or None, ) Sections.append(DevicesBox) if Config.ShowDevices and Config.ShowFiletree: Sections.append(self._BuildSectionDivider(DevicesBox, "DevicesMaxHeight")) if Config.ShowFiletree: self.FiletreeView = Filetree( OnNavigate=OnNavigate, Config=Config, RootPath=Path(Config.FiletreeRoot or "/"), OnFilesMoved=OnFilesMoved, ) if SplitEvenly: self.FiletreeView.expand = 1 else: self.FiletreeView.height = Config.FiletreeMaxHeight or Config.FiletreeMinHeight or None Sections.append(self.FiletreeView) BorderSide = ft.BorderSide(1, Palette.Surface1) Border = ft.Border(left=BorderSide) if Config.SidebarSide == "right" else ft.Border(right=BorderSide) super().__init__( content=ft.Column(Sections, spacing=0, expand=True), width=Config.SidebarWidth, padding=0, bgcolor=Palette.Mantle, border=Border, ) DefaultSectionHeight = 150 # starting point for a drag when a section had no explicit height yet def _BuildSectionDivider(self, Box: ft.Container, MaxHeightAttr: str) -> ft.Control: """A resize handle for the section above it (Box) — drags resize that section, whatever's below just reflows into the space that leaves, same "resize what's above the divider" convention as the file list's column dividers resize the column to their left.""" def OnDrag(Delta: float) -> None: Current = Box.height or self.DefaultSectionHeight Box.height = max(40, Current + Delta) Box.update() def OnDragEnd() -> None: setattr(self.Config, MaxHeightAttr, int(Box.height)) self.Config.Save() return ResizeHandle("vertical", self.Config.SidebarWidth, OnDrag, OnDragEnd) def _BuildBookmarkTiles(self) -> list[ft.Control]: self.LocationTiles = {} return [self._BuildBookmarkTile(B) for B in LoadBookmarks()] def RefreshBookmarks(self) -> None: """Called after add/edit/remove/reorder so the sidebar updates immediately — no restart needed for bookmark changes.""" if self.BookmarksListView is None: return self.BookmarksListView.controls = self._BuildBookmarkTiles() self.BookmarksListView.update() def _OnBookmarksReordered(self, e: ft.OnReorderEvent) -> None: Bookmarks = LoadBookmarks() Moved = Bookmarks.pop(e.old_index) Bookmarks.insert(e.new_index, Moved) SaveBookmarks(Bookmarks) self.RefreshBookmarks() def _BuildBookmarkTile(self, Bookmark) -> ft.Control: # Not a ListTile: the drag-to-reorder handle needs to wrap everything # except the triple-dot menu, which ListTile's fixed leading/title/ # trailing layout doesn't let us carve out — so this is a plain Row. # The selected background lives on OuterContainer (wrapping the whole # Row, triple-dot included) rather than just RowContent — it used to # stop short of the triple-dot, leaving its area unstyled ("I think # the background of the button should continue to the menu"). NameLabel = ft.Text(Bookmark.Name, size=14, color=Palette.Text) RowContent = ft.Container( content=ft.Row( [ ft.Icon(getattr(ft.Icons, Bookmark.Icon, ft.Icons.BOOKMARK_OUTLINE), size=20, color=Palette.Overlay0), NameLabel, ], spacing=12, ), padding=ft.Padding.symmetric(horizontal=16, vertical=10), ink=True, on_click=lambda e, p=Bookmark.Path: self.OnNavigate(p), expand=True, ) MenuButton = ft.PopupMenuButton( icon=ft.Icons.MORE_VERT, items=[ ft.PopupMenuItem(content="Edit", on_click=lambda e, b=Bookmark: self._ShowEditBookmarkDialog(b)), ], ) OuterContainer = ft.Container( content=ft.Row( [ft.Container(ft.ReorderableDragHandle(content=RowContent), expand=True), MenuButton], spacing=0, ), border_radius=4, ) def SetSelected(IsSelected: bool) -> None: OuterContainer.bgcolor = Palette.Surface1 if IsSelected else None NameLabel.color = Palette.Mauve if IsSelected else Palette.Text self.LocationTiles[Bookmark.Path] = SetSelected return ft.DragTarget( group=DragGroup, content=OuterContainer, on_accept=lambda e, dest=Bookmark.Path: self._OnEntryDropped(e, dest), ) def _OnEntryDropped(self, e: ft.DragTargetEvent, DestinationDir: Path) -> None: SourcePath = Path(e.src.data) try: MoveIntoDirectory(SourcePath, DestinationDir) except OSError as Error: self._Notify(str(Error)) return self.RefreshBookmarks() if self.FiletreeView is not None: self.FiletreeView.RevealPath(DestinationDir) if self.OnFilesMoved: self.OnFilesMoved() self._Notify(f'Moved "{SourcePath.name}" to {DestinationDir.name or DestinationDir}.', Kind="success") def _ShowEditBookmarkDialog(self, Bookmark) -> None: NameField = ft.TextField(value=Bookmark.Name, autofocus=True, label="Name") PathField = ft.TextField(value=str(Bookmark.Path), label="Path") IconDropdown = ft.Dropdown( value=Bookmark.Icon, label="Icon", options=[ ft.DropdownOption(key=Name, text=IconLabelOverrides.get(Name, Name.split("_")[0].title())) for Name in IconChoices ], ) def DoSave(e=None) -> None: self.page.pop_dialog() Bookmarks = LoadBookmarks() for B in Bookmarks: if B.Path == Bookmark.Path: B.Name = NameField.value.strip() or Bookmark.Name B.Path = Path(PathField.value.strip() or Bookmark.Path) B.Icon = IconDropdown.value or Bookmark.Icon SaveBookmarks(Bookmarks) self.RefreshBookmarks() self._Notify("Bookmark updated.", Kind="success") def DoRemove(e=None) -> None: self.page.pop_dialog() SaveBookmarks([B for B in LoadBookmarks() if B.Path != Bookmark.Path]) self.RefreshBookmarks() self._Notify("Bookmark removed.", Kind="success") Dialog = ft.AlertDialog( title="Edit Bookmark", content=ft.Column([NameField, PathField, IconDropdown], tight=True, width=320), actions=[ ft.OutlinedButton(content="Remove", style=ChipButtonStyle(), on_click=DoRemove), ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self.page.pop_dialog()), ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=DoSave), ], ) self.page.show_dialog(Dialog) def _Notify(self, Message: str, Kind: str = "error") -> None: Background, Accent = NotificationStyle(Kind) self.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 UpdateSelection(self, CurrentPath: Path) -> None: for LocationPath, SetSelected in self.LocationTiles.items(): SetSelected(LocationPath == CurrentPath) if self.LocationTiles: self.update() if self.DevicesView is not None: self.DevicesView.UpdateSelection(CurrentPath) if self.FiletreeView is not None: self.FiletreeView.RevealPath(CurrentPath)