diff --git a/doc/NODE_RENDERING_UI_QA.md b/doc/NODE_RENDERING_UI_QA.md new file mode 100644 index 0000000..89e5204 --- /dev/null +++ b/doc/NODE_RENDERING_UI_QA.md @@ -0,0 +1,169 @@ +# Node Rendering and UI QA Review + +**Date:** 2026-07-15 +**Scope:** QGraphics node rendering, canvas navigation, connection rendering, and primary window UI +**Status:** Findings documented and proposed local patch implemented; not pushed + +## Executive summary + +The app has a coherent visual direction and the main node families render successfully, but the canvas still has several correctness and interaction problems that keep it below production/SOTA quality: + +- geometry change notifications are ordered incorrectly in two node families and in connections; +- long Code nodes visibly truncate content with no way to scroll; +- canvas navigation has incomplete coverage for node types and can desynchronize zoom state; +- connection and scene rendering are tightly coupled to a live view; +- repaint and connection-update work is broader than necessary during interaction. + +The highest-value fixes have now been implemented locally: geometry and connection updates are ordered safely, long-form canvas content is scrollable, zoom/Fit All behavior is centralized, and the main canvas render paths use shared typography settings. + +## Verification performed + +- `python -m pytest -q` from `graphlink_app/`: **400 passed**, one unrelated deprecation warning. +- `python -m compileall -q .`: passed. +- Targeted headless canvas regression suite: **7 passed**. +- Headless Qt scene smoke render with long Chat, Code, Document, and Thinking nodes: all constructed and painted without an exception. +- Headless main-window construction/render at 1200×800 and 800×600: passed. At the narrower size, Qt moves most toolbar actions into its overflow area. +- No model/API calls, file attachments, or GitHub operations were performed. + +## Local implementation status + +- Findings 1 and 2: fixed. Document, Thinking, and connection geometry now prepare before bounds/path mutation; pin movement updates paths after the position change. +- Findings 3 and 4: fixed. CodeNode has a bounded internal scrollbar and the view routes wheel events by scroll capability rather than a short concrete-type list. +- Findings 5 and 6: fixed. Fit All uses persistent visible scene items, and toolbar/keyboard/Ctrl-wheel zoom share `ChatView.zoom_by()` with bounds and LOD refresh. +- Finding 7: fixed for bounded preview rendering and invalid-image feedback. The existing image context menu remains the full-size/save path. +- Finding 8: fixed. Connection culling is skipped safely when no view is attached. +- Finding 9: addressed. The view uses minimal viewport updates, connection paths use no stale device cache, node moves use an endpoint index, and minimap scene-change notifications are coalesced per event-loop turn. +- Finding 10: addressed for canvas node headers and canvas items, including Code, Image, Note, Frame, Container, and plugin node headers through shared typography helpers. Embedded plugin widget styles remain intentionally scoped to their own controls. + +The patch is intentionally still local. GitHub commit/push/PR actions were not performed. + +The current automated suite is primarily headless/unit coverage; it does not validate real mouse-wheel routing, drag gestures, hover states, or screenshots on a Windows desktop. + +## Findings + +### 1. Geometry change notification happens after the geometry mutation + +**Severity:** High +**Locations:** `graphlink_nodes/graphlink_node_document.py:263-284`, `graphlink_nodes/graphlink_node_thinking.py:87-108` + +`DocumentNode` and `ThinkingNode` assign a new `height` and only then call `prepareGeometryChange()`. Qt requires `prepareGeometryChange()` before any value used by `boundingRect()` changes. The code also calls it twice in the Thinking path. + +**Impact:** After font changes or other in-scene recalculation, the scene's spatial index, hit testing, culling, and parent/container geometry can retain the old bounds. Symptoms can include clicks missing the visible node, stale selection regions, or content/connection repaint not matching the new size. + +**Proposal:** Introduce one geometry-update helper per node family that computes the new dimensions first, calls `prepareGeometryChange()` exactly once when the dimensions actually change, applies the values, then updates scrollbar geometry and connections. Add a regression test that changes font size while the node is already in a `QGraphicsScene` and verifies `scene.items()`/`itemAt()` against the new bounds. + +### 2. Connection paths mutate before `prepareGeometryChange()` + +**Severity:** High +**Location:** `graphlink_connections.py:484-489` + +`ConnectionItem.update_path()` assigns `self.path = new_path`, clears the hover cache, and calls `prepareGeometryChange()` afterward. + +**Impact:** The connection's `boundingRect()` and `shape()` can be indexed using stale geometry. This is especially risky while dragging nodes or pins, where the path changes repeatedly; hit targets, culling, and cached repaint regions can lag behind the visible line. + +**Proposal:** Call `prepareGeometryChange()` before replacing `self.path`, then invalidate `hover_path`, assign the new path, and update. Add drag/pin tests that assert the connection bounding rectangle and hit shape follow the new endpoints on every update. + +### 3. Code nodes silently truncate long content + +**Severity:** High +**Locations:** `graphlink_nodes/graphlink_node_code.py:73-78`, `graphlink_nodes/graphlink_node_code.py:151-156` + +The node caps itself at `MAX_HEIGHT = 800`, clips the `QTextDocument` to that rectangle, and has no scrollbar or paging control. The headless render reproduced this with a 30-line repeated code sample: the node reached height 800 and had no scroll control, while Chat, Document, and Thinking nodes exposed scrollbars for overflow. + +**Impact:** Generated or pasted code beyond the cap is not discoverable in the canvas. This is a correctness issue for a code workspace, not only a visual limitation. + +**Proposal:** Give CodeNode the same scroll model as the other long-form nodes, including a visible scrollbar, clamped scroll range, and wheel routing. Preserve the fixed maximum node height. Add a test that verifies content below the viewport becomes visible after scrolling. + +### 4. Wheel-event routing only recognizes Chat and Document nodes + +**Severity:** Medium +**Location:** `graphlink_view.py:614-623` + +`_scrollable_item_at()` stops only when it finds `ChatNode` or `DocumentNode`. `ThinkingNode` and `Note` both implement internal scrolling but are not recognized, so the view treats the wheel as canvas navigation instead of forwarding it to the item. + +**Impact:** Wheel behavior changes by node type. Users can scroll long Chat/Document content in place but accidentally pan away from long Thinking/Note content, making the internal scrollbar difficult or impossible to use naturally. + +**Proposal:** Replace the concrete type tuple with a small scrollable-item protocol/capability check (`scrollbar`, collapsed state, and a handled wheel event), then walk proxy/child items to the owning item. Add simulated wheel tests for Chat, Document, Thinking, and Note. + +### 5. “Fit All” ignores most visual item families + +**Severity:** Medium +**Location:** `graphlink_view.py:720-726` + +The early-return guard checks only `scene.nodes`, `scene.code_nodes`, and `scene.image_nodes`. It ignores Document, Thinking, Chart, plugin, Note, Frame, and Container lists. A headless check with a scene containing only a Document or only a Thinking node left the transform at 1.0 and did not fit the item. + +**Impact:** Fit All appears to do nothing for valid sessions composed of attachment/reasoning/plugin/canvas items without a Chat/Code/Image node. + +**Proposal:** Base the guard on a single scene-level visual-item query, with explicit filtering for transient/hidden items. Use the same query for `itemsBoundingRect()` and minimap population so fit, navigation, and overview cannot disagree. Add one test per representative family and a mixed-scene test. + +### 6. Toolbar zoom bypasses `_zoom_factor` + +**Severity:** Medium +**Locations:** `graphlink_window.py:640-641`, `graphlink_view.py:559-577`, `graphlink_view.py:632-640` + +The toolbar buttons call `chat_view.scale()` directly, while keyboard and Ctrl+wheel paths update `_zoom_factor` and enforce 0.1–4.0 limits. + +**Impact:** After using toolbar zoom, subsequent keyboard/Ctrl+wheel zoom starts from stale state. The effective transform can exceed the intended limits or stop earlier/later than expected, and the LOD refresh behavior becomes inconsistent. + +**Proposal:** Add one `zoom_by(factor, anchor)` method that clamps against the actual transform, updates `_zoom_factor`, and schedules LOD refresh. Route toolbar, keyboard, wheel, reset, and fit actions through it. Add boundary and mixed-input tests. + +### 7. Image node height is unbounded by source aspect ratio + +**Severity:** Medium +**Location:** `graphlink_nodes/graphlink_node_image.py:32-41` + +Image height is calculated directly from the decoded image aspect ratio. There is no maximum display height, thumbnail mode, or scroll/pan viewport. An unusually tall attachment can therefore create a very large node and make layout/fit behavior unusable; invalid image data produces a blank dark body without an explicit placeholder. + +**Proposal:** Render images through a bounded preview viewport with a maximum display dimension, preserve aspect ratio, and expose an “open/full size” action for large assets. Draw a visible invalid-image state when `QImage.fromData()` returns null. Add tests for panoramic, portrait, and corrupt bytes. + +### 8. Connection paint assumes at least one attached view + +**Severity:** Medium +**Location:** `graphlink_connections.py:618-620` + +`ConnectionItem.paint()` unconditionally reads `self.scene().views()[0]` for culling. Scene rendering, export, headless QA, and teardown can legitimately occur with no attached view. + +**Impact:** Rendering a scene without a view can raise `IndexError`; the connection is more fragile than other scene items and cannot be safely rendered in isolation. + +**Proposal:** Make culling optional: use the painter/widget viewport when available, otherwise skip view-based culling and paint the path. Apply the same defensive pattern to any view-dependent helper. Add a scene-to-image test with no `QGraphicsView` attached. + +### 9. Canvas repaint and connection updates are too broad during interaction + +**Severity:** Medium +**Locations:** `graphlink_view.py:35`, `graphlink_scene.py:470-500`, `graphlink_scene.py:888-924` + +The view uses `FullViewportUpdate`. Each node move walks every connection list, and `scene_changed` is emitted for every movement. The base connection also uses `DeviceCoordinateCache` while its path is mutated frequently. + +**Impact:** Dragging and large graphs will do unnecessary full-viewport work and can produce frame drops, stale cache invalidation, or high CPU usage as node/connection counts grow. + +**Proposal:** Batch drag updates with a short timer or drag lifecycle, update only connections indexed by endpoint, and emit one scene-change notification at drag end. Prefer Qt's minimal/bounding-rect viewport updates after validating correctness. Benchmark 50/250/500-node graphs with and without animated arrows before choosing the final cache mode. + +### 10. Font/theme controls do not cover the full visual system + +**Severity:** Low–Medium +**Locations:** `graphlink_scene.py:189-200`, `graphlink_nodes/graphlink_node_code.py:136-146`, `graphlink_nodes/graphlink_node_image.py:94-99` + +The canvas Font control is presented as a global control, but `_update_all_node_fonts()` updates only Chat, Document, and Thinking nodes. Code and Image headers still select their own hard-coded fonts, and many plugin/canvas items retain independent typography and colors. + +**Impact:** Changing the setting produces a partially updated canvas with inconsistent typography and geometry. This is especially visible when comparing a Chat node with Code/Image/plugin nodes. + +**Proposal:** Either scope the control explicitly to “rich text nodes” or introduce a theme/typography token object consumed by every render path. Avoid per-file font literals for shared roles; recalculate geometry only for items whose metrics changed. Add a screenshot/metric test for a mixed node set after font changes. + +## Recommended implementation order + +1. Fix `prepareGeometryChange()` ordering for nodes and connections. +2. Add CodeNode scrolling and unify scroll-wheel routing. +3. Centralize fit-all and zoom behavior. +4. Make connection paint safe without a view. +5. Batch connection/repaint work and benchmark large graphs. +6. Finish typography/theme consistency and responsive UI polish. + +## Suggested acceptance checks + +- Long Chat, Code, Document, Thinking, and Note content can be read end-to-end with the wheel and scrollbar. +- Moving/resizing nodes updates connection paths and hit targets immediately without trails or missed clicks. +- Fit All works with every persisted node family, including a scene containing only one family. +- Toolbar, keyboard, and Ctrl+wheel zoom share one bounded state. +- A scene can render to an image with zero attached views. +- Mixed-node font/theme changes update consistently and preserve readable contrast. +- A Windows desktop smoke pass covers 100%, 125%, and 150% display scaling plus narrow (800px) and wide (1920px) windows. diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_container.py b/graphlink_app/graphlink_canvas/graphlink_canvas_container.py index 3d120a8..1a612bb 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_container.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_container.py @@ -8,7 +8,7 @@ from .graphlink_canvas_base import CanvasHeaderLineEdit, GhostFrame, update_connections_for_items from .graphlink_canvas_dialogs import ColorPickerDialog -from graphlink_config import get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors class Container(QGraphicsItem): @@ -352,7 +352,7 @@ def paint(self, painter, option, widget=None): # Draw the title text in collapsed mode. painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 12, QFont.Weight.Bold) + font = canvas_font(self.scene(), delta=2, weight=QFont.Weight.Bold) painter.setFont(font) title_rect = QRectF(self.rect.left() + 14, self.rect.top(), self.rect.width() - 56, self.rect.height()) display_title = painter.fontMetrics().elidedText(self.title, Qt.TextElideMode.ElideRight, int(title_rect.width())) @@ -420,7 +420,7 @@ def paint(self, painter, option, widget=None): # Draw the title, either in display or editing mode. painter.setPen(QPen(QColor("#ffffff"))) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) + font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) text_rect = self._header_text_rect() diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py b/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py index 6f8fc7a..e63f95b 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py @@ -10,7 +10,7 @@ from .graphlink_canvas_base import CanvasHeaderLineEdit, update_connections_for_items from .graphlink_canvas_dialogs import ColorPickerDialog -from graphlink_config import get_current_palette, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_semantic_color class Frame(QGraphicsItem): @@ -607,7 +607,7 @@ def paint(self, painter, option, widget=None): self.color_button_rect = QRectF() painter.setPen(QPen(QColor("#ffffff"))) - font = QFont("Segoe UI", 11, QFont.Weight.Bold) + font = canvas_font(self.scene(), delta=1, weight=QFont.Weight.Bold) painter.setFont(font) title_rect = QRectF(self.rect.left() + 14, self.rect.top(), self.rect.width() - 56, self.rect.height()) display_note = painter.fontMetrics().elidedText(self.note, Qt.TextElideMode.ElideRight, int(title_rect.width())) @@ -705,7 +705,7 @@ def paint(self, painter, option, widget=None): # Draw title (note). painter.setPen(QPen(QColor("#ffffff"))) - font = QFont("Segoe UI", 10) + font = canvas_font(self.scene()) painter.setFont(font) text_rect = self._header_text_rect() if not self.editing: diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_note.py b/graphlink_app/graphlink_canvas/graphlink_canvas_note.py index 693a24e..6095e91 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_note.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_note.py @@ -11,7 +11,7 @@ ) from .graphlink_canvas_dialogs import ColorPickerDialog -from graphlink_config import get_current_palette +from graphlink_config import canvas_font, get_current_palette from graphlink_widgets import ScrollBar @@ -123,6 +123,10 @@ def _setup_document(self): self._recalculate_geometry() + def update_font_settings(self, font_family, font_size, color): + self._setup_document() + self.update() + def _recalculate_geometry(self): """ Calculates the note's height based on its content, adding a scrollbar @@ -241,7 +245,7 @@ def paint(self, painter, option, widget=None): # --- Content Rendering --- painter.setPen(QPen(QColor("#ffffff"))) - font = QFont("Segoe UI", 10) + font = canvas_font(self.scene()) painter.setFont(font) content_rect = QRectF(self.PADDING, self.HEADER_HEIGHT + 10, self.width - (self.PADDING * 2), self.height - self.HEADER_HEIGHT - 20) @@ -332,11 +336,12 @@ def get_char_pos_at_x(self, x, y): a given x, y coordinate within the note's content area. This is crucial for placing the cursor correctly when the user clicks. """ - metrics = QFontMetrics(QFont("Segoe UI", 10)) + font = canvas_font(self.scene()) + metrics = QFontMetrics(font) content_rect = QRectF(self.PADDING, self.HEADER_HEIGHT + 10, self.width - (self.PADDING * 2), self.height - self.HEADER_HEIGHT - (self.PADDING * 2)) # Use QTextLayout to determine line breaks and character positions. - layout = QTextLayout(self.edit_text, QFont("Segoe UI", 10)) + layout = QTextLayout(self.edit_text, font) layout.setTextOption(QTextOption(alignment=Qt.AlignmentFlag.AlignLeft, wrapMode=QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)) layout.beginLayout() diff --git a/graphlink_app/graphlink_config.py b/graphlink_app/graphlink_config.py index 77a192f..9ae009a 100644 --- a/graphlink_app/graphlink_config.py +++ b/graphlink_app/graphlink_config.py @@ -1,4 +1,4 @@ -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QFont from PySide6.QtWidgets import QApplication from graphlink_styles import THEMES @@ -8,6 +8,24 @@ def get_current_palette(): return THEMES[CURRENT_THEME]["palette"] +def canvas_font(scene=None, delta=0, weight=QFont.Weight.Normal): + """Return a canvas font using the scene's live typography settings. + + Canvas items are painted manually, so widget-level application styles do not + reach their headers. Keeping this small helper in the shared config module + makes those headers follow the same family and scale as document-backed nodes. + """ + family = getattr(scene, "font_family", "Segoe UI") if scene else "Segoe UI" + base_size = getattr(scene, "font_size", 10) if scene else 10 + font = QFont(family, max(1, int(base_size) + int(delta)), weight) + return font + + +def canvas_font_color(scene=None, fallback="#dddddd"): + color = getattr(scene, "font_color", None) if scene else None + return QColor(color) if color is not None else QColor(fallback) + + def is_monochrome_theme(): return CURRENT_THEME == "mono" diff --git a/graphlink_app/graphlink_connections.py b/graphlink_app/graphlink_connections.py index f72e8a0..d854771 100644 --- a/graphlink_app/graphlink_connections.py +++ b/graphlink_app/graphlink_connections.py @@ -139,11 +139,11 @@ def itemChange(self, change, value): round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size ) - # Notify the parent connection to redraw its path - if isinstance(self.parentItem(), ConnectionItem): - self.parentItem().prepareGeometryChange() - self.parentItem().update_path() return new_pos + if change == QGraphicsItem.ItemPositionHasChanged: + parent_connection = self.parentItem() + if isinstance(parent_connection, ConnectionItem): + parent_connection.update_path() return super().itemChange(change, value) class ConnectionItem(QGraphicsItem): @@ -191,8 +191,10 @@ def __init__(self, start_node, end_node): self.update_path() - # Cache the item's painting to improve performance - self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + # Connections are mutable paths. DeviceCoordinateCache leaves stale + # pixels behind while nodes or pins move, so let Qt repaint the small + # geometry directly instead of caching an invalid bitmap. + self.setCacheMode(QGraphicsItem.CacheMode.NoCache) self.sync_visibility_mode() def sync_visibility_mode(self): @@ -483,9 +485,9 @@ def update_path(self): # If the path has changed, update geometry and cached hover path if new_path != old_path: + self.prepareGeometryChange() self.path = new_path self.hover_path = None - self.prepareGeometryChange() self.update() def startArrowAnimation(self): @@ -494,6 +496,9 @@ def startArrowAnimation(self): self.is_animating = True self.arrows = [] path_length = self.path.length() + if path_length <= 0: + self.is_animating = False + return # Pre-populate arrows along the path current_distance = 0 @@ -521,6 +526,9 @@ def updateArrows(self): return path_length = self.path.length() + if path_length <= 0: + self.stopArrowAnimation() + return arrows_to_remove = [] for arrow in self.arrows: @@ -614,11 +622,15 @@ def paint(self, painter, option, widget=None): return palette = get_current_palette() - # Culling: Don't draw if the connection is off-screen - view = self.scene().views()[0] - view_rect = view.mapToScene(view.viewport().rect()).boundingRect() - if not self.boundingRect().intersects(view_rect): - return + # Culling is only safe when the scene is attached to a view. Export and + # test renders intentionally paint scenes without one. + scene = self.scene() + views = scene.views() if scene else [] + if views: + view = views[0] + view_rect = view.mapToScene(view.viewport().rect()).boundingRect() + if not self.boundingRect().intersects(view_rect): + return painter.setRenderHint(QPainter.RenderHint.Antialiasing) diff --git a/graphlink_app/graphlink_conversation_node.py b/graphlink_app/graphlink_conversation_node.py index 337f30c..be49024 100644 --- a/graphlink_app/graphlink_conversation_node.py +++ b/graphlink_app/graphlink_conversation_node.py @@ -7,7 +7,7 @@ from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QTextDocument, QAction, QCursor, QFont import qtawesome as qta import markdown -from graphlink_config import get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color from graphlink_canvas_items import HoverAnimationMixin from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu @@ -480,7 +480,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) + font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Conversation") diff --git a/graphlink_app/graphlink_html_view.py b/graphlink_app/graphlink_html_view.py index cbf8bd5..c543998 100644 --- a/graphlink_app/graphlink_html_view.py +++ b/graphlink_app/graphlink_html_view.py @@ -6,7 +6,7 @@ from PySide6.QtCore import QRectF, Qt, Signal, QPoint, QRect from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QCursor, QFont import qtawesome as qta -from graphlink_config import get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color from graphlink_canvas_items import HoverAnimationMixin from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu @@ -545,7 +545,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) + font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "HTML Renderer") diff --git a/graphlink_app/graphlink_nodes/graphlink_node_code.py b/graphlink_app/graphlink_nodes/graphlink_node_code.py index 00850bc..1593cd1 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_code.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_code.py @@ -4,8 +4,9 @@ from PySide6.QtWidgets import QApplication, QGraphicsItem from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import get_current_palette, get_graph_node_colors, get_semantic_color +from graphlink_config import canvas_font, canvas_font_color, get_current_palette, get_graph_node_colors, get_semantic_color from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text +from graphlink_widgets import ScrollBar try: from pygments import highlight @@ -50,6 +51,7 @@ class CodeNode(QGraphicsItem, HoverAnimationMixin): PADDING = 15 HEADER_HEIGHT = 30 MAX_HEIGHT = 800 + SCROLLBAR_PADDING = 6 def __init__(self, code, language, parent_content_node, parent=None): super().__init__(parent) @@ -67,15 +69,65 @@ def __init__(self, code, language, parent_content_node, parent=None): self.highlighter = CodeHighlighter() self.document = QTextDocument() - self.document.setDefaultStyleSheet(self.highlighter.get_stylesheet()) - self.document.setHtml(self.highlighter.highlight(self.code, self.language)) + self.scroll_value = 0.0 + self.content_height = 1.0 + self.scrollbar = ScrollBar(self) + self.scrollbar.valueChanged.connect(self._on_scroll_value_changed) + self._set_document_style() self.width = 600 doc_width = self.width - (self.PADDING * 2) self.document.setTextWidth(doc_width) - content_height = self.document.size().height() - self.height = min(self.MAX_HEIGHT, content_height + self.HEADER_HEIGHT + self.PADDING) + self._recalculate_geometry() + + def _set_document_style(self): + scene = self.scene() + family = getattr(scene, "font_family", "Segoe UI") + size = max(1, int(getattr(scene, "font_size", 10))) + color = canvas_font_color(scene, "#dddddd").name() + stylesheet = self.highlighter.get_stylesheet() + stylesheet += f" body, pre {{ font-family: '{family}'; font-size: {size}pt; color: {color}; }}" + self.document.setDefaultStyleSheet(stylesheet) + self.document.setHtml(self.highlighter.highlight(self.code, self.language)) + + def _content_rect(self): + return QRectF( + self.PADDING, + self.HEADER_HEIGHT, + max(1.0, self.width - (self.PADDING * 2) - self.scrollbar.width - self.SCROLLBAR_PADDING), + max(1.0, self.height - self.HEADER_HEIGHT - self.PADDING), + ) + + def _recalculate_geometry(self): + content_width = max(1.0, self.width - (self.PADDING * 2) - self.scrollbar.width - self.SCROLLBAR_PADDING) + self.document.setTextWidth(content_width) + new_content_height = max(1.0, self.document.size().height()) + new_height = min(self.MAX_HEIGHT, new_content_height + self.HEADER_HEIGHT + self.PADDING) + if new_height != getattr(self, "height", None): + self.prepareGeometryChange() + self.height = new_height + self.content_height = new_content_height + + content_rect = self._content_rect() + is_scrollable = self.content_height > content_rect.height() + self.scrollbar.setVisible(is_scrollable) + if is_scrollable: + self.scrollbar.height = content_rect.height() + self.scrollbar.setPos(self.width - self.PADDING - self.scrollbar.width, content_rect.top()) + self.scrollbar.set_range(content_rect.height() / self.content_height) + else: + self.scroll_value = 0.0 + self.scrollbar.set_value(0.0) + self.update() + + def _on_scroll_value_changed(self, value): + self.scroll_value = value + self.update() + + def update_font_settings(self, font_family, font_size, color): + self._set_document_style() + self._recalculate_geometry() def boundingRect(self): return QRectF(-5, -5, self.width + 10, self.height + 10) @@ -134,7 +186,7 @@ def paint(self, painter, option, widget=None): painter.drawPath(header_path) painter.setPen(QColor("#cccccc")) - font = QFont('Consolas', 9) + font = canvas_font(self.scene(), delta=-1) painter.setFont(font) metrics = QFontMetrics(font) label_rect = QRectF(10, 0, self.width - 48, self.HEADER_HEIGHT) @@ -149,12 +201,17 @@ def paint(self, painter, option, widget=None): copy_icon.paint(painter, QRectF(self.width - 28, 7, 16, 16).toRect()) painter.save() - painter.translate(self.PADDING, self.HEADER_HEIGHT) - clip_rect = QRectF(0, 0, self.width - (self.PADDING * 2), self.height - self.HEADER_HEIGHT - self.PADDING) - painter.setClipRect(clip_rect) + content_rect = self._content_rect() + painter.setClipRect(content_rect) + scroll_offset = max(0.0, self.content_height - content_rect.height()) * self.scroll_value + painter.translate(content_rect.left(), content_rect.top() - scroll_offset) self.document.drawContents(painter) painter.restore() + if self.scrollbar.isVisible(): + self.scrollbar.height = content_rect.height() + self.scrollbar.setPos(self.width - self.PADDING - self.scrollbar.width, content_rect.top()) + def contextMenuEvent(self, event): from graphlink_nodes.graphlink_node_code_menu import CodeNodeContextMenu @@ -181,6 +238,15 @@ def mouseReleaseEvent(self, event): self.scene()._clear_smart_guides() super().mouseReleaseEvent(event) + def wheelEvent(self, event): + if self.scrollbar.isVisible() and self.content_height > self._content_rect().height(): + delta = event.delta() / 120.0 + step = min(0.25, 48.0 / max(1.0, self.content_height - self._content_rect().height())) + self.scrollbar.set_value(self.scrollbar.value - delta * step) + event.accept() + return + super().wheelEvent(event) + def hoverEnterEvent(self, event): self._handle_hover_enter(event) super().hoverEnterEvent(event) diff --git a/graphlink_app/graphlink_nodes/graphlink_node_document.py b/graphlink_app/graphlink_nodes/graphlink_node_document.py index a5ebe6f..1c566b0 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_document.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_document.py @@ -8,7 +8,7 @@ from graphlink_audio import format_duration from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text from graphlink_widgets import ScrollBar @@ -259,11 +259,15 @@ def _recalculate_geometry(self): doc_width = self._content_body_rect().width() self.document.setTextWidth(doc_width) - self.content_height = max(1.0, self.document.size().height()) - self.height = min( + new_content_height = max(1.0, self.document.size().height()) + new_height = min( self.MAX_HEIGHT, - self.CONTENT_PANEL_TOP + self.CONTENT_PANEL_BOTTOM + (self.CONTENT_PANEL_PADDING_Y * 2) + self.content_height, + self.CONTENT_PANEL_TOP + self.CONTENT_PANEL_BOTTOM + (self.CONTENT_PANEL_PADDING_Y * 2) + new_content_height, ) + if new_height != self.height: + self.prepareGeometryChange() + self.height = new_height + self.content_height = new_content_height is_scrollable = self.content_height > self._content_body_rect().height() self.scrollbar.setVisible(is_scrollable) @@ -281,7 +285,6 @@ def _recalculate_geometry(self): self.scroll_value = 0 self.scrollbar.set_value(0) - self.prepareGeometryChange() self.update() def _current_dimensions(self): @@ -359,7 +362,7 @@ def _update_action_button_rects(self, current_width, current_height): self.dock_button_rect = QRectF(current_width - 50, button_y, self.BUTTON_SIZE, self.BUTTON_SIZE) def _header_layout_metrics(self): - badge_font = QFont("Segoe UI", 7, QFont.Weight.DemiBold) + badge_font = canvas_font(self.scene(), delta=-3, weight=QFont.Weight.DemiBold) badge_metrics = QFontMetrics(badge_font) badge_text = self.preview_label or self._subtitle_text() badge_width = min(118, badge_metrics.horizontalAdvance(badge_text) + 14) @@ -455,7 +458,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRectF(10, 7, 16, 16).toRect()) painter.setPen(QColor("#cccccc")) - title_font = QFont("Segoe UI", 9, QFont.Weight.Bold) + title_font = canvas_font(self.scene(), delta=-1, weight=QFont.Weight.Bold) painter.setFont(title_font) title_metrics = QFontMetrics(title_font) badge_font, badge_metrics, badge_text, badge_rect, title_rect = self._header_layout_metrics() diff --git a/graphlink_app/graphlink_nodes/graphlink_node_image.py b/graphlink_app/graphlink_nodes/graphlink_node_image.py index bb22364..01cf7c7 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_image.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_image.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import QGraphicsItem from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, canvas_font_color, get_current_palette, get_graph_node_colors from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text @@ -13,6 +13,7 @@ class ImageNode(QGraphicsItem, HoverAnimationMixin): PADDING = 15 HEADER_HEIGHT = 30 + MAX_HEIGHT = 600 def __init__(self, image_bytes, parent_content_node, prompt="", parent=None): super().__init__(parent) @@ -30,16 +31,20 @@ def __init__(self, image_bytes, parent_content_node, prompt="", parent=None): self.is_search_match = False self.image = QImage.fromData(self.image_bytes) + self.image_valid = not self.image.isNull() self.width = 512 + (self.PADDING * 2) - if not self.image.isNull(): + if self.image_valid: aspect_ratio = self.image.height() / self.image.width() if self.image.width() > 0 else 1 content_width = self.width - (self.PADDING * 2) content_height = content_width * aspect_ratio - self.height = content_height + self.HEADER_HEIGHT + (self.PADDING * 2) + self.height = min(self.MAX_HEIGHT, content_height + self.HEADER_HEIGHT + (self.PADDING * 2)) else: self.height = 400 + def update_font_settings(self, font_family, font_size, color): + self.update() + def boundingRect(self): return QRectF(-5, -5, self.width + 10, self.height + 10) @@ -92,20 +97,43 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRectF(10, 7, 16, 16).toRect()) painter.setPen(QColor("#cccccc")) - font = QFont('Segoe UI', 9) + font = canvas_font(self.scene(), delta=-1) painter.setFont(font) metrics = QFontMetrics(font) elided_prompt = metrics.elidedText(f"Image: {self.prompt}", Qt.TextElideMode.ElideRight, self.width - 50) painter.drawText(header_rect.adjusted(35, 0, -10, 0), Qt.AlignmentFlag.AlignVCenter, elided_prompt) - if not self.image.isNull(): - image_rect = QRectF( - self.PADDING, - self.HEADER_HEIGHT + self.PADDING, - self.width - (self.PADDING * 2), - self.height - self.HEADER_HEIGHT - (self.PADDING * 2), + image_rect = QRectF( + self.PADDING, + self.HEADER_HEIGHT + self.PADDING, + self.width - (self.PADDING * 2), + self.height - self.HEADER_HEIGHT - (self.PADDING * 2), + ) + if self.image_valid: + scale = min( + image_rect.width() / max(1, self.image.width()), + image_rect.height() / max(1, self.image.height()), + ) + target_width = self.image.width() * scale + target_height = self.image.height() * scale + target_rect = QRectF( + image_rect.center().x() - target_width / 2, + image_rect.center().y() - target_height / 2, + target_width, + target_height, ) - painter.drawImage(image_rect, self.image) + painter.drawImage(target_rect, self.image) + else: + painter.save() + placeholder_color = canvas_font_color(self.scene(), "#aab2bb") + placeholder_color.setAlpha(180) + painter.setPen(QPen(placeholder_color, 1, Qt.PenStyle.DashLine)) + painter.setBrush(QColor(255, 255, 255, 10)) + painter.drawRoundedRect(image_rect, 8, 8) + painter.setPen(placeholder_color) + painter.setFont(canvas_font(self.scene(), delta=-1)) + painter.drawText(image_rect, Qt.AlignmentFlag.AlignCenter, "Image unavailable") + painter.restore() def contextMenuEvent(self, event): from graphlink_nodes.graphlink_node_image_menu import ImageNodeContextMenu diff --git a/graphlink_app/graphlink_nodes/graphlink_node_thinking.py b/graphlink_app/graphlink_nodes/graphlink_node_thinking.py index 68e9225..a3bb21e 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_thinking.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_thinking.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import QGraphicsItem from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text from graphlink_widgets import ScrollBar @@ -78,16 +78,18 @@ def _setup_document(self): self.document.setHtml(html) def _recalculate_geometry(self): - self.prepareGeometryChange() - doc_width = self._content_body_rect().width() self.document.setTextWidth(doc_width) - self.content_height = self.document.size().height() - self.height = min( + new_content_height = max(1.0, self.document.size().height()) + new_height = min( self.MAX_HEIGHT, - self.CONTENT_PANEL_TOP + self.CONTENT_PANEL_BOTTOM + (self.CONTENT_PANEL_PADDING_Y * 2) + self.content_height, + self.CONTENT_PANEL_TOP + self.CONTENT_PANEL_BOTTOM + (self.CONTENT_PANEL_PADDING_Y * 2) + new_content_height, ) + if new_height != self.height: + self.prepareGeometryChange() + self.height = new_height + self.content_height = new_content_height is_scrollable = self.content_height > self._content_body_rect().height() self.scrollbar.setVisible(is_scrollable) @@ -105,7 +107,6 @@ def _recalculate_geometry(self): self.scroll_value = 0 self.scrollbar.set_value(0) - self.prepareGeometryChange() self.update() def update_font_settings(self, font_family, font_size, color): @@ -207,7 +208,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRectF(10, 7, 16, 16).toRect()) painter.setPen(QColor("#cccccc")) - font = QFont('Segoe UI', 9, QFont.Weight.Bold) + font = canvas_font(self.scene(), delta=-1, weight=QFont.Weight.Bold) painter.setFont(font) title_metrics = QFontMetrics(font) diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py b/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py index f9604d7..f8ce861 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py @@ -9,7 +9,7 @@ from PySide6.QtCore import QRectF, Qt, Signal, QThread, QPointF, QSize from PySide6.QtGui import QPainter, QColor, QPen, QPainterPath, QFont, QBrush, QLinearGradient import qtawesome as qta -from graphlink_config import get_current_palette +from graphlink_config import canvas_font, get_current_palette from graphlink_config import get_semantic_color from graphlink_canvas_items import HoverAnimationMixin from graphlink_connections import ConnectionItem @@ -577,7 +577,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) + font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Artifact Drafter") diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py b/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py index ce1d0e4..5d21a5a 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py @@ -24,7 +24,7 @@ from graphlink_agents_code_sandbox import SandboxStage from graphlink_agents_pycoder import PyCoderStatus from graphlink_canvas_items import HoverAnimationMixin -from graphlink_config import get_current_palette, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_semantic_color from graphlink_connections import ConnectionItem from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu @@ -658,7 +658,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - painter.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) + painter.setFont(canvas_font(self.scene(), weight=QFont.Weight.Bold)) painter.drawText(QRectF(42, 0, self.width - 84, self.height), Qt.AlignmentFlag.AlignVCenter, "Execution Sandbox") qta.icon("fa5s.shield-alt", color=accent.name()).paint(painter, QRect(12, 11, 18, 18)) self.collapse_button_rect = QRectF(self.width - 34, 6, 28, 28) diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py b/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py index 40062d5..fe7e8c1 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py @@ -32,6 +32,7 @@ QVBoxLayout, QWidget, ) +from graphlink_config import canvas_font from graphlink_canvas_items import HoverAnimationMixin from graphlink_config import get_current_palette, get_semantic_color @@ -1624,7 +1625,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - painter.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) + painter.setFont(canvas_font(self.scene(), weight=QFont.Weight.Bold)) painter.drawText(QRectF(42, 0, self.width - 84, self.height), Qt.AlignmentFlag.AlignVCenter, "Gitlink") qta.icon("fa5s.link", color=node_color.name()).paint(painter, QRect(12, 10, 20, 20)) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py b/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py index 36b6f5f..952042c 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py @@ -390,6 +390,9 @@ def create_node( node.incoming_connection = connection scene.addItem(connection) scene_connections.append(connection) + register_connection = getattr(scene, "register_connection", None) + if callable(register_connection): + register_connection(connection) return node @@ -419,6 +422,9 @@ def _create_system_prompt_node(self): connection = SystemPromptConnectionItem(prompt_note, root_node) scene.addItem(connection) scene.system_prompt_connections.append(connection) + register_connection = getattr(scene, "register_connection", None) + if callable(register_connection): + register_connection(connection) return prompt_note def _create_pycoder_node(self): diff --git a/graphlink_app/graphlink_pycoder.py b/graphlink_app/graphlink_pycoder.py index 5f342f7..01a4014 100644 --- a/graphlink_app/graphlink_pycoder.py +++ b/graphlink_app/graphlink_pycoder.py @@ -15,7 +15,7 @@ from PySide6.QtCore import QRectF, Qt, Property, QPropertyAnimation, QEasingCurve, QPointF, QRegularExpression, QSize, QRect from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QIcon, QSyntaxHighlighter, QTextCharFormat, QFont import qtawesome as qta -from graphlink_config import get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_agents_pycoder import PyCoderStage, PyCoderStatus, PythonREPL @@ -698,7 +698,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) + font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) title = f"Py-Coder ({'AI' if self.mode == PyCoderMode.AI_DRIVEN else 'Manual'})" painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, title) diff --git a/graphlink_app/graphlink_scene.py b/graphlink_app/graphlink_scene.py index e508304..525df49 100644 --- a/graphlink_app/graphlink_scene.py +++ b/graphlink_app/graphlink_scene.py @@ -1,7 +1,7 @@ from PySide6.QtWidgets import ( QGraphicsItem, QGraphicsScene, QMessageBox, QGraphicsLineItem ) -from PySide6.QtCore import Qt, QPointF, QRectF, Signal +from PySide6.QtCore import Qt, QPointF, QRectF, Signal, QTimer from PySide6.QtGui import QColor, QPen, QTransform from graphlink_node import ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode @@ -63,6 +63,8 @@ def __init__(self, window): self.gitlink_nodes = [] self.chart_nodes = [] self.transient_layout_items = [] + self._connection_index = {} + self._scene_change_pending = False self.content_connections = [] self.document_connections = [] @@ -173,6 +175,45 @@ def _all_connection_lists(self): self.gitlink_connections, ] + def register_connection(self, connection): + """Add a connection to the endpoint index used during node drags.""" + if connection is None: + return + for endpoint in (getattr(connection, "start_node", None), getattr(connection, "end_node", None)): + if endpoint is not None: + self._connection_index.setdefault(endpoint, set()).add(connection) + + def unregister_connection(self, connection): + for endpoint in (getattr(connection, "start_node", None), getattr(connection, "end_node", None)): + connections = self._connection_index.get(endpoint) + if not connections: + continue + connections.discard(connection) + if not connections: + self._connection_index.pop(endpoint, None) + + def rebuild_connection_index(self): + self._connection_index.clear() + for conn_list in self._all_connection_lists(): + for connection in conn_list: + self.register_connection(connection) + + def connections_for_node(self, node): + return tuple( + connection for connection in self._connection_index.get(node, ()) + if connection.scene() == self + ) + + def _schedule_scene_changed(self): + if self._scene_change_pending: + return + self._scene_change_pending = True + QTimer.singleShot(0, self._emit_scheduled_scene_changed) + + def _emit_scheduled_scene_changed(self): + self._scene_change_pending = False + self.scene_changed.emit() + def _remove_connections_for_node(self, node, connection_lists=None): lists_to_scan = connection_lists if connection_lists is not None else self._all_connection_lists() for conn_list in lists_to_scan: @@ -186,12 +227,15 @@ def _remove_connections_for_node(self, node, connection_lists=None): if conn.scene() == self: self.removeItem(conn) + self.unregister_connection(conn) if conn in conn_list: conn_list.remove(conn) def _update_all_node_fonts(self): """Iterates through all nodes that support font changes and applies the current settings.""" - nodes_to_update = self.nodes + self.document_nodes + self.thinking_nodes + nodes_to_update = list(dict.fromkeys( + self._all_layout_nodes() + self.notes + self.frames + self.containers + )) for node in nodes_to_update: if hasattr(node, 'update_font_settings'): node.update_font_settings(self.font_family, self.font_size, self.font_color) @@ -318,6 +362,7 @@ def add_chat_node(self, text, is_user=True, parent_node=None, conversation_histo node.incoming_connection = connection self.addItem(connection) self.connections.append(connection) + self.register_connection(connection) else: # Default position for root nodes. root_base = QPointF(preferred_pos) if preferred_pos is not None else QPointF(50, 150) @@ -368,6 +413,7 @@ def add_code_node(self, code, language, parent_content_node): connection = ContentConnectionItem(parent_content_node, node) self.addItem(connection) self.content_connections.append(connection) + self.register_connection(connection) self.scene_changed.emit() return node @@ -393,6 +439,7 @@ def add_image_node(self, image_bytes, parent_chat_node, prompt=""): connection = ImageConnectionItem(parent_chat_node, node) self.addItem(connection) self.image_connections.append(connection) + self.register_connection(connection) self.scene_changed.emit() return node @@ -439,6 +486,7 @@ def add_document_node( connection = DocumentConnectionItem(parent_user_node, node) self.addItem(connection) self.document_connections.append(connection) + self.register_connection(connection) self.scene_changed.emit() return node @@ -463,6 +511,7 @@ def add_thinking_node(self, thinking_text, parent_chat_node): connection = ThinkingConnectionItem(parent_chat_node, node) self.addItem(connection) self.thinking_connections.append(connection) + self.register_connection(connection) self.scene_changed.emit() return node @@ -485,19 +534,10 @@ def nodeMoved(self, node): # Iterate through all connection types and update any connected to the moved node. # Note: Slicing `[:]` creates a copy, allowing safe removal from the list during iteration if a connection is invalid. - all_connection_lists = [ - self.connections, self.content_connections, self.document_connections, self.image_connections, - self.thinking_connections, self.system_prompt_connections, self.pycoder_connections, self.code_sandbox_connections, self.web_connections, - self.conversation_connections, self.group_summary_connections, - self.html_connections, self.artifact_connections, self.gitlink_connections - ] + for conn in self.connections_for_node(node): + conn.update_path() - for conn_list in all_connection_lists: - for conn in conn_list[:]: - if node in (conn.start_node, conn.end_node): - conn.update_path() - - self.scene_changed.emit() + self._schedule_scene_changed() def add_navigation_pin(self, pos): """ @@ -680,6 +720,25 @@ def _all_content_nodes(self): def _all_layout_nodes(self): return self._all_conversational_nodes() + self._all_content_nodes() + def overview_items(self): + """Return visible persistent canvas items suitable for Fit All.""" + candidates = self._all_layout_nodes() + self.notes + self.frames + self.containers + self.pins + seen = set() + items = [] + for item in candidates: + if item in seen or item.scene() != self or not item.isVisible(): + continue + seen.add(item) + items.append(item) + return items + + def overview_rect(self): + rect = QRectF() + for item in self.overview_items(): + item_rect = item.sceneBoundingRect() + rect = item_rect if not rect.isValid() else rect.united(item_rect) + return rect + def _all_branch_visibility_nodes(self): """Returns every node-like item that should participate in branch focus mode.""" return self._all_layout_nodes() @@ -923,6 +982,8 @@ def update_connections(self): if hasattr(conn, 'sync_visibility_mode'): conn.sync_visibility_mode() + self.rebuild_connection_index() + def toggle_branch_visibility(self, originating_node): """ Toggles the visibility of conversation branches, either isolating the diff --git a/graphlink_app/graphlink_session/deserializers.py b/graphlink_app/graphlink_session/deserializers.py index 07637a2..f37f276 100644 --- a/graphlink_app/graphlink_session/deserializers.py +++ b/graphlink_app/graphlink_session/deserializers.py @@ -74,6 +74,7 @@ def _deserialize_basic_connection(self, data, scene, all_nodes_map, connection_c self._set_incoming_connection(end_node, connection) scene.addItem(connection) getattr(scene, target_list_name).append(connection) + scene.register_connection(connection) return connection def deserialize_chart(self, data, scene, all_nodes_map): @@ -138,6 +139,7 @@ def deserialize_system_prompt_connection(self, data, scene, notes_map, nodes_map connection = SystemPromptConnectionItem(start_note, end_node) scene.addItem(connection) scene.system_prompt_connections.append(connection) + scene.register_connection(connection) return connection def deserialize_pycoder_connection(self, data, scene, all_nodes_map): @@ -185,6 +187,7 @@ def deserialize_group_summary_connection(self, data, scene, nodes_map, notes_map connection = GroupSummaryConnectionItem(start_node, end_note) scene.addItem(connection) scene.group_summary_connections.append(connection) + scene.register_connection(connection) return connection def deserialize_node(self, index, data, all_nodes_map): diff --git a/graphlink_app/graphlink_view.py b/graphlink_app/graphlink_view.py index da984c0..d119af2 100644 --- a/graphlink_app/graphlink_view.py +++ b/graphlink_app/graphlink_view.py @@ -32,7 +32,7 @@ def __init__(self, window): super().__init__() self.window = window self.setRenderHint(QPainter.RenderHint.Antialiasing) - self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate) + self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.MinimalViewportUpdate) # Disable default scrollbars to use custom ones. self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -124,6 +124,19 @@ def _refresh_scene_level_of_detail(self): max(0.01, self.transform().m11()), ) + def zoom_by(self, factor): + """Apply a bounded zoom step and keep the view's zoom state in sync.""" + factor = float(factor) + if factor <= 0: + return False + new_zoom_factor = self._zoom_factor * factor + if not 0.1 <= new_zoom_factor <= 4.0: + return False + self.scale(factor, factor) + self._zoom_factor = new_zoom_factor + self._schedule_scene_lod_refresh() + return True + def visible_scene_rect(self): return self.mapToScene(self.viewport().rect()).boundingRect() @@ -557,24 +570,12 @@ def keyPressEvent(self, event): return if key == Qt.Key.Key_E: # Zoom In - factor = 1.05 - self._zoom_factor *= factor - if self._zoom_factor <= 4.0: - self.scale(factor, factor) - self._schedule_scene_lod_refresh() - else: - self._zoom_factor /= factor + self.zoom_by(1.05) event.accept() return if key == Qt.Key.Key_Q: # Zoom Out - factor = 0.95 - self._zoom_factor *= factor - if self._zoom_factor >= 0.1: - self.scale(factor, factor) - self._schedule_scene_lod_refresh() - else: - self._zoom_factor /= factor + self.zoom_by(0.95) event.accept() return @@ -614,11 +615,14 @@ def keyReleaseEvent(self, event): def _scrollable_item_at(self, position): item = self.itemAt(position) while item is not None: - if isinstance(item, (ChatNode, DocumentNode)): - scrollbar = getattr(item, "scrollbar", None) - if scrollbar and scrollbar.isVisible() and not getattr(item, "is_collapsed", False): - return item - return None + scrollbar = getattr(item, "scrollbar", None) + if ( + scrollbar is not None + and scrollbar.isVisible() + and not getattr(item, "is_collapsed", False) + and callable(getattr(item, "wheelEvent", None)) + ): + return item item = item.parentItem() return None @@ -633,12 +637,7 @@ def wheelEvent(self, event): # Zoom the view if Ctrl is held. zoom_in = event.angleDelta().y() > 0 factor = 1.1 if zoom_in else 0.9 - new_zoom_factor = self._zoom_factor * factor - - if 0.1 <= new_zoom_factor <= 4.0: - self.scale(factor, factor) - self._zoom_factor = new_zoom_factor - self._schedule_scene_lod_refresh() + self.zoom_by(factor) return # Check if the item under the cursor is scrollable (e.g., a ChatNode with overflow). @@ -719,9 +718,13 @@ def reset_zoom(self): def fit_all(self): """Zooms and pans the view to fit all items in the scene.""" - if self.scene() and not self.scene().nodes and not self.scene().code_nodes and not self.scene().image_nodes: + scene = self.scene() + if scene is None: + return + rect = scene.overview_rect() if hasattr(scene, "overview_rect") else scene.itemsBoundingRect() + if not rect.isValid() or rect.width() <= 0 or rect.height() <= 0: return - self.fitInView(self.scene().itemsBoundingRect(), Qt.AspectRatioMode.KeepAspectRatio) + self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.AspectRatioMode.KeepAspectRatio) self._zoom_factor = self.transform().m11() self._schedule_scene_lod_refresh() diff --git a/graphlink_app/graphlink_web.py b/graphlink_app/graphlink_web.py index 374019d..8766fab 100644 --- a/graphlink_app/graphlink_web.py +++ b/graphlink_app/graphlink_web.py @@ -5,7 +5,7 @@ from PySide6.QtCore import QRectF, Qt, QPointF, Signal, QTimer, QRect from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QFont import qtawesome as qta -from graphlink_config import get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color from graphlink_connections import ConnectionItem from graphlink_canvas_items import HoverAnimationMixin from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state @@ -402,7 +402,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) + font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Web Search") diff --git a/graphlink_app/graphlink_window.py b/graphlink_app/graphlink_window.py index 1271f53..b937e8f 100644 --- a/graphlink_app/graphlink_window.py +++ b/graphlink_app/graphlink_window.py @@ -637,8 +637,8 @@ def setup_toolbar(self, toolbar): self.pins_btn = QToolButton(); self.pins_btn.setText("Pins"); self.pins_btn.clicked.connect(self.toggle_pin_overlay); toolbar.addWidget(self.pins_btn) organize_btn = QToolButton(); organize_btn.setText("Organize"); organize_btn.setObjectName("actionButton"); organize_btn.clicked.connect(lambda: self.chat_view.scene().organize_nodes()); toolbar.addWidget(organize_btn) toolbar.addSeparator() - zoom_in_btn = QToolButton(); zoom_in_btn.setText("Zoom In"); zoom_in_btn.clicked.connect(lambda: self.chat_view.scale(1.1, 1.1)); toolbar.addWidget(zoom_in_btn) - zoom_out_btn = QToolButton(); zoom_out_btn.setText("Zoom Out"); zoom_out_btn.clicked.connect(lambda: self.chat_view.scale(0.9, 0.9)); toolbar.addWidget(zoom_out_btn) + zoom_in_btn = QToolButton(); zoom_in_btn.setText("Zoom In"); zoom_in_btn.clicked.connect(lambda: self.chat_view.zoom_by(1.1)); toolbar.addWidget(zoom_in_btn) + zoom_out_btn = QToolButton(); zoom_out_btn.setText("Zoom Out"); zoom_out_btn.clicked.connect(lambda: self.chat_view.zoom_by(0.9)); toolbar.addWidget(zoom_out_btn) toolbar.addSeparator() reset_btn = QToolButton(); reset_btn.setText("Reset"); reset_btn.clicked.connect(self.chat_view.reset_zoom); toolbar.addWidget(reset_btn) fit_btn = QToolButton(); fit_btn.setText("Fit All"); fit_btn.clicked.connect(self.chat_view.fit_all); toolbar.addWidget(fit_btn) diff --git a/graphlink_app/graphlink_window_actions.py b/graphlink_app/graphlink_window_actions.py index 5d218b9..cf1e441 100644 --- a/graphlink_app/graphlink_window_actions.py +++ b/graphlink_app/graphlink_window_actions.py @@ -537,6 +537,7 @@ def handle_group_summary_response(self, response, note_pos, source_nodes): conn = GroupSummaryConnectionItem(source_node, note) scene.addItem(conn) scene.group_summary_connections.append(conn) + scene.register_connection(conn) except Exception as e: self.handle_error(f"Error creating summary note: {str(e)}") finally: diff --git a/graphlink_app/tests/test_canvas_rendering_ui.py b/graphlink_app/tests/test_canvas_rendering_ui.py new file mode 100644 index 0000000..c3976b5 --- /dev/null +++ b/graphlink_app/tests/test_canvas_rendering_ui.py @@ -0,0 +1,109 @@ +"""Headless regression coverage for canvas rendering and interaction paths.""" + +from unittest.mock import MagicMock + +from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QPointF +from PySide6.QtGui import QColor, QImage, QPainter + +from graphlink_config import canvas_font +from graphlink_connections import ConnectionItem +from graphlink_node import CodeNode, ImageNode +from graphlink_scene import ChatScene +from graphlink_view import ChatView + + +def _png_bytes(width=20, height=2000): + image = QImage(width, height, QImage.Format.Format_RGB32) + image.fill(QColor("#536273")) + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + image.save(buffer, "PNG") + return bytes(buffer.data()) + + +def _scene(): + return ChatScene(MagicMock()) + + +def test_code_node_scrolls_long_content_and_respects_max_height(): + scene = _scene() + parent = scene.add_chat_node("parent", preferred_pos=QPointF(0, 0)) + node = scene.add_code_node("\n".join(f"print({index})" for index in range(1200)), "python", parent) + + assert isinstance(node, CodeNode) + assert node.height <= node.MAX_HEIGHT + assert node.scrollbar.isVisible() + node.scrollbar.set_value(1.0) + assert node.scroll_value == 1.0 + + +def test_image_preview_is_bounded_and_invalid_data_gets_a_placeholder_state(): + valid = ImageNode(_png_bytes(), None, "tall image") + invalid = ImageNode(b"not an image", None, "missing image") + + assert valid.image_valid + assert valid.height <= valid.MAX_HEIGHT + assert not invalid.image_valid + assert invalid.height > invalid.HEADER_HEIGHT + + +def test_zoom_api_keeps_transform_and_state_in_sync(): + view = ChatView(MagicMock()) + assert view.zoom_by(1.25) + assert view._zoom_factor == view.transform().m11() + assert not view.zoom_by(100) + view.reset_zoom() + assert view._zoom_factor == 1.0 + assert view.transform().m11() == 1.0 + + +def test_view_wheel_routing_discovers_scrollable_items_by_capability(): + view = ChatView(MagicMock()) + scene = view.scene() + parent = scene.add_chat_node("parent", preferred_pos=QPointF(0, 0)) + code = scene.add_code_node("\n".join("x = 1" for _ in range(1200)), "python", parent) + view.itemAt = lambda _position: code + + assert view._scrollable_item_at(QPointF(0, 0)) is code + + +def test_fit_all_ignores_connection_bounds_and_transient_items(): + view = ChatView(MagicMock()) + view.resize(800, 600) + scene = view.scene() + root = scene.add_chat_node("root", preferred_pos=QPointF(1000, 1000)) + child = scene.add_chat_node("child", parent_node=root, preferred_pos=QPointF(1400, 1000)) + view.show() + view.fit_all() + + assert scene.overview_rect().contains(root.sceneBoundingRect()) + assert scene.overview_rect().contains(child.sceneBoundingRect()) + assert view._zoom_factor == view.transform().m11() + + +def test_connection_paints_when_scene_has_no_view(): + scene = _scene() + start = scene.add_chat_node("start", preferred_pos=QPointF(0, 0)) + end = scene.add_chat_node("end", parent_node=start, preferred_pos=QPointF(400, 0)) + connection = next(conn for conn in scene.connections if conn.end_node is end) + + image = QImage(800, 600, QImage.Format.Format_ARGB32) + image.fill(QColor("#252526")) + painter = QPainter(image) + scene.render(painter) + painter.end() + + assert connection.scene() is scene + + +def test_moving_a_node_uses_endpoint_index_and_font_settings_reach_canvas_items(): + scene = _scene() + root = scene.add_chat_node("root", preferred_pos=QPointF(0, 0)) + child = scene.add_chat_node("child", parent_node=root, preferred_pos=QPointF(400, 0)) + connection = next(conn for conn in scene.connections if conn.end_node is child) + + assert connection in scene.connections_for_node(child) + scene.setFontFamily("Arial") + assert canvas_font(scene).family() == "Arial" + scene.nodeMoved(child) + assert connection.path.length() > 0