mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
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:
1 parent
d0e5eb2086
commit
4c0a8e4d4f
3 files changed
+193
-18
No files matched your search
+182
-17
@@ -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")})
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user