From 4c0a8e4d4fb9df9b896c8aae8d1a34e4e25bab7a Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Thu, 8 Oct 2026 18:28:02 +0200 Subject: [PATCH] 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 --- FlowPage.py | 199 ++++++++++++++++++++++++++++++++++++++++++++++----- README.md | 4 ++ ToolsPage.py | 8 ++- 3 files changed, 193 insertions(+), 18 deletions(-) diff --git a/FlowPage.py b/FlowPage.py index 2c61e6e..6bd7ae3 100644 --- a/FlowPage.py +++ b/FlowPage.py @@ -7,8 +7,10 @@ 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 @@ -67,6 +69,21 @@ class FlowPage(ft.Column): 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) @@ -114,10 +131,16 @@ class FlowPage(ft.Column): self.EdgeList = TheSetup.Edges() self._Layout() self._BuildBoard() - self.Body.content = ft.Column( + 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.") + ) 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() @@ -202,6 +225,7 @@ class FlowPage(ft.Column): 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: @@ -311,22 +335,27 @@ class FlowPage(ft.Column): 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: + def _Label(self, FoundEdge: Edge, CenterX: float, CenterY: float) -> ft.GestureDetector: 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), + 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." + 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: @@ -408,7 +437,8 @@ class FlowPage(ft.Column): Width=620, ) - def _EdgeDialog(self, FoundEdge: Edge) -> None: + 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) @@ -417,7 +447,7 @@ class FlowPage(ft.Column): 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 self.EditMode: + 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), @@ -449,7 +479,7 @@ class FlowPage(ft.Column): Again = next((Other for Other in self.EdgeList if Other.Input == FoundEdge.Input and Other.Output == FoundEdge.Output), None) if Again: - self._EdgeDialog(Again) + self._EdgeDialog(Again, Editable) def RemoveOne(Index: int) -> None: self.App.Close() @@ -506,7 +536,7 @@ class FlowPage(ft.Column): self._SaveTemplateDialog(FoundEdge) Actions = [ChipButton("Save as template", SaveTemplate, ft.Icons.BOOKMARK_ADD_OUTLINED)] - if self.EditMode: + if Editable: Actions += [ ChipButton("Add patterns", AddMore, ft.Icons.ADD), ChipButton("Remove arrow", lambda e: (self.App.Close(), self.App.Confirm( @@ -517,7 +547,7 @@ class FlowPage(ft.Column): ] else: Actions.append(ChipButton("Close", lambda e: self.App.Close())) - ListHeight = (186 if self.EditMode else 48) * len(Rows) + 8 + ListHeight = (186 if Editable else 48) * len(Rows) + 8 self.App.ShowDialog( "Arrow", ft.Column([ @@ -526,7 +556,7 @@ class FlowPage(ft.Column): 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 self.EditMode else 620, + Actions, Width=760 if Editable else 620, ) def _SaveTemplateDialog(self, FoundEdge: Edge) -> None: @@ -589,6 +619,141 @@ class FlowPage(ft.Column): 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")}) diff --git a/README.md b/README.md index b72395f..5433643 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry ` as a template. A rule other inputs use too is split when you change it, so only this arrow changes. The bin on a box deletes the directory from SaraSortd: it leaves GlobalConf.toml and other outputs' rules, and its config and history files are deleted. The folder and its files stay. + **Right-click** works like in SaraFem, in or out of edit mode. A box: Details, Connect From Here, Edit + Config, Test a File, Make It an Input/Output Too, Open Folder, Copy Path, Delete. An arrow's label: Show + Patterns, Edit Rules, Add Patterns, Copy Patterns, Save as Template, Edit Output Config, Remove Arrow. + The background: Add Directory, Edit Mode, Tidy Layout, Reload. - **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the SaraSuite install it came from), and its logs: its own log files or the systemd journal. - **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with diff --git a/ToolsPage.py b/ToolsPage.py index 1e6663c..ddd7752 100644 --- a/ToolsPage.py +++ b/ToolsPage.py @@ -65,7 +65,11 @@ class ToolsPage(ft.Column): self.Body, ] - def Refresh(self) -> None: + def Refresh(self, Section: str = "", TestInput: str = "") -> None: + """Section and TestInput open a given tool, e.g. the tester for an input + right-clicked in Flow.""" + if Section in self.Buttons: + self.Section = Section self.TheSetup, Error = self.App.LoadSetup() if self.TheSetup is None: self.Body.content = EmptyState("Can't read SaraSortd's config.", Error) @@ -76,6 +80,8 @@ class ToolsPage(ft.Column): Current = Picker.value Picker.options = [ft.DropdownOption(key=PathText, text=DisplayPath(PathText)) for PathText in Inputs] Picker.value = Current if Current in Inputs else (Inputs[0] if Inputs else None) + if TestInput in Inputs: + self.TestInput.value = TestInput self._Show(self.Section) def _Show(self, Name: str) -> None: