"""Routing matrix screen — visual patchbay for audio routing. Shows a grid of sources (rows) vs destinations (columns) with toggle buttons for each connection. Supports scrolling for large matrices. Touch-optimized: large grid cells, pinch-zoom for dense matrices. """ from __future__ import annotations from kivy.clock import Clock from kivy.properties import ObjectProperty from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.scrollview import ScrollView from kivy.uix.screenmanager import Screen from kivy.metrics import dp from ..theme import Colors, Fonts, Sizes class RoutingCell(Button): """A single routing grid cell — tap to toggle connection.""" def __init__(self, source: str, dest: str, active: bool = False, **kwargs): super().__init__(**kwargs) self.source = source self.dest = dest self.is_active = active self.background_normal = "" self._update_color() def _update_color(self): self.background_color = Colors.ROUTE_ACTIVE if self.is_active else Colors.ROUTE_INACTIVE def on_press(self): self.is_active = not self.is_active self._update_color() class RoutingScreen(Screen): """Routing matrix with scrollable grid. Sources on left (rows), destinations on top (columns). Each cell toggles a connection between source→dest. """ client = ObjectProperty(None) _cells: dict[tuple[str, str], RoutingCell] = {} _refresh_event = None def __init__(self, **kwargs): super().__init__(**kwargs) self.name = "routing" root = BoxLayout(orientation="vertical") # Header header = BoxLayout( orientation="horizontal", size_hint=(1, None), height=Sizes.HEADER_HEIGHT, padding=[Sizes.PAD_MD, Sizes.PAD_XS], ) header.bg_color = Colors.BG_HEADER title = Label( text="ROUTING", color=Colors.ACCENT, font_size=Fonts.HEADER, bold=True, size_hint=(None, 1), width=dp(140), halign="left", ) title.bind(size=title.setter("text_size")) header.add_widget(title) # Grid container self._grid_container = BoxLayout(orientation="vertical", padding=[Sizes.PAD_SM]) self._grid_header = BoxLayout( orientation="horizontal", size_hint=(1, None), height=Sizes.ROUTE_LABEL_HEIGHT, ) self._grid_body_scroll = ScrollView( size_hint=(1, 1), do_scroll_x=True, do_scroll_y=True, bar_width=dp(4), ) self._grid_body = GridLayout( cols=1, size_hint=(None, None), spacing=Sizes.CELL_GAP, ) self._grid_body.bind(minimum_height=self._grid_body.setter("height")) self._grid_body.bind(minimum_width=self._grid_body.setter("width")) self._grid_body_scroll.add_widget(self._grid_body) self._grid_container.add_widget(self._grid_header) self._grid_container.add_widget(self._grid_body_scroll) root.add_widget(header) root.add_widget(self._grid_container) self.add_widget(root) self._default_sources = [ "Ch1 In", "Ch2 In", "Ch3 In", "Ch4 In", "Ch5 In", "Ch6 In", "Ch7 In", "Ch8 In", "USB 1/2", "USB 3/4", "USB 5/6", "USB 7/8", ] self._default_dests = [ "Master L/R", "Aux A", "Aux B", "Sub 1", "Sub 2", "Monitor L/R", ] def on_enter(self, *args): self._build_grid(self._default_sources, self._default_dests) self._refresh_event = Clock.schedule_interval( lambda dt: self._fetch_routing(), 0.5 ) def on_leave(self, *args): if self._refresh_event: self._refresh_event.cancel() self._refresh_event = None def _build_grid(self, sources: list[str], dests: list[str]) -> None: """Build the routing grid from source/destination lists.""" self._grid_header.clear_widgets() self._grid_body.clear_widgets() self._cells.clear() # Header row: empty corner cell + destination labels corner = Label( text="SRC \\ DST", color=Colors.TEXT_MUTED, font_size=Fonts.TINY, size_hint=(None, 1), width=Sizes.ROUTE_LABEL_WIDTH, ) self._grid_header.add_widget(corner) for dest in dests: lbl = Label( text=dest, color=Colors.TEXT_SECONDARY, font_size=Fonts.TINY, size_hint=(None, 1), width=Sizes.CELL_SIZE, halign="center", ) lbl.bind(size=lbl.setter("text_size")) self._grid_header.add_widget(lbl) # Body: one row per source self._grid_body.cols = len(dests) + 1 # +1 for label column for src in sources: # Source label row_label = Label( text=src, color=Colors.TEXT_SECONDARY, font_size=Fonts.TINY, size_hint=(None, None), size=(Sizes.ROUTE_LABEL_WIDTH, Sizes.ROUTE_LABEL_HEIGHT), halign="right", ) row_label.bind(size=row_label.setter("text_size")) self._grid_body.add_widget(row_label) for dest in dests: cell = RoutingCell( source=src, dest=dest, active=False, size_hint=(None, None), size=(Sizes.CELL_SIZE, Sizes.CELL_SIZE), ) cell.bind(on_press=self._make_cell_callback(src, dest)) self._cells[(src, dest)] = cell self._grid_body.add_widget(cell) def _make_cell_callback(self, src: str, dest: str): def cb(instance): if self.client: # Send routing change via REST self.client.set_parameter( "routing", 1.0 if instance.is_active else 0.0, channel=-1, ) return cb def _fetch_routing(self) -> None: if not self.client: return self.client.fetch_routing( on_success=self._on_routing_received, on_error=lambda e: None, ) def _on_routing_received(self, routing: dict) -> None: """Update cell states from routing data.""" edges = routing.get("edges", []) # Reset all cells for cell in self._cells.values(): cell.is_active = False cell._update_color() # Activate connected edges for edge in edges: src = edge.get("source", "") dest = edge.get("dest", "") is_active = edge.get("is_active", True) key = (src, dest) if key in self._cells: self._cells[key].is_active = is_active self._cells[key]._update_color()