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