From fb879d09ea9d4990bd382a2347d410a71a8742f6 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 15 Jan 2026 22:36:13 -0400 Subject: [PATCH] Improve flow view UX and fix step naming - Remove Fit All button, F key shortcut is sufficient - Add right-click context menu with "Fit All (F)" option - Add floating help banner that appears on focus and auto-hides - Banner stays visible when mouse hovers over it - Fix default step naming to increment per type (Code 1, Prompt 1, Code 2) instead of globally (Code 1, Prompt 2, Code 3) - List view still shows overall step number (1, 2, 3) with per-type names Co-Authored-By: Claude Opus 4.5 --- src/cmdforge/gui/pages/tool_builder_page.py | 12 +- src/cmdforge/gui/widgets/flow_graph.py | 151 +++++++++++++------- 2 files changed, 105 insertions(+), 58 deletions(-) diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index 104699d..188374c 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -307,13 +307,19 @@ class ToolBuilderPage(QWidget): """Refresh steps list and flow view.""" self.steps_list.clear() if self._tool and self._tool.steps: + # Count steps by type for default naming + prompt_count = 0 + code_count = 0 + for i, step in enumerate(self._tool.steps, 1): if isinstance(step, PromptStep): - # Use custom name if set, otherwise default format - step_name = step.name if step.name else f"Prompt {i}" + prompt_count += 1 + # Use custom name if set, otherwise default per-type naming + step_name = step.name if step.name else f"Prompt {prompt_count}" text = f"{i}. {step_name} [{step.provider}] → ${step.output_var}" elif isinstance(step, CodeStep): - step_name = step.name if step.name else f"Code {i}" + code_count += 1 + step_name = step.name if step.name else f"Code {code_count}" text = f"{i}. {step_name} [python] → ${step.output_var}" else: text = f"{i}. Unknown step" diff --git a/src/cmdforge/gui/widgets/flow_graph.py b/src/cmdforge/gui/widgets/flow_graph.py index 957116a..b5f98d0 100644 --- a/src/cmdforge/gui/widgets/flow_graph.py +++ b/src/cmdforge/gui/widgets/flow_graph.py @@ -2,9 +2,9 @@ from typing import Optional, List -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton -from PySide6.QtCore import Signal, Qt, QTimer, QEvent -from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QMenu +from PySide6.QtCore import Signal, Qt, QTimer, QEvent, QPropertyAnimation, QEasingCurve +from PySide6.QtGui import QKeyEvent, QAction from NodeGraphQt import NodeGraph, BaseNode @@ -138,52 +138,6 @@ class FlowGraphWidget(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Navigation help bar - help_bar = QWidget() - help_bar.setStyleSheet(""" - QWidget { - background-color: #edf2f7; - border-bottom: 1px solid #e2e8f0; - } - QLabel { - color: #718096; - font-size: 11px; - padding: 4px 8px; - } - """) - help_layout = QHBoxLayout(help_bar) - help_layout.setContentsMargins(8, 2, 8, 2) - - help_text = QLabel( - "Pan: Middle-click drag | " - "Zoom: Scroll wheel | " - "Select: Click or drag box | " - "Edit step: Double-click node" - ) - help_layout.addWidget(help_text) - help_layout.addStretch() - - # Fit view button - self.btn_fit = QPushButton("Fit All (F)") - self.btn_fit.setStyleSheet(""" - QPushButton { - background-color: #667eea; - color: white; - border: none; - border-radius: 4px; - padding: 4px 12px; - font-size: 11px; - font-weight: 500; - } - QPushButton:hover { - background-color: #5a67d8; - } - """) - self.btn_fit.clicked.connect(self.fit_all_nodes) - help_layout.addWidget(self.btn_fit) - - layout.addWidget(help_bar) - # Create node graph self._graph = NodeGraph() @@ -199,9 +153,49 @@ class FlowGraphWidget(QWidget): # Add graph widget layout.addWidget(self._graph.widget, 1) - # Install event filter to catch F key on the graph widget + # Install event filter to catch F key and focus events on the graph widget self._graph.widget.installEventFilter(self) + # Create floating help banner (overlay on graph widget) + self._help_banner = QLabel(self._graph.widget) + self._help_banner.setText( + "Pan: Middle-click drag | " + "Zoom: Scroll wheel | " + "Select: Click or drag box | " + "Edit: Double-click | " + "Fit All: F" + ) + self._help_banner.setStyleSheet(""" + QLabel { + background-color: rgba(45, 55, 72, 0.9); + color: #e2e8f0; + font-size: 11px; + padding: 8px 16px; + border-radius: 4px; + } + """) + self._help_banner.adjustSize() + self._help_banner.hide() + + # Animation for fading + self._fade_animation = QPropertyAnimation(self._help_banner, b"windowOpacity") + self._fade_animation.setDuration(500) + self._fade_animation.setEasingCurve(QEasingCurve.InOutQuad) + + # Timer for auto-hide + self._hide_timer = QTimer(self) + self._hide_timer.setSingleShot(True) + self._hide_timer.timeout.connect(self._fade_out_banner) + + # Track if mouse is over banner + self._help_banner.setMouseTracking(True) + self._help_banner.enterEvent = self._on_banner_enter + self._help_banner.leaveEvent = self._on_banner_leave + + # Set up context menu + self._graph.widget.setContextMenuPolicy(Qt.CustomContextMenu) + self._graph.widget.customContextMenuRequested.connect(self._show_context_menu) + def set_tool(self, tool: Optional[Tool]): """Set the tool to visualize.""" self._tool = tool @@ -234,10 +228,15 @@ class FlowGraphWidget(QWidget): x_pos = 0 prev_node = self._input_node + # Count steps by type for default naming + prompt_count = 0 + code_count = 0 + for i, step in enumerate(self._tool.steps or []): if isinstance(step, PromptStep): - # Use custom name if set, otherwise default to "Prompt N" - node_name = step.name if step.name else f'Prompt {i+1}' + prompt_count += 1 + # Use custom name if set, otherwise default to "Prompt N" (per type) + node_name = step.name if step.name else f'Prompt {prompt_count}' node = self._graph.create_node( 'cmdforge.PromptNode', name=node_name, @@ -245,8 +244,9 @@ class FlowGraphWidget(QWidget): ) node.set_step(step, i) elif isinstance(step, CodeStep): - # Use custom name if set, otherwise default to "Code N" - node_name = step.name if step.name else f'Code {i+1}' + code_count += 1 + # Use custom name if set, otherwise default to "Code N" (per type) + node_name = step.name if step.name else f'Code {code_count}' node = self._graph.create_node( 'cmdforge.CodeNode', name=node_name, @@ -318,12 +318,53 @@ class FlowGraphWidget(QWidget): super().keyPressEvent(event) def eventFilter(self, obj, event): - """Filter events from the graph widget to catch F key.""" + """Filter events from the graph widget to catch F key and focus.""" if event.type() == QEvent.KeyPress and event.key() == Qt.Key_F: self.fit_all_nodes() return True # Event handled + elif event.type() == QEvent.FocusIn: + self._show_help_banner() return super().eventFilter(obj, event) + def _show_help_banner(self): + """Show the help banner with fade effect.""" + if not self._help_banner: + return + + # Position at top center of the graph widget + parent_width = self._graph.widget.width() + banner_width = self._help_banner.width() + x = (parent_width - banner_width) // 2 + self._help_banner.move(x, 10) + + # Show and start auto-hide timer + self._help_banner.show() + self._hide_timer.start(3000) # Hide after 3 seconds + + def _fade_out_banner(self): + """Fade out the help banner.""" + if self._help_banner and self._help_banner.isVisible(): + # Use a simple hide with timer since windowOpacity doesn't work well on child widgets + self._help_banner.hide() + + def _on_banner_enter(self, event): + """Mouse entered banner - stop hide timer.""" + self._hide_timer.stop() + + def _on_banner_leave(self, event): + """Mouse left banner - restart hide timer.""" + self._hide_timer.start(1500) # Hide 1.5 seconds after mouse leaves + + def _show_context_menu(self, pos): + """Show context menu.""" + menu = QMenu(self._graph.widget) + + fit_action = QAction("Fit All (F)", menu) + fit_action.triggered.connect(self.fit_all_nodes) + menu.addAction(fit_action) + + menu.exec_(self._graph.widget.mapToGlobal(pos)) + def _on_node_double_clicked(self, node): """Handle node double-click.""" if hasattr(node, '_step_index') and node._step_index >= 0: