Files
SaraFem/Sidebar.py
T

357 lines
15 KiB
Python

"""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,
OnSortChanged: Optional[Callable[[], None]] = None,
):
self.OnNavigate = OnNavigate
self.Config = Config
self.OnFilesMoved = OnFilesMoved
self.OnSortChanged = OnSortChanged
self.LocationTiles: dict[Path, Callable[[bool], None]] = {}
self.BookmarksListView: Optional[ft.ReorderableListView] = None
self.DevicesView: Optional[DevicesPanel] = None
self.FiletreeView: Optional[Filetree] = None
# Both set below only if ShowDevices and AutoHideEmptyDevices are on
# — see _OnDevicesEmptyChanged (Features/Minor: "Option to hide
# devices section if there are none, but show if there are").
self.DevicesBox: Optional[ft.Container] = None
self.DevicesFiletreeDivider: Optional[ft.Control] = 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.ShowSort:
Sections.append(self._BuildSortControl())
if Config.ShowBookmarks or Config.ShowDevices or Config.ShowFiletree:
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
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,
OnEmptyChanged=self._OnDevicesEmptyChanged if Config.AutoHideEmptyDevices else None,
)
DevicesBox = ft.Container(
content=self.DevicesView,
height=Config.DevicesMaxHeight or Config.DevicesMinHeight or None,
)
self.DevicesBox = DevicesBox
Sections.append(DevicesBox)
if Config.ShowDevices and Config.ShowFiletree:
self.DevicesFiletreeDivider = self._BuildSectionDivider(DevicesBox, "DevicesMaxHeight")
Sections.append(self.DevicesFiletreeDivider)
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,
)
SortFields = ("Name", "Size", "Modified", "Type")
def _BuildSortControl(self) -> ft.Control:
""""[Sort: Name] v" — a dropdown for the field — plus a separate
ascending/descending arrow button to its right that flips on click
(Features/Major: search + sort). Directories always sort before
files regardless of field/direction — see Browser._SortEntries."""
FieldButton = ft.PopupMenuButton(
content=ft.Row(
[
ft.Text(f"Sort: {self.Config.SortField}", size=13),
ft.Icon(ft.Icons.ARROW_DROP_DOWN, size=18, color=Palette.Overlay0),
],
spacing=2,
tight=True,
),
items=[
ft.PopupMenuItem(content=Field, on_click=lambda e, F=Field: self._OnSortFieldChosen(F))
for Field in self.SortFields
],
)
self.SortFieldButton = FieldButton
self.SortDirectionButton = ft.IconButton(
icon=ft.Icons.ARROW_DROP_UP if self.Config.SortAscending else ft.Icons.ARROW_DROP_DOWN,
tooltip="Ascending" if self.Config.SortAscending else "Descending",
icon_size=20,
on_click=self._OnSortDirectionToggled,
)
return ft.Container(
content=ft.Row([FieldButton, self.SortDirectionButton], spacing=0),
padding=ft.Padding.symmetric(horizontal=4, vertical=2),
)
def _OnSortFieldChosen(self, Field: str) -> None:
self.Config.SortField = Field
self.Config.Save()
self.SortFieldButton.content.controls[0].value = f"Sort: {Field}"
self.SortFieldButton.update()
if self.OnSortChanged:
self.OnSortChanged()
def _OnSortDirectionToggled(self, Event: ft.ControlEvent) -> None:
self.Config.SortAscending = not self.Config.SortAscending
self.Config.Save()
self.SortDirectionButton.icon = ft.Icons.ARROW_DROP_UP if self.Config.SortAscending else ft.Icons.ARROW_DROP_DOWN
self.SortDirectionButton.tooltip = "Ascending" if self.Config.SortAscending else "Descending"
self.SortDirectionButton.update()
if self.OnSortChanged:
self.OnSortChanged()
def _OnDevicesEmptyChanged(self, IsEmpty: bool) -> None:
"""Only wired up when Config.AutoHideEmptyDevices is on. Hides the
Devices box AND its own dedicated divider (the one between it and
Filetree, which exists only because Devices does) together, so an
empty Devices section disappears cleanly rather than leaving a
double divider or dead space. The divider ABOVE Devices (shared
with Bookmarks) is deliberately left alone — it still correctly
separates Bookmarks from whatever's next once Devices itself is
gone, and removing it too would need to depend on Filetree's own
visibility, which this panel has no reason to know about."""
if self.DevicesBox is not None:
self.DevicesBox.visible = not IsEmpty
self.DevicesBox.update()
if self.DevicesFiletreeDivider is not None:
self.DevicesFiletreeDivider.visible = not IsEmpty
self.DevicesFiletreeDivider.update()
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()),
leading_icon=getattr(ft.Icons, Name),
)
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)