diff --git a/examples/canvas_graph_prototype.html b/examples/canvas_graph_prototype.html
index 8f0e5e9..392155a 100644
--- a/examples/canvas_graph_prototype.html
+++ b/examples/canvas_graph_prototype.html
@@ -274,18 +274,18 @@
// Data
// ═══════════════════════════════════════════════════════════
const NODES = [
- { id: 'app', label: 'app', type: 'root', kind: 'RootInstance' },
- { id: 'layout', label: 'layout', type: 'single', kind: 'Layout' },
- { id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
- { id: 'right_panel', label: 'right_panel', type: 'multiple', kind: 'Panel' },
- { id: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger' },
- { id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
- { id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph' },
- { id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager' },
- { id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid' },
- { id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
- { id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
- { id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy' },
+ { id: 'app', label: 'app', type: 'root', kind: 'RootInstance', description: 'Main application root' },
+ { id: 'layout', label: 'layout', type: 'single', kind: 'Layout', description: 'Main layout with 2 panels' },
+ { id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
+ { id: 'right_panel', label: 'right_panel', type: 'multiple', kind: 'Panel' },
+ { id: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger', description: 'Debug tool for instances' },
+ { id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
+ { id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph', description: 'Canvas-based graph view' },
+ { id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager', description: 'Manages all data grids' },
+ { id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid', description: '42 rows × 5 columns' },
+ { id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
+ { id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
+ { id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy', description: 'User authentication proxy' },
];
const EDGES = [
@@ -320,11 +320,16 @@ const DETAILS = {
// ═══════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════
-const NODE_W = 178;
-const NODE_H = 36;
-const LEVEL_H = 84; // vertical distance between levels
-const LEAF_GAP = 22; // horizontal gap between leaf slots
-const CHEV_ZONE = 26; // rightmost px = toggle hit zone
+const NODE_W = 178;
+const NODE_H_SMALL = 36; // Without description
+const NODE_H_LARGE = 54; // With description
+const LEVEL_H = 96; // Vertical distance between levels
+const LEAF_GAP = 22; // Horizontal gap between leaf slots
+const CHEV_ZONE = 26; // Rightmost px = toggle hit zone
+
+function getNodeHeight(node) {
+ return node.description ? NODE_H_LARGE : NODE_H_SMALL;
+}
const TYPE_COLOR = {
root: '#2563eb',
@@ -469,8 +474,12 @@ function draw() {
const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
- const x1 = p1.x, y1 = p1.y + NODE_H / 2;
- const x2 = p2.x, y2 = p2.y - NODE_H / 2;
+ const node1 = NODES.find(n => n.id === edge.from);
+ const node2 = NODES.find(n => n.id === edge.to);
+ const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL;
+ const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL;
+ const x1 = p1.x, y1 = p1.y + h1 / 2;
+ const x2 = p2.x, y2 = p2.y - h2 / 2;
const cy = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
@@ -498,7 +507,8 @@ function draw() {
// ── Node renderer ────────────────────────────────────────
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
- const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
+ const nodeH = getNodeHeight(node);
+ const hw = NODE_W / 2, hh = nodeH / 2, r = 6;
const x = cx - hw, y = cy - hh;
const color = TYPE_COLOR[node.type] || '#334155';
@@ -509,7 +519,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Background — dark card
ctx.beginPath();
- ctx.roundRect(x, y, NODE_W, NODE_H, r);
+ ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.fill();
ctx.shadowBlur = 0;
@@ -517,15 +527,15 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Left color strip (clipped)
ctx.save();
ctx.beginPath();
- ctx.roundRect(x, y, NODE_W, NODE_H, r);
+ ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.clip();
ctx.fillStyle = color;
- ctx.fillRect(x, y, 4, NODE_H);
+ ctx.fillRect(x, y, 4, nodeH);
ctx.restore();
// Border
ctx.beginPath();
- ctx.roundRect(x, y, NODE_W, NODE_H, r);
+ ctx.roundRect(x, y, NODE_W, nodeH, r);
if (isSel) {
ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 1.5;
@@ -545,7 +555,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
const badgeW = Math.min(rawW + 8, 66);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2;
- const badgeY = y + (NODE_H - 14) / 2;
+ const badgeY = y + (nodeH - 14) / 2;
ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
ctx.fillStyle = `${color}22`;
@@ -559,7 +569,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
if (kLabel !== kindText) kLabel += '…';
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
- // Label
+ // Label (centered if no description, top if description)
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
ctx.textAlign = 'left';
@@ -569,7 +579,18 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…';
- ctx.fillText(label, labelX, cy);
+ const labelY = node.description ? cy - 9 : cy;
+ ctx.fillText(label, labelX, labelY);
+
+ // Description (bottom line, only if present)
+ if (node.description) {
+ ctx.font = '9px system-ui';
+ ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)';
+ let desc = node.description;
+ while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1);
+ if (desc !== node.description) desc += '…';
+ ctx.fillText(desc, labelX, cy + 8);
+ }
// Chevron toggle (if has children)
if (hasChildren(node.id)) {
@@ -606,13 +627,18 @@ function drawChevron(ctx, cx, cy, pointDown, color) {
function fitAll() {
const vn = visNodes();
if (vn.length === 0) return;
- const xs = vn.map(n => pos[n.id]?.x ?? 0);
- const ys = vn.map(n => pos[n.id]?.y ?? 0);
const pad = 48;
- const minX = Math.min(...xs) - NODE_W / 2 - pad;
- const maxX = Math.max(...xs) + NODE_W / 2 + pad;
- const minY = Math.min(...ys) - NODE_H / 2 - pad;
- const maxY = Math.max(...ys) + NODE_H / 2 + pad;
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ for (const n of vn) {
+ const p = pos[n.id];
+ if (!p) continue;
+ const h = getNodeHeight(n);
+ minX = Math.min(minX, p.x - NODE_W / 2);
+ maxX = Math.max(maxX, p.x + NODE_W / 2);
+ minY = Math.min(minY, p.y - h / 2);
+ maxY = Math.max(maxY, p.y + h / 2);
+ }
+ minX -= pad; maxX += pad; minY -= pad; maxY += pad;
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
transform.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
@@ -631,7 +657,8 @@ function hitTest(sx, sy) {
const n = vn[i];
const p = pos[n.id];
if (!p) continue;
- if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= NODE_H / 2) {
+ const nodeH = getNodeHeight(n);
+ if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= nodeH / 2) {
const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
return { node: n, isToggle };
}
diff --git a/src/myfasthtml/assets/core/hierarchical_canvas_graph.js b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js
index 2a4119e..66d073a 100644
--- a/src/myfasthtml/assets/core/hierarchical_canvas_graph.js
+++ b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js
@@ -65,16 +65,23 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════
// Configuration & Constants
// ═══════════════════════════════════════════════════════════
- const NODES = options.nodes || [];
- const EDGES = options.edges || [];
- const EVENTS = options.events || {};
+ const NODES = options.nodes || [];
+ const EDGES = options.edges || [];
+ const EVENTS = options.events || {};
+ // filtered_nodes: null = no filter, [] = filter but no matches, [ids] = filter with matches
+ const FILTERED_NODES = options.filtered_nodes === null ? null : new Set(options.filtered_nodes);
// ═══════════════════════════════════════════════════════════
// Visual Constants
// ═══════════════════════════════════════════════════════════
- const NODE_W = 178; // Node width in pixels
- const NODE_H = 36; // Node height in pixels
- const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
+ const NODE_W = 178; // Node width in pixels
+ const NODE_H_SMALL = 36; // Node height without description
+ const NODE_H_LARGE = 54; // Node height with description
+ const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
+
+ function getNodeHeight(node) {
+ return node.description ? NODE_H_LARGE : NODE_H_SMALL;
+ }
const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
@@ -97,7 +104,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const VERTICAL_MODE_SPACING = {
levelGap: 220, // horizontal distance between parent-child (after swap)
- siblingGap: 14 // gap between siblings (in addition to NODE_H)
+ siblingGap: 14 // gap between siblings (in addition to NODE_H_LARGE)
};
function getSpacing() {
@@ -167,6 +174,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
return hidden;
}
+ function getDescendants(nodeId) {
+ /**
+ * Get all descendant node IDs for a given node.
+ * Used to highlight descendants when a node is selected.
+ */
+ const descendants = new Set();
+ function addDesc(id) {
+ for (const child of childMap[id] || []) {
+ if (!descendants.has(child)) {
+ descendants.add(child);
+ addDesc(child);
+ }
+ }
+ }
+ addDesc(nodeId);
+ return descendants;
+ }
+
function visNodes() {
const h = getHiddenSet();
return NODES.filter(n => !h.has(n.id));
@@ -203,16 +228,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const positions = {};
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap };
- // Sibling stride: in vertical mode, use NODE_H (height); in horizontal mode, use NODE_W (width)
- const siblingStride = layoutMode === 'vertical'
- ? NODE_H + spacing.siblingGap // Vertical: nodes stack by height
- : NODE_W + spacing.siblingGap; // Horizontal: nodes spread by width
+ // Create node map for quick access by ID
+ const nodeMap = {};
+ for (const n of nodes) nodeMap[n.id] = n;
+
+ // Sibling stride for horizontal mode
+ const siblingStride = NODE_W + spacing.siblingGap;
// DFS to assign x (sibling spacing)
let slot = 0;
+ let currentX = 0; // For dynamic spacing in vertical mode
+
function dfs(id) {
const children = cm[id] || [];
- if (children.length === 0) { positions[id].x = slot++ * siblingStride; return; }
+ if (children.length === 0) {
+ // Leaf node: assign x position based on layout mode
+ if (layoutMode === 'vertical') {
+ // Dynamic spacing based on actual node height
+ const node = nodeMap[id];
+ const h = getNodeHeight(node);
+ positions[id].x = currentX + h / 2; // Center of the node
+ currentX += h + spacing.siblingGap; // Move to next position
+ } else {
+ // Horizontal mode: constant spacing
+ positions[id].x = slot++ * siblingStride;
+ }
+ return;
+ }
+ // Non-leaf: recurse children, then center between them
for (const c of children) dfs(c);
const xs = children.map(c => positions[c].x);
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
@@ -320,12 +363,14 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
drawDotGrid();
- const q = filterQuery.trim().toLowerCase();
- const matchIds = q
- ? new Set(NODES.filter(n =>
- n.label.toLowerCase().includes(q) || n.type.toLowerCase().includes(q)
- ).map(n => n.id))
- : null;
+ // Calculate matchIds based on filter state:
+ // - FILTERED_NODES === null: no filter active → matchIds = null (nothing dimmed)
+ // - FILTERED_NODES.size === 0: filter active, no matches → matchIds = empty Set (everything dimmed)
+ // - FILTERED_NODES.size > 0: filter active with matches → matchIds = FILTERED_NODES (dim non-matches)
+ const matchIds = FILTERED_NODES === null ? null : FILTERED_NODES;
+
+ // Get descendants of selected node for highlighting
+ const descendantIds = selectedId ? getDescendants(selectedId) : new Set();
const vn = visNodes();
@@ -338,6 +383,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
+ const isHighlighted = selectedId && (edge.from === selectedId || descendantIds.has(edge.from));
+
+ // Get dynamic heights for source and target nodes
+ const node1 = NODES.find(n => n.id === edge.from);
+ const node2 = NODES.find(n => n.id === edge.to);
+ const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL;
+ const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL;
const tp1 = transformPos(p1);
const tp2 = transformPos(p2);
@@ -345,8 +397,8 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
let x1, y1, x2, y2, cx, cy;
if (layoutMode === 'horizontal') {
// Horizontal: edges go from bottom of parent to top of child
- x1 = tp1.x; y1 = tp1.y + NODE_H / 2;
- x2 = tp2.x; y2 = tp2.y - NODE_H / 2;
+ x1 = tp1.x; y1 = tp1.y + h1 / 2;
+ x2 = tp2.x; y2 = tp2.y - h2 / 2;
cy = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
@@ -361,8 +413,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
}
- ctx.strokeStyle = dimmed ? UI_COLORS.edgeDimmed : UI_COLORS.edge;
- ctx.lineWidth = 1.5;
+ if (isHighlighted) {
+ ctx.strokeStyle = UI_COLORS.nodeBorderSel;
+ ctx.lineWidth = 2.5;
+ } else if (dimmed) {
+ ctx.strokeStyle = UI_COLORS.edgeDimmed;
+ ctx.lineWidth = 1.5;
+ } else {
+ ctx.strokeStyle = UI_COLORS.edge;
+ ctx.lineWidth = 1.5;
+ }
ctx.stroke();
}
@@ -372,17 +432,19 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
if (!p) continue;
const tp = transformPos(p);
const isSel = node.id === selectedId;
+ const isDesc = descendantIds.has(node.id);
const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = matchIds !== null && !matchIds.has(node.id);
- drawNode(node, tp.x, tp.y, isSel, isMatch, isDim, transform.scale);
+ drawNode(node, tp.x, tp.y, isSel, isDesc, isMatch, isDim, transform.scale);
}
ctx.restore();
}
- function drawNode(node, cx, cy, isSel, isMatch, isDim, zoomLevel) {
- // Nodes always keep same dimensions and horizontal text
- const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
+ function drawNode(node, cx, cy, isSel, isDesc, isMatch, isDim, zoomLevel) {
+ // Nodes have dynamic height (with or without description)
+ const nodeH = getNodeHeight(node);
+ const hw = NODE_W / 2, hh = nodeH / 2, r = 6;
const x = cx - hw, y = cy - hh;
const color = KIND_COLOR[node.kind] || '#334155';
@@ -393,7 +455,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Background
ctx.beginPath();
- ctx.roundRect(x, y, NODE_W, NODE_H, r);
+ ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg;
ctx.fill();
ctx.shadowBlur = 0;
@@ -401,16 +463,17 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Left color strip (always on left, regardless of mode)
ctx.save();
ctx.beginPath();
- ctx.roundRect(x, y, NODE_W, NODE_H, r);
+ ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.clip();
ctx.fillStyle = color;
- ctx.fillRect(x, y, 4, NODE_H);
+ ctx.fillRect(x, y, 4, nodeH);
ctx.restore();
// Border
ctx.beginPath();
- ctx.roundRect(x, y, NODE_W, NODE_H, r);
- if (isSel) {
+ ctx.roundRect(x, y, NODE_W, nodeH, r);
+ if (isSel || isDesc) {
+ // Selected node or descendant: orange border
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
ctx.lineWidth = 1.5;
} else if (isMatch) {
@@ -432,7 +495,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const badgeW = Math.min(rawW + 8, 66 * zoomLevel);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel;
- const badgeY = (y + (NODE_H - 14) / 2) * zoomLevel;
+ const badgeY = (y + (nodeH - 14) / 2) * zoomLevel;
ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel);
ctx.fillStyle = `${color}22`;
@@ -446,7 +509,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.fillText(kLabel, Math.round(badgeX + badgeW / 2), Math.round(badgeY + 7 * zoomLevel));
ctx.restore();
- // Label (always horizontal) - with dynamic font size for sharp rendering at all zoom levels
+ // Label (centered if no description, top if description) - with dynamic font size for sharp rendering at all zoom levels
const labelFontSize = 12 * zoomLevel;
ctx.save();
ctx.scale(1 / zoomLevel, 1 / zoomLevel);
@@ -459,7 +522,19 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…';
- ctx.fillText(label, Math.round(labelX), Math.round(cy * zoomLevel));
+ const labelY = node.description ? (cy - 9) * zoomLevel : cy * zoomLevel;
+ ctx.fillText(label, Math.round(labelX), Math.round(labelY));
+
+ // Description (bottom line, only if present)
+ if (node.description) {
+ const descFontSize = 9 * zoomLevel;
+ ctx.font = `${descFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`;
+ ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)';
+ let desc = node.description;
+ while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1);
+ if (desc !== node.description) desc += '…';
+ ctx.fillText(desc, Math.round(labelX), Math.round((cy + 8) * zoomLevel));
+ }
ctx.restore();
// Chevron toggle (same position in both modes)
@@ -499,16 +574,26 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const vn = visNodes();
if (vn.length === 0) return;
- // Get transformed positions
- const tps = vn.map(n => pos[n.id] ? transformPos(pos[n.id]) : null).filter(p => p !== null);
- if (tps.length === 0) return;
+ // Calculate bounds using dynamic node heights
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ for (const n of vn) {
+ const p = pos[n.id];
+ if (!p) continue;
+ const tp = transformPos(p);
+ const h = getNodeHeight(n);
+ minX = Math.min(minX, tp.x - NODE_W / 2);
+ maxX = Math.max(maxX, tp.x + NODE_W / 2);
+ minY = Math.min(minY, tp.y - h / 2);
+ maxY = Math.max(maxY, tp.y + h / 2);
+ }
+
+ if (!isFinite(minX)) return;
+
+ minX -= FIT_PADDING;
+ maxX += FIT_PADDING;
+ minY -= FIT_PADDING;
+ maxY += FIT_PADDING;
- const xs = tps.map(p => p.x);
- const ys = tps.map(p => p.y);
- const minX = Math.min(...xs) - NODE_W / 2 - FIT_PADDING;
- const maxX = Math.max(...xs) + NODE_W / 2 + FIT_PADDING;
- const minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING;
- const maxY = Math.max(...ys) + NODE_H / 2 + FIT_PADDING;
const scale = Math.min(logicalWidth / (maxX - minX), logicalHeight / (maxY - minY), FIT_MAX_SCALE);
transform.scale = scale;
transform.x = (logicalWidth - (minX + maxX) * scale) / 2;
@@ -543,12 +628,30 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const p = pos[n.id];
if (!p) continue;
const tp = transformPos(p);
+ const nodeH = getNodeHeight(n);
- // Nodes keep same dimensions in both modes
- if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= NODE_H / 2) {
- // Toggle zone always on the right side of node
+ // Nodes have dynamic height based on description
+ if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= nodeH / 2) {
+ const hw = NODE_W / 2, hh = nodeH / 2;
+ const x = tp.x - hw, y = tp.y - hh;
+
+ // Check border (left strip) - 4px wide
+ const isBorder = wx >= x && wx <= x + 4;
+
+ // Check toggle zone (chevron on right)
const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE;
- return { node: n, isToggle };
+
+ // Check badge (type badge) - approximate zone (right side, excluding toggle)
+ // Badge is positioned at: x + NODE_W - chevSpace - badgeW - 2
+ // For hit testing, we use a simplified zone: last ~70px before toggle area
+ const chevSpace = hasChildren(n.id) ? CHEV_ZONE : 8;
+ const badgeZoneStart = x + NODE_W - chevSpace - 70;
+ const badgeZoneEnd = x + NODE_W - chevSpace - 2;
+ const badgeY = y + (nodeH - 14) / 2;
+ const isBadge = !isToggle && wx >= badgeZoneStart && wx <= badgeZoneEnd
+ && wy >= badgeY && wy <= badgeY + 14;
+
+ return { node: n, isToggle, isBadge, isBorder };
}
}
return null;
@@ -564,7 +667,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
return;
}
htmx.ajax('POST', handler.url, {
- values: { event_data: JSON.stringify(eventData) },
+ values: eventData, // Send data as separate fields (default command engine behavior)
target: handler.target || 'body',
swap: handler.swap || 'none'
});
@@ -643,11 +746,25 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit) {
if (hit.isToggle) {
+ // Save screen position of clicked node before layout change
+ const oldPos = pos[hit.node.id];
+ const oldTp = transformPos(oldPos);
+ const screenX = oldTp.x * transform.scale + transform.x;
+ const screenY = oldTp.y * transform.scale + transform.y;
+
// Toggle collapse
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
else collapsed.add(hit.node.id);
recomputeLayout();
+ // Adjust transform to keep clicked node at same screen position
+ const newPos = pos[hit.node.id];
+ if (newPos) {
+ const newTp = transformPos(newPos);
+ transform.x = screenX - newTp.x * transform.scale;
+ transform.y = screenY - newTp.y * transform.scale;
+ }
+
// Post toggle_node event
postEvent('toggle_node', {
node_id: hit.node.id,
@@ -658,6 +775,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null;
}
+ } else if (hit.isBadge) {
+ // Badge click: filter by type
+ postEvent('_internal_filter_by_type', { query_param: 'type', value: hit.node.type });
+ } else if (hit.isBorder) {
+ // Border click: filter by kind
+ postEvent('_internal_filter_by_kind', { query_param: 'kind', value: hit.node.kind });
} else {
selectedId = hit.node.id;
diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index 65d02e6..83fb0f9 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -14,12 +14,12 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
-from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf
+from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk, column_type_defaults
@@ -142,7 +142,7 @@ class Commands(BaseCommands):
return Command("Filter",
"Filter Grid",
self._owner,
- self._owner.filter
+ self._owner.filter,
)
def change_selection_mode(self):
@@ -212,8 +212,8 @@ class DataGrid(MultipleInstance):
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
- # add DataGridQuery
- self._datagrid_filter = DataGridQuery(self)
+ # add Query
+ self._datagrid_filter = Query(self)
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
@@ -295,7 +295,7 @@ class DataGrid(MultipleInstance):
for col_id, values in self._state.filtered.items():
if col_id == FILTER_INPUT_CID:
if values is not None:
- if self._datagrid_filter.get_query_type() == DG_QUERY_FILTER:
+ if self._datagrid_filter.get_query_type() == QUERY_FILTER:
visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns]
df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
else:
@@ -632,6 +632,9 @@ class DataGrid(MultipleInstance):
def get_state(self):
return self._state
+ def get_description(self) -> str:
+ return self.get_table_name()
+
def get_settings(self):
return self._settings
diff --git a/src/myfasthtml/controls/HierarchicalCanvasGraph.py b/src/myfasthtml/controls/HierarchicalCanvasGraph.py
index 6931f6a..547e418 100644
--- a/src/myfasthtml/controls/HierarchicalCanvasGraph.py
+++ b/src/myfasthtml/controls/HierarchicalCanvasGraph.py
@@ -7,6 +7,7 @@ from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
+from myfasthtml.controls.Query import Query, QueryConf
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -47,6 +48,11 @@ class HierarchicalCanvasGraphState(DbObject):
# Persisted: layout orientation ('horizontal' or 'vertical')
self.layout_mode: str = 'horizontal'
+ # Persisted: filter state
+ self.filter_text: Optional[str] = None # Text search filter
+ self.filter_type: Optional[str] = None # Type filter (badge click)
+ self.filter_kind: Optional[str] = None # Kind filter (border click)
+
# Not persisted: current selection (ephemeral)
self.ns_selected_id: Optional[str] = None
@@ -65,6 +71,19 @@ class Commands(BaseCommands):
self._owner,
self._owner._handle_update_view_state
).htmx(target=f"#{self._id}", swap='none')
+
+ def apply_filter(self):
+ """Apply current filter and update the graph display.
+
+ This command is called when the filter changes (search text, type, or kind).
+ """
+ return Command(
+ "ApplyFilter",
+ "Apply filter to graph",
+ self._owner,
+ self._owner._handle_apply_filter,
+ key="#{id}-apply-filter",
+ ).htmx(target=f"#{self._id}")
class HierarchicalCanvasGraph(MultipleInstance):
@@ -100,6 +119,11 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state = HierarchicalCanvasGraphState(self)
self.commands = Commands(self)
+ # Add Query component for filtering
+ self._query = Query(self, QueryConf(placeholder="Filter instances..."), _id="-query")
+ self._query.bind_command("QueryChanged", self.commands.apply_filter())
+ self._query.bind_command("CancelQuery", self.commands.apply_filter())
+
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
@@ -148,25 +172,102 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state.collapsed = list(collapsed_set)
return self
- def _handle_update_view_state(self, event_data: dict):
+ def _handle_update_view_state(self, transform=None, layout_mode=None):
"""Internal handler to update view state from client.
Args:
- event_data: Dictionary with 'transform' and/or 'layout_mode' keys
+ transform: Optional dict with zoom/pan transform state
+ layout_mode: Optional string with layout orientation
Returns:
str: Empty string (no UI update needed)
"""
- if 'transform' in event_data:
- self._state.transform = event_data['transform']
+ if transform is not None:
+ self._state.transform = transform
logger.debug(f"Transform updated: {self._state.transform}")
- if 'layout_mode' in event_data:
- self._state.layout_mode = event_data['layout_mode']
+ if layout_mode is not None:
+ self._state.layout_mode = layout_mode
logger.debug(f"Layout mode updated: {self._state.layout_mode}")
return ""
+ def _handle_apply_filter(self, query_param="text", value=None):
+ """Internal handler to apply filter and re-render the graph.
+
+ Args:
+ query_param: Type of filter - "text", "type", or "kind"
+ value: The filter value (type name or kind name). Toggles off if same value clicked again.
+
+ Returns:
+ self: For HTMX to render the updated graph
+ """
+ # Save old values to detect toggle
+ old_filter_type = self._state.filter_type
+ old_filter_kind = self._state.filter_kind
+
+ # Reset all filters
+ self._state.filter_text = None
+ self._state.filter_type = None
+ self._state.filter_kind = None
+
+ # Apply the requested filter
+ if query_param == "text":
+ # Text filter from Query component
+ self._state.filter_text = self._query.get_query()
+
+ elif query_param == "type":
+ # Type filter from badge click - toggle if same type clicked again
+ if old_filter_type != value:
+ self._state.filter_type = value
+
+ elif query_param == "kind":
+ # Kind filter from border click - toggle if same kind clicked again
+ if old_filter_kind != value:
+ self._state.filter_kind = value
+
+ logger.debug(f"Applying filter: query_param={query_param}, value={value}, "
+ f"text={self._state.filter_text}, type={self._state.filter_type}, kind={self._state.filter_kind}")
+
+ return self
+
+ def _calculate_filtered_nodes(self) -> Optional[list[str]]:
+ """Calculate which node IDs match the current filter criteria.
+
+ Returns:
+ Optional[list[str]]:
+ - None: No filter is active (all nodes visible, nothing dimmed)
+ - []: Filter active but no matches (all nodes dimmed)
+ - [ids]: Filter active with matches (only these nodes visible)
+ """
+ # If no filters are active, return None (no filtering)
+ if not self._state.filter_text and not self._state.filter_type and not self._state.filter_kind:
+ return None
+
+ filtered_ids = []
+ for node in self.conf.nodes:
+ matches = True
+
+ # Check text filter (searches in id, label, type, kind)
+ if self._state.filter_text:
+ search_text = self._state.filter_text.lower()
+ searchable = f"{node.get('id', '')} {node.get('label', '')} {node.get('type', '')} {node.get('kind', '')}".lower()
+ if search_text not in searchable:
+ matches = False
+
+ # Check type filter
+ if self._state.filter_type and node.get('type') != self._state.filter_type:
+ matches = False
+
+ # Check kind filter
+ if self._state.filter_kind and node.get('kind') != self._state.filter_kind:
+ matches = False
+
+ if matches:
+ filtered_ids.append(node['id'])
+
+ return filtered_ids
+
def _prepare_options(self) -> dict:
"""Prepare JavaScript options object.
@@ -179,17 +280,25 @@ class HierarchicalCanvasGraph(MultipleInstance):
# Add internal handler for view state persistence
events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options()
+ # Add internal handlers for filtering by type and kind (badge/border clicks)
+ events['_internal_filter_by_type'] = self.commands.apply_filter().ajax_htmx_options()
+ events['_internal_filter_by_kind'] = self.commands.apply_filter().ajax_htmx_options()
+
# Add user-provided event handlers
if self.conf.events_handlers:
for event_name, command in self.conf.events_handlers.items():
events[event_name] = command.ajax_htmx_options()
+ # Calculate filtered nodes
+ filtered_nodes = self._calculate_filtered_nodes()
+
return {
"nodes": self.conf.nodes,
"edges": self.conf.edges,
"collapsed": self._state.collapsed,
"transform": self._state.transform,
"layout_mode": self._state.layout_mode,
+ "filtered_nodes": filtered_nodes,
"events": events
}
@@ -203,6 +312,9 @@ class HierarchicalCanvasGraph(MultipleInstance):
options_json = json.dumps(options, indent=2)
return Div(
+ # Query filter bar
+ self._query,
+
# Canvas element (sized by JS to fill container)
Div(
id=f"{self._id}_container",
diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py
index 689637b..86161aa 100644
--- a/src/myfasthtml/controls/InstancesDebugger.py
+++ b/src/myfasthtml/controls/InstancesDebugger.py
@@ -25,22 +25,22 @@ class InstancesDebugger(SingleInstance):
self.conf = conf if conf is not None else InstancesDebuggerConf()
self._panel = Panel(self, _id="-panel")
self._select_command = Command("ShowInstance",
- "Display selected Instance",
- self,
- self.on_select_node).htmx(target=f"#{self._panel.get_ids().right}")
-
+ "Display selected Instance",
+ self,
+ self.on_select_node).htmx(target=f"#{self._panel.get_ids().right}")
+
def render(self):
nodes, edges = self._get_nodes_and_edges()
graph_conf = HierarchicalCanvasGraphConf(
nodes=nodes,
edges=edges,
events_handlers={
- "select_node": self._select_command
+ "select_node": self._select_command
}
)
canvas_graph = HierarchicalCanvasGraph(self, conf=graph_conf, _id="-canvas-graph")
return self._panel.set_main(canvas_graph)
-
+
def on_select_node(self, event_data: dict):
"""Handle node selection event from canvas graph.
@@ -50,23 +50,23 @@ class InstancesDebugger(SingleInstance):
node_id = event_data.get("node_id")
if not node_id:
return None
-
+
# Parse full ID (session#instance_id)
parts = node_id.split("#")
session = parts[0]
instance_id = "#".join(parts[1:])
-
+
properties_def = {
- "Main": {"Id": "_id", "Parent Id": "_parent._id"},
- "State": {"_name": "_state._name", "*": "_state"},
- "Commands": {"*": "commands"},
+ "Main": {"Id": "_id", "Parent Id": "_parent._id"},
+ "State": {"_name": "_state._name", "*": "_state"},
+ "Commands": {"*": "commands"},
}
-
+
return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id),
properties_def,
_id="-properties"))
-
+
def _get_instance_kind(self, instance) -> str:
"""Determine the instance kind for visualization.
@@ -87,7 +87,7 @@ class InstancesDebugger(SingleInstance):
return 'multiple'
else:
return 'multiple' # Default
-
+
def _get_nodes_and_edges(self):
"""Build nodes and edges from current instances.
@@ -95,57 +95,58 @@ class InstancesDebugger(SingleInstance):
tuple: (nodes, edges) where nodes include id, label, kind, type
"""
instances = self._get_instances()
-
+
nodes = []
edges = []
existing_ids = set()
-
+
# Create nodes with kind (instance kind) and type (class name)
for instance in instances:
node_id = instance.get_full_id()
existing_ids.add(node_id)
-
+
nodes.append({
- "id": node_id,
- "label": instance.get_id(),
- "kind": self._get_instance_kind(instance),
- "type": instance.__class__.__name__
+ "id": node_id,
+ "label": instance.get_id(),
+ "kind": self._get_instance_kind(instance),
+ "type": instance.__class__.__name__,
+ "description": instance.get_description()
})
-
+
# Track nodes with parents
nodes_with_parent = set()
-
+
# Create edges
for instance in instances:
node_id = instance.get_full_id()
parent_id = instance.get_parent_full_id()
-
+
if parent_id is None or parent_id == "":
continue
-
+
nodes_with_parent.add(node_id)
-
+
edges.append({
- "from": parent_id,
- "to": node_id
+ "from": parent_id,
+ "to": node_id
})
-
+
# Create ghost node if parent not in existing instances
if parent_id not in existing_ids:
nodes.append({
- "id": parent_id,
- "label": f"Ghost: {parent_id}",
- "kind": "multiple", # Default kind for ghost nodes
- "type": "Ghost"
+ "id": parent_id,
+ "label": f"Ghost: {parent_id}",
+ "kind": "multiple", # Default kind for ghost nodes
+ "type": "Ghost"
})
existing_ids.add(parent_id)
-
+
# Group siblings by type if configured
if self.conf.group_siblings_by_type:
edges = self._sort_edges_by_sibling_type(nodes, edges)
-
+
return nodes, edges
-
+
def _sort_edges_by_sibling_type(self, nodes, edges):
"""Sort edges so that siblings (same parent) are grouped by type.
@@ -157,15 +158,15 @@ class InstancesDebugger(SingleInstance):
list: Sorted edges with siblings grouped by type
"""
from collections import defaultdict
-
+
# Create mapping node_id -> type for quick lookup
node_types = {node["id"]: node["type"] for node in nodes}
-
+
# Group edges by parent
edges_by_parent = defaultdict(list)
for edge in edges:
edges_by_parent[edge["from"]].append(edge)
-
+
# Sort each parent's children by type and rebuild edges list
sorted_edges = []
for parent_id in edges_by_parent:
@@ -174,9 +175,9 @@ class InstancesDebugger(SingleInstance):
key=lambda e: node_types.get(e["to"], "")
)
sorted_edges.extend(parent_edges)
-
+
return sorted_edges
-
+
def _get_instances(self):
return list(InstancesManager.instances.values())
diff --git a/src/myfasthtml/controls/Query.py b/src/myfasthtml/controls/Query.py
new file mode 100644
index 0000000..968fa0e
--- /dev/null
+++ b/src/myfasthtml/controls/Query.py
@@ -0,0 +1,109 @@
+import logging
+from dataclasses import dataclass
+from typing import Optional
+
+from fasthtml.components import *
+
+from myfasthtml.controls.BaseCommands import BaseCommands
+from myfasthtml.controls.helpers import mk
+from myfasthtml.core.commands import Command
+from myfasthtml.core.dbmanager import DbObject
+from myfasthtml.core.instances import MultipleInstance
+from myfasthtml.icons.fluent import brain_circuit20_regular
+from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular
+from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular
+
+logger = logging.getLogger("Query")
+
+QUERY_FILTER = "filter"
+QUERY_SEARCH = "search"
+QUERY_AI = "ai"
+
+query_type = {
+ QUERY_FILTER: filter20_regular,
+ QUERY_SEARCH: search20_regular,
+ QUERY_AI: brain_circuit20_regular
+}
+
+
+@dataclass
+class QueryConf:
+ """Configuration for Query control.
+
+ Attributes:
+ placeholder: Placeholder text for the search input
+ """
+ placeholder: str = "Search..."
+
+
+class QueryState(DbObject):
+ def __init__(self, owner):
+ with self.initializing():
+ super().__init__(owner)
+ self.filter_type: str = "filter"
+ self.query: Optional[str] = None
+
+
+class Commands(BaseCommands):
+ def change_filter_type(self):
+ return Command("ChangeFilterType",
+ "Change filter type",
+ self._owner,
+ self._owner.change_query_type).htmx(target=f"#{self._id}")
+
+ def on_filter_changed(self):
+ return Command("QueryChanged",
+ "Query changed",
+ self._owner,
+ self._owner.query_changed).htmx(target=None) # prevent focus loss when typing
+
+ def on_cancel_query(self):
+ return Command("CancelQuery",
+ "Cancel query",
+ self._owner,
+ self._owner.query_changed,
+ kwargs={"query": ""}
+ ).htmx(target=f"#{self._id}")
+
+
+class Query(MultipleInstance):
+ def __init__(self, parent, conf: Optional[QueryConf] = None, _id=None):
+ super().__init__(parent, _id=_id)
+ self.conf = conf or QueryConf()
+ self.commands = Commands(self)
+ self._state = QueryState(self)
+
+ def get_query(self):
+ return self._state.query
+
+ def get_query_type(self):
+ return self._state.filter_type
+
+ def change_query_type(self):
+ keys = list(query_type.keys()) # ["filter", "search", "ai"]
+ current_idx = keys.index(self._state.filter_type)
+ self._state.filter_type = keys[(current_idx + 1) % len(keys)]
+ return self
+
+ def query_changed(self, query):
+ logger.debug(f"query_changed {query=}")
+ self._state.query = query.strip() if query is not None else None
+ return self # needed anyway to allow oob swap
+
+ def render(self):
+ return Div(
+ mk.label(
+ Input(name="query",
+ value=self._state.query if self._state.query is not None else "",
+ placeholder=self.conf.placeholder,
+ **self.commands.on_filter_changed().get_htmx_params(values_encode="json")),
+ icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()),
+ cls="input input-xs flex gap-3"
+ ),
+ mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()),
+ cls="flex",
+ id=self._id
+ )
+
+ def __ft__(self):
+ return self.render()
diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py
index 3681b0a..7d0b45c 100644
--- a/src/myfasthtml/core/commands.py
+++ b/src/myfasthtml/core/commands.py
@@ -77,7 +77,8 @@ class Command:
return key
- def __init__(self, name,
+ def __init__(self,
+ name,
description,
owner=None,
callback=None,
@@ -102,10 +103,10 @@ class Command:
if auto_register:
if self._key is not None:
if self._key in CommandsManager.commands_by_key:
- #logger.debug(f"Command {self.name} with key={self._key} will not be registered.")
+ # logger.debug(f"Command {self.name} with key={self._key} will not be registered.")
self.id = CommandsManager.commands_by_key[self._key].id
else:
- #logger.debug(f"Command {self.name} with key={self._key} will be registered.")
+ # logger.debug(f"Command {self.name} with key={self._key} will be registered.")
CommandsManager.register(self)
else:
logger.warning(f"Command {self.name} has no key, it will not be registered.")
@@ -150,13 +151,13 @@ class Command:
logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...")
r = bound_cmd.command.execute(client_response)
ret_from_before_commands.append(r)
-
+
# Execute main callback
kwargs = self._create_kwargs(self.default_kwargs,
client_response,
{"client_response": client_response or {}})
ret = self.callback(*self.default_args, **kwargs)
-
+
# Execute "after" bound commands
ret_from_after_commands = []
if self.owner:
@@ -165,12 +166,15 @@ class Command:
logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...")
r = bound_cmd.command.execute(client_response)
ret_from_after_commands.append(r)
-
+
all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results)
# Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]:
- for r in all_ret[1:]:
+ for index, r in enumerate(all_ret[1:]):
+ if hasattr(r, "__ft__"):
+ r = r.__ft__()
+ all_ret[index + 1] = r
if (hasattr(r, 'attrs')
and "hx-swap-oob" not in r.attrs
and r.get("id", None) is not None):
diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py
index 170736e..eecd92a 100644
--- a/src/myfasthtml/core/instances.py
+++ b/src/myfasthtml/core/instances.py
@@ -106,6 +106,9 @@ class BaseInstance:
def get_full_id(self) -> str:
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
+ def get_description(self) -> str:
+ pass
+
def get_parent_full_id(self) -> Optional[str]:
parent = self.get_parent()
return parent.get_full_id() if parent else None
@@ -118,8 +121,21 @@ class BaseInstance:
command: Command name or Command instance to bind to
command_to_bind: Command to execute when the main command is triggered
when: "before" or "after" - when to execute the bound command (default: "after")
+
+ Note:
+ Duplicate bindings are automatically prevented using two mechanisms:
+ 1. Check if the same binding already exists
"""
command_name = command.name if hasattr(command, "name") else command
+
+ # Protection 1: Check if this binding already exists to prevent duplicates
+ existing_bindings = self._bound_commands.get(command_name, [])
+ for existing in existing_bindings:
+ if existing.command.name == command_to_bind.name and existing.when == when:
+ # Binding already exists, don't add it again
+ return
+
+ # Add new binding
bound = BoundCommand(command=command_to_bind, when=when)
self._bound_commands.setdefault(command_name, []).append(bound)