mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
The window keeps its own title bar (or none, per HideWindowTitleBar), with SaraSuite's header (name, Add to app menu for AppImages, gear). Settings is now a dialog laid out like SaraFem's, with export/import on the SaraSuite tab, and notifications use SaraFem's tinted toast style. Co-Authored-By: Claude Opus 5.5 <[email protected]>
166 lines
6.7 KiB
Python
166 lines
6.7 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, 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:
|
|
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,
|
|
)
|