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