Files
SaraTag/Widgets.py
T

242 lines
10 KiB
Python

"""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, 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 = ["xdg-open", str(TargetPath)]
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:
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')}"