"""What every page shares: the App context (config, notifications, dialogs, opening files) and small reusable controls, including right-click menus that work like SaraFem's and SaraSort's.""" import asyncio import os import shutil import subprocess import time from pathlib import Path from typing import Callable, Optional import flet as ft from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette from Library import ConfValue class App: def __init__(self, Page: ft.Page, Config: AppConfig): self.Page = Page self.Config = Config self.Picker = ft.FilePicker() # registers itself with the page self.Navigate: Callable[..., None] = lambda Name, **Arguments: None # set by Main # Text fields with focus; while any has it, list keyboard shortcuts stay off. self.Typing = 0 def Notify(self, Text: str, Kind: str = "info") -> None: Background, Accent = NotificationStyle(Kind) self.Page.show_dialog(ft.SnackBar( content=ft.Text(Text, color=Accent), bgcolor=Background, shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)), open=True, )) def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 560) -> ft.AlertDialog: Dialog = ft.AlertDialog(title=Title, content=ft.Container(Content, width=Width), actions=Actions) self.Page.show_dialog(Dialog) return Dialog def Close(self) -> None: self.Page.pop_dialog() def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Remove", Accent: str = "") -> None: def Yes(e) -> None: self.Close() OnYes() self.ShowDialog(Title, ft.Text(Text), [ ChipButton("Cancel", lambda e: self.Close()), ChipButton(YesText, Yes, Accent=Accent or Palette.Red), ], Width=460) def Ask(self, Title: str, Label: str, Value: str, OnOk: Callable[[str], None], OkText: str = "Save", Help: str = "") -> None: """A one-field dialog; Enter or the button submits.""" Field = TextInput(self, Label=Label, Value=Value, Autofocus=True) def Ok(e=None) -> None: self.Close() OnOk(Field.value or "") Field.on_submit = Ok self.ShowDialog(Title, ft.Column([Field] + ([Note(Help)] if Help else []), tight=True, spacing=8), [ ChipButton("Cancel", lambda e: self.Close()), ChipButton(OkText, Ok), ], Width=460) def OpenPath(self, TargetPath: Path) -> None: """Videos go to [Media] VideoPlayer from SaraTagConf.toml, everything else to the default app.""" from Query import VideoExtensions Command = None if IsWindows else ["xdg-open", str(TargetPath)] # Windows: os.startfile below if TargetPath.suffix.lower() in VideoExtensions: Player = str(ConfValue("Media", "VideoPlayer", "") or "").strip() if Player and shutil.which(Player.split()[0]): Command = Player.split() + [str(TargetPath)] elif Player: self.Notify(f"Video player '{Player}' isn't installed; using the default app.", "warning") try: if Command is None: os.startfile(str(TargetPath)) # the default app else: subprocess.Popen(Command, env=ChildEnvironment(), start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError as Error: self.Notify(f"Couldn't open {TargetPath.name}: {Error}", "error") def CopyText(self, Text: str, What: str) -> None: async def Copy() -> None: await ft.Clipboard().set(Text) self.Notify(f"Copied {What}.", "success") self.Page.run_task(Copy) def TextInput(TheApp: App, **Options) -> ft.TextField: """A dense TextField that pauses list shortcuts while it has focus. Label/Value/Autofocus/Expand/Width/Hint map to Flet's own names.""" Names = {"Label": "label", "Value": "value", "Autofocus": "autofocus", "Expand": "expand", "Width": "width", "Hint": "hint_text", "OnChange": "on_change", "OnSubmit": "on_submit", "Prefix": "prefix_icon"} Field = ft.TextField(dense=True, **{Names.get(Key, Key): Value for Key, Value in Options.items()}) OnFocus, OnBlur = Field.on_focus, Field.on_blur def Focus(e) -> None: TheApp.Typing += 1 if OnFocus: OnFocus(e) def Blur(e) -> None: TheApp.Typing = max(0, TheApp.Typing - 1) if OnBlur: OnBlur(e) Field.on_focus, Field.on_blur = Focus, Blur return Field def PathField(TheApp: App, Label: str, Value: str = "", Directory: bool = True, Extensions: Optional[list[str]] = None) -> tuple[ft.Row, ft.TextField]: """A path field with a Browse button. Returns (row, field).""" Field = TextInput(TheApp, Label=Label, Value=Value, Expand=True) async def Browse(e) -> None: if Directory: Chosen = await TheApp.Picker.get_directory_path(dialog_title=Label, initial_directory=Field.value or None) else: Start = Path(os.path.expanduser(Field.value or "")).parent if Field.value else None Files = await TheApp.Picker.pick_files( dialog_title=Label, initial_directory=str(Start) if Start else None, allowed_extensions=Extensions, file_type=ft.FilePickerFileType.CUSTOM if Extensions else ft.FilePickerFileType.ANY, ) Chosen = Files[0].path if Files else None if Chosen: Field.value = Chosen Field.update() return ft.Row([Field, ft.IconButton(ft.Icons.FOLDER_OPEN_OUTLINED, tooltip="Browse", on_click=Browse)]), Field class RightClickHost(ft.ContextMenu): """One shared popup host per page, opened programmatically so a right-click resolves to exactly one menu (the innermost target's), the same way SaraFem and SaraSort do it. Wrap the page's content in it and call Request(Target, Event) from on_secondary_tap_down handlers; Items turns a target (None for the background) into menu items.""" def __init__(self, Items: Callable[[object], list[ft.PopupMenuItem]], Content: ft.Control): # Built-in triggers can't be switched off (Flet reads a None trigger as # its default), so LONG_PRESS with no per-button items keeps them inert. super().__init__( content=Content, expand=True, secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS, tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS, on_select=self._Closed, on_dismiss=self._Closed, ) self.ItemsFor = Items self.MenuOpen = False self.OpenedAt = 0.0 self.Pending: Optional[tuple[object, ft.Offset]] = None def Request(self, Target: object, Event: ft.TapEvent) -> None: if self.MenuOpen: # Content can't be clicked while the menu's barrier covers it, so # getting here means the menu is gone and its close event is late. if time.monotonic() - self.OpenedAt < 0.5: return self.MenuOpen = False First = self.Pending is None if First or Target is not None: self.Pending = (Target, Event.global_position) if First: self.page.run_task(self._OpenPending) async def _OpenPending(self) -> None: await asyncio.sleep(0.08) Target, Position = self.Pending self.Pending = None Items = self.ItemsFor(Target) if not Items: return # open() shows `items`, and a method call doesn't flush property # changes, so the items are sent first. self.items = Items self.update() self.MenuOpen = True self.OpenedAt = time.monotonic() try: await self.open(global_position=Position) except Exception: self.MenuOpen = False raise def _Closed(self, Event: ft.ContextMenuDismissEvent) -> None: # button is None only for menus opened through open(); the inert # built-in triggers report "secondary"/"tertiary". if Event.button is None: self.MenuOpen = False def MenuItem(Text: str, OnClick: Callable[[], None], Icon=None) -> ft.PopupMenuItem: return ft.PopupMenuItem(content=Text, icon=Icon, on_click=lambda e: OnClick()) def Panel(Content: ft.Control, **Options) -> ft.Container: """A Mantle box with a thin border, like SaraFem's side panels.""" return ft.Container(Content, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6, padding=Options.pop("padding", 12), **Options) def SectionTitle(Text: str, Trailing: Optional[list[ft.Control]] = None) -> ft.Row: return ft.Row([ft.Text(Text, color=Palette.Mauve, size=16), ft.Container(expand=True), *(Trailing or [])], vertical_alignment=ft.CrossAxisAlignment.CENTER) def Badge(Text: str, Color: str) -> ft.Container: return ft.Container(ft.Text(Text, size=11, color=Color), border=ft.Border.all(1, Color), border_radius=4, padding=ft.Padding.symmetric(horizontal=6, vertical=1)) def EmptyState(Text: str, Detail: str = "", Icon=None) -> ft.Control: Controls: list[ft.Control] = [ft.Icon(Icon, size=40, color=Palette.Surface1)] if Icon else [] Controls += [ft.Text(Text, color=Palette.Overlay0, size=16, text_align=ft.TextAlign.CENTER)] if Detail: Controls.append(Note(Detail)) return ft.Container(ft.Column(Controls, horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True), alignment=ft.Alignment.CENTER, expand=True) def HumanSize(NumBytes: float) -> str: Size = float(NumBytes) for Unit in ("B", "KB", "MB", "GB", "TB"): if Size < 1024 or Unit == "TB": return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}" Size /= 1024 return f"{Size:.1f} TB" def Plural(Count: int, Word: str, PluralWord: str = "") -> str: return f"{Count} {Word if Count == 1 else (PluralWord or Word + 's')}"