Graph: pan with left or right, open with the other

This commit is contained in:
sarah committed 2026-10-09 09:51:38 +02:00
1 parent d97f82beb2
commit cc7d624a57
8 files changed
+50 -62

No files matched your search

+3 -2
View File
@@ -49,9 +49,10 @@ DefaultSettings: dict[str, Any] = {
# live preview). "Separate": rendered View and raw Edit are two modes. # live preview). "Separate": rendered View and raw Edit are two modes.
"MarkdownEditing": "Live", "MarkdownEditing": "Live",
# Graph view: directories as dots too (off: only files and the references # Graph view: directories as dots too (off: only files and the references
# between them), and the mouse button that pans ("Left", "Middle", "Right"). # between them), and the mouse button that pans ("Left" or "Right"; the
# other one opens a dot).
"GraphShowDirectories": True, "GraphShowDirectories": True,
"GraphPanButton": "Middle", "GraphPanButton": "Right",
} }
DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes" DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes"
+3 -2
View File
@@ -168,9 +168,10 @@ class AppConfig:
# in place (like Obsidian's live preview). Only affects Markdown. # in place (like Obsidian's live preview). Only affects Markdown.
MarkdownEditing: str = "Live" MarkdownEditing: str = "Live"
# Graph view: directories as dots too (off: only files and the references # Graph view: directories as dots too (off: only files and the references
# between them), and the mouse button that pans ("Left", "Middle", "Right"). # between them), and the mouse button that pans ("Left" or "Right"; the
# other one opens a dot).
GraphShowDirectories: bool = True GraphShowDirectories: bool = True
GraphPanButton: str = "Middle" GraphPanButton: str = "Right"
# Double-click on a file: True opens it in SaraView's own viewer, False # Double-click on a file: True opens it in SaraView's own viewer, False
# hands it to the system's default program. The menu always offers the other. # hands it to the system's default program. The menu always offers the other.
OpenFilesInApp: bool = True OpenFilesInApp: bool = True
+16 -31
View File
@@ -5,8 +5,8 @@ colours by depth, files White), joined to its parent directory and to every file
On a server the graph comes from /api/graph; locally BuildLocalGraph makes On a server the graph comes from /api/graph; locally BuildLocalGraph makes
the same one with the same reference rules as Backend/Graph.py. The layout is the same one with the same reference rules as Backend/Graph.py. The layout is
the web view's force simulation (same constants), in numpy, drawn as it the web view's force simulation (same constants), in numpy, drawn as it
settles. Scroll to zoom, pan with the button picked in Settings > Graph, settles. Scroll to zoom, hover a dot to light up its links; one mouse button
hover a dot to light up its links, click it to open it.""" pans and the other opens a dot (Settings > Graph)."""
import math import math
import re import re
@@ -238,9 +238,8 @@ def FilterGraph(Graph: dict, ShowDirectories: bool) -> dict:
class GraphView(ft.Container): class GraphView(ft.Container):
"""Pan and zoom are done here rather than with an InteractiveViewer, so """Pan and zoom are done here rather than with an InteractiveViewer, so
the pan button can be picked (Settings > Graph). Flet reports drags for the buttons can be picked (Settings > Graph): one mouse button pans, the
the left and right buttons only, so Middle works like middle-click other opens the dot it clicks."""
autoscroll: click it to grab the graph, move to pan, click again to let go."""
def __init__(self, TheApp: App, Root: str, DirectoryPath: str, def __init__(self, TheApp: App, Root: str, DirectoryPath: str,
OnOpenNode: Callable[[dict], None], OnClose: Callable[[], None]): OnOpenNode: Callable[[dict], None], OnClose: Callable[[], None]):
@@ -248,7 +247,9 @@ class GraphView(ft.Container):
self.Root = Root self.Root = Root
self.DirectoryPath = DirectoryPath self.DirectoryPath = DirectoryPath
self.OnOpenNode = OnOpenNode self.OnOpenNode = OnOpenNode
self.PanButton = TheApp.Config.GraphPanButton # Anything but "Left" (including an old "Middle") pans with Right.
self.PanButton = "Left" if TheApp.Config.GraphPanButton == "Left" else "Right"
self.OpenButton = "Right" if self.PanButton == "Left" else "Left"
self.ShowDirectories = TheApp.Config.GraphShowDirectories self.ShowDirectories = TheApp.Config.GraphShowDirectories
self.Layout: Optional[Layout] = None self.Layout: Optional[Layout] = None
self.ScopePath = "" self.ScopePath = ""
@@ -264,7 +265,6 @@ class GraphView(ft.Container):
self.PanX = 0.0 self.PanX = 0.0
self.PanY = 0.0 self.PanY = 0.0
self.DragFrom: Optional[tuple[float, float]] = None self.DragFrom: Optional[tuple[float, float]] = None
self.Grabbed = False # Middle: grabbed by a middle click
self.ScopePicker = ft.Dropdown( self.ScopePicker = ft.Dropdown(
value="Root", value="Root",
@@ -288,21 +288,21 @@ class GraphView(ft.Container):
# Hidden (not just its text) once the graph is up: even empty, it sits # Hidden (not just its text) once the graph is up: even empty, it sits
# over the graph and would catch every click. # over the graph and would catch every click.
self.StatusBox = ft.Container(self.Status, alignment=ft.Alignment.CENTER) self.StatusBox = ft.Container(self.Status, alignment=ft.Alignment.CENTER)
Hint = {"Left": "Drag to pan", "Right": "Right-drag to pan", Hint = ("Drag to pan, right-click a dot to open it" if self.PanButton == "Left"
"Middle": "Middle-click to grab and pan, again to let go"}.get(self.PanButton, "") else "Right-drag to pan, click a dot to open it")
self.Surface = ft.GestureDetector( self.Surface = ft.GestureDetector(
content=ft.Container(self.Plane, expand=True, clip_behavior=ft.ClipBehavior.HARD_EDGE, bgcolor=Palette.Base), content=ft.Container(self.Plane, expand=True, clip_behavior=ft.ClipBehavior.HARD_EDGE, bgcolor=Palette.Base),
on_tap_up=self._OnTap, on_tap_up=lambda e: self._OnClick(e, "Left"),
on_secondary_tap_up=lambda e: self._OnClick(e, "Right"),
on_hover=self._OnHover, on_hover=self._OnHover,
on_exit=lambda e: self._SetHovered(None), on_exit=lambda e: self._SetHovered(None),
on_scroll=self._OnScroll, on_scroll=self._OnScroll,
on_pan_start=lambda e: self._StartDrag(e, "Left"), on_pan_down=lambda e: self._StartDrag(e, "Left"), # down, not start: start comes after the drag slop
on_pan_update=lambda e: self._Drag(e, "Left"), on_pan_update=lambda e: self._Drag(e, "Left"),
on_pan_end=lambda e: self._EndDrag(), on_pan_end=lambda e: self._EndDrag(),
on_right_pan_start=lambda e: self._StartDrag(e, "Right"), on_right_pan_start=lambda e: self._StartDrag(e, "Right"),
on_right_pan_update=lambda e: self._Drag(e, "Right"), on_right_pan_update=lambda e: self._Drag(e, "Right"),
on_right_pan_end=lambda e: self._EndDrag(), on_right_pan_end=lambda e: self._EndDrag(),
on_tertiary_tap_up=self._OnMiddleClick,
hover_interval=20, hover_interval=20,
drag_interval=16, drag_interval=16,
expand=True, expand=True,
@@ -317,7 +317,7 @@ class GraphView(ft.Container):
], spacing=12), ], spacing=12),
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
ft.Stack([self.Surface, self.StatusBox], expand=True), ft.Stack([self.Surface, self.StatusBox], expand=True),
ft.Text(f"{Hint}, scroll to zoom, click a dot to open it.", size=11, color=Palette.Overlay0), ft.Text(f"{Hint}, scroll to zoom.", size=11, color=Palette.Overlay0),
], spacing=8, expand=True), ], spacing=8, expand=True),
expand=True, bgcolor=Palette.Base, padding=12, expand=True, bgcolor=Palette.Base, padding=12,
) )
@@ -488,29 +488,15 @@ class GraphView(ft.Container):
def _OnHover(self, Event) -> None: def _OnHover(self, Event) -> None:
X, Y = Event.local_position.x, Event.local_position.y X, Y = Event.local_position.x, Event.local_position.y
if self.Grabbed and self.DragFrom is not None:
self._PanBy(X - self.DragFrom[0], Y - self.DragFrom[1])
self.DragFrom = (X, Y)
return
self._SetHovered(self._NodeAt(X, Y)) self._SetHovered(self._NodeAt(X, Y))
def _OnTap(self, Event) -> None: def _OnClick(self, Event, Button: str) -> None:
if self.Grabbed: # a left click also lets go if Button != self.OpenButton:
self.Grabbed = False
self.DragFrom = None
return return
Number = self._NodeAt(Event.local_position.x, Event.local_position.y) Number = self._NodeAt(Event.local_position.x, Event.local_position.y)
if Number is not None: if Number is not None:
self.OnOpenNode(self.Layout.Nodes[Number]) self.OnOpenNode(self.Layout.Nodes[Number])
def _OnMiddleClick(self, Event) -> None:
if self.PanButton != "Middle":
return
self.Grabbed = not self.Grabbed
self.DragFrom = (Event.local_position.x, Event.local_position.y) if self.Grabbed else None
self.Surface.mouse_cursor = ft.MouseCursor.MOVE if self.Grabbed else ft.MouseCursor.BASIC
self.Surface.update()
def _StartDrag(self, Event, Button: str) -> None: def _StartDrag(self, Event, Button: str) -> None:
if Button == self.PanButton: if Button == self.PanButton:
self.DragFrom = (Event.local_position.x, Event.local_position.y) self.DragFrom = (Event.local_position.x, Event.local_position.y)
@@ -523,8 +509,7 @@ class GraphView(ft.Container):
self.DragFrom = (X, Y) self.DragFrom = (X, Y)
def _EndDrag(self) -> None: def _EndDrag(self) -> None:
if not self.Grabbed: self.DragFrom = None
self.DragFrom = None
def _OnScroll(self, Event) -> None: def _OnScroll(self, Event) -> None:
"""Zooms around the pointer.""" """Zooms around the pointer."""
+4 -6
View File
@@ -122,11 +122,10 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
value=Config.GraphShowDirectories) value=Config.GraphShowDirectories)
PanButtonPicker = ft.RadioGroup( PanButtonPicker = ft.RadioGroup(
ft.Column([ ft.Column([
ft.Radio(value="Left", label="Left"), ft.Radio(value="Right", label="Right (left-click opens a dot)"),
ft.Radio(value="Middle", label="Middle (click to grab, move, click again to let go)"), ft.Radio(value="Left", label="Left (right-click opens a dot)"),
ft.Radio(value="Right", label="Right"),
], spacing=0), ], spacing=0),
value=Config.GraphPanButton, value="Left" if Config.GraphPanButton == "Left" else "Right",
) )
# Connection. # Connection.
@@ -224,8 +223,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Pan with mouse button"), ft.Text("Pan with mouse button"),
PanButtonPicker, PanButtonPicker,
_Note("Scroll zooms. Flet can't follow a held middle button, so Middle works like " _Note("Scroll zooms. The other button opens the dot you click."),
"middle-click autoscroll."),
], spacing=12, scroll=ft.ScrollMode.AUTO), ], spacing=12, scroll=ft.ScrollMode.AUTO),
"Connection": ft.Column([ "Connection": ft.Column([
_Note("Browse a directory on this computer, or connect to a SaraView server."), _Note("Browse a directory on this computer, or connect to a SaraView server."),
+1 -1
View File
@@ -383,7 +383,7 @@ export default function App() {
RootName={ActiveRoot} RootName={ActiveRoot}
DirectoryPath={CurrentDirectoryPath} DirectoryPath={CurrentDirectoryPath}
ShowDirectories={Settings ? Settings.GraphShowDirectories : true} ShowDirectories={Settings ? Settings.GraphShowDirectories : true}
PanButton={Settings ? Settings.GraphPanButton : "Middle"} PanButton={Settings ? Settings.GraphPanButton : "Right"}
OnOpenNode={HandleOpenGraphNode} OnOpenNode={HandleOpenGraphNode}
OnClose={() => SetIsGraphOpen(false)} OnClose={() => SetIsGraphOpen(false)}
/> />
+13 -12
View File
@@ -8,8 +8,9 @@ import { FetchGraph } from "../Api.js";
// it (Backend/Graph.py). With directories turned off (Settings > Graph) only // it (Backend/Graph.py). With directories turned off (Settings > Graph) only
// the files and the references between them are left. // the files and the references between them are left.
// The layout is a small force simulation: dots push each other apart and // The layout is a small force simulation: dots push each other apart and
// links pull them together until it settles. Pan with the button picked in // links pull them together until it settles. One mouse button pans (Settings
// Settings > Graph, scroll to zoom, drag a dot to move it, click one to open it. // > Graph), the other opens the dot it clicks or moves it when dragged.
// Scroll zooms.
const ChildLength = 50; const ChildLength = 50;
const ReferenceLength = 80; const ReferenceLength = 80;
@@ -19,7 +20,6 @@ const LabelZoom = 0.7; // below this, only hovered labels are drawn
const ClickSlop = 4; // pixels a click may wander and still count as a click const ClickSlop = 4; // pixels a click may wander and still count as a click
// Directories by depth, like the headings: h1 Purple, h2 Blue, ... // Directories by depth, like the headings: h1 Purple, h2 Blue, ...
const DepthRoles = ["Purple", "Blue", "Green", "Yellow", "Orange", "Red"]; const DepthRoles = ["Purple", "Blue", "Green", "Yellow", "Orange", "Red"];
const ButtonNumbers = { Left: 0, Middle: 1, Right: 2 };
function ReadRole(Name) { function ReadRole(Name) {
return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim(); return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim();
@@ -191,7 +191,9 @@ export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, Pa
const Depth = (Node.Id ? Node.Id.split("/").length : 0) - BaseDepth; const Depth = (Node.Id ? Node.Id.split("/").length : 0) - BaseDepth;
Node.Colour = Node.IsDirectory ? DepthColours[Depth % DepthColours.length] : Colours.File; Node.Colour = Node.IsDirectory ? DepthColours[Depth % DepthColours.length] : Colours.File;
} }
const PanNumber = ButtonNumbers[PanButton] ?? 1; // Anything but "Left" (including an old "Middle") pans with Right.
const PanNumber = PanButton === "Left" ? 0 : 2;
const OpenNumber = PanNumber === 0 ? 2 : 0;
const State = { const State = {
Zoom: 1, Zoom: 1,
@@ -313,11 +315,9 @@ export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, Pa
const Node = NodeAt(X, Y); const Node = NodeAt(X, Y);
State.Moved = false; State.Moved = false;
State.DownAt = [X, Y]; State.DownAt = [X, Y];
if (Event.button !== 0 || Event.button === PanNumber) { Event.preventDefault();
Event.preventDefault(); // no middle-click autoscroll or right-click menu if (Event.button === OpenNumber && Node) {
} // The open button on a dot: a click opens it, a drag moves it.
if (Event.button === 0 && Node) {
// Left on a dot: a click opens it, a drag moves it.
State.Dragging = Node; State.Dragging = Node;
Node.Pinned = true; Node.Pinned = true;
} else if (Event.button === PanNumber) { } else if (Event.button === PanNumber) {
@@ -365,7 +365,7 @@ export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, Pa
} }
function OnContextMenu(Event) { function OnContextMenu(Event) {
if (PanNumber === 2) Event.preventDefault(); Event.preventDefault(); // right-click pans or opens here, never the browser menu
} }
function OnLeave() { function OnLeave() {
@@ -432,8 +432,9 @@ export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, Pa
{!Graph && !Error && <div className="PreviewEmpty">Building the graph…</div>} {!Graph && !Error && <div className="PreviewEmpty">Building the graph…</div>}
{Graph && <canvas ref={CanvasRef} className="GraphCanvas" />} {Graph && <canvas ref={CanvasRef} className="GraphCanvas" />}
<div className="GraphHint"> <div className="GraphHint">
{PanButton === "Left" ? "Drag" : `${PanButton}-drag`} the background to pan, scroll to zoom, drag a dot to move it, {PanButton === "Left"
click one to open it. ? "Drag to pan, right-click a dot to open it (right-drag moves it), scroll to zoom."
: "Right-drag to pan, click a dot to open it (drag moves it), scroll to zoom."}
</div> </div>
</div> </div>
); );
+6 -4
View File
@@ -412,10 +412,12 @@ export default function SettingsPanel({
</div> </div>
<div className="SettingsRow"> <div className="SettingsRow">
<label className="SettingsLabel">Pan with mouse button</label> <label className="SettingsLabel">Pan with mouse button</label>
<select value={Settings.GraphPanButton} onChange={(Event) => UpdateField("GraphPanButton", Event.target.value)}> <select
<option value="Left">Left</option> value={Settings.GraphPanButton === "Left" ? "Left" : "Right"}
<option value="Middle">Middle</option> onChange={(Event) => UpdateField("GraphPanButton", Event.target.value)}
<option value="Right">Right</option> >
<option value="Right">Right (left-click opens a dot)</option>
<option value="Left">Left (right-click opens a dot)</option>
</select> </select>
</div> </div>
</div> </div>
+4 -4
View File
@@ -302,10 +302,10 @@ the current directory, as dots like Obsidian's graph: directories in the
heading colours by depth (purple, blue, green, ...), files white, each heading colours by depth (purple, blue, green, ...), files white, each
joined to its parent directory and to the files that reference it (Markdown joined to its parent directory and to the files that reference it (Markdown
links and images, `[[wiki links]]` and portals). Hover a dot to light up its links and images, `[[wiki links]]` and portals). Hover a dot to light up its
links, click it to open it, scroll to zoom. Settings > Graph picks the mouse links, scroll to zoom. One mouse button pans and the other opens the dot
button that pans (Middle by default) and can hide directories, leaving only you click: right-drag pans and left-click opens by default, or the other way
files and the links between them. In the client, Flet can't follow a held round in Settings > Graph, which can also hide directories, leaving only
middle button, so Middle there is click to grab, move, click to let go. files and the links between them.
Rename takes a `/` to move, like the web view (`../Archive/Notes.md`). Rename takes a `/` to move, like the web view (`../Archive/Notes.md`).
Shortcuts: Enter opens, Backspace goes up, F2 renames, Delete deletes, Shortcuts: Enter opens, Backspace goes up, F2 renames, Delete deletes,