Implemented new InstancesDebugger.py. Based on the HierarchicalCanvasGraph.py

This commit is contained in:
2026-02-22 17:51:39 +01:00
parent 8b8172231a
commit 0686103a8f
8 changed files with 536 additions and 141 deletions

View File

@@ -274,18 +274,18 @@
// Data // Data
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const NODES = [ const NODES = [
{ id: 'app', label: 'app', type: 'root', kind: 'RootInstance' }, { id: 'app', label: 'app', type: 'root', kind: 'RootInstance', description: 'Main application root' },
{ id: 'layout', label: 'layout', type: 'single', kind: 'Layout' }, { 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: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
{ id: 'right_panel', label: 'right_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: '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: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
{ id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph' }, { 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' }, { 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' }, { 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_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
{ id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' }, { id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
{ id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy' }, { id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy', description: 'User authentication proxy' },
]; ];
const EDGES = [ const EDGES = [
@@ -321,10 +321,15 @@ const DETAILS = {
// Constants // Constants
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const NODE_W = 178; const NODE_W = 178;
const NODE_H = 36; const NODE_H_SMALL = 36; // Without description
const LEVEL_H = 84; // vertical distance between levels const NODE_H_LARGE = 54; // With description
const LEAF_GAP = 22; // horizontal gap between leaf slots const LEVEL_H = 96; // Vertical distance between levels
const CHEV_ZONE = 26; // rightmost px = toggle hit zone 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 = { const TYPE_COLOR = {
root: '#2563eb', root: '#2563eb',
@@ -469,8 +474,12 @@ function draw() {
const p1 = pos[edge.from], p2 = pos[edge.to]; const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue; if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to); const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
const x1 = p1.x, y1 = p1.y + NODE_H / 2; const node1 = NODES.find(n => n.id === edge.from);
const x2 = p2.x, y2 = p2.y - NODE_H / 2; 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; const cy = (y1 + y2) / 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x1, y1); ctx.moveTo(x1, y1);
@@ -498,7 +507,8 @@ function draw() {
// ── Node renderer ──────────────────────────────────────── // ── Node renderer ────────────────────────────────────────
function drawNode(node, cx, cy, isSel, isMatch, isDim) { 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 x = cx - hw, y = cy - hh;
const color = TYPE_COLOR[node.type] || '#334155'; const color = TYPE_COLOR[node.type] || '#334155';
@@ -509,7 +519,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Background — dark card // Background — dark card
ctx.beginPath(); 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.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
@@ -517,15 +527,15 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Left color strip (clipped) // Left color strip (clipped)
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.clip(); ctx.clip();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(x, y, 4, NODE_H); ctx.fillRect(x, y, 4, nodeH);
ctx.restore(); ctx.restore();
// Border // Border
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
if (isSel) { if (isSel) {
ctx.strokeStyle = '#f0883e'; ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
@@ -545,7 +555,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
const badgeW = Math.min(rawW + 8, 66); const badgeW = Math.min(rawW + 8, 66);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8; const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2; const badgeX = x + NODE_W - chevSpace - badgeW - 2;
const badgeY = y + (NODE_H - 14) / 2; const badgeY = y + (nodeH - 14) / 2;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3); ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
ctx.fillStyle = `${color}22`; ctx.fillStyle = `${color}22`;
@@ -559,7 +569,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
if (kLabel !== kindText) kLabel += '…'; if (kLabel !== kindText) kLabel += '…';
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7); 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.font = `${isSel ? 500 : 400} 12px monospace`;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3'; ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
@@ -569,7 +579,18 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
let label = node.label; let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1); while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…'; 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) // Chevron toggle (if has children)
if (hasChildren(node.id)) { if (hasChildren(node.id)) {
@@ -606,13 +627,18 @@ function drawChevron(ctx, cx, cy, pointDown, color) {
function fitAll() { function fitAll() {
const vn = visNodes(); const vn = visNodes();
if (vn.length === 0) return; 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 pad = 48;
const minX = Math.min(...xs) - NODE_W / 2 - pad; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const maxX = Math.max(...xs) + NODE_W / 2 + pad; for (const n of vn) {
const minY = Math.min(...ys) - NODE_H / 2 - pad; const p = pos[n.id];
const maxY = Math.max(...ys) + NODE_H / 2 + pad; 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); const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
transform.scale = scale; transform.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2; transform.x = (canvas.width - (minX + maxX) * scale) / 2;
@@ -631,7 +657,8 @@ function hitTest(sx, sy) {
const n = vn[i]; const n = vn[i];
const p = pos[n.id]; const p = pos[n.id];
if (!p) continue; 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; const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
return { node: n, isToggle }; return { node: n, isToggle };
} }

View File

@@ -68,14 +68,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const NODES = options.nodes || []; const NODES = options.nodes || [];
const EDGES = options.edges || []; const EDGES = options.edges || [];
const EVENTS = options.events || {}; 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 // Visual Constants
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const NODE_W = 178; // Node width in pixels const NODE_W = 178; // Node width in pixels
const NODE_H = 36; // Node height 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) 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_SIZE = 32; // Toggle button dimensions
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
@@ -97,7 +104,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const VERTICAL_MODE_SPACING = { const VERTICAL_MODE_SPACING = {
levelGap: 220, // horizontal distance between parent-child (after swap) 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() { function getSpacing() {
@@ -167,6 +174,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
return hidden; 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() { function visNodes() {
const h = getHiddenSet(); const h = getHiddenSet();
return NODES.filter(n => !h.has(n.id)); return NODES.filter(n => !h.has(n.id));
@@ -203,16 +228,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const positions = {}; const positions = {};
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap }; 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) // Create node map for quick access by ID
const siblingStride = layoutMode === 'vertical' const nodeMap = {};
? NODE_H + spacing.siblingGap // Vertical: nodes stack by height for (const n of nodes) nodeMap[n.id] = n;
: NODE_W + spacing.siblingGap; // Horizontal: nodes spread by width
// Sibling stride for horizontal mode
const siblingStride = NODE_W + spacing.siblingGap;
// DFS to assign x (sibling spacing) // DFS to assign x (sibling spacing)
let slot = 0; let slot = 0;
let currentX = 0; // For dynamic spacing in vertical mode
function dfs(id) { function dfs(id) {
const children = cm[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); for (const c of children) dfs(c);
const xs = children.map(c => positions[c].x); const xs = children.map(c => positions[c].x);
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2; positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
@@ -320,12 +363,14 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.clearRect(0, 0, logicalWidth, logicalHeight); ctx.clearRect(0, 0, logicalWidth, logicalHeight);
drawDotGrid(); drawDotGrid();
const q = filterQuery.trim().toLowerCase(); // Calculate matchIds based on filter state:
const matchIds = q // - FILTERED_NODES === null: no filter active → matchIds = null (nothing dimmed)
? new Set(NODES.filter(n => // - FILTERED_NODES.size === 0: filter active, no matches → matchIds = empty Set (everything dimmed)
n.label.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) // - FILTERED_NODES.size > 0: filter active with matches → matchIds = FILTERED_NODES (dim non-matches)
).map(n => n.id)) const matchIds = FILTERED_NODES === null ? null : FILTERED_NODES;
: null;
// Get descendants of selected node for highlighting
const descendantIds = selectedId ? getDescendants(selectedId) : new Set();
const vn = visNodes(); const vn = visNodes();
@@ -338,6 +383,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const p1 = pos[edge.from], p2 = pos[edge.to]; const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue; if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to); 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 tp1 = transformPos(p1);
const tp2 = transformPos(p2); const tp2 = transformPos(p2);
@@ -345,8 +397,8 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
let x1, y1, x2, y2, cx, cy; let x1, y1, x2, y2, cx, cy;
if (layoutMode === 'horizontal') { if (layoutMode === 'horizontal') {
// Horizontal: edges go from bottom of parent to top of child // Horizontal: edges go from bottom of parent to top of child
x1 = tp1.x; y1 = tp1.y + NODE_H / 2; x1 = tp1.x; y1 = tp1.y + h1 / 2;
x2 = tp2.x; y2 = tp2.y - NODE_H / 2; x2 = tp2.x; y2 = tp2.y - h2 / 2;
cy = (y1 + y2) / 2; cy = (y1 + y2) / 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x1, y1); ctx.moveTo(x1, y1);
@@ -361,8 +413,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2); ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
} }
ctx.strokeStyle = dimmed ? UI_COLORS.edgeDimmed : UI_COLORS.edge; if (isHighlighted) {
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
ctx.lineWidth = 2.5;
} else if (dimmed) {
ctx.strokeStyle = UI_COLORS.edgeDimmed;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = UI_COLORS.edge;
ctx.lineWidth = 1.5;
}
ctx.stroke(); ctx.stroke();
} }
@@ -372,17 +432,19 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
if (!p) continue; if (!p) continue;
const tp = transformPos(p); const tp = transformPos(p);
const isSel = node.id === selectedId; const isSel = node.id === selectedId;
const isDesc = descendantIds.has(node.id);
const isMatch = matchIds !== null && matchIds.has(node.id); const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = 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(); ctx.restore();
} }
function drawNode(node, cx, cy, isSel, isMatch, isDim, zoomLevel) { function drawNode(node, cx, cy, isSel, isDesc, isMatch, isDim, zoomLevel) {
// Nodes always keep same dimensions and horizontal text // Nodes have dynamic height (with or without description)
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 x = cx - hw, y = cy - hh;
const color = KIND_COLOR[node.kind] || '#334155'; const color = KIND_COLOR[node.kind] || '#334155';
@@ -393,7 +455,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Background // Background
ctx.beginPath(); 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.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg;
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
@@ -401,16 +463,17 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Left color strip (always on left, regardless of mode) // Left color strip (always on left, regardless of mode)
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.clip(); ctx.clip();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(x, y, 4, NODE_H); ctx.fillRect(x, y, 4, nodeH);
ctx.restore(); ctx.restore();
// Border // Border
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
if (isSel) { if (isSel || isDesc) {
// Selected node or descendant: orange border
ctx.strokeStyle = UI_COLORS.nodeBorderSel; ctx.strokeStyle = UI_COLORS.nodeBorderSel;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else if (isMatch) { } else if (isMatch) {
@@ -432,7 +495,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const badgeW = Math.min(rawW + 8, 66 * zoomLevel); const badgeW = Math.min(rawW + 8, 66 * zoomLevel);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8; const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel; 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.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel); ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel);
ctx.fillStyle = `${color}22`; 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.fillText(kLabel, Math.round(badgeX + badgeW / 2), Math.round(badgeY + 7 * zoomLevel));
ctx.restore(); 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; const labelFontSize = 12 * zoomLevel;
ctx.save(); ctx.save();
ctx.scale(1 / zoomLevel, 1 / zoomLevel); ctx.scale(1 / zoomLevel, 1 / zoomLevel);
@@ -459,7 +522,19 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
let label = node.label; let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1); while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…'; 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(); ctx.restore();
// Chevron toggle (same position in both modes) // Chevron toggle (same position in both modes)
@@ -499,16 +574,26 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const vn = visNodes(); const vn = visNodes();
if (vn.length === 0) return; if (vn.length === 0) return;
// Get transformed positions // Calculate bounds using dynamic node heights
const tps = vn.map(n => pos[n.id] ? transformPos(pos[n.id]) : null).filter(p => p !== null); let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
if (tps.length === 0) return; 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); const scale = Math.min(logicalWidth / (maxX - minX), logicalHeight / (maxY - minY), FIT_MAX_SCALE);
transform.scale = scale; transform.scale = scale;
transform.x = (logicalWidth - (minX + maxX) * scale) / 2; transform.x = (logicalWidth - (minX + maxX) * scale) / 2;
@@ -543,12 +628,30 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const p = pos[n.id]; const p = pos[n.id];
if (!p) continue; if (!p) continue;
const tp = transformPos(p); const tp = transformPos(p);
const nodeH = getNodeHeight(n);
// Nodes keep same dimensions in both modes // Nodes have dynamic height based on description
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= NODE_H / 2) { if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= nodeH / 2) {
// Toggle zone always on the right side of node 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; 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; return null;
@@ -564,7 +667,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
return; return;
} }
htmx.ajax('POST', handler.url, { 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', target: handler.target || 'body',
swap: handler.swap || 'none' swap: handler.swap || 'none'
}); });
@@ -643,11 +746,25 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top); const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit) { if (hit) {
if (hit.isToggle) { 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 // Toggle collapse
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id); if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
else collapsed.add(hit.node.id); else collapsed.add(hit.node.id);
recomputeLayout(); 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 // Post toggle_node event
postEvent('toggle_node', { postEvent('toggle_node', {
node_id: hit.node.id, node_id: hit.node.id,
@@ -658,6 +775,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
if (selectedId && !visNodes().find(n => n.id === selectedId)) { if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null; 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 { } else {
selectedId = hit.node.id; selectedId = hit.node.id;

View File

@@ -14,12 +14,12 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.DslEditor import DslEditorConf from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk, column_type_defaults from myfasthtml.controls.helpers import mk, column_type_defaults
@@ -142,7 +142,7 @@ class Commands(BaseCommands):
return Command("Filter", return Command("Filter",
"Filter Grid", "Filter Grid",
self._owner, self._owner,
self._owner.filter self._owner.filter,
) )
def change_selection_mode(self): 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("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right")) self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
# add DataGridQuery # add Query
self._datagrid_filter = DataGridQuery(self) self._datagrid_filter = Query(self)
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter()) self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter()) self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
self._datagrid_filter.bind_command("ChangeFilterType", 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(): for col_id, values in self._state.filtered.items():
if col_id == FILTER_INPUT_CID: if col_id == FILTER_INPUT_CID:
if values is not None: 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] 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)] df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
else: else:
@@ -632,6 +632,9 @@ class DataGrid(MultipleInstance):
def get_state(self): def get_state(self):
return self._state return self._state
def get_description(self) -> str:
return self.get_table_name()
def get_settings(self): def get_settings(self):
return self._settings return self._settings

View File

@@ -7,6 +7,7 @@ from fasthtml.components import Div
from fasthtml.xtend import Script from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Query import Query, QueryConf
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -47,6 +48,11 @@ class HierarchicalCanvasGraphState(DbObject):
# Persisted: layout orientation ('horizontal' or 'vertical') # Persisted: layout orientation ('horizontal' or 'vertical')
self.layout_mode: str = 'horizontal' 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) # Not persisted: current selection (ephemeral)
self.ns_selected_id: Optional[str] = None self.ns_selected_id: Optional[str] = None
@@ -66,6 +72,19 @@ class Commands(BaseCommands):
self._owner._handle_update_view_state self._owner._handle_update_view_state
).htmx(target=f"#{self._id}", swap='none') ).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): class HierarchicalCanvasGraph(MultipleInstance):
"""A canvas-based hierarchical graph visualization control. """A canvas-based hierarchical graph visualization control.
@@ -100,6 +119,11 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state = HierarchicalCanvasGraphState(self) self._state = HierarchicalCanvasGraphState(self)
self.commands = Commands(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}, " logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}") f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
@@ -148,25 +172,102 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state.collapsed = list(collapsed_set) self._state.collapsed = list(collapsed_set)
return self 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. """Internal handler to update view state from client.
Args: 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: Returns:
str: Empty string (no UI update needed) str: Empty string (no UI update needed)
""" """
if 'transform' in event_data: if transform is not None:
self._state.transform = event_data['transform'] self._state.transform = transform
logger.debug(f"Transform updated: {self._state.transform}") logger.debug(f"Transform updated: {self._state.transform}")
if 'layout_mode' in event_data: if layout_mode is not None:
self._state.layout_mode = event_data['layout_mode'] self._state.layout_mode = layout_mode
logger.debug(f"Layout mode updated: {self._state.layout_mode}") logger.debug(f"Layout mode updated: {self._state.layout_mode}")
return "" 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: def _prepare_options(self) -> dict:
"""Prepare JavaScript options object. """Prepare JavaScript options object.
@@ -179,17 +280,25 @@ class HierarchicalCanvasGraph(MultipleInstance):
# Add internal handler for view state persistence # Add internal handler for view state persistence
events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options() 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 # Add user-provided event handlers
if self.conf.events_handlers: if self.conf.events_handlers:
for event_name, command in self.conf.events_handlers.items(): for event_name, command in self.conf.events_handlers.items():
events[event_name] = command.ajax_htmx_options() events[event_name] = command.ajax_htmx_options()
# Calculate filtered nodes
filtered_nodes = self._calculate_filtered_nodes()
return { return {
"nodes": self.conf.nodes, "nodes": self.conf.nodes,
"edges": self.conf.edges, "edges": self.conf.edges,
"collapsed": self._state.collapsed, "collapsed": self._state.collapsed,
"transform": self._state.transform, "transform": self._state.transform,
"layout_mode": self._state.layout_mode, "layout_mode": self._state.layout_mode,
"filtered_nodes": filtered_nodes,
"events": events "events": events
} }
@@ -203,6 +312,9 @@ class HierarchicalCanvasGraph(MultipleInstance):
options_json = json.dumps(options, indent=2) options_json = json.dumps(options, indent=2)
return Div( return Div(
# Query filter bar
self._query,
# Canvas element (sized by JS to fill container) # Canvas element (sized by JS to fill container)
Div( Div(
id=f"{self._id}_container", id=f"{self._id}_container",

View File

@@ -109,7 +109,8 @@ class InstancesDebugger(SingleInstance):
"id": node_id, "id": node_id,
"label": instance.get_id(), "label": instance.get_id(),
"kind": self._get_instance_kind(instance), "kind": self._get_instance_kind(instance),
"type": instance.__class__.__name__ "type": instance.__class__.__name__,
"description": instance.get_description()
}) })
# Track nodes with parents # Track nodes with parents

View File

@@ -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()

View File

@@ -77,7 +77,8 @@ class Command:
return key return key
def __init__(self, name, def __init__(self,
name,
description, description,
owner=None, owner=None,
callback=None, callback=None,
@@ -170,7 +171,10 @@ class Command:
# Set the hx-swap-oob attribute on all elements returned by the callback # Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]: 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') if (hasattr(r, 'attrs')
and "hx-swap-oob" not in r.attrs and "hx-swap-oob" not in r.attrs
and r.get("id", None) is not None): and r.get("id", None) is not None):

View File

@@ -106,6 +106,9 @@ class BaseInstance:
def get_full_id(self) -> str: def get_full_id(self) -> str:
return f"{InstancesManager.get_session_id(self._session)}#{self._id}" return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
def get_description(self) -> str:
pass
def get_parent_full_id(self) -> Optional[str]: def get_parent_full_id(self) -> Optional[str]:
parent = self.get_parent() parent = self.get_parent()
return parent.get_full_id() if parent else None 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: Command name or Command instance to bind to
command_to_bind: Command to execute when the main command is triggered command_to_bind: Command to execute when the main command is triggered
when: "before" or "after" - when to execute the bound command (default: "after") 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 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) bound = BoundCommand(command=command_to_bind, when=when)
self._bound_commands.setdefault(command_name, []).append(bound) self._bound_commands.setdefault(command_name, []).append(bound)