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]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-08 18:28:02 +02:00
1 parent d0e5eb2086
commit 4c0a8e4d4f
3 files changed
+193 -18

No files matched your search

+182 -17
View File
@@ -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. boxes connects them, and clicking an arrow's label edits or removes it.
""" """
import asyncio
import math import math
import os import os
import time
from typing import Optional from typing import Optional
import flet as ft import flet as ft
@@ -67,6 +69,21 @@ class FlowPage(ft.Column):
self.TheSetup: Optional[Setup] = None self.TheSetup: Optional[Setup] = None
self.NodeControls: dict[str, ft.GestureDetector] = {} self.NodeControls: dict[str, ft.GestureDetector] = {}
self.DragStart: dict = {} 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.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 = ChipButton("Add directory", self._AddDirectoryDialog, ft.Icons.CREATE_NEW_FOLDER_OUTLINED)
@@ -114,10 +131,16 @@ class FlowPage(ft.Column):
self.EdgeList = TheSetup.Edges() self.EdgeList = TheSetup.Edges()
self._Layout() self._Layout()
self._BuildBoard() self._BuildBoard()
self.Body.content = ft.Column( Content = ft.Column(
[ft.Row([self.Board], scroll=ft.ScrollMode.AUTO)], [ft.Row([self.Board], scroll=ft.ScrollMode.AUTO)],
scroll=ft.ScrollMode.AUTO, expand=True, 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._UpdateHint()
self.update() self.update()
@@ -202,6 +225,7 @@ class FlowPage(ft.Column):
on_pan_start=lambda e, PathText=Node.Path: self._DragStart(PathText, e), 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_update=lambda e, PathText=Node.Path: self._DragUpdate(PathText, e),
on_pan_end=lambda e: SaveLayout(self.Positions), 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: 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(), 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))] ], 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) Short, Full, Width = self._LabelText(FoundEdge)
return ft.Container( return ft.GestureDetector(
ft.Text(Short, size=12, color=Palette.Text, no_wrap=True), content=ft.Container(
left=CenterX - Width / 2, top=CenterY - LabelHeight / 2, width=Width, height=LabelHeight, ft.Text(Short, size=12, color=Palette.Text, no_wrap=True),
alignment=ft.Alignment.CENTER, width=Width, height=LabelHeight, alignment=ft.Alignment.CENTER,
bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Mauve if self.EditMode else Palette.Surface1), 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, border_radius=4, tooltip=Full if Short != Full else None,
on_click=lambda e: self._EdgeDialog(FoundEdge), ),
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 # Interaction
def _UpdateHint(self) -> None: def _UpdateHint(self) -> None:
if not self.EditMode: 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: elif self.Selected:
self.Hint.value = f"Now click the directory {os.path.basename(self.Selected)} should sort into (or it again to cancel)." self.Hint.value = f"Now click the directory {os.path.basename(self.Selected)} should sort into (or it again to cancel)."
else: else:
@@ -408,7 +437,8 @@ class FlowPage(ft.Column):
Width=620, 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 TheSetup = self.TheSetup
Entries = (TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}).get("Files", []) Entries = (TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}).get("Files", [])
Editors: list[tuple] = [] # (Rule, Entry, PatternField, NameField, CaseSwitch, OverwriteSwitch) 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 {} Entry = Entries[Rule.Index] if Rule.Index < len(Entries) else {}
Others = [os.path.basename(Other) for Other in TheSetup.RuleInputs(FoundEdge.Output, Rule.Index) Others = [os.path.basename(Other) for Other in TheSetup.RuleInputs(FoundEdge.Output, Rule.Index)
if Other != FoundEdge.Input] if Other != FoundEdge.Input]
if not self.EditMode: if not Editable:
Scope = f"also from {', '.join(Others)}" if Others else "only from this input" Scope = f"also from {', '.join(Others)}" if Others else "only from this input"
Rows.append(ft.Row([ Rows.append(ft.Row([
ft.Text(Rule.Pattern, color=Palette.Mauve, width=150, selectable=True), 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 Again = next((Other for Other in self.EdgeList
if Other.Input == FoundEdge.Input and Other.Output == FoundEdge.Output), None) if Other.Input == FoundEdge.Input and Other.Output == FoundEdge.Output), None)
if Again: if Again:
self._EdgeDialog(Again) self._EdgeDialog(Again, Editable)
def RemoveOne(Index: int) -> None: def RemoveOne(Index: int) -> None:
self.App.Close() self.App.Close()
@@ -506,7 +536,7 @@ class FlowPage(ft.Column):
self._SaveTemplateDialog(FoundEdge) self._SaveTemplateDialog(FoundEdge)
Actions = [ChipButton("Save as template", SaveTemplate, ft.Icons.BOOKMARK_ADD_OUTLINED)] Actions = [ChipButton("Save as template", SaveTemplate, ft.Icons.BOOKMARK_ADD_OUTLINED)]
if self.EditMode: if Editable:
Actions += [ Actions += [
ChipButton("Add patterns", AddMore, ft.Icons.ADD), ChipButton("Add patterns", AddMore, ft.Icons.ADD),
ChipButton("Remove arrow", lambda e: (self.App.Close(), self.App.Confirm( ChipButton("Remove arrow", lambda e: (self.App.Close(), self.App.Confirm(
@@ -517,7 +547,7 @@ class FlowPage(ft.Column):
] ]
else: else:
Actions.append(ChipButton("Close", lambda e: self.App.Close())) 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( self.App.ShowDialog(
"Arrow", "Arrow",
ft.Column([ ft.Column([
@@ -526,7 +556,7 @@ class FlowPage(ft.Column):
ft.Column(Rows, scroll=ft.ScrollMode.AUTO, height=min(460, ListHeight), spacing=8), 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."), Note("SaraSortd tries outputs in OutputDir order and patterns top to bottom; the first match wins."),
], tight=True, spacing=8), ], 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: def _SaveTemplateDialog(self, FoundEdge: Edge) -> None:
@@ -589,6 +619,141 @@ class FlowPage(ft.Column):
Actions, Width=620, 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: def _DeleteDialog(self, PathText: str) -> None:
TheSetup = self.TheSetup TheSetup = self.TheSetup
ConfNames = sorted({TheSetup.ConfFile(PathText, Kind).name for Kind in ("In", "Out")}) ConfNames = sorted({TheSetup.ConfFile(PathText, Kind).name for Kind in ("In", "Out")})
+4
View File
@@ -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. 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' 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. 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 - **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. 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 - **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with
+7 -1
View File
@@ -65,7 +65,11 @@ class ToolsPage(ft.Column):
self.Body, 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() self.TheSetup, Error = self.App.LoadSetup()
if self.TheSetup is None: if self.TheSetup is None:
self.Body.content = EmptyState("Can't read SaraSortd's config.", Error) self.Body.content = EmptyState("Can't read SaraSortd's config.", Error)
@@ -76,6 +80,8 @@ class ToolsPage(ft.Column):
Current = Picker.value Current = Picker.value
Picker.options = [ft.DropdownOption(key=PathText, text=DisplayPath(PathText)) for PathText in Inputs] 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) 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) self._Show(self.Section)
def _Show(self, Name: str) -> None: def _Show(self, Name: str) -> None: