Repository URL to install this package:
|
Version:
0.10.6 ▾
|
"""
MatrixWidget - Unified matrix-style connection view widget for MIDI and Audio ports.
This module provides a matrix-style view for MIDI or Audio port connections,
parameterized by port_type. It uses the MatrixInterface to access the capabilities
it needs from the main application, enabling better testability and reduced coupling.
"""
import dataclasses
import logging
logger = logging.getLogger(__name__)
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QGridLayout,
QLabel,
QScrollArea,
QFrame,
QVBoxLayout,
QSizePolicy,
QSplitter,
)
from PyQt6.QtCore import (
Qt,
QSize,
QRect,
QRectF,
QTimer,
QPointF,
pyqtSignal,
QEvent,
QObject,
)
from PyQt6.QtGui import QPolygonF
from PyQt6.QtGui import (
QFont,
QColor,
QPainter,
QPen,
QBrush,
QPalette,
QPainterPath,
QLinearGradient,
)
from cables.jack_service import get_jack_service
from .matrix_model import MatrixModel
from .midi_matrix_grid import _MatrixGridWidget, _OutputLabelsWidget, _InputLabelsWidget
from cable_core import config_keys as keys
from typing import TYPE_CHECKING, Optional, Any, Union, List, Tuple, Dict, Literal
if TYPE_CHECKING:
from cables.interfaces import MatrixInterface
from cables.features.node_visibility_manager import NodeVisibilityManager
from PyQt6.QtGui import QEvent
from PyQt6.QtCore import QPoint, QSize
PortType = Literal["midi", "audio"]
# Config key mapping per port type
_MATRIX_CONFIG_KEYS: Dict[str, Dict[str, str]] = {
"midi": {
"zoom_level": keys.MIDI_MATRIX_ZOOM_LEVEL,
"splitter_sizes": keys.MIDI_MATRIX_SPLITTER_SIZES,
"v_splitter_sizes": keys.MIDI_MATRIX_V_SPLITTER_SIZES,
"make_connection": "make_midi_connection",
"break_connection": "break_midi_connection",
},
"audio": {
"zoom_level": keys.AUDIO_MATRIX_ZOOM_LEVEL,
"splitter_sizes": keys.AUDIO_MATRIX_SPLITTER_SIZES,
"v_splitter_sizes": keys.AUDIO_MATRIX_V_SPLITTER_SIZES,
"make_connection": "make_connection",
"break_connection": "break_connection",
},
}
@dataclasses.dataclass
class MatrixStyleConfig:
"""Configuration for visual styling of the Matrix."""
# --- Input Labels (Bottom) ---
# Rotation angle for input port labels.
input_label_rotation: int = 55
# Horizontal positioning factor for input labels showing clients and ports combo. Smaller values move labels left.
input_label_x_pos_factor_client_port: float = 1.15
# Horizontal positioning factor for input labels showing ports only. Smaller values move labels left.
input_label_x_pos_factor_ports_only: float = 1.4
input_label_x_pos_factor: float = 1.5
# Vertical offset between client and port names in input labels.
input_label_port_y_offset: int = -7
# --- Output Labels (Left) ---
# Vertical offset between client and port names in output labels.
output_label_port_v_offset: int = -7
# --- Selection Arrow ---
selection_arrow_line_width: int = 3
selection_arrowhead_length: int = 15
selection_arrowhead_width: int = 8
# --- Connection Guide Lines ---
# Line width for non-hovered connection guide lines.
guide_line_width: int = 3
# Line width for hovered connection guide lines (for the active cell).
guide_line_hover_width: int = 3
# Arrowhead size for connection guide lines
guide_arrowhead_length: int = 12
guide_arrowhead_width: int = 8
# Dot size for connection guide lines (at output end)
guide_dot_radius: int = 4
# --- Grid Square Colors (Dark Theme) ---
disconnected_square_color_dark: tuple = (32, 35, 38)
self_connection_square_color_dark: tuple = (60, 63, 65)
hover_highlight_square_color_dark: tuple = (51, 51, 70)
# --- Grid Square Colors (Light Theme) ---
disconnected_square_color_light: tuple = (226, 226, 226)
self_connection_square_color_light: tuple = (215, 216, 217)
hover_highlight_square_color_light: tuple = (195, 196, 197)
# --- Grid Border Width --
# Width of grid lines that separate squares
grid_line_width: int = 0.3
# Width of borders around individual squares
square_border_width: int = 0.1
# --- Scrollbar Trigger Padding --
horizontal_padding: int = 400
vertical_padding: int = 200
# Backward compatibility aliases
MidiMatrixStyleConfig = MatrixStyleConfig
AudioMatrixStyleConfig = MatrixStyleConfig
class MatrixWidget(QWidget):
"""
A matrix-style widget for displaying and managing MIDI or Audio port connections.
Shows output ports (sources) on the vertical left axis and input ports (destinations)
on the horizontal bottom axis.
This class uses the MatrixInterface protocol to access capabilities
from the main application. The connection_manager parameter must implement:
- ConfigProvider: for config_manager access
- ConnectionOperations: for make_connection, break_connection, make_midi_connection, break_midi_connection
- ColorProvider: for background_color
"""
fullscreen_request_signal = pyqtSignal()
def __init__(
self,
connection_manager: "MatrixInterface",
parent: Optional[QWidget] = None,
port_type: PortType = "midi",
) -> None:
"""
Initialize the matrix widget.
Args:
connection_manager: Reference to an object implementing MatrixInterface.
Typically the JackConnectionManager instance.
parent: The parent widget
port_type: Either 'midi' or 'audio' to determine which ports to display
"""
super().__init__(parent)
self.connection_manager = connection_manager
self.port_type = port_type
self._jack_service = get_jack_service()
self.style_config = MatrixStyleConfig()
self.node_visibility_manager: Optional["NodeVisibilityManager"] = None
self._config_keys = _MATRIX_CONFIG_KEYS[port_type]
# Data model for ports, connections, and colors
self.model = MatrixModel(self._jack_service, port_type=port_type)
# Zoom configuration
self.zoom_level = connection_manager.config_manager.get_float_setting(
self._config_keys["zoom_level"], 10.0
)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._setup_ui()
# Connect to JACK signals
jack_service = get_jack_service()
jack_service.port_added.connect(self.on_port_added_or_removed)
jack_service.port_removed.connect(self.on_port_added_or_removed)
jack_service.client_removed.connect(self.on_client_removed)
jack_service.connection_made.connect(self.on_connection_changed)
jack_service.connection_broken.connect(self.on_connection_changed)
# Initial refresh
self.refresh_matrix()
def set_node_visibility_manager(
self, node_visibility_manager: "NodeVisibilityManager"
) -> None:
"""
Set the node visibility manager for this widget.
Args:
node_visibility_manager: The NodeVisibilityManager instance
"""
self.node_visibility_manager = node_visibility_manager
self.model.set_node_visibility_manager(node_visibility_manager)
# Refresh the matrix to apply visibility settings
self.refresh_matrix()
def _setup_ui(self) -> None:
"""Set up the UI components."""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
hide_splitters = self.connection_manager.config_manager.get_bool(
keys.HIDE_MATRIX_SPLITTERS, False
)
self.v_splitter = QSplitter(Qt.Orientation.Vertical)
self.v_splitter.setChildrenCollapsible(True)
if hide_splitters:
self.v_splitter.setHandleWidth(0)
self.v_splitter.setStyleSheet(
"QSplitter::handle { background-color: transparent; border: none; }"
)
else:
self.v_splitter.setHandleWidth(2)
self.v_splitter.setStyleSheet(
"QSplitter::handle { background-color: #444444; border: none; }"
)
# --- Top panel: scroll area with output labels + grid ---
self.main_scroll_area = QScrollArea()
self.main_scroll_area.setWidgetResizable(
True
) # Resize content to fit, but we'll override for scrolling
self.main_scroll_area.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.main_scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.main_scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.main_scroll_area.setStyleSheet("QScrollArea { background-color: transparent; } QWidget#qt_scrollarea_viewport { background-color: transparent; }")
# Create horizontal splitter for adjustable output label area
self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
self.main_splitter.setChildrenCollapsible(True)
if hide_splitters:
self.main_splitter.setHandleWidth(0)
self.main_splitter.setStyleSheet(
"QSplitter::handle { background-color: transparent; border: none; }"
)
else:
self.main_splitter.setHandleWidth(2)
self.main_splitter.setStyleSheet(
"QSplitter::handle { background-color: #444444; border: none; }"
)
self.main_splitter.setSizePolicy(
QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum
)
# Left panel - Output labels (no individual scroll area)
self.output_labels_widget = _OutputLabelsWidget(self)
self.output_labels_widget.setMinimumWidth(
20
) # Allow collapse but prevent disappearing
# Right panel - Grid (no individual scroll area)
self.matrix_widget = _MatrixGridWidget(self)
# Add panels directly to splitter (no nested scroll areas)
self.main_splitter.addWidget(self.output_labels_widget)
self.main_splitter.addWidget(self.matrix_widget)
# Set the splitter as the widget for the main scroll area
self.main_scroll_area.setWidget(self.main_splitter)
# Load and set horizontal splitter proportions from config
splitter_sizes_str = self.connection_manager.config_manager.get_str(
self._config_keys["splitter_sizes"], "200,800"
)
try:
sizes = [int(x.strip()) for x in splitter_sizes_str.split(",")]
if len(sizes) == 2:
self.main_splitter.setSizes(sizes)
else:
self.main_splitter.setSizes(
[200, 800]
) # Fallback to defaults - more space for grid
except (ValueError, IndexError):
self.main_splitter.setSizes(
[200, 800]
) # Fallback to defaults - more space for grid
# Connect horizontal splitter signals
self.main_splitter.splitterMoved.connect(self._on_main_splitter_moved)
# --- Bottom panel: input labels (outside scroll area, always visible) ---
self.input_labels_widget = _InputLabelsWidget(self)
# Add to vertical splitter
self.v_splitter.addWidget(self.main_scroll_area)
self.v_splitter.addWidget(self.input_labels_widget)
# Load vertical splitter sizes from config
v_splitter_sizes_str = self.connection_manager.config_manager.get_str(
self._config_keys["v_splitter_sizes"], "400,120"
)
try:
sizes = [int(x.strip()) for x in v_splitter_sizes_str.split(",")]
if len(sizes) == 2:
self.v_splitter.setSizes(sizes)
else:
self.v_splitter.setSizes([400, 120])
except (ValueError, IndexError):
self.v_splitter.setSizes([400, 120])
# Connect vertical splitter signals
self.v_splitter.splitterMoved.connect(self._on_v_splitter_moved)
layout.addWidget(self.v_splitter)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Sync horizontal scrolling to input labels
self.main_scroll_area.horizontalScrollBar().valueChanged.connect(
self.input_labels_widget.set_scroll_offset
)
# Install event filter on scroll area viewport to intercept Ctrl+scroll for zooming
self.main_scroll_area.viewport().installEventFilter(self)
def resizeEvent(self, event: Optional["QEvent"]) -> None:
"""Handle resize event to ensure proper layout."""
super().resizeEvent(event)
# Force the matrix widget to recalculate its size
if hasattr(self, "matrix_widget"):
self.matrix_widget.update_matrix()
# Defer the call to prevent potential resize loops
QTimer.singleShot(0, self._update_scroll_behavior)
def keyPressEvent(self, event: "QEvent") -> None:
from PyQt6.QtGui import QKeyEvent
if isinstance(event, QKeyEvent):
if event.key() == Qt.Key.Key_F:
self.fullscreen_request_signal.emit()
event.accept()
elif event.key() == Qt.Key.Key_Escape:
if self.window().isFullScreen():
self.fullscreen_request_signal.emit()
event.accept()
else:
super().keyPressEvent(event)
else:
super().keyPressEvent(event)
def eventFilter(self, obj: Optional[QObject], event: Optional[QEvent]) -> bool:
"""Intercept Ctrl+scroll wheel events on the scroll area viewport for zooming."""
if event is not None and event.type() == QEvent.Type.Wheel:
from PyQt6.QtGui import QWheelEvent
if isinstance(event, QWheelEvent) and (
event.modifiers() & Qt.KeyboardModifier.ControlModifier
):
if event.angleDelta().y() > 0:
self.zoom_in()
else:
self.zoom_out()
return True # Consume the event
return super().eventFilter(obj, event)
def _on_main_splitter_moved(self, pos: int, index: int) -> None:
"""Handle main splitter movement to update output label truncation and save position."""
# Update output labels when main splitter moves (changes output label width)
self.output_labels_widget._calculate_truncation_lengths()
self.output_labels_widget.update()
# Sync input labels with new output labels width
self.input_labels_widget.sync_with_grid()
self.input_labels_widget.update()
# Save splitter sizes to config
sizes = self.main_splitter.sizes()
if len(sizes) == 2:
sizes_str = f"{sizes[0]},{sizes[1]}"
self.connection_manager.config_manager.set_str(
self._config_keys["splitter_sizes"], sizes_str
)
def _on_v_splitter_moved(self, pos: int, index: int) -> None:
"""Handle vertical splitter movement to save position."""
sizes = self.v_splitter.sizes()
if len(sizes) == 2:
sizes_str = f"{sizes[0]},{sizes[1]}"
self.connection_manager.config_manager.set_str(
self._config_keys["v_splitter_sizes"], sizes_str
)
def _is_dark_mode(self) -> bool:
"""Check if the current theme is dark mode."""
window_color = self.palette().color(QPalette.ColorRole.Window)
return (
window_color.red() + window_color.green() + window_color.blue()
) / 3 < 128
def refresh_matrix(self) -> None:
"""Refresh the entire matrix by reloading ports and connections."""
self.model.load_ports(self._is_dark_mode())
self.model.load_connections()
self.matrix_widget.update_matrix()
self.output_labels_widget.update_labels()
self.input_labels_widget.update_labels()
# Update scroll behavior so scrollbars adjust to new grid size
# when ports are added/removed without needing a zoom or resize.
QTimer.singleShot(0, self._update_scroll_behavior)
def is_connected(self, output_port: str, input_port: str) -> bool:
"""Check if two ports are connected."""
return self.model.is_connected(output_port, input_port)
def toggle_connection(self, output_port: str, input_port: str) -> None:
"""Toggle the connection between two ports."""
connected = self.is_connected(output_port, input_port)
handler = self.connection_manager.jack_handler
if self.port_type == "midi":
if connected:
handler.break_midi_connection(output_port, input_port)
else:
handler.make_midi_connection(output_port, input_port)
else:
if connected:
handler.break_connection(output_port, input_port)
else:
handler.make_connection(output_port, input_port)
def on_port_added_or_removed(
self,
port_name: str,
client_name: Optional[str] = None,
port_flags: Any = None,
port_type: Optional[str] = None,
is_input: Optional[bool] = None,
) -> None:
"""Handle port addition/removal events."""
should_refresh = False
if port_type == self.port_type:
should_refresh = True
elif port_name and self._jack_service.client:
try:
kwarg = (
{"is_midi": True}
if self.port_type == "midi"
else {"is_audio": True}
)
should_refresh = any(
p.name == port_name for p in self._jack_service.get_ports(**kwarg)
)
except Exception:
# If we can't get ports (e.g., JackError), assume it's related to be safe
should_refresh = True
if should_refresh:
QTimer.singleShot(10, self.refresh_matrix) # Debounce updates
def on_client_removed(self, client_name: str) -> None:
"""Handle client removal events."""
QTimer.singleShot(10, self.refresh_matrix) # Debounce updates
def on_connection_changed(self, output_port: str, input_port: str) -> None:
"""Handle connection change events."""
# Check if this affects our port type's connections
is_relevant_connection = False
try:
kwarg = (
{"is_midi": True, "is_output": True}
if self.port_type == "midi"
else {"is_audio": True, "is_output": True}
)
if any(
p.name == output_port for p in self._jack_service.get_ports(**kwarg)
):
is_relevant_connection = True
except Exception as e:
logger.warning(
f"Could not check {self.port_type.upper()} port status for connection change: {e}"
)
if is_relevant_connection:
QTimer.singleShot(10, self.refresh_matrix) # Debounce updates
def reshuffle_colors(self) -> None:
"""Reshuffle client colors."""
self.model.reshuffle_colors()
self.refresh_matrix()
def zoom_in(self) -> None:
"""Increase zoom level."""
max_zoom = 20.0
zoom_step = 0.25
if self.zoom_level < max_zoom:
self.zoom_level = min(self.zoom_level + zoom_step, max_zoom)
self.connection_manager.config_manager.set_float_setting(
self._config_keys["zoom_level"], self.zoom_level
)
self.matrix_widget.font_size = int(self.zoom_level)
self.matrix_widget.client_name_font_size = int(self.zoom_level) + 2
self.matrix_widget.grid_cell_scaling = self._calculate_grid_scaling(
self.zoom_level
)
self.output_labels_widget.font_size = int(self.zoom_level)
self.output_labels_widget.client_name_font_size = int(self.zoom_level) + 2
self.input_labels_widget.font_size = int(self.zoom_level)
self.input_labels_widget.client_name_font_size = int(self.zoom_level) + 2
self.refresh_matrix()
QTimer.singleShot(0, self._update_scroll_behavior)
def zoom_out(self) -> None:
"""Decrease zoom level."""
min_zoom = 4.0
zoom_step = 0.25
if self.zoom_level > min_zoom:
self.zoom_level = max(self.zoom_level - zoom_step, min_zoom)
self.connection_manager.config_manager.set_float_setting(
self._config_keys["zoom_level"], self.zoom_level
)
self.matrix_widget.font_size = int(self.zoom_level)
self.matrix_widget.client_name_font_size = int(self.zoom_level) + 2
self.matrix_widget.grid_cell_scaling = self._calculate_grid_scaling(
self.zoom_level
)
self.output_labels_widget.font_size = int(self.zoom_level)
self.output_labels_widget.client_name_font_size = int(self.zoom_level) + 2
self.input_labels_widget.font_size = int(self.zoom_level)
self.input_labels_widget.client_name_font_size = int(self.zoom_level) + 2
self.refresh_matrix()
QTimer.singleShot(0, self._update_scroll_behavior)
def _calculate_grid_scaling(self, zoom_level: float) -> float:
"""Calculate grid cell scaling factor based on zoom level."""
# Base zoom level is 10, base minimum size is 35px
# Scale proportionally
base_zoom = 10.0
base_size = 35.0
# Scale factor: at zoom 10 = 1.0, zoom 6 = 0.6, zoom 20 = 2.0
scale_factor = zoom_level / base_zoom
# Apply scaling but ensure minimum size stays reasonable
return max(scale_factor, 0.3)
def _update_scroll_behavior(self) -> None:
"""Update scroll area behavior based on content size vs viewport size."""
if not hasattr(self, "main_scroll_area") or not hasattr(self, "main_splitter"):
return
# Ensure input labels are synced with the evaluated splitter sizes
if hasattr(self, "input_labels_widget"):
self.input_labels_widget.sync_with_grid()
self.input_labels_widget.update()
# Calculate proper minimum size accounting for angled input labels
proper_min_size = self._calculate_proper_minimum_size()
# Explicitly set the minimum size of the splitter.
self.main_splitter.setMinimumSize(proper_min_size)
# Set maximum height on the scroll area to prevent it from growing larger than the true grid content.
# This forces the vertical QSplitter to pull the input labels widget up exactly to the bottom of the grid,
# "magnetically" attaching them. The user can still drag the splitter UP to compress the scroll area.
self.main_scroll_area.setMaximumHeight(proper_min_size.height())
# ALWAYS enable widget resizability. This ensures that:
# 1. If content is larger, the minimum size is respected and scrollbars appear automatically.
# 2. If content is smaller, the widget seamlessly expands to fill the viewport, preventing random gaps.
self.main_scroll_area.setWidgetResizable(True)
def _calculate_proper_minimum_size(self) -> "QSize":
"""Calculate the proper minimum size for the splitter accounting for angled input labels."""
if (
not hasattr(self, "main_splitter")
or not hasattr(self, "output_labels_widget")
or not hasattr(self, "matrix_widget")
):
return QSize(200, 200)
# Get the output labels widget's current width from the splitter
splitter_sizes = self.main_splitter.sizes()
output_min_width = (
splitter_sizes[0]
if splitter_sizes
else self.output_labels_widget.minimumWidth()
)
# Get the matrix widget minimum size
matrix_min_size = self.matrix_widget.minimumSize()
# Calculate total width: output labels + matrix + some padding for angled labels
total_width = (
output_min_width
+ matrix_min_size.width()
+ self.style_config.horizontal_padding
)
# Height is determined by the taller of the two widgets (no padding needed because we want the labels to magnetically snap to it)
total_height = max(self.output_labels_widget.minimumHeight(), matrix_min_size.height())
return QSize(total_width, total_height)
# Backward compatibility aliases
MIDIMatrixWidget = MatrixWidget
AudioMatrixWidget = MatrixWidget