mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-13 18:28:16 +02:00
242 lines
10 KiB
Python
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')}"
|