Files
2026-10-09 14:16:50 +02:00

169 lines
6.8 KiB
Python

"""What every page shares: the App context (config, notifications, reloading
SaraSortd's setup, restarting it after changes) and small reusable controls."""
import os
import subprocess
from typing import Callable, Optional
import flet as ft
from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette
from Service import RestartIfRunning
from Sortd import Setup, SortdError
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
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 LoadSetup(self) -> tuple[Optional[Setup], str]:
try:
return Setup(self.Config), ""
except SortdError as Error:
return None, str(Error)
def AfterGlobalChange(self) -> None:
"""GlobalConf.toml changed; SaraSortd only reads it when it starts."""
if not self.Config.AutoRestart:
self.Notify("Saved. Restart SaraSortd (Service page) for it to take effect.", "warning")
return
Error = RestartIfRunning(self.Config.ServiceName)
if Error:
self.Notify(f"Saved, but restarting SaraSortd failed: {Error}", "error")
else:
self.Notify("Saved; SaraSortd restarted.", "success")
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") -> None:
def Yes(e) -> None:
self.Close()
OnYes()
self.ShowDialog(Title, ft.Text(Text), [
ChipButton("Cancel", lambda e: self.Close()),
ChipButton(YesText, Yes, Accent=Palette.Red),
], Width=440)
def OpenFolder(self, PathText: str) -> None:
try:
if IsWindows:
os.startfile(PathText) # Explorer
else:
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
except OSError as Error:
self.Notify(f"Couldn't open {PathText}: {Error}", "error")
def DirField(TheApp: App, Label: str, Value: str = "", Expand: bool = True) -> tuple[ft.Row, ft.TextField]:
"""A path field with a Browse button. Returns (row, field)."""
Field = ft.TextField(label=Label, value=Value, expand=Expand, dense=True)
async def Browse(e) -> None:
Chosen = await TheApp.Picker.get_directory_path(dialog_title=Label, initial_directory=Field.value or 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
def ExpandPath(Text: str) -> str:
return os.path.abspath(os.path.expanduser(Text.strip())).rstrip("/") if Text.strip() else ""
class NameField(ft.Column):
"""A NewFileName field with buttons that insert SaraSortd's variables and
a live example of the resulting name."""
def __init__(self, TheSetup: Optional[Setup], Value: str, OutputPath: str = "", Entry: Optional[dict] = None,
Label: str = "NewFileName", OnChange: Optional[Callable[[str], None]] = None):
super().__init__(spacing=4)
self.TheSetup = TheSetup
self.OutputPath = OutputPath or "/Output"
self.Entry = Entry or {}
self.OnChangeCallback = OnChange
self.ExampleFile = ft.TextField(value="Holiday photo.png", label="Example file", dense=True, width=180,
on_change=lambda e: self._Refresh())
self.Field = ft.TextField(label=Label, value=Value, dense=True, expand=True, on_change=lambda e: self._Changed())
self.Preview = ft.Text("", color=Palette.Green, size=12, selectable=True)
Chips = []
if TheSetup:
V = TheSetup.Vars
for Key, Tip in [
("OrgFileName", "original name"), ("OrgFileType", "original extension"), ("NextNum", "next number"),
("NextChar", "next letter"), ("Parent", "folder name"), ("Year", "year"), ("Month", "month"),
("Day", "day"), ("Hour", "hour"), ("Minute", "minute"), ("Second", "second"),
]:
if V.get(Key):
Chips.append(ft.Container(
ft.Text(f"{V[Key]}", size=12, color=Palette.Mauve),
tooltip=f"{Key}: {Tip}", padding=ft.Padding.symmetric(horizontal=6, vertical=2),
border=ft.Border.all(1, Palette.Surface1), border_radius=4,
on_click=lambda e, Text=V[Key]: self._Insert(Text),
))
self.controls = [
ft.Row([self.Field, self.ExampleFile]),
ft.Row(Chips, wrap=True, spacing=4, run_spacing=4),
self.Preview,
]
self._Refresh(Update=False)
@property
def value(self) -> str:
return self.Field.value or ""
def _Insert(self, Text: str) -> None:
self.Field.value = (self.Field.value or "") + Text
self.Field.update()
self._Changed()
def _Changed(self) -> None:
self._Refresh()
if self.OnChangeCallback:
self.OnChangeCallback(self.value)
def _Refresh(self, Update: bool = True) -> None:
if self.TheSetup:
Example = self.ExampleFile.value or "file.txt"
try:
Name = self.TheSetup.PreviewName(self.value or "~^", Example, self.OutputPath, self.Entry)
self.Preview.value = f"{Example} -> {Name}"
except Exception as Error: # a half-typed pattern shouldn't break the dialog
self.Preview.value = f"(can't preview: {Error})"
if Update:
self.Preview.update()
def KindColor(Kind: str) -> str:
return {"Input": Palette.Red, "Output": Palette.Blue, "Both": Palette.Mauve}[Kind]
def EmptyState(Text: str, Detail: str = "") -> ft.Control:
return ft.Container(
ft.Column([ft.Text(Text, color=Palette.Overlay0, size=16), Note(Detail)], horizontal_alignment=ft.CrossAxisAlignment.CENTER),
alignment=ft.Alignment.CENTER, expand=True,
)