]> Repositorios git - classgraph.git/commitdiff
Add LR/TD layout toggle and render equality superclasses as arrows
authorJavier Sagredo <[email protected]>
Fri, 19 Jun 2026 15:58:47 +0000 (17:58 +0200)
committerJavier Sagredo <[email protected]>
Fri, 19 Jun 2026 15:58:47 +0000 (17:58 +0200)
Two pieces of showcase feedback:

1. Layout direction toggle. Class/method names are usually wider than
   tall, so a left-to-right flow often packs the graph more readably than
   the default top-to-bottom. Add a #dir-btn control (and `L` shortcut)
   that flips the shared dagre rankDir and re-lays-out the current view.

2. Equality superclass constraints as arrows. Previously `class (a ~ b)
   => C` dropped the equality entirely (predToSuperEdges returned no edge
   for the boxed `~`/`~~` classes and for EqPred). Now they become a
   superclass edge to the equality operator's node, carrying both operands
   as positional args, so the classes view draws `-{a}-{b}-> (~)` the same
   way any multi-param superclass renders. External operator node labels
   are parenthesised (`~` -> `(~)`). Instance-context equalities are
   unchanged — they still surface as chips on the instance node.

The demo gains a NormIsInt class exercising the new superclass-equality
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
data/viewer.css
data/viewer.html
data/viewer.js
examples/demo/src/Demo/Equality.hs
src/Classgraph/Extract.hs
src/Classgraph/Render.hs

index 953de09d35b0d6995e9c28a42cd0d2a58c2048c3..07c345efdf54194ab4aaaa08542ad643644c118e 100644 (file)
@@ -21,7 +21,7 @@ body { display: flex; }
   z-index: 10;
 }
 
-#fit-btn {
+#fit-btn, #dir-btn {
   appearance: none;
   background: #fff;
   border: 1px solid #cbd5e1;
@@ -32,8 +32,8 @@ body { display: flex; }
   color: #1f2937;
   box-shadow: 0 1px 3px rgba(15, 23, 42, 0.1);
 }
-#fit-btn:hover { background: #f1f5f9; }
-#fit-btn:active { background: #e2e8f0; }
+#fit-btn:hover, #dir-btn:hover { background: #f1f5f9; }
+#fit-btn:active, #dir-btn:active { background: #e2e8f0; }
 
 #help-panel {
   background: #fff;
index 6db75b53f7ae4b45dc12b949136f35b1844cf483..8ebeee9381c1b0fd1c4c3e7cfb9237cb3154c9e6 100644 (file)
@@ -18,6 +18,7 @@
     </div>
     <div id="cy"></div>
     <div id="canvas-controls">
+      <button id="dir-btn" type="button" title="Flip layout direction: top-down ⇄ left-right (L)">⬍ Top-down</button>
       <button id="fit-btn" type="button" title="Fit graph to viewport (F)">⊡ Fit</button>
       <details id="help-panel">
         <summary>Help / legend</summary>
@@ -51,7 +52,7 @@
           <h4>Controls</h4>
           <div class="help-tip">Click → highlight + side panel</div>
           <div class="help-tip">Double-click a class → instances; a family → type instances</div>
-          <div class="help-tip"><kbd>/</kbd> focuses search · <kbd>F</kbd> fits the graph</div>
+          <div class="help-tip"><kbd>/</kbd> focuses search · <kbd>F</kbd> fits the graph · <kbd>L</kbd> flips layout (top-down ⇄ left-right)</div>
           <div class="help-tip">Focus / mute via the right-side buttons (chips appear in the topbar)</div>
         </div>
       </details>
index 1d9067e7f11acd300aa8c58a47f435e914d080e9..15a30c9a69baeefc1c9acbc18c196b5b254db07a 100644 (file)
 
   const state = { view: null, classId: null };
 
+  // Layout direction for the dagre layout, shared by every view. Class/method
+  // names are usually wider than tall, so a left-to-right ('LR') flow often
+  // packs the graph more readably than the default top-to-bottom ('TB'). The
+  // #dir-btn toggle (and the `L` shortcut) flip this and re-run the layout.
+  let rankDir = 'TB';
+
   // Replace the cytoscape elements wholesale. We don't use cy.json({elements})
   // because that *merges* by id, which causes per-view data attributes
   // (e.g. `focused: true` on a class node in the instance view) to bleed
     state.classId = null;
     state.familyId = null;
     loadGraph(buildClassesView());
