Add step type icons and inline name editing
- Add icons module with programmatically drawn icons: - Speech bubble for prompt steps - Code brackets </> for code steps - Arrow icons for input/output nodes - Display icons in list view next to step names - Display icons in flow view node headers - Add inline name editing in flow view via property_changed signal - Sync name changes from flow view to tool model and list view Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc970fb9f7
commit
8e6b03cade
|
|
@ -13,6 +13,7 @@ from ...tool import (
|
||||||
Tool, ToolArgument, PromptStep, CodeStep,
|
Tool, ToolArgument, PromptStep, CodeStep,
|
||||||
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
||||||
)
|
)
|
||||||
|
from ..widgets.icons import get_prompt_icon, get_code_icon
|
||||||
|
|
||||||
|
|
||||||
class ToolBuilderPage(QWidget):
|
class ToolBuilderPage(QWidget):
|
||||||
|
|
@ -242,6 +243,7 @@ class ToolBuilderPage(QWidget):
|
||||||
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
|
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
|
||||||
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
|
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
|
||||||
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
|
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
|
||||||
|
self._flow_widget.step_name_changed.connect(self._on_flow_step_name_changed)
|
||||||
|
|
||||||
# Replace placeholder
|
# Replace placeholder
|
||||||
old_widget = self.steps_stack.widget(1)
|
old_widget = self.steps_stack.widget(1)
|
||||||
|
|
@ -283,6 +285,17 @@ class ToolBuilderPage(QWidget):
|
||||||
# Refresh the list view (flow view will be refreshed by set_tool)
|
# Refresh the list view (flow view will be refreshed by set_tool)
|
||||||
self._refresh_steps()
|
self._refresh_steps()
|
||||||
|
|
||||||
|
def _on_flow_step_name_changed(self, step_index: int, new_name: str):
|
||||||
|
"""Handle step name change from flow view inline editing."""
|
||||||
|
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
||||||
|
return
|
||||||
|
|
||||||
|
step = self._tool.steps[step_index]
|
||||||
|
step.name = new_name
|
||||||
|
|
||||||
|
# Refresh list view to show updated name
|
||||||
|
self._refresh_steps_list_only()
|
||||||
|
|
||||||
def _on_flow_steps_reordered(self, new_order: list):
|
def _on_flow_steps_reordered(self, new_order: list):
|
||||||
"""Handle step reordering from flow view.
|
"""Handle step reordering from flow view.
|
||||||
|
|
||||||
|
|
@ -371,15 +384,20 @@ class ToolBuilderPage(QWidget):
|
||||||
prompt_count += 1
|
prompt_count += 1
|
||||||
# Use custom name if set, otherwise default per-type naming
|
# Use custom name if set, otherwise default per-type naming
|
||||||
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||||
text = f"{i}. {step_name} [{step.provider}] → ${step.output_var}"
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||||
|
icon = get_prompt_icon(20)
|
||||||
elif isinstance(step, CodeStep):
|
elif isinstance(step, CodeStep):
|
||||||
code_count += 1
|
code_count += 1
|
||||||
step_name = step.name if step.name else f"Code {code_count}"
|
step_name = step.name if step.name else f"Code {code_count}"
|
||||||
text = f"{i}. {step_name} [python] → ${step.output_var}"
|
text = f"{step_name} [python] → ${step.output_var}"
|
||||||
|
icon = get_code_icon(20)
|
||||||
else:
|
else:
|
||||||
text = f"{i}. Unknown step"
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
item = QListWidgetItem(text)
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
item.setData(Qt.UserRole, step)
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
# Check for broken dependencies
|
# Check for broken dependencies
|
||||||
|
|
@ -401,6 +419,59 @@ class ToolBuilderPage(QWidget):
|
||||||
if self._flow_widget:
|
if self._flow_widget:
|
||||||
self._flow_widget.set_tool(self._tool)
|
self._flow_widget.set_tool(self._tool)
|
||||||
|
|
||||||
|
def _refresh_steps_list_only(self):
|
||||||
|
"""Refresh only the steps list (not flow view).
|
||||||
|
|
||||||
|
Used when a change originated from the flow view to avoid
|
||||||
|
rebuilding the graph unnecessarily.
|
||||||
|
"""
|
||||||
|
self.steps_list.clear()
|
||||||
|
if self._tool and self._tool.steps:
|
||||||
|
# Count steps by type for default naming
|
||||||
|
prompt_count = 0
|
||||||
|
code_count = 0
|
||||||
|
|
||||||
|
# Track available variables for dependency checking
|
||||||
|
available_vars = {"input"}
|
||||||
|
if self._tool.arguments:
|
||||||
|
for arg in self._tool.arguments:
|
||||||
|
if arg.variable:
|
||||||
|
available_vars.add(arg.variable)
|
||||||
|
|
||||||
|
for i, step in enumerate(self._tool.steps, 1):
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
prompt_count += 1
|
||||||
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||||
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||||
|
icon = get_prompt_icon(20)
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
code_count += 1
|
||||||
|
step_name = step.name if step.name else f"Code {code_count}"
|
||||||
|
text = f"{step_name} [python] → ${step.output_var}"
|
||||||
|
icon = get_code_icon(20)
|
||||||
|
else:
|
||||||
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
|
# Check for broken dependencies
|
||||||
|
refs = self._get_step_variable_refs(step)
|
||||||
|
broken_refs = [r for r in refs if r not in available_vars]
|
||||||
|
if broken_refs:
|
||||||
|
item.setForeground(Qt.red)
|
||||||
|
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
||||||
|
|
||||||
|
self.steps_list.addItem(item)
|
||||||
|
|
||||||
|
# Add this step's output to available vars for next iteration
|
||||||
|
if hasattr(step, 'output_var') and step.output_var:
|
||||||
|
for var in step.output_var.split(','):
|
||||||
|
available_vars.add(var.strip())
|
||||||
|
|
||||||
def _add_argument(self):
|
def _add_argument(self):
|
||||||
"""Add a new argument."""
|
"""Add a new argument."""
|
||||||
from ..dialogs.argument_dialog import ArgumentDialog
|
from ..dialogs.argument_dialog import ArgumentDialog
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ from PySide6.QtGui import QKeyEvent, QAction
|
||||||
from NodeGraphQt import NodeGraph, BaseNode
|
from NodeGraphQt import NodeGraph, BaseNode
|
||||||
|
|
||||||
from ...tool import Tool, PromptStep, CodeStep
|
from ...tool import Tool, PromptStep, CodeStep
|
||||||
|
from .icons import (
|
||||||
|
get_prompt_icon_path, get_code_icon_path,
|
||||||
|
get_input_icon_path, get_output_icon_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -35,6 +39,7 @@ class InputNode(CmdForgeBaseNode):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(90, 90, 160) # Indigo
|
self.set_color(90, 90, 160) # Indigo
|
||||||
|
self.set_icon(get_input_icon_path(16))
|
||||||
# Default output for stdin
|
# Default output for stdin
|
||||||
self.add_output('input', color=(180, 180, 250))
|
self.add_output('input', color=(180, 180, 250))
|
||||||
|
|
||||||
|
|
@ -56,7 +61,8 @@ class PromptNode(CmdForgeBaseNode):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
|
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
|
||||||
self.add_input('in', color=(180, 180, 250), multi_input=True)
|
self.set_icon(get_prompt_icon_path(16))
|
||||||
|
self.add_input('in', color=(180, 180, 250))
|
||||||
self.add_output('out', color=(180, 250, 180))
|
self.add_output('out', color=(180, 250, 180))
|
||||||
|
|
||||||
# Add properties for display
|
# Add properties for display
|
||||||
|
|
@ -82,7 +88,8 @@ class CodeNode(CmdForgeBaseNode):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(72, 187, 120) # Green
|
self.set_color(72, 187, 120) # Green
|
||||||
self.add_input('in', color=(180, 250, 180), multi_input=True)
|
self.set_icon(get_code_icon_path(16))
|
||||||
|
self.add_input('in', color=(180, 250, 180))
|
||||||
self.add_output('out', color=(250, 220, 180))
|
self.add_output('out', color=(250, 220, 180))
|
||||||
|
|
||||||
# Add properties
|
# Add properties
|
||||||
|
|
@ -106,7 +113,8 @@ class OutputNode(CmdForgeBaseNode):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(237, 137, 54) # Orange
|
self.set_color(237, 137, 54) # Orange
|
||||||
self.add_input('in', color=(250, 220, 180), multi_input=True)
|
self.set_icon(get_output_icon_path(16))
|
||||||
|
self.add_input('in', color=(250, 220, 180))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -114,7 +122,12 @@ class OutputNode(CmdForgeBaseNode):
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class FlowGraphWidget(QWidget):
|
class FlowGraphWidget(QWidget):
|
||||||
"""Widget for visualizing tool flow as a node graph."""
|
"""Widget for visualizing tool flow as a node graph.
|
||||||
|
|
||||||
|
Note: Currently the flow view is read-only for connections. Reordering
|
||||||
|
steps by manipulating connections is a future enhancement (TODO).
|
||||||
|
Use the list view for reordering steps via drag-drop.
|
||||||
|
"""
|
||||||
|
|
||||||
# Emitted when a node is double-clicked (step_index, step_type)
|
# Emitted when a node is double-clicked (step_index, step_type)
|
||||||
node_double_clicked = Signal(int, str)
|
node_double_clicked = Signal(int, str)
|
||||||
|
|
@ -128,6 +141,9 @@ class FlowGraphWidget(QWidget):
|
||||||
# Emitted when steps are reordered (new order as list of step indices)
|
# Emitted when steps are reordered (new order as list of step indices)
|
||||||
steps_reordered = Signal(list)
|
steps_reordered = Signal(list)
|
||||||
|
|
||||||
|
# Emitted when a step name is changed (step_index, new_name)
|
||||||
|
step_name_changed = Signal(int, str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._tool: Optional[Tool] = None
|
self._tool: Optional[Tool] = None
|
||||||
|
|
@ -157,7 +173,7 @@ class FlowGraphWidget(QWidget):
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
||||||
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
|
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
|
||||||
self._graph.port_disconnected.connect(self._on_port_disconnected)
|
self._graph.property_changed.connect(self._on_property_changed)
|
||||||
|
|
||||||
# Add graph widget
|
# Add graph widget
|
||||||
layout.addWidget(self._graph.widget, 1)
|
layout.addWidget(self._graph.widget, 1)
|
||||||
|
|
@ -172,7 +188,6 @@ class FlowGraphWidget(QWidget):
|
||||||
"Zoom: Scroll | "
|
"Zoom: Scroll | "
|
||||||
"Select: Click/drag | "
|
"Select: Click/drag | "
|
||||||
"Edit: Double-click | "
|
"Edit: Double-click | "
|
||||||
"Cut links: Alt+Shift+drag | "
|
|
||||||
"Select All: A | "
|
"Select All: A | "
|
||||||
"Fit: F"
|
"Fit: F"
|
||||||
)
|
)
|
||||||
|
|
@ -449,59 +464,23 @@ class FlowGraphWidget(QWidget):
|
||||||
self.steps_deleted.emit(deleted_indices)
|
self.steps_deleted.emit(deleted_indices)
|
||||||
self.flow_changed.emit()
|
self.flow_changed.emit()
|
||||||
|
|
||||||
def _on_port_disconnected(self, input_port, output_port):
|
def _on_property_changed(self, node, prop_name, value):
|
||||||
"""Handle port disconnection - reorder steps.
|
"""Handle node property change.
|
||||||
|
|
||||||
When a connection is broken:
|
Filters for 'name' property to detect step name changes.
|
||||||
1. The node that lost its input moves to the end
|
|
||||||
2. The chain reconnects (previous node → next node)
|
|
||||||
3. Disconnected node attaches at the end before Output
|
|
||||||
"""
|
"""
|
||||||
# Ignore during rebuild
|
# Ignore during rebuild
|
||||||
if self._rebuilding:
|
if self._rebuilding:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._tool or not self._tool.steps:
|
# Only interested in name changes
|
||||||
|
if prop_name != 'name':
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the nodes involved
|
|
||||||
# input_port belongs to the node that lost its input
|
|
||||||
# output_port belongs to the node that was providing the input
|
|
||||||
disconnected_node = input_port.node()
|
|
||||||
source_node = output_port.node()
|
|
||||||
|
|
||||||
# Only handle step nodes (not Input/Output)
|
# Only handle step nodes (not Input/Output)
|
||||||
if not hasattr(disconnected_node, '_step_index') or disconnected_node._step_index < 0:
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
return
|
self.step_name_changed.emit(node._step_index, value)
|
||||||
|
self.flow_changed.emit()
|
||||||
disconnected_idx = disconnected_node._step_index
|
|
||||||
|
|
||||||
# Find the node that the disconnected node was connected to (its output target)
|
|
||||||
next_node = None
|
|
||||||
if disconnected_node.output_ports():
|
|
||||||
out_port = disconnected_node.output_ports()[0]
|
|
||||||
connected_ports = out_port.connected_ports()
|
|
||||||
if connected_ports:
|
|
||||||
next_node = connected_ports[0].node()
|
|
||||||
|
|
||||||
# Build new order: move disconnected step to end
|
|
||||||
old_order = list(range(len(self._tool.steps)))
|
|
||||||
old_order.remove(disconnected_idx)
|
|
||||||
old_order.append(disconnected_idx)
|
|
||||||
|
|
||||||
# Reconnect the graph visually
|
|
||||||
# Connect source to next (if next is a step node)
|
|
||||||
if next_node and hasattr(next_node, '_step_index') and next_node._step_index >= 0:
|
|
||||||
if source_node.output_ports() and next_node.input_ports():
|
|
||||||
source_node.output_ports()[0].connect_to(next_node.input_ports()[0])
|
|
||||||
|
|
||||||
# Connect the last step (before disconnected) to disconnected node
|
|
||||||
# and disconnected node to output
|
|
||||||
# This will be handled by the rebuild after reorder
|
|
||||||
|
|
||||||
# Emit the new order
|
|
||||||
self.steps_reordered.emit(old_order)
|
|
||||||
self.flow_changed.emit()
|
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
"""Refresh the graph from current tool data."""
|
"""Refresh the graph from current tool data."""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""Step type icons for CmdForge GUI."""
|
||||||
|
|
||||||
|
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QPen, QBrush, QPainterPath, QFont
|
||||||
|
from PySide6.QtCore import Qt, QRect, QRectF
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create a speech bubble icon for prompt steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to indigo #667eea)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(102, 126, 234) # Indigo
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
# Draw speech bubble
|
||||||
|
pen = QPen(color, 1.5)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
|
||||||
|
# Bubble body
|
||||||
|
margin = size * 0.1
|
||||||
|
bubble_rect = QRectF(margin, margin, size - 2 * margin, size * 0.65)
|
||||||
|
painter.drawRoundedRect(bubble_rect, 4, 4)
|
||||||
|
|
||||||
|
# Bubble tail (triangle pointing down-left)
|
||||||
|
path = QPainterPath()
|
||||||
|
tail_x = margin + size * 0.2
|
||||||
|
tail_y = bubble_rect.bottom()
|
||||||
|
path.moveTo(tail_x, tail_y - 2)
|
||||||
|
path.lineTo(tail_x - size * 0.1, tail_y + size * 0.2)
|
||||||
|
path.lineTo(tail_x + size * 0.15, tail_y - 2)
|
||||||
|
path.closeSubpath()
|
||||||
|
painter.drawPath(path)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_code_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create a code brackets icon for code steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to green #48bb78)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(72, 187, 120) # Green
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
# Draw < > brackets
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
bracket_height = size * 0.5
|
||||||
|
|
||||||
|
# Left bracket <
|
||||||
|
left_x = margin + size * 0.1
|
||||||
|
painter.drawLine(
|
||||||
|
int(left_x + size * 0.2), int(mid_y - bracket_height / 2),
|
||||||
|
int(left_x), int(mid_y)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(left_x), int(mid_y),
|
||||||
|
int(left_x + size * 0.2), int(mid_y + bracket_height / 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right bracket >
|
||||||
|
right_x = size - margin - size * 0.1
|
||||||
|
painter.drawLine(
|
||||||
|
int(right_x - size * 0.2), int(mid_y - bracket_height / 2),
|
||||||
|
int(right_x), int(mid_y)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(right_x), int(mid_y),
|
||||||
|
int(right_x - size * 0.2), int(mid_y + bracket_height / 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Slash in middle /
|
||||||
|
slash_width = size * 0.12
|
||||||
|
painter.drawLine(
|
||||||
|
int(size / 2 + slash_width), int(mid_y - bracket_height / 2.5),
|
||||||
|
int(size / 2 - slash_width), int(mid_y + bracket_height / 2.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_input_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create an input icon (arrow pointing right into box).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to indigo #5a5a9f)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(90, 90, 160) # Indigo
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
|
||||||
|
# Arrow pointing right
|
||||||
|
arrow_start = margin
|
||||||
|
arrow_end = size - margin - size * 0.15
|
||||||
|
|
||||||
|
# Arrow shaft
|
||||||
|
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||||
|
|
||||||
|
# Arrow head
|
||||||
|
head_size = size * 0.2
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y - head_size)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y + head_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_output_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create an output icon (arrow pointing right out of box).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to orange #ed8936)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(237, 137, 54) # Orange
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
|
||||||
|
# Arrow pointing right (export style)
|
||||||
|
arrow_start = margin + size * 0.15
|
||||||
|
arrow_end = size - margin
|
||||||
|
|
||||||
|
# Arrow shaft
|
||||||
|
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||||
|
|
||||||
|
# Arrow head
|
||||||
|
head_size = size * 0.2
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y - head_size)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y + head_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
# Cached icons for reuse
|
||||||
|
_icon_cache = {}
|
||||||
|
|
||||||
|
# Cached icon file paths for NodeGraphQt (which requires file paths)
|
||||||
|
_icon_path_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_icon_path(icon_type: str, size: int, create_func) -> str:
|
||||||
|
"""Get or create a cached icon file path.
|
||||||
|
|
||||||
|
NodeGraphQt requires file paths for icons, so we save to temp files.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
key = (icon_type, size)
|
||||||
|
if key not in _icon_path_cache:
|
||||||
|
icon = create_func(size)
|
||||||
|
pixmap = icon.pixmap(size, size)
|
||||||
|
|
||||||
|
# Save to temp directory with a stable name
|
||||||
|
temp_dir = os.path.join(tempfile.gettempdir(), 'cmdforge_icons')
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
path = os.path.join(temp_dir, f'{icon_type}_{size}.png')
|
||||||
|
pixmap.save(path, 'PNG')
|
||||||
|
_icon_path_cache[key] = path
|
||||||
|
|
||||||
|
return _icon_path_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to prompt icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('prompt', size, create_prompt_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_code_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to code icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('code', size, create_code_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to input icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('input', size, create_input_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to output icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('output', size, create_output_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached prompt icon."""
|
||||||
|
key = ('prompt', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_prompt_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_code_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached code icon."""
|
||||||
|
key = ('code', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_code_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached input icon."""
|
||||||
|
key = ('input', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_input_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached output icon."""
|
||||||
|
key = ('output', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_output_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
Loading…
Reference in New Issue