mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
- New optional list-view columns: Description, Size (now includes directories), Modified, Owner, Created (via statx birth time), Content (item/line count or image dimensions), Access (rwx) - Drag-to-resize: list view column headers, sidebar sections (Bookmarks/ Devices/Filetree), Preview/Tags split, and the sidebar/preview side panels themselves — all commit to Config on release, not per drag frame - Column resizing mutates existing row cells live instead of rebuilding, with metadata cached per (file, mtime) — fixes lag in large folders - Fix list view background click (rows were full ListTile width with no empty space to click); bookmark selected-background now covers the triple-dot menu too; batch rename/new-file dialogs pre-select their default text instead of just pre-filling it - Toolbar scrolls instead of clipping when buttons don't fit; toolbar and breadcrumbs are independently toggleable - Add a "Server" bookmark icon Co-Authored-By: Claude Sonnet 5 <[email protected]>
270 lines
11 KiB
Python
270 lines
11 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,
|
|
):
|
|
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)
|