Files
SaraTag/BrowsePage.py

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,
)