mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-14 18:48:11 +02:00
SaraSort: Flet GUI for configuring SaraSortd
Flowchart of inputs/outputs with editable arrows, service page (status, controls, autostart, version, logs), config editor (form + raw), templates, tools (warnings, filename tester, failed files) and settings with SaraFem-compatible export/import. AppImage build and a SaraSuite manifest in Packaging/. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
f7ed23edf9
22 files changed
+3399
No files matched your search
+162
@@ -0,0 +1,162 @@
|
||||
"""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, 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:
|
||||
Color = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Text)
|
||||
self.Page.show_dialog(ft.SnackBar(
|
||||
ft.Text(Text, color=Color), bgcolor=Palette.Surface0, duration=ft.Duration(seconds=4),
|
||||
))
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in new issue
Block a user