Files
SaraSort/FlowPage.py
T
sarahandClaude Opus 5.5 f7ed23edf9 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]>
2026-10-07 22:23:36 +02:00

561 lines
26 KiB
Python

"""The flowchart: every input (red), output (blue) and both (purple)
directory as a box, with an arrow per input -> output pair labelled with the
file patterns that travel along it.
Boxes start in columns (inputs left, both middle, outputs right) and can be
dragged; dragged positions are kept in Layout.toml. In edit mode, clicking two
boxes connects them, and clicking an arrow's label edits or removes it.
"""
import math
import os
from typing import Optional
import flet as ft
import flet.canvas as cv
import tomlkit
from Core import ChipButton, ChipButtonStyle, DisplayPath, Heading, LayoutPath, Note, Palette
from Sortd import DirNode, Edge, Setup
from Templates import SaveUserTemplate, SortTemplate
from Widgets import App, DirField, EmptyState, ExpandPath, KindColor, NameField
NodeWidth = 210
NodeHeight = 58
ColumnGap = 330
RowGap = 96
Margin = 40
LabelHeight = 24
CharWidth = 7.5 # Fixedsys at size 12
def LoadLayout() -> dict[str, tuple[float, float]]:
try:
Data = tomlkit.parse(LayoutPath.read_text()).unwrap()
except (OSError, tomlkit.exceptions.TOMLKitError):
return {}
return {Key: (float(Value[0]), float(Value[1])) for Key, Value in Data.get("Positions", {}).items()
if isinstance(Value, list) and len(Value) == 2}
def SaveLayout(Positions: dict[str, tuple[float, float]]) -> None:
LayoutPath.parent.mkdir(parents=True, exist_ok=True)
LayoutPath.write_text(tomlkit.dumps({"Positions": {Key: [round(X), round(Y)] for Key, (X, Y) in Positions.items()}}))
def ClipToBox(CenterX: float, CenterY: float, TowardX: float, TowardY: float) -> tuple[float, float]:
"""Where the line from a box's centre toward a point leaves the box."""
DX, DY = TowardX - CenterX, TowardY - CenterY
if DX == 0 and DY == 0:
return CenterX, CenterY
Scale = min(
(NodeWidth / 2) / abs(DX) if DX else math.inf,
(NodeHeight / 2) / abs(DY) if DY else math.inf,
)
return CenterX + DX * Scale, CenterY + DY * Scale
class FlowPage(ft.Column):
def __init__(self, TheApp: App):
super().__init__(expand=True, spacing=0)
self.App = TheApp
self.EditMode = False
self.Selected: Optional[str] = None
self.Positions: dict[str, tuple[float, float]] = {}
self.Nodes: dict[str, DirNode] = {}
self.EdgeList: list[Edge] = []
self.TheSetup: Optional[Setup] = None
self.NodeControls: dict[str, ft.GestureDetector] = {}
self.DragStart: dict = {}
self.EditButton = ChipButton("Edit mode", self._ToggleEdit, ft.Icons.EDIT_OUTLINED)
self.AddButton = ChipButton("Add directory", self._AddDirectoryDialog, ft.Icons.CREATE_NEW_FOLDER_OUTLINED)
self.AddButton.visible = False
self.Hint = Note("")
self.Canvas = cv.Canvas(shapes=[])
self.Board = ft.Stack([], width=800, height=600)
self.Body = ft.Container(expand=True)
def Legend(Text: str, Color: str) -> ft.Row:
return ft.Row([
ft.Container(width=14, height=14, bgcolor=ft.Colors.with_opacity(0.25, Color), border=ft.Border.all(2, Color), border_radius=3),
ft.Text(Text, size=12),
], spacing=6)
self.controls = [
ft.Container(
ft.Row([
Heading("Flow"),
ft.Container(width=12),
Legend("Input", Palette.Red), Legend("Output", Palette.Blue), Legend("Both", Palette.Mauve),
ft.Container(expand=True),
self.AddButton,
ChipButton("Tidy layout", self._ResetLayout, ft.Icons.AUTO_FIX_HIGH_OUTLINED),
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH),
self.EditButton,
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
padding=ft.Padding.only(left=16, right=16, top=12, bottom=4),
),
ft.Container(self.Hint, padding=ft.Padding.only(left=16, bottom=8)),
ft.Divider(height=1, color=Palette.Surface1),
self.Body,
]
# Loading and layout
def Refresh(self) -> None:
TheSetup, Error = self.App.LoadSetup()
self.TheSetup = TheSetup
if TheSetup is None:
self.Body.content = EmptyState("Can't read SaraSortd's config.", f"{Error}\nCheck the paths in Settings.")
self.update()
return
self.Nodes = TheSetup.Nodes()
self.EdgeList = TheSetup.Edges()
self._Layout()
self._BuildBoard()
self.Body.content = ft.Column(
[ft.Row([self.Board], scroll=ft.ScrollMode.AUTO)],
scroll=ft.ScrollMode.AUTO, expand=True,
) if self.Nodes else EmptyState("No directories yet.", "Turn on Edit mode and add one, or install a template.")
self._UpdateHint()
self.update()
def _AutoPositions(self) -> dict[str, tuple[float, float]]:
Columns: dict[str, list[str]] = {"Input": [], "Both": [], "Output": []}
for PathText, Node in self.Nodes.items():
Columns[Node.Kind].append(PathText)
Used = [Kind for Kind in ("Input", "Both", "Output") if Columns[Kind]]
Result = {}
for ColumnIndex, Kind in enumerate(Used):
for RowIndex, PathText in enumerate(Columns[Kind]):
Result[PathText] = (Margin + ColumnIndex * ColumnGap, Margin + 30 + RowIndex * RowGap)
return Result
def _Layout(self) -> None:
Auto = self._AutoPositions()
Saved = LoadLayout()
self.Positions = {PathText: Saved.get(PathText, Auto[PathText]) for PathText in self.Nodes}
def _ResetLayout(self, e) -> None:
self.Positions = self._AutoPositions()
SaveLayout({})
self._BuildBoard()
self.Board.update()
def _BoardSize(self) -> tuple[float, float]:
if not self.Positions:
return 800, 600
Width = max(X for X, Y in self.Positions.values()) + NodeWidth + Margin + 80
Height = max(Y for X, Y in self.Positions.values()) + NodeHeight + Margin + 40
return max(Width, 800), max(Height, 500)
# Drawing
def _BuildBoard(self) -> None:
self.NodeControls = {PathText: self._NodeControl(Node) for PathText, Node in self.Nodes.items()}
self._Redraw(Update=False)
def _Redraw(self, Update: bool = True) -> None:
Shapes, Labels = self._EdgeDrawing()
self.Canvas.shapes = Shapes
Width, Height = self._BoardSize()
self.Canvas.width, self.Canvas.height = Width, Height
self.Board.width, self.Board.height = Width, Height
for PathText, Control in self.NodeControls.items():
Control.left, Control.top = self.Positions[PathText]
self.Board.controls = [self.Canvas] + Labels + list(self.NodeControls.values())
if Update:
self.Board.update()
def _NodeControl(self, Node: DirNode) -> ft.GestureDetector:
Color = KindColor(Node.Kind)
Selected = Node.Path == self.Selected
Name = os.path.basename(Node.Path) or Node.Path
Exists = os.path.isdir(Node.Path)
Box = ft.Container(
ft.Column([
ft.Row([
ft.Icon(ft.Icons.FOLDER_OUTLINED if Exists else ft.Icons.FOLDER_OFF_OUTLINED, color=Color, size=16),
ft.Text(Name, size=15, color=Palette.Text, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS, expand=True),
], spacing=6),
ft.Text(DisplayPath(os.path.dirname(Node.Path)), size=11, color=Palette.Overlay0, no_wrap=True,
overflow=ft.TextOverflow.ELLIPSIS),
], spacing=2, alignment=ft.MainAxisAlignment.CENTER),
width=NodeWidth, height=NodeHeight,
padding=ft.Padding.symmetric(horizontal=10, vertical=6),
bgcolor=ft.Colors.with_opacity(0.18, Color) if not Selected else ft.Colors.with_opacity(0.4, Color),
border=ft.Border.all(3 if Selected else 2, Palette.Yellow if Selected else Color),
border_radius=6,
tooltip=f"{Node.Path}\n{Node.Kind}" + ("" if Exists else "\n(doesn't exist yet)"),
)
return ft.GestureDetector(
content=Box,
mouse_cursor=ft.MouseCursor.CLICK,
drag_interval=16,
on_tap=lambda e, PathText=Node.Path: self._NodeClicked(PathText),
on_pan_start=lambda e, PathText=Node.Path: self._DragStart(PathText, e),
on_pan_update=lambda e, PathText=Node.Path: self._DragUpdate(PathText, e),
on_pan_end=lambda e: SaveLayout(self.Positions),
)
def _DragStart(self, PathText: str, e) -> None:
self.DragStart = {"Pointer": (e.global_position.x, e.global_position.y), "Box": self.Positions[PathText]}
def _DragUpdate(self, PathText: str, e) -> None:
if not self.DragStart:
return
StartX, StartY = self.DragStart["Pointer"]
BoxX, BoxY = self.DragStart["Box"]
self.Positions[PathText] = (
max(0, BoxX + e.global_position.x - StartX),
max(0, BoxY + e.global_position.y - StartY),
)
self._Redraw()
def _EdgeDrawing(self) -> tuple[list[cv.Shape], list[ft.Control]]:
Shapes: list[cv.Shape] = []
Labels: list[ft.Control] = []
Segments: list = []
Pairs = {(FoundEdge.Input, FoundEdge.Output) for FoundEdge in self.EdgeList}
for FoundEdge in self.EdgeList:
if FoundEdge.Input not in self.Positions or FoundEdge.Output not in self.Positions:
continue
Color = Palette.Overlay0
Paint = ft.Paint(color=Color, stroke_width=2, style=ft.PaintingStyle.STROKE)
AX, AY = self.Positions[FoundEdge.Input]
BX, BY = self.Positions[FoundEdge.Output]
if FoundEdge.Input == FoundEdge.Output:
# Sorting into itself: a loop over the top of the box.
Left, Top = AX + NodeWidth * 0.3, AY
Right = AX + NodeWidth * 0.7
Shapes.append(cv.Path([
cv.Path.MoveTo(Left, Top),
cv.Path.CubicTo(Left - 10, Top - 50, Right + 10, Top - 50, Right, Top),
], paint=Paint))
Shapes.extend(self._ArrowHead(Right + 6, Top - 14, Right, Top, Color))
Labels.append(self._Label(FoundEdge, AX + NodeWidth / 2, Top - 46))
continue
ACX, ACY = AX + NodeWidth / 2, AY + NodeHeight / 2
BCX, BCY = BX + NodeWidth / 2, BY + NodeHeight / 2
# Two arrows between the same boxes (both ways) are pushed apart.
OffsetX = OffsetY = 0.0
if (FoundEdge.Output, FoundEdge.Input) in Pairs:
Length = math.hypot(BCX - ACX, BCY - ACY) or 1
OffsetX, OffsetY = -(BCY - ACY) / Length * 12, (BCX - ACX) / Length * 12
StartX, StartY = ClipToBox(ACX, ACY, BCX, BCY)
EndX, EndY = ClipToBox(BCX, BCY, ACX, ACY)
StartX, StartY, EndX, EndY = StartX + OffsetX, StartY + OffsetY, EndX + OffsetX, EndY + OffsetY
Shapes.append(cv.Line(StartX, StartY, EndX, EndY, paint=Paint))
Shapes.extend(self._ArrowHead(StartX, StartY, EndX, EndY, Color))
Segments.append((FoundEdge, StartX, StartY, EndX, EndY))
Labels.extend(self._PlaceLabels(Segments))
return Shapes, Labels
def _LabelText(self, FoundEdge: Edge) -> tuple[str, str, float]:
Full = ", ".join(FoundEdge.Patterns)
Limit = max(6, self.App.Config.LabelLength)
Short = Full if len(Full) <= Limit else Full[: Limit - 1] + "…"
return Short, Full, len(Short) * CharWidth + 18
def _PlaceLabels(self, Segments: list) -> list[ft.Control]:
"""Each label goes on its own arrow, slid along it until it's clear of
boxes, other labels and other arrows (or as clear as it gets)."""
Boxes = [(X, Y, X + NodeWidth, Y + NodeHeight) for X, Y in self.Positions.values()]
Placed: list[tuple[float, float, float, float]] = []
Labels = []
def Overlaps(A, B) -> bool:
return A[0] < B[2] and B[0] < A[2] and A[1] < B[3] and B[1] < A[3]
def Crossings(Rect, Own) -> int:
Count = 0
for Other in Segments:
if Other is Own:
continue
_, SX, SY, EX, EY = Other
if any(Rect[0] <= SX + (EX - SX) * Step / 24 <= Rect[2] and Rect[1] <= SY + (EY - SY) * Step / 24 <= Rect[3]
for Step in range(25)):
Count += 1
return Count
for Segment in Segments:
FoundEdge, SX, SY, EX, EY = Segment
_, _, Width = self._LabelText(FoundEdge)
Best = None
for Fraction in (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75):
CX, CY = SX + (EX - SX) * Fraction, SY + (EY - SY) * Fraction
Rect = (CX - Width / 2 - 4, CY - LabelHeight / 2 - 2, CX + Width / 2 + 4, CY + LabelHeight / 2 + 2)
Score = 10 * sum(Overlaps(Rect, Other) for Other in Placed + Boxes) + Crossings(Rect, Segment)
if Best is None or Score < Best[0]:
Best = (Score, CX, CY, Rect)
if Score == 0:
break
Placed.append(Best[3])
Labels.append(self._Label(FoundEdge, Best[1], Best[2]))
return Labels
def _ArrowHead(self, FromX: float, FromY: float, ToX: float, ToY: float, Color: str) -> list[cv.Shape]:
Angle = math.atan2(ToY - FromY, ToX - FromX)
Size = 11
Points = [
(ToX - Size * math.cos(Angle - 0.45), ToY - Size * math.sin(Angle - 0.45)),
(ToX - Size * math.cos(Angle + 0.45), ToY - Size * math.sin(Angle + 0.45)),
]
return [cv.Path([
cv.Path.MoveTo(ToX, ToY), cv.Path.LineTo(*Points[0]), cv.Path.LineTo(*Points[1]), cv.Path.Close(),
], paint=ft.Paint(color=Color, style=ft.PaintingStyle.FILL))]
def _Label(self, FoundEdge: Edge, CenterX: float, CenterY: float) -> ft.Container:
Short, Full, Width = self._LabelText(FoundEdge)
return ft.Container(
ft.Text(Short, size=12, color=Palette.Text, no_wrap=True),
left=CenterX - Width / 2, top=CenterY - LabelHeight / 2, width=Width, height=LabelHeight,
alignment=ft.Alignment.CENTER,
bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Mauve if self.EditMode else Palette.Surface1),
border_radius=4, tooltip=Full if Short != Full else None,
on_click=lambda e: self._EdgeDialog(FoundEdge),
)
# Interaction
def _UpdateHint(self) -> None:
if not self.EditMode:
self.Hint.value = "Click a box for details, an arrow's label for all of its patterns. Drag boxes to move them."
elif self.Selected:
self.Hint.value = f"Now click the directory {os.path.basename(self.Selected)} should sort into (or it again to cancel)."
else:
self.Hint.value = "Edit mode: click a directory, then the one it should sort into. Click a label to change or remove an arrow."
self.Hint.color = Palette.Yellow if self.EditMode else Palette.Overlay0
def _ToggleEdit(self, e) -> None:
self.EditMode = not self.EditMode
self.Selected = None
self.EditButton.content = "Done" if self.EditMode else "Edit mode"
self.EditButton.style = ChipButtonStyle(Palette.Yellow if self.EditMode else "")
self.AddButton.visible = self.EditMode
self._UpdateHint()
if self.TheSetup:
self._BuildBoard()
self.update()
def _NodeClicked(self, PathText: str) -> None:
if not self.EditMode:
self._NodeDialog(PathText)
return
if self.Selected is None:
self.Selected = PathText
elif self.Selected == PathText:
self.Selected = None
else:
Source, self.Selected = self.Selected, None
self._ConnectDialog(Source, PathText)
self._UpdateHint()
self._BuildBoard()
self.update()
def _AfterChange(self, GlobalChanged: bool) -> None:
if GlobalChanged:
self.App.AfterGlobalChange()
self.Refresh()
def _ConnectDialog(self, InputPath: str, OutputPath: str, Preset: Optional[list[str]] = None) -> None:
TheSetup = self.TheSetup
Defaults = TheSetup.TemplateFileDefaults()
PatternField = ft.TextField(
label="File types (patterns), separated by commas", value=", ".join(Preset or []),
hint_text="*.png, *.jp*g", autofocus=True, dense=True,
)
Name = NameField(TheSetup, str(Defaults.get("NewFileName", "~^")), OutputPath, Defaults)
Move = ft.Switch(label="Move files (off: copy them)", value=True)
OutConf = TheSetup.LoadDirConf(OutputPath, "Out") or {}
Move.value = OutConf.get("DeleteOrg", 1) == 1
def Save(e) -> None:
Patterns = [Part.strip() for Part in (PatternField.value or "").split(",") if Part.strip()]
if not Patterns:
PatternField.error = "Add at least one pattern, e.g. *.png"
PatternField.update()
return
self.App.Close()
Overrides = {}
if Name.value and Name.value != Defaults.get("NewFileName"):
Overrides["NewFileName"] = Name.value
try:
Changed = TheSetup.Connect(InputPath, OutputPath, Patterns, Overrides, {"DeleteOrg": 1 if Move.value else 0})
except Exception as Error:
self.App.Notify(f"Couldn't connect: {Error}", "error")
return
self._AfterChange(Changed)
self.App.ShowDialog(
"Connect",
ft.Column([
ft.Text(f"{DisplayPath(InputPath)} -> {DisplayPath(OutputPath)}", color=Palette.Mauve, selectable=True),
PatternField,
Note("* is any text, ? one character. Each pattern becomes its own [[Files]] entry; the other "
"settings (NextNum, Overwrite...) come from OutputDirConf.toml."),
Name,
Move,
], tight=True, spacing=10),
[ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Connect", Save)],
Width=620,
)
def _EdgeDialog(self, FoundEdge: Edge) -> None:
TheSetup = self.TheSetup
Rows = []
for Rule in FoundEdge.Rules:
Scope = "only from this input" if Rule.OwnInputs else "from every input this output accepts"
Row = [
ft.Text(Rule.Pattern, color=Palette.Mauve, width=150, selectable=True),
ft.Column([
ft.Text(f"named {Rule.NewFileName or '~^'}", size=12),
Note(Scope, 11),
], spacing=0, expand=True),
]
if self.EditMode:
Row.append(ft.IconButton(
ft.Icons.DELETE_OUTLINE, icon_color=Palette.Red, tooltip="Stop sending this pattern here",
on_click=lambda e, Index=Rule.Index: RemoveOne(Index),
))
Rows.append(ft.Row(Row))
def RemoveOne(Index: int) -> None:
self.App.Close()
Message = TheSetup.RemoveRule(FoundEdge.Input, FoundEdge.Output, Index)
if Message:
self.App.Notify(Message, "warning")
self.Refresh()
def RemoveAll() -> None:
Messages = TheSetup.Disconnect(FoundEdge.Input, FoundEdge.Output)
if Messages:
self.App.Notify(" ".join(Messages), "warning")
self.Refresh()
def AddMore(e) -> None:
self.App.Close()
self._ConnectDialog(FoundEdge.Input, FoundEdge.Output)
def SaveTemplate(e) -> None:
self.App.Close()
self._SaveTemplateDialog(FoundEdge)
Actions = [ChipButton("Save as template", SaveTemplate, ft.Icons.BOOKMARK_ADD_OUTLINED)]
if self.EditMode:
Actions += [
ChipButton("Add patterns", AddMore, ft.Icons.ADD),
ChipButton("Remove arrow", lambda e: (self.App.Close(), self.App.Confirm(
"Remove arrow", f"Stop sorting from {DisplayPath(FoundEdge.Input)} into {DisplayPath(FoundEdge.Output)}?", RemoveAll,
)), ft.Icons.LINK_OFF, Accent=Palette.Red),
]
Actions.append(ChipButton("Close", lambda e: self.App.Close()))
self.App.ShowDialog(
"Arrow",
ft.Column([
ft.Text(f"{DisplayPath(FoundEdge.Input)} -> {DisplayPath(FoundEdge.Output)}", color=Palette.Mauve, selectable=True),
ft.Divider(height=1, color=Palette.Surface1),
ft.Column(Rows, scroll=ft.ScrollMode.AUTO, height=min(360, 48 * len(Rows) + 8), spacing=4),
Note("SaraSortd tries outputs in OutputDir order and patterns top to bottom; the first match wins."),
], tight=True, spacing=8),
Actions, Width=620,
)
def _SaveTemplateDialog(self, FoundEdge: Edge) -> None:
NameInput = ft.TextField(label="Template name", value=os.path.basename(FoundEdge.Output), autofocus=True, dense=True)
Description = ft.TextField(label="Description", dense=True)
NewNames = {Rule.NewFileName for Rule in FoundEdge.Rules}
def Save(e) -> None:
if not (NameInput.value or "").strip():
return
self.App.Close()
OutConf = self.TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}
Settings = {Key: OutConf[Key] for Key in ("DeleteOrg", "Unzip") if Key in OutConf}
Saved = SaveUserTemplate(SortTemplate(
NameInput.value.strip(), Description.value or "", Patterns=FoundEdge.Patterns,
NewFileName=NewNames.pop() if len(NewNames) == 1 else "",
SuggestedOutput=DisplayPath(FoundEdge.Output), OutputSettings=Settings,
))
self.App.Notify(f"Saved {Saved.name} in Templates.", "success")
self.App.ShowDialog("Save as template", ft.Column([NameInput, Description], tight=True),
[ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Save", Save)], Width=440)
def _NodeDialog(self, PathText: str) -> None:
Node = self.Nodes[PathText]
TheSetup = self.TheSetup
Incoming = [FoundEdge for FoundEdge in self.EdgeList if FoundEdge.Output == PathText]
Outgoing = [FoundEdge for FoundEdge in self.EdgeList if FoundEdge.Input == PathText]
def Lines(Edges: list[Edge], Other: str) -> list[ft.Control]:
return [ft.Text(f"{os.path.basename(getattr(FoundEdge, Other))}: {', '.join(FoundEdge.Patterns)}", size=12, selectable=True)
for FoundEdge in Edges] or [Note("none")]
Kinds = (["In"] if Node.IsInput else []) + (["Out"] if Node.IsOutput else [])
ConfButtons = []
for Kind in dict.fromkeys(Kinds):
ConfPath = TheSetup.ConfFile(PathText, Kind)
if ConfPath.exists():
ConfButtons.append(ChipButton(
f"Edit {'input' if Kind == 'In' else 'output'} config" if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) > 1 else "Edit config",
lambda e, ConfPath=ConfPath: (self.App.Close(), self.App.Navigate("Configs", Open=str(ConfPath))),
ft.Icons.TUNE,
))
if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) == 1:
break
def Remove() -> None:
if TheSetup.RemoveDir(PathText):
Positions = LoadLayout()
Positions.pop(PathText, None)
SaveLayout(Positions)
self._AfterChange(True)
Actions = ConfButtons + [ChipButton("Open folder", lambda e: self.App.OpenFolder(PathText), ft.Icons.FOLDER_OPEN_OUTLINED)]
Actions.append(ChipButton("Remove", lambda e: (self.App.Close(), self.App.Confirm(
"Remove directory",
f"Take {DisplayPath(PathText)} out of SaraSortd? The folder, its files and its config stay on disk.", Remove,
)), ft.Icons.DELETE_OUTLINE, Accent=Palette.Red))
Actions.append(ChipButton("Close", lambda e: self.App.Close()))
self.App.ShowDialog(
os.path.basename(PathText) or PathText,
ft.Column([
ft.Text(PathText, selectable=True, color=KindColor(Node.Kind)),
Note(f"{Node.Kind}" + ("" if os.path.isdir(PathText) else " - doesn't exist yet")),
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Receives", color=Palette.Mauve), *Lines(Incoming, "Input"),
ft.Text("Sends", color=Palette.Mauve), *Lines(Outgoing, "Output"),
], tight=True, spacing=6, scroll=ft.ScrollMode.AUTO),
Actions, Width=620,
)
def _AddDirectoryDialog(self, e) -> None:
if not self.TheSetup:
return
Row, Field = DirField(self.App, "Directory")
Kind = ft.RadioGroup(ft.Row([
ft.Radio(value="InputDir", label="Input"), ft.Radio(value="OutputDir", label="Output"),
ft.Radio(value="Both", label="Both"),
]), value="InputDir")
def Add(e) -> None:
PathText = ExpandPath(Field.value or "")
if not PathText:
return
self.App.Close()
Keys = ["InputDir", "OutputDir"] if Kind.value == "Both" else [Kind.value]
try:
Changed = self.TheSetup.AddDirectory(PathText, Keys)
except OSError as Error:
self.App.Notify(f"Couldn't add it: {Error}", "error")
return
self._AfterChange(Changed)
self.App.ShowDialog("Add directory", ft.Column([
Row, Kind,
Note("Its config is made from InputDirConf.toml / OutputDirConf.toml, like SaraSortd does. "
"A new output takes every file (the template's \"*\") until you change it."),
], tight=True, spacing=10), [ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Add", Add)], Width=560)