mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-13 18:28:16 +02:00
796 lines
39 KiB
Python
796 lines
39 KiB
Python
"""Browse: pick a Record or Library on the left, walk its files in the middle
|
|
(filter and sort them), and see the selected file's preview and edit its tags
|
|
on the right. Tick several files to edit their shared tags at once.
|
|
|
|
Keys (while no text field has focus): Up/Down move, Enter opens, Space ticks,
|
|
F2 renames, Ctrl+A ticks all, Backspace goes up a directory, Escape clears
|
|
the ticks or leaves the fullscreen view, Left/Right step through it.
|
|
"""
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import flet as ft
|
|
|
|
from Books import Book, CreateBook, IsBook, ReadBook, RenameFile
|
|
from Core import ChipButton, DisplayPath, Note, Palette
|
|
from History import ExplorerState, RecordRecentFile, SaveExplorerState
|
|
from Library import ConfValue, DataError, TagCore
|
|
from Query import Entry, CriterionLabel, Matches, ParseFilter, SortEntries, SortNames
|
|
from Records import CreateDirectoryLexicon, DirectoryState, LexiconFor, ListLibraries, ListRecords, LibraryEntry, Record
|
|
from TagEditor import BookProblems, TagEditor, TagSummary
|
|
from Widgets import App, EmptyState, HumanSize, MenuItem, Plural, RightClickHost, TextInput
|
|
|
|
RowHeight = 40
|
|
SidebarWidth = 210
|
|
DoubleClickSeconds = 0.4
|
|
FilterHelp = (
|
|
"Conditions separated by spaces; all must match.\n"
|
|
" *beach* name (wildcards * ? [..])\n"
|
|
" ext:png type:image|video|file|directory\n"
|
|
" size:>=5MB modified:>=2026-07-01 accessed: created:\n"
|
|
" Colour:Orange a tag (or tag.Colour:Orange)\n"
|
|
" Rating:>=2 Legs:!=4 Name:? record:Cats\n"
|
|
"Operators: = (default) != > >= < <="
|
|
)
|
|
|
|
|
|
class BrowsePage(ft.Container):
|
|
def __init__(self, TheApp: App):
|
|
super().__init__(expand=True)
|
|
self.App = TheApp
|
|
self.Records: list[Record] = []
|
|
self.Libraries: list[LibraryEntry] = []
|
|
self.Source: Optional[tuple[str, object]] = None # ("Record", Record) / ("Library", LibraryEntry)
|
|
self.Directory: Optional[Path] = None
|
|
self.Entries: list[Entry] = []
|
|
self.Visible: list[Entry] = []
|
|
self.Focused: Optional[Path] = None
|
|
self.Checked: set[Path] = set()
|
|
self.Sorts: list[str] = ["Name"]
|
|
self.Descending = False
|
|
self.FilterError = ""
|
|
self.LastTap = (None, 0.0)
|
|
self.LoadGeneration = 0
|
|
self.Fullscreen: Optional[Path] = None
|
|
self.ShowSidebar = True
|
|
self.RowControls: dict[Path, ft.Container] = {}
|
|
|
|
self.SourceList = ft.ListView(spacing=0, expand=True)
|
|
self.Sidebar = ft.Container(
|
|
ft.Column([ft.Text("Collections", color=Palette.Overlay0, size=12), self.SourceList], spacing=6, expand=True),
|
|
width=SidebarWidth, bgcolor=Palette.Mantle, padding=ft.Padding.only(left=8, right=8, top=8),
|
|
border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)),
|
|
)
|
|
|
|
self.Breadcrumbs = ft.Row(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
|
self.UpButton = ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Up a directory (Backspace)", on_click=lambda e: self.GoUp())
|
|
self.FilterField = TextInput(TheApp, Hint="Filter… e.g. Rating:>=2 ext:png", Width=300,
|
|
Prefix=ft.Icons.FILTER_ALT_OUTLINED, OnChange=lambda e: self._FilterChanged())
|
|
self.SortButton = ft.PopupMenuButton(icon=ft.Icons.SORT, tooltip="Sort", items=[])
|
|
self.DirectionButton = ft.IconButton(ft.Icons.ARROW_DOWNWARD, tooltip="Ascending", on_click=lambda e: self._ToggleDirection())
|
|
self.PreviewToggle = ft.IconButton(ft.Icons.VIEW_SIDEBAR_OUTLINED, tooltip="Show/hide the side panel",
|
|
on_click=lambda e: self._TogglePanel())
|
|
Toolbar = ft.Container(
|
|
ft.Row([
|
|
ft.IconButton(ft.Icons.MENU_OPEN, tooltip="Show/hide collections", on_click=lambda e: self._ToggleSidebar()),
|
|
self.UpButton, self.Breadcrumbs, self.FilterField,
|
|
ft.IconButton(ft.Icons.HELP_OUTLINE, icon_color=Palette.Overlay0, tooltip=FilterHelp),
|
|
self.SortButton, self.DirectionButton,
|
|
ft.IconButton(ft.Icons.REFRESH, tooltip="Reload", on_click=lambda e: self.Reload()),
|
|
self.PreviewToggle,
|
|
], spacing=2, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
|
padding=ft.Padding.symmetric(horizontal=8, vertical=4),
|
|
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)),
|
|
)
|
|
|
|
self.ListArea = ft.ListView(expand=True, item_extent=RowHeight, spacing=0)
|
|
# The list never leaves the page: Flet drops the session when a reply
|
|
# (e.g. to scroll_to) arrives for a control that was swapped out. The
|
|
# spinner, empty states and fullscreen view show beside it instead.
|
|
self.Overlay = ft.Container(expand=True, visible=False)
|
|
self.ListHost = ft.Column([self.ListArea, self.Overlay], expand=True, spacing=0)
|
|
self.Status = ft.Text("", size=12, color=Palette.Overlay0, expand=True)
|
|
self.TickButtons = ft.Row([
|
|
ft.TextButton("Tick all", on_click=lambda e: self.TickAll()),
|
|
ft.TextButton("Clear ticks", on_click=lambda e: self.ClearTicks()),
|
|
], spacing=0)
|
|
StatusBar = ft.Container(
|
|
ft.Row([self.Status, self.TickButtons], vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
|
padding=ft.Padding.symmetric(horizontal=12), height=34,
|
|
border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)), bgcolor=Palette.Mantle,
|
|
)
|
|
self.Menu = RightClickHost(self._MenuItems, ft.GestureDetector(
|
|
content=self.ListHost, expand=True,
|
|
on_secondary_tap_down=lambda e: self.Menu.Request(None, e),
|
|
))
|
|
self.Middle = ft.Column([Toolbar, self.Menu, StatusBar], spacing=0, expand=True)
|
|
|
|
self.PreviewBox = ft.Container(height=300, alignment=ft.Alignment.CENTER, padding=8)
|
|
self.Editor = TagEditor(TheApp, self._BooksChanged)
|
|
self.SidePanel = ft.Container(
|
|
ft.Column([
|
|
self.PreviewBox,
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
ft.Container(self.Editor, expand=True, padding=ft.Padding.only(left=12, right=12, top=8)),
|
|
], spacing=0, expand=True),
|
|
width=TheApp.Config.PreviewWidth, bgcolor=Palette.Mantle, visible=TheApp.Config.ShowPreview,
|
|
border=ft.Border.only(left=ft.BorderSide(1, Palette.Surface1)),
|
|
)
|
|
self.content = ft.Row([self.Sidebar, self.Middle, self.SidePanel], spacing=0, expand=True,
|
|
vertical_alignment=ft.CrossAxisAlignment.STRETCH)
|
|
|
|
# Page lifecycle (Main calls Refresh on every visit)
|
|
|
|
def Refresh(self, Record: str = "", Library: str = "", Directory: str = "", Select: str = "") -> None:
|
|
self.page.on_keyboard_event = self._OnKey
|
|
try:
|
|
self.Records = [Item for Item in ListRecords()[0] if Item.Enabled]
|
|
self.Libraries = [Item for Item in ListLibraries() if Item.Enabled]
|
|
except DataError as Error:
|
|
self.App.Notify(str(Error), "error")
|
|
self.Records, self.Libraries = [], []
|
|
Wanted: Optional[tuple[str, object]] = None
|
|
if Record:
|
|
Wanted = next((("Record", Item) for Item in self.Records if str(Item.Directory) == Record), None)
|
|
elif Library:
|
|
Wanted = next((("Library", Item) for Item in self.Libraries if Item.Name == Library), None)
|
|
elif self.Source is not None: # same collection as before, refreshed
|
|
Kind, Old = self.Source
|
|
Pool = self.Records if Kind == "Record" else self.Libraries
|
|
Wanted = next(((Kind, Item) for Item in Pool if self._SameSource(Item, Old)), None)
|
|
if Wanted is None and self.Source is None and (self.Records or self.Libraries):
|
|
Wanted = ("Record", self.Records[0]) if self.Records else ("Library", self.Libraries[0])
|
|
self._BuildSources()
|
|
if Wanted is None:
|
|
self.Source = None
|
|
self._ShowEmpty()
|
|
return
|
|
if Wanted != self.Source or Directory or Select or Record or Library:
|
|
self.Open(Wanted, Path(Directory) if Directory else None, Path(Select) if Select else None)
|
|
else:
|
|
self.Reload()
|
|
|
|
@staticmethod
|
|
def _SameSource(New, Old) -> bool:
|
|
return New.Directory == Old.Directory if isinstance(New, Record) else New.Name == Old.Name
|
|
|
|
def Leave(self) -> None:
|
|
self._SaveState()
|
|
|
|
def _ShowEmpty(self) -> None:
|
|
self._ShowOverlay(EmptyState("No Records yet.", "Add a directory on the Records page to start tagging.",
|
|
ft.Icons.FOLDER_OFF_OUTLINED))
|
|
self.Breadcrumbs.controls = []
|
|
self.Status.value = ""
|
|
self.Editor.ShowBooks([], Update=False)
|
|
self.update()
|
|
|
|
def _BuildSources(self) -> None:
|
|
Tiles: list[ft.Control] = []
|
|
|
|
def Tile(Text: str, Icon, Selected: bool, OnClick, Warning: str = "") -> ft.Control:
|
|
return ft.Container(
|
|
ft.Row([ft.Icon(Icon, size=16, color=Palette.Mauve if Selected else Palette.Overlay0),
|
|
ft.Text(Text, size=13, color=Palette.Mauve if Selected else Palette.Text, expand=True,
|
|
max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
|
*([ft.Icon(ft.Icons.WARNING_AMBER, size=14, color=Palette.Yellow, tooltip=Warning)] if Warning else [])],
|
|
spacing=8),
|
|
padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4,
|
|
bgcolor=Palette.Surface0 if Selected else None, on_click=OnClick, ink=True,
|
|
)
|
|
|
|
if self.Libraries:
|
|
Tiles.append(Note("Libraries", Size=11))
|
|
for Item in self.Libraries:
|
|
Selected = self.Source is not None and self.Source[0] == "Library" and self.Source[1].Name == Item.Name
|
|
Tiles.append(Tile(Item.Name, ft.Icons.COLLECTIONS_BOOKMARK_OUTLINED, Selected,
|
|
lambda e, Item=Item: self.Open(("Library", Item))))
|
|
Tiles.append(Note("Records", Size=11))
|
|
for Item in self.Records:
|
|
Selected = self.Source is not None and self.Source[0] == "Record" and self.Source[1].Directory == Item.Directory
|
|
Tiles.append(Tile(Item.Name, ft.Icons.FOLDER_SPECIAL_OUTLINED, Selected,
|
|
lambda e, Item=Item: self.Open(("Record", Item)), Item.Problem))
|
|
self.SourceList.controls = Tiles
|
|
|
|
# Opening and loading
|
|
|
|
def Open(self, Source: tuple[str, object], Directory: Optional[Path] = None, Select: Optional[Path] = None) -> None:
|
|
if self.Source is not None:
|
|
self._SaveState()
|
|
self.Source = Source
|
|
Kind, Item = Source
|
|
State = ExplorerState(Root=Item.Directory) if Kind == "Record" else ExplorerState(Library=Item.Name)
|
|
self.Checked.clear()
|
|
self.Fullscreen = None
|
|
self.FilterField.value = State.get("Filter", "")
|
|
self.Sorts = [Value for Value in State.get("Sorts", ["Name"]) if isinstance(Value, str)] or ["Name"]
|
|
self.Descending = State.get("SortDirection") == "Descending"
|
|
if Kind == "Record":
|
|
Remembered = Path(State["Directory"]) if State.get("Directory") else None
|
|
Target = Directory or Remembered or Item.Directory
|
|
if not (Target.is_dir() and Target.resolve().is_relative_to(Item.Directory)):
|
|
Target = Item.Directory
|
|
self.Directory = Target.resolve()
|
|
else:
|
|
self.Directory = None
|
|
Remembered = State.get("SelectedFile")
|
|
self.Focused = Select or (Path(Remembered) if Remembered else None)
|
|
self._BuildSources()
|
|
self.Reload()
|
|
|
|
def Reload(self) -> None:
|
|
if self.Source is None:
|
|
return
|
|
self.LoadGeneration += 1
|
|
Generation = self.LoadGeneration
|
|
self._ShowOverlay(ft.Container(ft.ProgressRing(width=28, height=28), alignment=ft.Alignment.CENTER, expand=True))
|
|
self._UpdateToolbar()
|
|
self.update()
|
|
self.page.run_thread(self._LoadEntries, Generation)
|
|
|
|
def _LoadEntries(self, Generation: int) -> None:
|
|
Kind, Item = self.Source
|
|
Cache: dict = {}
|
|
Entries: list[Entry] = []
|
|
Error = ""
|
|
HideUntagged = bool(ConfValue("Explorer", "HideUntaggedDirectories", True))
|
|
HideTagFiles = bool(ConfValue("Explorer", "HideTagFiles", True))
|
|
ShowInvalid = bool(ConfValue("Explorer", "ShowInvalidEntries", True))
|
|
if Kind == "Record":
|
|
Sources = [(self.Directory, "", True)]
|
|
else:
|
|
Names = {Record.Directory: Record.Name for Record in self.Records}
|
|
Sources = [(Member, Names.get(Member, Member.name), False) for Member in Item.Members]
|
|
for Directory, RecordName, WithDirectories in Sources:
|
|
try:
|
|
Children = list(Directory.iterdir())
|
|
except OSError as Problem:
|
|
Error = f"Couldn't read {DisplayPath(str(Directory))}: {Problem}"
|
|
continue
|
|
OwnLexicon = TagCore.GetLexiconPath(Directory) if HideTagFiles else None
|
|
for Child in Children:
|
|
if Child.name.startswith(".") or IsBook(Child) or Child == OwnLexicon:
|
|
continue
|
|
try:
|
|
IsDirectory = Child.is_dir()
|
|
Stat = Child.stat()
|
|
except OSError:
|
|
continue
|
|
if IsDirectory:
|
|
if not WithDirectories or (HideUntagged and DirectoryState(Child) == "Untagged"):
|
|
continue
|
|
Entries.append(Entry(Child, True, Stat, RecordName=RecordName))
|
|
continue
|
|
TheEntry = Entry(Child, False, Stat, RecordName=RecordName)
|
|
try:
|
|
TheEntry.Book = ReadBook(Child, Cache)
|
|
except DataError as Problem:
|
|
TheEntry.BookError = str(Problem)
|
|
if not ShowInvalid and (TheEntry.BookError or BookProblems(TheEntry.Book)):
|
|
continue
|
|
Entries.append(TheEntry)
|
|
if Generation != self.LoadGeneration:
|
|
return
|
|
if Generation != self.LoadGeneration:
|
|
return
|
|
self.Entries = Entries
|
|
self.LoadError = Error
|
|
self._ApplyView()
|
|
self._UpdateSortMenu()
|
|
self.update()
|
|
if self.Focused is not None:
|
|
self._ScrollTo(self.Focused) # only after update(): the list must exist on the client first
|
|
|
|
# Filter, sort and the list
|
|
|
|
def _FilterChanged(self) -> None:
|
|
self._ApplyView()
|
|
self.update()
|
|
|
|
def _ApplyView(self) -> None:
|
|
try:
|
|
Conditions = ParseFilter(self.FilterField.value or "")
|
|
self.FilterError = ""
|
|
except ValueError as Error:
|
|
Conditions = []
|
|
self.FilterError = str(Error)
|
|
self.FilterField.error = self.FilterError or None
|
|
# Directories always stay, so the filter never hides the way down.
|
|
Shown = [Item for Item in self.Entries if Item.IsDirectory or Matches(Item, Conditions)]
|
|
self.Visible = SortEntries(Shown, self.Sorts, self.Descending)
|
|
if self.Focused is not None and not any(Item.Path == self.Focused for Item in self.Visible):
|
|
self.Focused = None
|
|
self._BuildList()
|
|
self._ShowSelection(Update=False)
|
|
|
|
def _BuildList(self) -> None:
|
|
self.RowControls = {}
|
|
if not self.Visible:
|
|
Message = "Nothing matches the filter." if self.Entries else "This directory is empty."
|
|
self._ShowOverlay(EmptyState(Message, getattr(self, "LoadError", ""), ft.Icons.SEARCH_OFF))
|
|
else:
|
|
self.ListArea.controls = [self._Row(Item) for Item in self.Visible]
|
|
self._ShowOverlay(None)
|
|
self._UpdateStatus()
|
|
|
|
def _Row(self, Item: Entry) -> ft.Control:
|
|
if Item.IsDirectory:
|
|
Icon, IconColor = ft.Icons.FOLDER, Palette.Mauve
|
|
elif Item.Kind == "image":
|
|
Icon, IconColor = ft.Icons.IMAGE_OUTLINED, Palette.Blue
|
|
elif Item.Kind == "video":
|
|
Icon, IconColor = ft.Icons.MOVIE_OUTLINED, Palette.Peach
|
|
else:
|
|
Icon, IconColor = ft.Icons.INSERT_DRIVE_FILE_OUTLINED, Palette.Overlay0
|
|
if Item.IsDirectory:
|
|
Status = ft.Container(width=16)
|
|
Summary = "Has its own Lexicon" if TagCore.GetLexiconPath(Item.Path).is_file() else ""
|
|
else:
|
|
Problems = [Item.BookError] if Item.BookError else BookProblems(Item.Book)
|
|
if Item.Book is None and not Item.BookError:
|
|
Status = ft.Icon(ft.Icons.CIRCLE_OUTLINED, size=12, color=Palette.Surface1, tooltip="No Book")
|
|
elif Problems:
|
|
Status = ft.Icon(ft.Icons.ERROR_OUTLINE, size=14, color=Palette.Red, tooltip="\n".join(Problems[:6]))
|
|
else:
|
|
Status = ft.Icon(ft.Icons.CIRCLE, size=10, color=Palette.Mauve, tooltip="Tagged")
|
|
Summary = TagSummary(Item.Book)
|
|
Checkbox = ft.Checkbox(value=Item.Path in self.Checked, on_change=lambda e, P=Item.Path: self.Tick(P, e.control.value),
|
|
visible=not Item.IsDirectory)
|
|
Name = ft.Text(Item.Path.name, size=13, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=3)
|
|
Children: list[ft.Control] = [
|
|
ft.Container(Checkbox, width=36) if not Item.IsDirectory else ft.Container(width=36),
|
|
ft.Icon(Icon, size=18, color=IconColor), Name,
|
|
]
|
|
if Item.RecordName:
|
|
Children.append(ft.Text(Item.RecordName, size=11, color=Palette.Overlay0, width=110, max_lines=1,
|
|
overflow=ft.TextOverflow.ELLIPSIS))
|
|
Children += [ft.Text(Summary, size=11, color=Palette.Overlay0, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=4),
|
|
Status]
|
|
Row = ft.Container(
|
|
ft.Row(Children, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
|
height=RowHeight, padding=ft.Padding.only(left=4, right=12),
|
|
bgcolor=self._RowColor(Item.Path),
|
|
)
|
|
self.RowControls[Item.Path] = Row
|
|
return ft.GestureDetector(
|
|
content=Row,
|
|
on_tap=lambda e, Item=Item: self._Tap(Item),
|
|
on_secondary_tap_down=lambda e, Item=Item: self._RightClick(Item, e),
|
|
mouse_cursor=ft.MouseCursor.CLICK,
|
|
)
|
|
|
|
def _ShowOverlay(self, Content: Optional[ft.Control]) -> None:
|
|
"""Content in place of the list, or None for the list itself."""
|
|
self.Overlay.content = Content
|
|
self.Overlay.visible = Content is not None
|
|
self.ListArea.visible = Content is None
|
|
|
|
def _RowColor(self, PathValue: Path) -> Optional[str]:
|
|
if PathValue == self.Focused:
|
|
return Palette.Surface1
|
|
if PathValue in self.Checked:
|
|
return Palette.Surface0
|
|
return None
|
|
|
|
def _Recolor(self, Paths: list[Optional[Path]]) -> None:
|
|
for PathValue in Paths:
|
|
Row = self.RowControls.get(PathValue) if PathValue else None
|
|
if Row is not None:
|
|
Row.bgcolor = self._RowColor(PathValue)
|
|
|
|
def _UpdateStatus(self) -> None:
|
|
Files = [Item for Item in self.Visible if not Item.IsDirectory]
|
|
WithBooks = sum(1 for Item in Files if Item.Book is not None)
|
|
Parts = [Plural(len(Files), "file"), f"{WithBooks} with Books"]
|
|
Directories = len(self.Visible) - len(Files)
|
|
if Directories:
|
|
Parts.insert(0, Plural(Directories, "directory", "directories"))
|
|
if len(self.Visible) != len(self.Entries):
|
|
Parts.append(f"{len(self.Entries) - len(self.Visible)} filtered out")
|
|
if self.Checked:
|
|
Parts.append(f"{len(self.Checked)} ticked")
|
|
self.Status.value = " · ".join(Parts)
|
|
|
|
def _UpdateToolbar(self) -> None:
|
|
Kind, Item = self.Source
|
|
Crumbs: list[ft.Control] = []
|
|
if Kind == "Library":
|
|
Crumbs.append(ft.Text(f"Library: {Item.Name}", color=Palette.Mauve, size=14))
|
|
self.UpButton.disabled = True
|
|
else:
|
|
Parts: list[Path] = [self.Directory]
|
|
while Parts[-1] != Item.Directory and Parts[-1].parent != Parts[-1]:
|
|
Parts.append(Parts[-1].parent)
|
|
for Index, PathValue in enumerate(reversed(Parts)):
|
|
if Index:
|
|
Crumbs.append(ft.Icon(ft.Icons.CHEVRON_RIGHT, size=16, color=Palette.Overlay0))
|
|
Label = Item.Name if PathValue == Item.Directory else PathValue.name
|
|
Crumbs.append(ft.TextButton(Label, on_click=lambda e, P=PathValue: self.GoTo(P),
|
|
style=ft.ButtonStyle(color=Palette.Mauve if PathValue == self.Directory else Palette.Text,
|
|
padding=ft.Padding.symmetric(horizontal=6))))
|
|
self.UpButton.disabled = self.Directory == Item.Directory
|
|
self.Breadcrumbs.controls = Crumbs
|
|
self.DirectionButton.icon = ft.Icons.ARROW_UPWARD if self.Descending else ft.Icons.ARROW_DOWNWARD
|
|
self.DirectionButton.tooltip = "Descending" if self.Descending else "Ascending"
|
|
|
|
def _UpdateSortMenu(self) -> None:
|
|
TagNames: list[str] = []
|
|
for Item in self.Entries:
|
|
if Item.Book:
|
|
TagNames += [Name for Name in list(Item.Book.Definitions) + list(Item.Book.Tags) if Name not in TagNames]
|
|
Items: list[ft.PopupMenuItem] = [ft.PopupMenuItem(content=Note("Click to add or remove; first = most important"))]
|
|
|
|
def Choice(Criterion: str) -> ft.PopupMenuItem:
|
|
Label = SortNames.get(Criterion, Criterion[4:] if Criterion.startswith("Tag:") else Criterion)
|
|
if Criterion in self.Sorts:
|
|
Label = f"{self.Sorts.index(Criterion) + 1}. {Label}"
|
|
return ft.PopupMenuItem(content=Label, checked=Criterion in self.Sorts,
|
|
on_click=lambda e, Criterion=Criterion: self._ToggleSort(Criterion))
|
|
|
|
Basics = [Name for Name in SortNames if Name != "Record" or (self.Source and self.Source[0] == "Library")]
|
|
Items += [Choice(Name) for Name in Basics]
|
|
if TagNames:
|
|
Items.append(ft.PopupMenuItem()) # divider
|
|
Items += [Choice(f"Tag:{Name}") for Name in TagNames]
|
|
Items += [ft.PopupMenuItem(), ft.PopupMenuItem(content="Reset to Name", icon=ft.Icons.CLEAR_ALL,
|
|
on_click=lambda e: self._SetSorts(["Name"]))]
|
|
self.SortButton.items = Items
|
|
self.SortButton.tooltip = "Sorted by " + ", then ".join(CriterionLabel(Criterion) for Criterion in self.Sorts)
|
|
|
|
def _ToggleSort(self, Criterion: str) -> None:
|
|
Sorts = list(self.Sorts)
|
|
if Criterion in Sorts:
|
|
Sorts.remove(Criterion)
|
|
else:
|
|
if Sorts == ["Name"]:
|
|
Sorts = [] # Name is the tie-breaker anyway
|
|
Sorts.append(Criterion)
|
|
self._SetSorts(Sorts or ["Name"])
|
|
|
|
def _SetSorts(self, Sorts: list[str]) -> None:
|
|
self.Sorts = Sorts
|
|
self._ApplyView()
|
|
self._UpdateSortMenu()
|
|
self._SaveState()
|
|
self.update()
|
|
|
|
def _ToggleDirection(self) -> None:
|
|
self.Descending = not self.Descending
|
|
self._UpdateToolbar()
|
|
self._ApplyView()
|
|
self._SaveState()
|
|
self.update()
|
|
|
|
# Selection, ticks and the side panel
|
|
|
|
def _Tap(self, Item: Entry) -> None:
|
|
Now = time.monotonic()
|
|
Double = self.LastTap[0] == Item.Path and Now - self.LastTap[1] < DoubleClickSeconds
|
|
self.LastTap = (None, 0.0) if Double else (Item.Path, Now)
|
|
if Double:
|
|
self.OpenEntry(Item)
|
|
return
|
|
self.Focus(Item.Path)
|
|
self._SaveState() # so the selection is remembered even if the window just closes
|
|
|
|
def Focus(self, PathValue: Optional[Path], Scroll: bool = False) -> None:
|
|
Previous = self.Focused
|
|
self.Focused = PathValue
|
|
self._Recolor([Previous, PathValue])
|
|
self._ShowSelection(Update=False)
|
|
self.update()
|
|
if Scroll and PathValue is not None:
|
|
self._ScrollTo(PathValue)
|
|
|
|
def _ScrollTo(self, PathValue: Path) -> None:
|
|
Index = next((Number for Number, Item in enumerate(self.Visible) if Item.Path == PathValue), None)
|
|
if Index is None or not self.ListArea.visible:
|
|
return
|
|
|
|
async def Scroll() -> None:
|
|
try:
|
|
await self.ListArea.scroll_to(offset=max(0, (Index - 3) * RowHeight), duration=0)
|
|
except Exception:
|
|
pass # the list was swapped out meanwhile; nothing to scroll
|
|
|
|
self.page.run_task(Scroll)
|
|
|
|
def Tick(self, PathValue: Path, Value: bool) -> None:
|
|
if Value:
|
|
self.Checked.add(PathValue)
|
|
else:
|
|
self.Checked.discard(PathValue)
|
|
self._Recolor([PathValue])
|
|
self._UpdateStatus()
|
|
self._ShowSelection(Update=False)
|
|
self.update()
|
|
|
|
def TickAll(self) -> None:
|
|
self.Checked = {Item.Path for Item in self.Visible if not Item.IsDirectory}
|
|
self._BuildList()
|
|
self._ShowSelection(Update=False)
|
|
self.update()
|
|
|
|
def ClearTicks(self) -> None:
|
|
self.Checked.clear()
|
|
self._BuildList()
|
|
self._ShowSelection(Update=False)
|
|
self.update()
|
|
|
|
def _EntryFor(self, PathValue: Optional[Path]) -> Optional[Entry]:
|
|
return next((Item for Item in self.Entries if Item.Path == PathValue), None) if PathValue else None
|
|
|
|
def _ShowSelection(self, Update: bool = True) -> None:
|
|
"""The preview shows the focused entry; the tag panel edits the ticked
|
|
files, or the focused one when nothing is ticked."""
|
|
Focused = self._EntryFor(self.Focused)
|
|
self._ShowPreview(Focused)
|
|
if self.Checked:
|
|
Ticked = [Item for Item in self.Entries if Item.Path in self.Checked]
|
|
Books = [Item.Book for Item in Ticked if Item.Book is not None]
|
|
Missing = len(Ticked) - len(Books)
|
|
Header = f"{len(Ticked)} files ticked" + (f" ({Missing} without a Book)" if Missing else "")
|
|
self.Editor.ShowBooks(Books, Header if Books else f"None of the {len(Ticked)} ticked files has a Book.", Update=False)
|
|
elif Focused is None or Focused.IsDirectory:
|
|
self.Editor.ShowBooks([], "", Update=False)
|
|
elif Focused.Book is None:
|
|
self.Editor.ShowBooks([], Focused.BookError or "This file has no Book yet.", Update=False)
|
|
if not Focused.BookError and LexiconFor(Focused.Path.parent):
|
|
self.Editor.controls.append(ChipButton("Create its Book", lambda e: self.MakeBook(Focused), ft.Icons.ADD))
|
|
else:
|
|
self.Editor.ShowBooks([Focused.Book], Focused.Path.name, Update=False)
|
|
if Update:
|
|
self.update()
|
|
|
|
def _ShowPreview(self, Item: Optional[Entry]) -> None:
|
|
Box = self.PreviewBox
|
|
if Item is None:
|
|
Box.content = Note("Nothing selected.")
|
|
return
|
|
Lines = [ft.Text(Item.Path.name, size=12, text_align=ft.TextAlign.CENTER, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS)]
|
|
if Item.Stat is not None and not Item.IsDirectory:
|
|
Lines.append(Note(f"{HumanSize(Item.Stat.st_size)} · {time.strftime('%Y-%m-%d %H:%M', time.localtime(Item.Stat.st_mtime))}", Size=11))
|
|
if Item.IsDirectory:
|
|
Visual: ft.Control = ft.Icon(ft.Icons.FOLDER, size=64, color=Palette.Mauve)
|
|
elif Item.Kind == "image" and ConfValue("Explorer", "ShowImagePreviews", True):
|
|
Visual = ft.GestureDetector(
|
|
content=ft.Image(src=str(Item.Path), fit=ft.BoxFit.CONTAIN, gapless_playback=True,
|
|
error_content=ft.Icon(ft.Icons.BROKEN_IMAGE_OUTLINED, size=48, color=Palette.Overlay0)),
|
|
on_tap=lambda e: self.ShowFullscreen(Item.Path), mouse_cursor=ft.MouseCursor.ZOOM_IN, expand=True,
|
|
)
|
|
elif Item.Kind == "video":
|
|
Visual = ft.Column([
|
|
ft.Icon(ft.Icons.MOVIE_OUTLINED, size=64, color=Palette.Peach),
|
|
ChipButton("Play", lambda e: self.App.OpenPath(Item.Path), ft.Icons.PLAY_ARROW),
|
|
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True)
|
|
else:
|
|
Visual = ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=64, color=Palette.Overlay0)
|
|
Box.content = ft.Column([ft.Container(Visual, expand=True, alignment=ft.Alignment.CENTER), *Lines],
|
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=4)
|
|
|
|
def _BooksChanged(self, Books: list[Book]) -> None:
|
|
"""The tag panel wrote some Books: refresh their rows (and the order,
|
|
when sorting by a tag)."""
|
|
self._ApplyView()
|
|
self.update()
|
|
|
|
def _TogglePanel(self) -> None:
|
|
self.SidePanel.visible = not self.SidePanel.visible
|
|
self.App.Config.ShowPreview = self.SidePanel.visible
|
|
self.App.Config.Save()
|
|
self.update()
|
|
|
|
def _ToggleSidebar(self) -> None:
|
|
self.Sidebar.visible = not self.Sidebar.visible
|
|
self.update()
|
|
|
|
# Navigation and actions
|
|
|
|
def GoTo(self, Directory: Path) -> None:
|
|
if self.Source is None or self.Source[0] != "Record":
|
|
return
|
|
Previous = self.Directory
|
|
self._SaveState()
|
|
self.Directory = Directory
|
|
self.Checked.clear()
|
|
self.Focused = Previous if Previous and Previous.parent == Directory else None
|
|
self.Reload()
|
|
|
|
def GoUp(self) -> None:
|
|
if self.Source and self.Source[0] == "Record" and self.Directory != self.Source[1].Directory:
|
|
self.GoTo(self.Directory.parent)
|
|
|
|
def OpenEntry(self, Item: Entry) -> None:
|
|
if Item.IsDirectory:
|
|
self.GoTo(Item.Path)
|
|
return
|
|
RecordRecentFile(Item.Path)
|
|
if Item.Kind == "image":
|
|
self.ShowFullscreen(Item.Path)
|
|
else:
|
|
self.App.OpenPath(Item.Path)
|
|
|
|
def MakeBook(self, Item: Entry) -> None:
|
|
try:
|
|
Item.Book = CreateBook(Item.Path)
|
|
except DataError as Error:
|
|
self.App.Notify(str(Error), "error")
|
|
return
|
|
self.App.Notify(f"Created a Book for {Item.Path.name}.", "success")
|
|
self._ApplyView()
|
|
self.update()
|
|
|
|
def MakeBooks(self, Items: list[Entry]) -> None:
|
|
Made = 0
|
|
for Item in Items:
|
|
if Item.Book is None and not Item.BookError:
|
|
try:
|
|
Item.Book = CreateBook(Item.Path)
|
|
Made += 1
|
|
except DataError as Error:
|
|
self.App.Notify(str(Error), "error")
|
|
break
|
|
if Made:
|
|
self.App.Notify(f"Created {Plural(Made, 'Book')}.", "success")
|
|
self._ApplyView()
|
|
self.update()
|
|
|
|
def Rename(self, Item: Entry) -> None:
|
|
def Done(NewName: str) -> None:
|
|
try:
|
|
NewPath = RenameFile(Item.Path, NewName)
|
|
except DataError as Error:
|
|
self.App.Notify(str(Error), "error")
|
|
return
|
|
if NewPath != Item.Path:
|
|
if Item.Path in self.Checked:
|
|
self.Checked.discard(Item.Path)
|
|
self.Checked.add(NewPath)
|
|
self.Focused = NewPath
|
|
self.App.Notify(f"Renamed to {NewName.strip()}" + (" (its Book came along)." if Item.Book else "."), "success")
|
|
self.Reload()
|
|
|
|
self.App.Ask("Rename", "New name", Item.Path.name, Done, "Rename",
|
|
"The file's Book is renamed with it.")
|
|
|
|
def TagDirectory(self, Item: Entry) -> None:
|
|
Stop = self.Source[1].Directory if self.Source and self.Source[0] == "Record" else None
|
|
try:
|
|
LexiconPath = CreateDirectoryLexicon(Item.Path, Stop)
|
|
except DataError as Error:
|
|
self.App.Notify(str(Error), "error")
|
|
return
|
|
self.App.Notify(f"Created {LexiconPath.name}. Run Update to give its files Books.", "success")
|
|
self.Reload()
|
|
|
|
# Fullscreen image view
|
|
|
|
def ShowFullscreen(self, PathValue: Path) -> None:
|
|
self.Fullscreen = PathValue
|
|
Images = [Item.Path for Item in self.Visible if Item.Kind == "image"]
|
|
Index = Images.index(PathValue) if PathValue in Images else 0
|
|
Header = ft.Row([
|
|
ft.IconButton(ft.Icons.ARROW_BACK, tooltip="Back to the list (Escape)", on_click=lambda e: self.CloseFullscreen()),
|
|
ft.Text(PathValue.name, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
|
Note(f"{Index + 1} / {len(Images)}"),
|
|
ft.IconButton(ft.Icons.CHEVRON_LEFT, tooltip="Previous (Left)", on_click=lambda e: self.StepFullscreen(-1)),
|
|
ft.IconButton(ft.Icons.CHEVRON_RIGHT, tooltip="Next (Right)", on_click=lambda e: self.StepFullscreen(1)),
|
|
ft.IconButton(ft.Icons.OPEN_IN_NEW, tooltip="Open in the default app", on_click=lambda e: self.App.OpenPath(PathValue)),
|
|
])
|
|
self._ShowOverlay(ft.Container(ft.Column([
|
|
Header,
|
|
ft.Container(ft.Image(src=str(PathValue), fit=ft.BoxFit.CONTAIN, gapless_playback=True), expand=True,
|
|
alignment=ft.Alignment.CENTER),
|
|
], expand=True), padding=8, bgcolor=Palette.Crust, expand=True))
|
|
Previous = self.Focused
|
|
self.Focused = PathValue
|
|
self._Recolor([Previous, PathValue])
|
|
self._ShowSelection(Update=False)
|
|
self.update()
|
|
|
|
def StepFullscreen(self, Offset: int) -> None:
|
|
Images = [Item.Path for Item in self.Visible if Item.Kind == "image"]
|
|
if not Images or self.Fullscreen is None:
|
|
return
|
|
Index = Images.index(self.Fullscreen) if self.Fullscreen in Images else 0
|
|
self.ShowFullscreen(Images[(Index + Offset) % len(Images)])
|
|
|
|
def CloseFullscreen(self) -> None:
|
|
self.Fullscreen = None
|
|
self._BuildList()
|
|
self.update()
|
|
if self.Focused:
|
|
self._ScrollTo(self.Focused)
|
|
|
|
# Right-click menus
|
|
|
|
def _RightClick(self, Item: Entry, Event) -> None:
|
|
if Item.Path != self.Focused:
|
|
self.Focus(Item.Path)
|
|
self.Menu.Request(Item, Event)
|
|
|
|
def _MenuItems(self, Target) -> list[ft.PopupMenuItem]:
|
|
if Target is None:
|
|
Items = [MenuItem("Reload", self.Reload, ft.Icons.REFRESH)]
|
|
if self.Visible:
|
|
Items.insert(0, MenuItem("Tick All", self.TickAll, ft.Icons.CHECK_BOX_OUTLINED))
|
|
if self.Checked:
|
|
Items.insert(1, MenuItem("Clear Ticks", self.ClearTicks, ft.Icons.CHECK_BOX_OUTLINE_BLANK))
|
|
Without = [Item for Item in self.Entries if Item.Path in self.Checked and Item.Book is None]
|
|
if Without:
|
|
Items.append(MenuItem(f"Create Books for {Plural(len(Without), 'Ticked File')}", lambda: self.MakeBooks(Without), ft.Icons.ADD))
|
|
if self.Source and self.Source[0] == "Record" and self.Directory != self.Source[1].Directory:
|
|
Items.append(MenuItem("Up a Directory", self.GoUp, ft.Icons.ARROW_UPWARD))
|
|
if self.Directory:
|
|
Items.append(MenuItem("Open Folder", lambda: self.App.OpenPath(self.Directory), ft.Icons.FOLDER_OPEN_OUTLINED))
|
|
return Items
|
|
Item: Entry = Target
|
|
if Item.IsDirectory:
|
|
Items = [MenuItem("Open", lambda: self.GoTo(Item.Path), ft.Icons.FOLDER_OPEN)]
|
|
OwnLexicon = TagCore.GetLexiconPath(Item.Path)
|
|
if OwnLexicon.is_file():
|
|
Items.append(MenuItem("Edit Its Lexicon", lambda: self.App.Navigate("Lexicons", Open=str(OwnLexicon)), ft.Icons.EDIT_NOTE))
|
|
else:
|
|
Items.append(MenuItem("Add a Lexicon (Tag It)", lambda: self.TagDirectory(Item), ft.Icons.NEW_LABEL_OUTLINED))
|
|
else:
|
|
Items = [MenuItem("Open", lambda: self.OpenEntry(Item), ft.Icons.OPEN_IN_NEW)]
|
|
if Item.Kind == "image":
|
|
Items.append(MenuItem("View Fullscreen", lambda: self.ShowFullscreen(Item.Path), ft.Icons.FULLSCREEN))
|
|
Items.append(MenuItem("Rename", lambda: self.Rename(Item), ft.Icons.DRIVE_FILE_RENAME_OUTLINE))
|
|
Items.append(MenuItem("Untick" if Item.Path in self.Checked else "Tick",
|
|
lambda: self.Tick(Item.Path, Item.Path not in self.Checked), ft.Icons.CHECK_BOX_OUTLINED))
|
|
if Item.Book is None and not Item.BookError and LexiconFor(Item.Path.parent):
|
|
Items.append(MenuItem("Create Its Book", lambda: self.MakeBook(Item), ft.Icons.ADD))
|
|
if Item.Book is not None and Item.Book.LexiconPath and Item.Book.LexiconPath.is_file():
|
|
Items.append(MenuItem("Edit Its Lexicon", lambda: self.App.Navigate("Lexicons", Open=str(Item.Book.LexiconPath)), ft.Icons.EDIT_NOTE))
|
|
Items.append(MenuItem("Open Containing Folder", lambda: self.App.OpenPath(Item.Path.parent), ft.Icons.FOLDER_OPEN_OUTLINED))
|
|
Items.append(MenuItem("Copy Path", lambda: self.App.CopyText(str(Item.Path), "the path"), ft.Icons.COPY))
|
|
return Items
|
|
|
|
# Keyboard
|
|
|
|
def _OnKey(self, Event: ft.KeyboardEvent) -> None:
|
|
if self.App.Typing or self.Source is None or self.page is None:
|
|
return
|
|
Key = Event.key
|
|
if self.Fullscreen is not None:
|
|
if Key == "Escape":
|
|
self.CloseFullscreen()
|
|
elif Key in ("Arrow Left", "Arrow Up"):
|
|
self.StepFullscreen(-1)
|
|
elif Key in ("Arrow Right", "Arrow Down", " "):
|
|
self.StepFullscreen(1)
|
|
return
|
|
Index = next((Number for Number, Item in enumerate(self.Visible) if Item.Path == self.Focused), -1)
|
|
Current = self.Visible[Index] if Index >= 0 else None
|
|
if Key in ("Arrow Down", "Arrow Up") and self.Visible:
|
|
Step = 1 if Key == "Arrow Down" else -1
|
|
Target = 0 if Index < 0 else max(0, min(len(self.Visible) - 1, Index + Step))
|
|
self.Focus(self.Visible[Target].Path, Scroll=True)
|
|
elif Key == "Enter" and Current is not None:
|
|
self.OpenEntry(Current)
|
|
elif Key == " " and Current is not None and not Current.IsDirectory:
|
|
self.Tick(Current.Path, Current.Path not in self.Checked)
|
|
elif Key == "F2" and Current is not None and not Current.IsDirectory:
|
|
self.Rename(Current)
|
|
elif Key == "A" and Event.ctrl:
|
|
self.TickAll()
|
|
elif Key == "Backspace" or (Key == "Arrow Left" and Event.alt):
|
|
self.GoUp()
|
|
elif Key == "Escape" and self.Checked:
|
|
self.ClearTicks()
|
|
|
|
# History
|
|
|
|
def _SaveState(self) -> None:
|
|
if self.Source is None:
|
|
return
|
|
Kind, Item = self.Source
|
|
SaveExplorerState(
|
|
Item.Directory if Kind == "Record" else None, Item.Name if Kind == "Library" else None,
|
|
self.Directory, self.FilterField.value or "", self.Sorts, self.Descending,
|
|
self.Focused if self.Focused and not self.Focused.is_dir() else None,
|
|
)
|