-    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 50, rankSep: 90 }).run();
+    cy.layout({ name: 'dagre', rankDir, nodeSep: 50, rankSep: 90 }).run();
     setTopbar('classgraph', '');
     setHint('classes');
     setBackVisible(false);
     state.classId = classId;
     state.familyId = null;
     loadGraph(els);
-    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 80 }).run();
+    cy.layout({ name: 'dagre', rankDir, nodeSep: 30, rankSep: 80 }).run();
     const cls = classById.get(classId);
     const subtitle = cls ? `${cls.ciName.qnPackage} · ${cls.ciName.qnModule}` : '';
     setTopbar(`Instances of ${cls ? cls.ciName.qnName : classId}`, subtitle);
     const pan  = cy.pan();
     const zoom = cy.zoom();
     loadGraph(buildInstanceView(state.classId));
-    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 80 }).run();
+    cy.layout({ name: 'dagre', rankDir, nodeSep: 30, rankSep: 80 }).run();
     cy.pan(pan);
     cy.zoom(zoom);
     const cls = classById.get(state.classId);
     const pan  = cy.pan();
     const zoom = cy.zoom();
     loadGraph(buildFamilyView(state.familyId));
-    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 80 }).run();
+    cy.layout({ name: 'dagre', rankDir, nodeSep: 30, rankSep: 80 }).run();
     cy.pan(pan);
     cy.zoom(zoom);
     const fis = famInstsByFamily.get(state.familyId) || [];
     state.classId = null;
     state.familyId = familyId;
     loadGraph(els);
-    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 80 }).run();
+    cy.layout({ name: 'dagre', rankDir, nodeSep: 30, rankSep: 80 }).run();
     const fam = familyById.get(familyId);
     const subtitle = fam ? `${fam.tfName.qnPackage} · ${fam.tfName.qnModule}` : '';
     setTopbar(`Type instances of ${fam ? fam.tfName.qnName : familyId}`, subtitle);
   function relayoutClassesView() {
     if (state.view !== 'classes') return;
     loadGraph(buildClassesView());
-    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 50, rankSep: 90 }).run();
+    cy.layout({ name: 'dagre', rankDir, nodeSep: 50, rankSep: 90 }).run();
     setCounts(focusCountsLine());
   }
 
     } else if (evt.key === 'f' || evt.key === 'F') {
       evt.preventDefault();
       fitGraph();
+    } else if (evt.key === 'l' || evt.key === 'L') {
+      evt.preventDefault();
+      toggleDirection();
     }
   });
 
 
   document.getElementById('fit-btn').addEventListener('click', fitGraph);
 
+  // Layout-direction toggle. Re-runs the dagre layout on the *current*
+  // elements (no rebuild) with the flipped rankDir, then fits. The classes
+  // view uses slightly looser spacing than the drill-in views, mirroring the
+  // per-view layout calls above.
+  const dirBtn = document.getElementById('dir-btn');
+  function syncDirButton() {
+    if (!dirBtn) return;
+    dirBtn.textContent = rankDir === 'TB' ? '⬍ Top-down' : '⬌ Left-right';
+  }
+  function toggleDirection() {
+    rankDir = rankDir === 'TB' ? 'LR' : 'TB';
+    syncDirButton();
+    if (state.view === null) return;
+    const opts = state.view === 'classes'
+      ? { name: 'dagre', rankDir, nodeSep: 50, rankSep: 90 }
+      : { name: 'dagre', rankDir, nodeSep: 30, rankSep: 80 };
+    cy.layout(opts).run();
+    fitGraph();
+  }
+  if (dirBtn) {
+    syncDirButton();
+    dirBtn.addEventListener('click', toggleDirection);
+  }
+
   // ---------------------------------------------------------------------------
   // Helpers
 
index f66a9cdb58cee5fd3421bb8b4e67ac5572e1c519..93b24f06b5ff10e58fcebefb81cd8fcf79efd169 100644 (file)
@@ -34,3 +34,12 @@ class NormalisesToInt a where
 
 instance (Norm a ~ Int, Pretty a) => NormalisesToInt a where
   toInt _ = 0
+
+-- An equality constraint in *superclass* position (not an instance
+-- context). Unlike the instance-context equalities above — which surface
+-- as chips on the instance node — a superclass equality is part of the
+-- class hierarchy, so the classes view draws it as an arrow to a synthetic
+-- @(~)@ node labelled @{Norm a} {Int}@.
+class (Norm a ~ Int) => NormIsInt a where
+  normInt :: a -> Int
+  normInt _ = 0
index 0d5cf3837f3f0be9818a0ae5f6e5e07b71b58e19..453731e5ccd5c80a5fb52f7cd5de1d6e143168ce 100644 (file)
@@ -215,16 +215,30 @@ extractClass mDocs cls = ClassInfo
     boundTvs = classTyVars cls
 
 -- | Decompose one PredType into 0..n 'SuperclassEdge's. Class predicates
