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