Files
sarahandClaude Opus 5.5 4c0a8e4d4f Flow: right-click menus like SaraFem's
Boxes, arrow labels and the background each get a menu (edit, delete,
connect, test a file, copy path/patterns, ...), using SaraFem's single
shared ContextMenu opened from _RequestMenu. Tools can open on the tester
for a given input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 18:28:02 +02:00

807 lines
39 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 asyncio
import math
import os
import time
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 = {}
# Right-click menus work like SaraFem's: one shared popup host, opened
# programmatically (see _RequestMenu) so a right-click resolves to exactly
# one menu. Its built-in triggers can't be switched off (Flet reads a None
# trigger as its default), so LONG_PRESS with no per-button items keeps them inert.
self.MenuOpen = False
self.MenuOpenedAt = 0.0
self.PendingMenu: Optional[tuple[Optional[tuple[str, object]], ft.Offset]] = None
self.Menu = ft.ContextMenu(
content=ft.Container(), # the board, set by Refresh
expand=True,
secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS,
tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS,
on_select=self._OnMenuClosed,
on_dismiss=self._OnMenuClosed,
)
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()
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.", "Right-click to add one, or install a template.")
self.Menu.content = ft.GestureDetector(
content=ft.Container(Content, expand=True, bgcolor=Palette.Base),
expand=True,
on_secondary_tap_down=lambda e: self._RequestMenu(None, e),
)
self.Body.content = self.Menu
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),
*([ft.Container(
ft.Icon(ft.Icons.DELETE_OUTLINE, color=Palette.Red, size=16),
tooltip="Delete from SaraSortd (the folder stays)", border_radius=4,
on_click=lambda e, PathText=Node.Path: self._DeleteDialog(PathText),
)] if self.EditMode else []),
], 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),
on_secondary_tap_down=lambda e, PathText=Node.Path: self._RequestMenu(("Node", PathText), e),
)
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.GestureDetector:
Short, Full, Width = self._LabelText(FoundEdge)
return ft.GestureDetector(
content=ft.Container(
ft.Text(Short, size=12, color=Palette.Text, no_wrap=True),
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,
),
left=CenterX - Width / 2, top=CenterY - LabelHeight / 2,
mouse_cursor=ft.MouseCursor.CLICK,
on_tap=lambda e: self._EdgeDialog(FoundEdge),
on_secondary_tap_down=lambda e: self._RequestMenu(("Edge", FoundEdge), e),
)
# 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; right-click anything for more.")
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 edit "
"or remove an arrow's rules; the bin on a box deletes it from SaraSortd.")
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, Editable: Optional[bool] = None) -> None:
Editable = self.EditMode if Editable is None else Editable
TheSetup = self.TheSetup
Entries = (TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}).get("Files", [])
Editors: list[tuple] = [] # (Rule, Entry, PatternField, NameField, CaseSwitch, OverwriteSwitch)
Rows = []
for Rule in FoundEdge.Rules:
Entry = Entries[Rule.Index] if Rule.Index < len(Entries) else {}
Others = [os.path.basename(Other) for Other in TheSetup.RuleInputs(FoundEdge.Output, Rule.Index)
if Other != FoundEdge.Input]
if not Editable:
Scope = f"also from {', '.join(Others)}" if Others else "only from this input"
Rows.append(ft.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),
]))
continue
PatternField = ft.TextField(label="Pattern", value=Rule.Pattern, dense=True, width=220)
CaseSwitch = ft.Switch(label="Case-sensitive", value=Entry.get("CaseSensitive", 0) == 1)
OverwriteSwitch = ft.Switch(label="Overwrite", value=Entry.get("Overwrite", 1) == 1)
Name = NameField(TheSetup, Rule.NewFileName or "~^", FoundEdge.Output, Entry)
Editors.append((Rule, Entry, PatternField, Name, CaseSwitch, OverwriteSwitch))
Rows.append(ft.Container(
ft.Column([
ft.Row([
PatternField, CaseSwitch, OverwriteSwitch, ft.Container(expand=True),
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)),
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
Name,
Note(f"Also used by {', '.join(Others)}: changing it here gives this arrow its own copy." if Others
else "Only this arrow uses it.", 11),
], spacing=6),
padding=10, bgcolor=Palette.Base, border=ft.Border.all(1, Palette.Surface1), border_radius=6,
))
def Reopen() -> None:
"""After a change: show the same arrow again, if it's still there."""
self.Refresh()
Again = next((Other for Other in self.EdgeList
if Other.Input == FoundEdge.Input and Other.Output == FoundEdge.Output), None)
if Again:
self._EdgeDialog(Again, Editable)
def RemoveOne(Index: int) -> None:
self.App.Close()
Message = TheSetup.RemoveRule(FoundEdge.Input, FoundEdge.Output, Index)
if Message:
self.App.Notify(Message, "warning")
Reopen()
def SaveRules(e) -> None:
Edits = []
for Rule, Entry, PatternField, Name, CaseSwitch, OverwriteSwitch in Editors:
Pattern = (PatternField.value or "").strip()
if not Pattern:
PatternField.error = "A pattern is needed, e.g. *.png"
PatternField.update()
return
Wanted = {
"Pattern": Pattern,
"NewFileName": Name.value or "~^",
"CaseSensitive": 1 if CaseSwitch.value else 0,
"Overwrite": 1 if OverwriteSwitch.value else 0,
}
Changes = {Key: Value for Key, Value in Wanted.items() if Entry.get(Key) != Value}
if Changes:
Edits.append((Rule.Index, Changes))
self.App.Close()
Messages = []
# Highest index first: a split inserts a copy, which would shift later entries.
for Index, Changes in sorted(Edits, key=lambda Item: Item[0], reverse=True):
try:
Message = TheSetup.EditRule(FoundEdge.Input, FoundEdge.Output, Index, Changes)
except OSError as Error:
Message = f"Couldn't save: {Error}"
if Message:
Messages.append(Message)
if Messages:
self.App.Notify(" ".join(Messages), "warning")
elif Edits:
self.App.Notify("Saved; SaraSortd uses it on its next check.", "success")
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 Editable:
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),
ChipButton("Cancel", lambda e: self.App.Close()),
ChipButton("Save", SaveRules, ft.Icons.SAVE_OUTLINED),
]
else:
Actions.append(ChipButton("Close", lambda e: self.App.Close()))
ListHeight = (186 if Editable else 48) * len(Rows) + 8
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(460, ListHeight), spacing=8),
Note("SaraSortd tries outputs in OutputDir order and patterns top to bottom; the first match wins."),
], tight=True, spacing=8),
Actions, Width=760 if Editable else 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
Actions = ConfButtons + [ChipButton("Open folder", lambda e: self.App.OpenFolder(PathText), ft.Icons.FOLDER_OPEN_OUTLINED)]
Actions.append(ChipButton("Delete", lambda e: (self.App.Close(), self._DeleteDialog(PathText)),
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,
)
# Right-click menus
def _RequestMenu(self, Target: Optional[tuple[str, object]], Event: ft.TapEvent) -> None:
"""Right-click on a box (("Node", path)), an arrow's label (("Edge", edge))
or the background (None). A box or label sits inside the background's
GestureDetector and both report the right-click, so this collects them for
a moment and opens one menu, the box's or label's winning (like SaraFem)."""
if self.MenuOpen:
# A click can't reach content while the menu's barrier covers it, so
# getting here means the menu is gone and its close event is late.
if time.monotonic() - self.MenuOpenedAt < 0.5:
return
self.MenuOpen = False
First = self.PendingMenu is None
if First or Target is not None:
self.PendingMenu = (Target, Event.global_position)
if First:
self.page.run_task(self._OpenPendingMenu)
async def _OpenPendingMenu(self) -> None:
await asyncio.sleep(0.08)
Target, Position = self.PendingMenu
self.PendingMenu = None
if Target is None:
Items = self._BackgroundMenuItems()
elif Target[0] == "Node":
Items = self._NodeMenuItems(Target[1])
else:
Items = self._EdgeMenuItems(Target[1])
# open() shows `items`, and a method call doesn't flush property changes,
# so the items are sent first.
self.Menu.items = Items
self.Menu.update()
self.MenuOpen = True
self.MenuOpenedAt = time.monotonic()
try:
await self.Menu.open(global_position=Position)
except Exception:
self.MenuOpen = False
raise
def _OnMenuClosed(self, Event: ft.ContextMenuDismissEvent) -> None:
# button is None only for menus opened through open(); the inert
# built-in triggers report "secondary"/"tertiary".
if Event.button is None:
self.MenuOpen = False
def _CopyText(self, Text: str, What: str) -> None:
async def Copy() -> None:
await ft.Clipboard().set(Text)
self.App.Notify(f"Copied {What}.", "success")
self.page.run_task(Copy)
def _NodeMenuItems(self, PathText: str) -> list[ft.PopupMenuItem]:
Node = self.Nodes.get(PathText)
TheSetup = self.TheSetup
if Node is None or TheSetup is None:
return []
Kinds = (["In"] if Node.IsInput else []) + (["Out"] if Node.IsOutput else [])
ConfPaths = {Kind: TheSetup.ConfFile(PathText, Kind) for Kind in Kinds}
Items = [
ft.PopupMenuItem(content="Details", on_click=lambda e: self._NodeDialog(PathText)),
ft.PopupMenuItem(content="Connect From Here", on_click=lambda e: self._StartConnect(PathText)),
]
if len(set(ConfPaths.values())) == 1:
ConfPath = next(iter(ConfPaths.values()))
if ConfPath.exists():
Items.append(ft.PopupMenuItem(content="Edit Config", on_click=lambda e: self.App.Navigate("Configs", Open=str(ConfPath))))
else:
for Kind, ConfPath in ConfPaths.items():
if ConfPath.exists():
Items.append(ft.PopupMenuItem(
content=f"Edit {'Input' if Kind == 'In' else 'Output'} Config",
on_click=lambda e, ConfPath=ConfPath: self.App.Navigate("Configs", Open=str(ConfPath)),
))
if Node.IsInput:
Items.append(ft.PopupMenuItem(content="Test a File", on_click=lambda e: self.App.Navigate("Tools", Section="Tester", TestInput=PathText)))
if not Node.IsInput:
Items.append(ft.PopupMenuItem(content="Make It an Input Too", on_click=lambda e: self._AddRole(PathText, "InputDir")))
if not Node.IsOutput:
Items.append(ft.PopupMenuItem(content="Make It an Output Too", on_click=lambda e: self._AddRole(PathText, "OutputDir")))
Items += [
ft.PopupMenuItem(content="Open Folder", on_click=lambda e: self.App.OpenFolder(PathText)),
ft.PopupMenuItem(content="Copy Path", on_click=lambda e: self._CopyText(PathText, "path")),
ft.PopupMenuItem(content="Delete", on_click=lambda e: self._DeleteDialog(PathText)),
]
return Items
def _EdgeMenuItems(self, FoundEdge: Edge) -> list[ft.PopupMenuItem]:
def RemoveArrow() -> None:
Messages = self.TheSetup.Disconnect(FoundEdge.Input, FoundEdge.Output)
if Messages:
self.App.Notify(" ".join(Messages), "warning")
self.Refresh()
return [
ft.PopupMenuItem(content="Show Patterns", on_click=lambda e: self._EdgeDialog(FoundEdge, Editable=False)),
ft.PopupMenuItem(content="Edit Rules", on_click=lambda e: self._EdgeDialog(FoundEdge, Editable=True)),
ft.PopupMenuItem(content="Add Patterns", on_click=lambda e: self._ConnectDialog(FoundEdge.Input, FoundEdge.Output)),
ft.PopupMenuItem(content="Copy Patterns", on_click=lambda e: self._CopyText(", ".join(FoundEdge.Patterns), "patterns")),
ft.PopupMenuItem(content="Save as Template", on_click=lambda e: self._SaveTemplateDialog(FoundEdge)),
ft.PopupMenuItem(content="Edit Output Config", on_click=lambda e: self.App.Navigate(
"Configs", Open=str(self.TheSetup.ConfFile(FoundEdge.Output, "Out")))),
ft.PopupMenuItem(content="Remove Arrow", on_click=lambda e: self.App.Confirm(
"Remove arrow",
f"Stop sorting from {DisplayPath(FoundEdge.Input)} into {DisplayPath(FoundEdge.Output)}?", RemoveArrow,
)),
]
def _BackgroundMenuItems(self) -> list[ft.PopupMenuItem]:
return [
ft.PopupMenuItem(content="Add Directory", on_click=self._AddDirectoryDialog),
ft.PopupMenuItem(content="Leave Edit Mode" if self.EditMode else "Edit Mode", on_click=self._ToggleEdit),
ft.PopupMenuItem(content="Tidy Layout", on_click=self._ResetLayout),
ft.PopupMenuItem(content="Reload", on_click=lambda e: self.Refresh()),
]
def _StartConnect(self, PathText: str) -> None:
"""Edit mode with this box picked: the next click is where it sorts into."""
if not self.EditMode:
self._ToggleEdit(None)
self.Selected = PathText
self._UpdateHint()
self._BuildBoard()
self.update()
def _AddRole(self, PathText: str, Key: str) -> None:
try:
Changed = self.TheSetup.AddDirectory(PathText, [Key])
except OSError as Error:
self.App.Notify(f"Couldn't change it: {Error}", "error")
return
self._AfterChange(Changed)
def _DeleteDialog(self, PathText: str) -> None:
TheSetup = self.TheSetup
ConfNames = sorted({TheSetup.ConfFile(PathText, Kind).name for Kind in ("In", "Out")})
def Delete() -> None:
try:
Changed = TheSetup.DeleteDirectory(PathText)
except OSError as Error:
self.App.Notify(f"Couldn't delete it: {Error}", "error")
return
Positions = LoadLayout()
Positions.pop(PathText, None)
SaveLayout(Positions)
self._AfterChange(Changed)
self.App.Confirm(
"Delete directory",
f"Delete {DisplayPath(PathText)} from SaraSortd?\n\n"
f"It's taken out of GlobalConf.toml and other outputs' rules, and its config "
f"({', '.join(ConfNames)}) and history file are deleted. The folder and its files stay.",
Delete, "Delete",
)
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)