--- become a single edge; constraint tuples are flattened recursively;
--- the boxed equality "classes" @(~)@ and @(~~)@ produce no edge (they
--- don't map onto a meaningful class node — they're handled at the
--- 'PredInfo' level instead). Quantified/irreducible predicates produce
--- no edge either.
+-- become a single edge; constraint tuples are flattened recursively.
+--
+-- Equality constraints — both the boxed equality "classes" @(~)@ / @(~~)@
+-- and the unboxed 'EqPred' form — become an edge to the equality operator's
+-- node (@~@, @~~@, or @Coercible@ for representational equality), carrying
+-- both operands as positional args. The two-arg edge then renders the way
+-- any multi-param superclass does, e.g. @-{a}-{b}-> (~)@. Quantified /
+-- irreducible predicates produce no edge.
 predToSuperEdges :: [Var] -> Type -> [SuperclassEdge]
 predToSuperEdges boundTvs predTy = case classifyPredType predTy of
   ClassPred cls args
     | isCTupleClass cls -> concatMap (predToSuperEdges boundTvs) args
-    | isEqClass cls     -> []
+    | isEqClass cls     ->
+        -- Boxed equality carries leading kind arg(s) before the two
+        -- operands; keep only the trailing pair (mirrors 'predToPredInfos').
+        let ops = if length args >= 2 then drop (length args - 2) args else args
+         in case ops of
+              [a, b] ->
+                [ SuperclassEdge
+                    { seSuperclass = qualName (className cls)
+                    , seArgs       = [typeArg boundTvs a, typeArg boundTvs b]
+                    }
+                ]
+              _ -> []
     | otherwise         ->
         [ SuperclassEdge
             { seSuperclass = qualName (className cls)
@@ -232,7 +246,12 @@ predToSuperEdges boundTvs predTy = case classifyPredType predTy of
                                  (visibleArgs (classTyCon cls) args)
             }
         ]
-  EqPred{}     -> []
+  EqPred eqRel a b ->
+    [ SuperclassEdge
+        { seSuperclass = QualName "<builtin>" "GHC.Builtin" (eqOpName eqRel)
+        , seArgs       = [typeArg boundTvs a, typeArg boundTvs b]
+        }
+    ]
   IrredPred{}  -> []
   ForAllPred{} -> []
 
index 50d23534ab12ef23ebed5a46c1711a50fd927475..0358dfb8bad8663201d426002cb2ac8af30c77d7 100644 (file)
@@ -16,6 +16,7 @@ import qualified Data.ByteString as BS
 import qualified Data.ByteString.Lazy as BL
 import Data.ByteString.Builder (Builder)
 import qualified Data.ByteString.Builder as BB
+import qualified Data.Char as Char
 import Data.FileEmbed (embedFile)
 import Data.List (nub, sortOn)
 import qualified Data.Map.Strict as Map
@@ -228,7 +229,7 @@ buildGraph pd sourceRoots = CyGraph
     externalNodes =
       [ CyNode $ Aeson.object
           [ "id"       Aeson..= renderQualName q
-          , "label"    Aeson..= qnName q
+          , "label"    Aeson..= externalLabel q
           , "kind"     Aeson..= externalKind q
             -- Family refs (associated types and FamilyApp use sites)
             -- are tagged "family" so they get the grey-diamond
@@ -248,6 +249,16 @@ buildGraph pd sourceRoots = CyGraph
       | Set.member (renderQualName q) externalFamilyIds = "family" :: Text
       | otherwise                                       = "class"
 
+    -- Symbolic operator names (the equality operators @~@ / @~~@ reach
+    -- the canvas as external nodes via equality superclass edges) read
+    -- better parenthesised, the way they'd appear in prefix position.
+    externalLabel q =
+      let nm = qnName q
+       in if not (T.null nm) && isOperatorChar (T.head nm)
+             then "(" <> nm <> ")"
+             else nm
+    isOperatorChar c = not (Char.isLetter c || c == '_' || c == '(' || c == '[')
+
     -- Helper: like collectFamilyApps but applied to a list of TypeArgs.
     collectFamilyAppsInArgs as = nub (concatMap collectFamilyApps as)