Graph colours by depth, pan button and directory settings

This commit is contained in:
sarah committed 2026-10-09 09:43:33 +02:00
1 parent 57a07874ee
commit d97f82beb2
10 files changed
+288 -58

No files matched your search

+154 -34
View File
@@ -1,12 +1,12 @@
"""The graph view, like Obsidian's (and the web view's GraphPane.jsx): every
directory and file under a directory as a dot (directories Mauve, files
White), joined to its parent directory and to every file that references it.
directory and file under a directory as a dot (directories in the heading
colours by depth, files White), joined to its parent directory and to every file that references it.
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 web view's force simulation (same constants), in numpy, drawn as it
settles. Scroll or pinch to zoom, drag to pan, hover a dot to light up its
links, click it to open it."""
settles. Scroll to zoom, pan with the button picked in Settings > Graph,
hover a dot to light up its links, click it to open it."""
import math
import re
@@ -217,20 +217,54 @@ class Layout:
# --- View --------------------------------------------------------------------
# Directories go down the heading colours by depth (h1 Mauve, h2 Blue, ...),
# starting again after Red. Files are White.
DepthColours = ["Mauve", "Blue", "Green", "Yellow", "Peach", "Red"]
def DirectoryColour(Depth: int) -> str:
return getattr(Palette, DepthColours[Depth % len(DepthColours)])
def FilterGraph(Graph: dict, ShowDirectories: bool) -> dict:
"""Without directories: just the files and the references between them
(files with none float on their own)."""
if ShowDirectories:
return Graph
Nodes = [Node for Node in Graph["Nodes"] if not Node["IsDirectory"]]
Links = [Link for Link in Graph["Links"] if Link["Kind"] == "Reference"]
return {**Graph, "Nodes": Nodes, "Links": Links}
class GraphView(ft.Container):
"""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 left and right buttons only, so Middle works like middle-click
autoscroll: click it to grab the graph, move to pan, click again to let go."""
def __init__(self, TheApp: App, Root: str, DirectoryPath: str,
OnOpenNode: Callable[[dict], None], OnClose: Callable[[], None]):
self.TheApp = TheApp
self.Root = Root
self.DirectoryPath = DirectoryPath
self.OnOpenNode = OnOpenNode
self.PanButton = TheApp.Config.GraphPanButton
self.ShowDirectories = TheApp.Config.GraphShowDirectories
self.Layout: Optional[Layout] = None
self.ScopePath = ""
self.Size = (800.0, 600.0)
self.Screen = np.zeros((0, 2)) # each dot's position on the canvas
self.Radius = np.zeros(0)
self.Colours: list[str] = []
self.Hovered: Optional[int] = None
self.Closed = False
self.Generation = 0 # bumps when the scope changes, so an old layout thread stops
# The view: canvas point P shows at P * Zoom + Pan.
self.Zoom = 1.0
self.PanX = 0.0
self.PanY = 0.0
self.DragFrom: Optional[tuple[float, float]] = None
self.Grabbed = False # Middle: grabbed by a middle click
self.ScopePicker = ft.Dropdown(
value="Root",
@@ -241,24 +275,36 @@ class GraphView(ft.Container):
on_select=lambda e: self.Reload(),
)
self.CountText = ft.Text("", size=12, color=Palette.Overlay0)
Legend = ft.Row([
ft.Text("●", color=Palette.Mauve), ft.Text("Directory", size=12, color=Palette.Overlay0),
ft.Text("●", color=Palette.White), ft.Text("File", size=12, color=Palette.Overlay0),
ft.Text("─", color=Palette.Blue), ft.Text("Reference", size=12, color=Palette.Overlay0),
], spacing=4, tight=True)
LegendItems: list[ft.Control] = []
if self.ShowDirectories:
LegendItems += [ft.Text("●", color=Palette.Mauve), ft.Text("●", color=Palette.Blue), ft.Text("●", color=Palette.Green),
ft.Text("Directories by depth", size=12, color=Palette.Overlay0)]
LegendItems += [ft.Text("●", color=Palette.White), ft.Text("File", size=12, color=Palette.Overlay0),
ft.Text("─", color=Palette.Blue), ft.Text("Reference", size=12, color=Palette.Overlay0)]
self.BaseCanvas = cv.Canvas(shapes=[], expand=True, on_resize=self._OnResize, resize_interval=100)
self.LitCanvas = cv.Canvas(shapes=[], expand=True)
self.Plane = ft.Container(content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True), expand=True)
self.Status = ft.Text("Building the graph…", color=Palette.Overlay0)
Surface = ft.GestureDetector(
content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True),
# Hidden (not just its text) once the graph is up: even empty, it sits
# over the graph and would catch every click.
self.StatusBox = ft.Container(self.Status, alignment=ft.Alignment.CENTER)
Hint = {"Left": "Drag to pan", "Right": "Right-drag to pan",
"Middle": "Middle-click to grab and pan, again to let go"}.get(self.PanButton, "")
self.Surface = ft.GestureDetector(
content=ft.Container(self.Plane, expand=True, clip_behavior=ft.ClipBehavior.HARD_EDGE, bgcolor=Palette.Base),
on_tap_up=self._OnTap,
on_hover=self._OnHover,
hover_interval=50,
mouse_cursor=ft.MouseCursor.BASIC,
expand=True,
)
self.Viewer = ft.InteractiveViewer(
content=Surface, min_scale=0.2, max_scale=8, boundary_margin=ft.Margin.all(4000),
on_exit=lambda e: self._SetHovered(None),
on_scroll=self._OnScroll,
on_pan_start=lambda e: self._StartDrag(e, "Left"),
on_pan_update=lambda e: self._Drag(e, "Left"),
on_pan_end=lambda e: self._EndDrag(),
on_right_pan_start=lambda e: self._StartDrag(e, "Right"),
on_right_pan_update=lambda e: self._Drag(e, "Right"),
on_right_pan_end=lambda e: self._EndDrag(),
on_tertiary_tap_up=self._OnMiddleClick,
hover_interval=20,
drag_interval=16,
expand=True,
)
super().__init__(
@@ -266,10 +312,12 @@ class GraphView(ft.Container):
ft.Row([
ft.IconButton(icon=ft.Icons.ARROW_BACK, tooltip="Back to the file list (Esc)", on_click=lambda e: OnClose()),
ft.Text("Graph", weight=ft.FontWeight.BOLD),
self.ScopePicker, Legend, ft.Container(expand=True), self.CountText,
self.ScopePicker, ft.Row(LegendItems, spacing=4, tight=True),
ft.Container(expand=True), self.CountText,
], spacing=12),
ft.Divider(height=1, color=Palette.Surface1),
ft.Stack([self.Viewer, ft.Container(self.Status, alignment=ft.Alignment.CENTER)], 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),
], spacing=8, expand=True),
expand=True, bgcolor=Palette.Base, padding=12,
)
@@ -285,14 +333,15 @@ class GraphView(ft.Container):
def Reload(self) -> None:
self.Generation += 1
Generation = self.Generation
Path = self.DirectoryPath if self.ScopePicker.value == "Directory" else ""
self.ScopePath = self.DirectoryPath if self.ScopePicker.value == "Directory" else ""
self.Status.value = "Building the graph…"
self.Status.visible = True
self.StatusBox.visible = True
self.BaseCanvas.shapes = []
self.LitCanvas.shapes = []
self.Layout = None
self.Hovered = None
self.update()
self.page.run_thread(self._Build, Generation, Path)
self.page.run_thread(self._Build, Generation, self.ScopePath)
def _Build(self, Generation: int, Path: str) -> None:
try:
@@ -304,13 +353,22 @@ class GraphView(ft.Container):
return
if Generation != self.Generation or self.Closed:
return
Files = sum(1 for Node in Graph["Nodes"] if not Node["IsDirectory"])
self.CountText.value = (f"{len(Graph['Nodes']) - Files} directories, {Files} files"
+ (" (cut short: too many to show)" if Graph.get("Truncated") else ""))
Directories = sum(1 for Node in Graph["Nodes"] if Node["IsDirectory"])
Files = len(Graph["Nodes"]) - Directories
Graph = FilterGraph(Graph, self.ShowDirectories)
Count = f"{Directories} directories, {Files} files" if self.ShowDirectories else f"{Files} files"
self.CountText.value = Count + (" (cut short: too many to show)" if Graph.get("Truncated") else "")
self.CountText.update()
if not Graph["Nodes"]:
self.Status.value = "Nothing to show here."
self.Status.update()
return
self.Layout = Layout(Graph)
self.Status.visible = False
self.Status.update()
Base = len(Path.split("/")) if Path else 0
self.Colours = [DirectoryColour((len(Node["Id"].split("/")) if Node["Id"] else 0) - Base) if Node["IsDirectory"]
else Palette.White for Node in self.Layout.Nodes]
self.StatusBox.visible = False
self.StatusBox.update()
# Settle in steps, drawing as it goes, like the web view's animation.
while not self.Layout.Settled:
for _ in range(TicksPerDraw):
@@ -323,13 +381,13 @@ class GraphView(ft.Container):
# --- Drawing ------------------------------------------------------------
def _Fit(self) -> None:
"""Maps the layout onto the canvas, so the whole graph fits at zoom 1
(the viewer's own zoom and pan go from there)."""
"""Maps the layout onto the canvas, so the whole graph fits at zoom 1."""
Position = self.Layout.Position
Low, High = Position.min(0), Position.max(0)
Width, Height = self.Size
Margin = 40
Scale = min((Width - 2 * Margin) / max(High[0] - Low[0], 1), (Height - 2 * Margin) / max(High[1] - Low[1], 1), 1.5)
Scale = max(Scale, 0.05) # the canvas can start out smaller than the margins
Middle = (Low + High) / 2
self.Screen = (Position - Middle) * Scale + (Width / 2, Height / 2)
Zoom = math.sqrt(Scale)
@@ -345,8 +403,7 @@ class GraphView(ft.Container):
def _Dot(self, Number: int) -> cv.Circle:
X, Y = self.Screen[Number]
Colour = Palette.Mauve if self.Layout.Nodes[Number]["IsDirectory"] else Palette.White
return cv.Circle(X, Y, self.Radius[Number], paint=ft.Paint(color=Colour))
return cv.Circle(X, Y, self.Radius[Number], paint=ft.Paint(color=self.Colours[Number]))
def _Line(self, LinkNumber: int, Colour: str, Width: float = 1) -> cv.Line:
A, B = self.Screen[self.Layout.Sources[LinkNumber]], self.Screen[self.Layout.Targets[LinkNumber]]
@@ -391,26 +448,89 @@ class GraphView(ft.Container):
self.LitCanvas.shapes = Shapes
self.LitCanvas.update()
# --- View transform -----------------------------------------------------
def _ApplyView(self) -> None:
self.Plane.transform = ft.Transform(
matrix=ft.Matrix4.identity().translate(self.PanX, self.PanY).scale(self.Zoom, self.Zoom),
alignment=ft.Alignment.TOP_LEFT,
)
self.Plane.update()
def _ToCanvas(self, X: float, Y: float) -> tuple[float, float]:
return (X - self.PanX) / self.Zoom, (Y - self.PanY) / self.Zoom
def _PanBy(self, DX: float, DY: float) -> None:
self.PanX += DX
self.PanY += DY
self._ApplyView()
# --- Input --------------------------------------------------------------
def _NodeAt(self, X: float, Y: float) -> Optional[int]:
"""The dot under a point on the surface (screen coordinates)."""
if self.Layout is None or not len(self.Screen):
return None
X, Y = self._ToCanvas(X, Y)
Distance = np.hypot(self.Screen[:, 0] - X, self.Screen[:, 1] - Y)
Number = int(Distance.argmin())
return Number if Distance[Number] <= max(6, self.Radius[Number] + 4) else None
Reach = max(6 / self.Zoom, self.Radius[Number] + 4 / self.Zoom) # at least 6 screen pixels
return Number if Distance[Number] <= Reach else None
def _OnResize(self, Event) -> None:
self.Size = (Event.width, Event.height)
self._Draw()
def _OnHover(self, Event) -> None:
Number = self._NodeAt(Event.local_position.x, Event.local_position.y)
def _SetHovered(self, Number: Optional[int]) -> None:
if Number != self.Hovered:
self.Hovered = Number
self._DrawHighlight()
def _OnHover(self, Event) -> None:
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))
def _OnTap(self, Event) -> None:
if self.Grabbed: # a left click also lets go
self.Grabbed = False
self.DragFrom = None
return
Number = self._NodeAt(Event.local_position.x, Event.local_position.y)
if Number is not None:
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:
if Button == self.PanButton:
self.DragFrom = (Event.local_position.x, Event.local_position.y)
def _Drag(self, Event, Button: str) -> None:
if Button != self.PanButton or self.DragFrom is None:
return
X, Y = Event.local_position.x, Event.local_position.y
self._PanBy(X - self.DragFrom[0], Y - self.DragFrom[1])
self.DragFrom = (X, Y)
def _EndDrag(self) -> None:
if not self.Grabbed:
self.DragFrom = None
def _OnScroll(self, Event) -> None:
"""Zooms around the pointer."""
X, Y = Event.local_position.x, Event.local_position.y
CanvasX, CanvasY = self._ToCanvas(X, Y)
self.Zoom = min(8.0, max(0.2, self.Zoom * math.exp(-Event.scroll_delta.y * 0.0015)))
self.PanX = X - CanvasX * self.Zoom
self.PanY = Y - CanvasY * self.Zoom
self._ApplyView()