From 4f66ddb51521e1d8f151d05bf694fbd76a525cc9 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 2 Jul 2026 09:25:52 -0400 Subject: [PATCH] =?UTF-8?q?P6b:=20mobile=20node=20editor=20=E2=80=94=20Foc?= =?UTF-8?q?us=20&=20Patch=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the desktop node-graph canvas with touch-native views on mobile (wireframe Plate 07), gated on is_mobile in NodeGraphPane::render_content; the desktop draw_graph_editor path is untouched. New submodule panes/node_graph/mobile.rs. Focus view: - The focused module's parameters as touch controls (slider / dropdown / field by the desktop widget rule, with a visible slider rail); editing mutates the graph ValueType so the existing check_parameter_changes dispatches SetParameterAction. - A full-width minimap strip (tap the nearest node to focus it), travel chips naming connected endpoints (tap to jump), and an Add-node picker that creates the frontend node + AddNodeAction and focuses it. - Bespoke nodes (Sampler/Script/Sequencer/AmpSim/Oscilloscope) render their existing desktop bottom_ui; sampler/script-sample loads are wired (other custom interactions are a case-by-case follow-up). Patch view: - Per-section egui::Grid so input/output port arrows align in a column. Each port's Lucide direction arrow (from-line/to-line, tinted by DataType) is clickable to arm a cable; a compatibility-filtered picker completes it (ConnectAction). Cable chips (remote-node · remote-port) tap to disconnect (DisconnectAction). Parallel cables allowed. Also: add ARROW_RIGHT_FROM_LINE / ARROW_RIGHT_TO_LINE to mobile/icons.rs (codepoints extracted from lucide.ttf via ttx). --- .../lightningbeam-editor/src/mobile/icons.rs | 2 + .../src/panes/node_graph/mobile.rs | 736 ++++++++++++++++++ .../src/panes/node_graph/mod.rs | 12 + 3 files changed, 750 insertions(+) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index 22c9eaf..979abd5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -36,6 +36,8 @@ pub fn font(size: f32) -> egui::FontId { pub const MAXIMIZE: &str = "\u{e112}"; pub const MINIMIZE: &str = "\u{e11a}"; pub const ARROW_LEFT_RIGHT: &str = "\u{e24a}"; +pub const ARROW_RIGHT_FROM_LINE: &str = "\u{e458}"; // output port +pub const ARROW_RIGHT_TO_LINE: &str = "\u{e459}"; // input port pub const GRIP_HORIZONTAL: &str = "\u{e0ea}"; pub const CHEVRONS_UP: &str = "\u{e074}"; pub const PLAY: &str = "\u{e13c}"; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs new file mode 100644 index 0000000..c35a0c0 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs @@ -0,0 +1,736 @@ +//! Mobile (touch) node editor — Focus & Patch views (wireframe Plate 07). +//! +//! Swapped in for the desktop `draw_graph_editor` canvas when `shared.is_mobile`. Focus shows one +//! module's parameters as big touch controls plus navigation; Patch does tap-to-cable wiring. All +//! edits reuse the existing dispatch: mutating a param's `ValueType` in place is picked up by +//! `check_parameter_changes`, and add/connect/disconnect go through `NodeGraphAction`. + +use super::graph_data::{DataType, NodeTemplate, ValueType}; +use super::{actions, NodeGraphPane}; +use crate::mobile::icons; +use eframe::egui; +use egui_node_graph2::{InputId, InputParamKind, NodeDataTrait, NodeId, NodeTemplateTrait, OutputId}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum NodeViewMode { + Focus, + Patch, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PortDir { + In, + Out, +} + +/// An armed port awaiting a compatible endpoint to cable to. +#[derive(Clone, Copy)] +pub struct PatchPick { + node: NodeId, + port: usize, + dir: PortDir, + typ: DataType, +} + +pub struct MobileNodeState { + pub mode: NodeViewMode, + /// The module currently shown in Focus (and centred in Patch). + pub focus_node: Option, + /// Armed cable source in Patch: (node, output-port index). + pub patch_source: Option<(NodeId, usize)>, + /// Whether the add-node picker overlay is open. + pub show_add: bool, + /// Search filter in the add-node picker. + pub add_search: String, + /// Armed port in Patch awaiting a compatible endpoint. + patch_pick: Option, +} + +impl Default for MobileNodeState { + fn default() -> Self { + Self { + mode: NodeViewMode::Focus, + focus_node: None, + patch_source: None, + show_add: false, + add_search: String::new(), + patch_pick: None, + } + } +} + +impl NodeGraphPane { + pub(super) fn render_mobile( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &mut crate::panes::SharedPaneState, + ) { + let bg = shared.theme.bg_color(&["#node-editor", ".pane-content"], ui.ctx(), egui::Color32::from_gray(28)); + ui.painter().rect_filled(rect, 0.0, bg); + + // Resolve the focus node: keep the current one if it still exists, else the selected node, + // else the first node in the graph. + let focus_valid = self + .mobile + .focus_node + .map_or(false, |id| self.state.graph.nodes.get(id).is_some()); + if !focus_valid { + self.mobile.focus_node = self + .state + .selected_nodes + .iter() + .next() + .copied() + .or_else(|| self.state.graph.iter_nodes().next()); + } + + // Header: Focus/Patch toggle + focused node name. + let header_h = 44.0; + let header = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), rect.top() + header_h)); + let body = egui::Rect::from_min_max(egui::pos2(rect.left(), header.bottom()), rect.max); + + let name = self + .mobile + .focus_node + .and_then(|id| self.state.graph.nodes.get(id)) + .map(|n| n.label.clone()) + .unwrap_or_else(|| "—".to_string()); + ui.painter().line_segment( + [egui::pos2(header.left(), header.bottom()), egui::pos2(header.right(), header.bottom())], + egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + ); + let mut mode = self.mobile.mode; + ui.scope_builder( + egui::UiBuilder::new().max_rect(header.shrink(8.0)).layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + if ui.selectable_label(mode == NodeViewMode::Focus, "Focus").clicked() { + mode = NodeViewMode::Focus; + } + if ui.selectable_label(mode == NodeViewMode::Patch, "Patch").clicked() { + mode = NodeViewMode::Patch; + } + ui.add_space(8.0); + ui.label(egui::RichText::new(name).strong()); + }, + ); + self.mobile.mode = mode; + + // Keep backend-id map current so embedded `bottom_ui` (sampler/script/etc.) targets the + // right backend node (mirrors the desktop pre-draw sync). + self.user_state.node_backend_ids = self + .node_id_map + .iter() + .map(|(&nid, bid)| (nid, bid.index())) + .collect(); + + match self.mobile.mode { + NodeViewMode::Focus => self.render_mobile_focus(ui, body, shared), + NodeViewMode::Patch => self.render_mobile_patch(ui, body, shared), + } + + // Same dispatch tail as the desktop path: apply param edits and run any queued action. + self.check_parameter_changes(shared); + self.execute_pending_action(shared); + + // Drain the custom-UI loads that have self-contained handlers (sampler + script sample). + // Other bespoke interactions (sequencer grid, NAM model, script canvas) are a case-by-case + // follow-up; clear their queues so they don't accumulate. + if let Some(load) = self.user_state.pending_sampler_load.take() { + self.handle_pending_sampler_load(load, shared); + } + if let Some(load) = self.user_state.pending_script_sample_load.take() { + self.handle_pending_script_sample_load(load, shared); + } + self.user_state.pending_sequencer_changes.clear(); + self.user_state.pending_draw_param_changes.clear(); + self.user_state.pending_root_note_changes.clear(); + } + + fn render_mobile_focus( + &mut self, + ui: &mut egui::Ui, + body: egui::Rect, + shared: &mut crate::panes::SharedPaneState, + ) { + let Some(focus) = self.mobile.focus_node else { + return; + }; + + // Full-width minimap strip across the top; everything else below it. + let mm_h = 96.0; + let mm = egui::Rect::from_min_max(body.min, egui::pos2(body.right(), body.top() + mm_h)); + // Reserve an add-node button strip at the bottom. + let add_h = 46.0; + let add_rect = egui::Rect::from_min_max(egui::pos2(body.left(), body.bottom() - add_h), body.max); + let content = egui::Rect::from_min_max(egui::pos2(body.left(), mm.bottom()), egui::pos2(body.right(), add_rect.top())); + + // Minimap first (tap a node to focus it). + let mut jump: Option = self.render_minimap(ui, mm, focus); + + // Connection travel chips (owned) + params. + let (in_chips, out_chips) = self.focus_chips(focus); + let inputs: Vec<(String, InputId)> = self + .state + .graph + .nodes + .get(focus) + .map(|n| n.inputs.clone()) + .unwrap_or_default(); + + ui.scope_builder( + egui::UiBuilder::new().max_rect(content.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + if !in_chips.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("in").weak()); + for (label, node) in &in_chips { + if ui.button(label.as_str()).clicked() { + jump = Some(*node); + } + } + }); + } + if !out_chips.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("out").weak()); + for (label, node) in &out_chips { + if ui.button(label.as_str()).clicked() { + jump = Some(*node); + } + } + }); + } + if !in_chips.is_empty() || !out_chips.is_empty() { + ui.separator(); + } + egui::ScrollArea::vertical().show(ui, |ui| { + let mut any = false; + for (name, input_id) in &inputs { + let Some(param) = self.state.graph.inputs.get_mut(*input_id) else { + continue; + }; + if matches!(param.kind, InputParamKind::ConnectionOnly) { + continue; + } + any = true; + render_param_row(ui, name, &mut param.value); + ui.add_space(6.0); + } + if !any { + ui.label(egui::RichText::new("No editable parameters").weak()); + } + // Embedded desktop custom UI (sampler picker, sequencer grid, script UI, …). + // Standard nodes render nothing here. + if let Some(node) = self.state.graph.nodes.get(focus) { + let _ = node.user_data.bottom_ui(ui, focus, &self.state.graph, &mut self.user_state); + } + }); + }, + ); + + // Add-node button. + ui.scope_builder( + egui::UiBuilder::new().max_rect(add_rect.shrink(8.0)).layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + if ui.button(egui::RichText::new("+ Add node").size(15.0)).clicked() { + self.mobile.show_add = true; + } + }, + ); + + if let Some(nid) = jump { + self.mobile.focus_node = Some(nid); + } + + if self.mobile.show_add { + self.render_add_picker(ui, body, shared); + } + } + + /// Connection chips for the focus node: (label, remote node) for upstream inputs and downstream + /// outputs. Label names the remote endpoint (`node ▸ port`). + fn focus_chips(&self, focus: NodeId) -> (Vec<(String, NodeId)>, Vec<(String, NodeId)>) { + let mut ins = Vec::new(); + let mut outs = Vec::new(); + for (input_id, outputs) in self.state.graph.iter_connection_groups() { + let in_node = self.state.graph.inputs.get(input_id).map(|p| p.node); + for output_id in outputs { + let out_node = self.state.graph.outputs.get(output_id).map(|p| p.node); + if in_node == Some(focus) { + if let Some(src) = out_node { + ins.push((format!("{} · {}", self.node_label(src), self.output_port_name(src, output_id)), src)); + } + } + if out_node == Some(focus) { + if let Some(dst) = in_node { + outs.push((format!("{} · {}", self.node_label(dst), self.input_port_name(dst, input_id)), dst)); + } + } + } + } + (ins, outs) + } + + fn node_label(&self, n: NodeId) -> String { + self.state.graph.nodes.get(n).map(|x| x.label.clone()).unwrap_or_default() + } + fn output_port_name(&self, n: NodeId, oid: OutputId) -> String { + self.state + .graph + .nodes + .get(n) + .and_then(|node| node.outputs.iter().find(|(_, id)| *id == oid).map(|(nm, _)| nm.clone())) + .unwrap_or_default() + } + fn input_port_name(&self, n: NodeId, iid: InputId) -> String { + self.state + .graph + .nodes + .get(n) + .and_then(|node| node.inputs.iter().find(|(_, id)| *id == iid).map(|(nm, _)| nm.clone())) + .unwrap_or_default() + } + + /// Draw a minimap of the graph; returns a node id if the user tapped one. + fn render_minimap(&self, ui: &mut egui::Ui, rect: egui::Rect, focus: NodeId) -> Option { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 4.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120)); + painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.0, egui::Color32::from_gray(70)), egui::StrokeKind::Inside); + + let nodes: Vec<(NodeId, egui::Pos2)> = self + .state + .graph + .iter_nodes() + .filter_map(|n| self.state.node_positions.get(n).map(|p| (n, *p))) + .collect(); + if nodes.is_empty() { + return None; + } + let (mut mnx, mut mny, mut mxx, mut mxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for (_, p) in &nodes { + mnx = mnx.min(p.x); + mny = mny.min(p.y); + mxx = mxx.max(p.x); + mxy = mxy.max(p.y); + } + let pad = 10.0; + let inner = rect.shrink(pad); + let span = egui::vec2((mxx - mnx).max(1.0), (mxy - mny).max(1.0)); + let map = |p: egui::Pos2| { + egui::pos2( + inner.left() + (p.x - mnx) / span.x * inner.width(), + inner.top() + (p.y - mny) / span.y * inner.height(), + ) + }; + + // Connection lines. + for (input_id, outputs) in self.state.graph.iter_connection_groups() { + let in_pos = self.state.graph.inputs.get(input_id).and_then(|p| self.state.node_positions.get(p.node)).copied(); + for output_id in outputs { + let out_pos = self.state.graph.outputs.get(output_id).and_then(|p| self.state.node_positions.get(p.node)).copied(); + if let (Some(a), Some(b)) = (out_pos, in_pos) { + painter.line_segment([map(a), map(b)], egui::Stroke::new(1.0, egui::Color32::from_gray(90))); + } + } + } + + let resp = ui.interact(rect, ui.id().with("node_minimap"), egui::Sense::click()); + let click = if resp.clicked() { resp.interact_pointer_pos() } else { None }; + let mut hit = None; + let mut best_d = f32::MAX; + for (nid, p) in &nodes { + let c = map(*p); + let focused = *nid == focus; + let color = if focused { egui::Color32::from_rgb(0x4a, 0xa3, 0xff) } else { egui::Color32::from_gray(200) }; + painter.circle_filled(c, if focused { 7.0 } else { 5.0 }, color); + if focused { + painter.circle_stroke(c, 9.0, egui::Stroke::new(1.5, color)); + } + // Tap selects the nearest node (dots are small, so don't require a precise hit). + if let Some(cp) = click { + let d = (cp - c).length(); + if d < best_d { + best_d = d; + hit = Some(*nid); + } + } + } + hit + } + + /// The add-node picker overlay: a searchable list of templates. + fn render_add_picker(&mut self, ui: &mut egui::Ui, body: egui::Rect, shared: &mut crate::panes::SharedPaneState) { + let panel = egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(320.0), body.height().min(420.0))); + let mut chosen: Option = None; + let mut close = false; + ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Add node").strong()); + if ui.button("✕").clicked() { + close = true; + } + }); + ui.add(egui::TextEdit::singleline(&mut self.mobile.add_search).hint_text("Search…")); + let q = self.mobile.add_search.to_lowercase(); + egui::ScrollArea::vertical().show(ui, |ui| { + for template in NodeTemplate::all_finder_kinds() { + let label = template.node_finder_label(&mut self.user_state).to_string(); + if !q.is_empty() && !label.to_lowercase().contains(&q) { + continue; + } + if ui.button(&label).clicked() { + chosen = Some(template); + } + } + }); + }); + }); + if let Some(t) = chosen { + self.mobile_add_node(t, shared); + self.mobile.show_add = false; + self.mobile.add_search.clear(); + } + if close { + self.mobile.show_add = false; + } + } + + /// Create a node (frontend + backend action) and focus it, mirroring the desktop CreatedNode path. + fn mobile_add_node(&mut self, template: NodeTemplate, _shared: &mut crate::panes::SharedPaneState) { + let Some(track_id) = self.track_id else { + return; + }; + // Place near the current focus node, else the origin. + let base = self + .mobile + .focus_node + .and_then(|id| self.state.node_positions.get(id).copied()) + .unwrap_or(egui::Pos2::ZERO); + let pos = base + egui::vec2(160.0, 0.0); + + let label = template.node_graph_label(&mut self.user_state); + let user_data = template.user_data(&mut self.user_state); + let new_id = self + .state + .graph + .add_node(label, user_data, |graph, id| template.build_node(graph, &mut self.user_state, id)); + self.state.node_positions.insert(new_id, pos); + + let node_type = template.backend_type_name().to_string(); + self.pending_action = Some(Box::new(actions::NodeGraphAction::AddNode( + actions::AddNodeAction::new(track_id, node_type.clone(), (pos.x, pos.y)), + ))); + self.pending_node_addition = Some((new_id, node_type, (pos.x, pos.y))); + self.mobile.focus_node = Some(new_id); + } + + fn render_mobile_patch( + &mut self, + ui: &mut egui::Ui, + body: egui::Rect, + _shared: &mut crate::panes::SharedPaneState, + ) { + let Some(focus) = self.mobile.focus_node else { + return; + }; + + // Build owned row data (immutable reads) so rendering can freely queue ops. + let inputs: Vec<(String, InputId)> = self.state.graph.nodes.get(focus).map(|n| n.inputs.clone()).unwrap_or_default(); + let outputs: Vec<(String, OutputId)> = self.state.graph.nodes.get(focus).map(|n| n.outputs.clone()).unwrap_or_default(); + + // Rows: (idx, name, type, cables[(remote_node, remote_port, other_node, other_port)]). + let mut in_rows: Vec<(usize, String, DataType, Vec<(String, String, NodeId, usize)>)> = Vec::new(); + for (idx, (name, input_id)) in inputs.iter().enumerate() { + let Some(typ) = self.state.graph.inputs.get(*input_id).map(|p| p.typ) else { continue }; + let mut cables = Vec::new(); + if let Some(outs) = self.state.graph.connections.get(*input_id) { + for oid in outs { + if let Some(src) = self.state.graph.outputs.get(*oid).map(|o| o.node) { + let sp = self.output_port_index(src, *oid); + cables.push((self.node_label(src), self.output_port_name(src, *oid), src, sp)); + } + } + } + in_rows.push((idx, name.clone(), typ, cables)); + } + let mut out_rows: Vec<(usize, String, DataType, Vec<(String, String, NodeId, usize)>)> = Vec::new(); + for (idx, (name, output_id)) in outputs.iter().enumerate() { + let Some(typ) = self.state.graph.outputs.get(*output_id).map(|o| o.typ) else { continue }; + let mut cables = Vec::new(); + for (input_id, outs) in self.state.graph.iter_connection_groups() { + if outs.iter().any(|o| o == output_id) { + if let Some(dst) = self.state.graph.inputs.get(input_id).map(|i| i.node) { + let dp = self.input_port_index(dst, input_id); + cables.push((self.node_label(dst), self.input_port_name(dst, input_id), dst, dp)); + } + } + } + out_rows.push((idx, name.clone(), typ, cables)); + } + + // Queue ops during render, apply after (avoids borrowing self mid-render). + enum Op { + Disconnect(NodeId, usize, NodeId, usize), // (out_node, out_port, in_node, in_port) + Connect(NodeId, usize, NodeId, usize), + Pick(PatchPick), + ClearPick, + } + let mut ops: Vec = Vec::new(); + let pick = self.mobile.patch_pick; + + ui.scope_builder( + egui::UiBuilder::new().max_rect(body.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + // A Grid aligns each section's port arrows into a column. Tapping a port arrow + // arms a cable from it (also for unconnected ports); tapping a cable chip removes + // it. Inputs read `[cables] ⥓ name`; outputs read `name ↦ [cables]`. + ui.label(egui::RichText::new("Inputs").weak()); + egui::Grid::new("patch_inputs").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + for (idx, name, typ, cables) in &in_rows { + ui.horizontal(|ui| { + for (rnode, rport, sn, sp) in cables { + if cable_chip(ui, rnode, rport, *typ).clicked() { + ops.push(Op::Disconnect(*sn, *sp, focus, *idx)); + } + } + }); + if arrow_button(ui, icons::ARROW_RIGHT_TO_LINE, *typ).clicked() { + ops.push(Op::Pick(PatchPick { node: focus, port: *idx, dir: PortDir::In, typ: *typ })); + } + ui.label(name.as_str()); + ui.end_row(); + } + }); + ui.add_space(10.0); + ui.label(egui::RichText::new("Outputs").weak()); + egui::Grid::new("patch_outputs").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + for (idx, name, typ, cables) in &out_rows { + ui.label(name.as_str()); + if arrow_button(ui, icons::ARROW_RIGHT_FROM_LINE, *typ).clicked() { + ops.push(Op::Pick(PatchPick { node: focus, port: *idx, dir: PortDir::Out, typ: *typ })); + } + ui.horizontal(|ui| { + for (rnode, rport, dn, dp) in cables { + if cable_chip(ui, rnode, rport, *typ).clicked() { + ops.push(Op::Disconnect(focus, *idx, *dn, *dp)); + } + } + }); + ui.end_row(); + } + }); + }); + }, + ); + + // Compatible-endpoint picker overlay when a port is armed. + if let Some(p) = pick { + let candidates = self.patch_candidates(p); + let panel = egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(320.0), body.height().min(360.0))); + ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + port_marker(ui, p.typ, p.dir); + ui.label(egui::RichText::new("Connect to…").strong()); + if ui.button("✕").clicked() { + ops.push(Op::ClearPick); + } + }); + egui::ScrollArea::vertical().show(ui, |ui| { + if candidates.is_empty() { + ui.label(egui::RichText::new("No compatible ports").weak()); + } + for (label, other, other_port) in &candidates { + if ui.button(egui::RichText::new(label.as_str()).color(type_color(p.typ))).clicked() { + // Orient the cable: armed In needs an Out source; armed Out needs an In target. + match p.dir { + PortDir::In => ops.push(Op::Connect(*other, *other_port, p.node, p.port)), + PortDir::Out => ops.push(Op::Connect(p.node, p.port, *other, *other_port)), + } + ops.push(Op::ClearPick); + } + } + }); + }); + }); + } + + for op in ops { + match op { + Op::Disconnect(on, op_, inn, ip) => self.mobile_disconnect(on, op_, inn, ip), + Op::Connect(on, op_, inn, ip) => self.mobile_connect(on, op_, inn, ip), + Op::Pick(p) => self.mobile.patch_pick = Some(p), + Op::ClearPick => self.mobile.patch_pick = None, + } + } + } + + /// Compatible endpoints on OTHER nodes for an armed port (opposite direction, same type). + fn patch_candidates(&self, p: PatchPick) -> Vec<(String, NodeId, usize)> { + let mut out = Vec::new(); + for node in self.state.graph.iter_nodes() { + if node == p.node { + continue; + } + let Some(n) = self.state.graph.nodes.get(node) else { continue }; + match p.dir { + // Armed input → list outputs of matching type. + PortDir::In => { + for (i, (name, oid)) in n.outputs.iter().enumerate() { + if self.state.graph.outputs.get(*oid).map(|o| o.typ) == Some(p.typ) { + out.push((format!("{} · {}", self.node_label(node), name), node, i)); + } + } + } + // Armed output → list inputs of matching type. + PortDir::Out => { + for (i, (name, iid)) in n.inputs.iter().enumerate() { + if self.state.graph.inputs.get(*iid).map(|x| x.typ) == Some(p.typ) { + out.push((format!("{} · {}", self.node_label(node), name), node, i)); + } + } + } + } + } + out + } + + fn output_port_index(&self, n: NodeId, oid: OutputId) -> usize { + self.state.graph.nodes.get(n).and_then(|node| node.outputs.iter().position(|(_, id)| *id == oid)).unwrap_or(0) + } + fn input_port_index(&self, n: NodeId, iid: InputId) -> usize { + self.state.graph.nodes.get(n).and_then(|node| node.inputs.iter().position(|(_, id)| *id == iid)).unwrap_or(0) + } + + /// Cable an output port → input port: update the frontend graph + dispatch `Connect`. + fn mobile_connect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) { + let Some(track_id) = self.track_id else { return }; + let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id); + let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id); + let (Some(output_id), Some(input_id)) = (output_id, input_id) else { return }; + if let Some(conns) = self.state.graph.connections.get_mut(input_id) { + if !conns.contains(&output_id) { + conns.push(output_id); + } + } else { + self.state.graph.connections.insert(input_id, vec![output_id]); + } + if let (Some(&from_id), Some(&to_id)) = (self.node_id_map.get(&out_node), self.node_id_map.get(&in_node)) { + self.pending_action = Some(Box::new(actions::NodeGraphAction::Connect( + actions::ConnectAction::new(track_id, from_id, out_port, to_id, in_port), + ))); + } + } + + /// Remove a cable: update the frontend graph + dispatch `Disconnect`. + fn mobile_disconnect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) { + let Some(track_id) = self.track_id else { return }; + let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id); + let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id); + let (Some(output_id), Some(input_id)) = (output_id, input_id) else { return }; + if let Some(conns) = self.state.graph.connections.get_mut(input_id) { + conns.retain(|o| *o != output_id); + } + if let (Some(&from_id), Some(&to_id)) = (self.node_id_map.get(&out_node), self.node_id_map.get(&in_node)) { + self.pending_action = Some(Box::new(actions::NodeGraphAction::Disconnect( + actions::DisconnectAction::new(track_id, from_id, out_port, to_id, in_port), + ))); + } + } +} + +/// Desktop port color per signal type (matches `DataType::data_type_color`). +fn type_color(t: DataType) -> egui::Color32 { + match t { + DataType::Audio => egui::Color32::from_rgb(100, 150, 255), // blue + DataType::Midi => egui::Color32::from_rgb(100, 255, 100), // green + DataType::CV => egui::Color32::from_rgb(255, 150, 100), // orange + } +} + +/// A single Lucide direction arrow glyph, tinted by signal type. +fn arrow(ui: &mut egui::Ui, glyph: &str, t: DataType) { + ui.label( + egui::RichText::new(glyph) + .color(type_color(t)) + .family(egui::FontFamily::Name(icons::FAMILY.into())) + .size(15.0), + ); +} + +/// A port marker: a Lucide direction arrow (out = from-line, in = to-line) tinted by signal type. +fn port_marker(ui: &mut egui::Ui, t: DataType, dir: PortDir) { + let glyph = match dir { + PortDir::In => icons::ARROW_RIGHT_TO_LINE, + PortDir::Out => icons::ARROW_RIGHT_FROM_LINE, + }; + arrow(ui, glyph, t); +} + +/// A clickable, aligned port arrow (out = from-line, in = to-line) tinted by signal type. Tapping it +/// arms a cable from the port. Frameless so it reads like the desktop port dot. +fn arrow_button(ui: &mut egui::Ui, glyph: &str, t: DataType) -> egui::Response { + ui.add( + egui::Button::new( + egui::RichText::new(glyph) + .color(type_color(t)) + .family(egui::FontFamily::Name(icons::FAMILY.into())) + .size(16.0), + ) + .frame(false), + ) +} + +/// A cable badge naming the remote endpoint (`remote-node · remote-port`), framed and tinted by +/// signal type. Returns a click response (used to disconnect the cable). +fn cable_chip(ui: &mut egui::Ui, remote_node: &str, remote_port: &str, t: DataType) -> egui::Response { + egui::Frame::group(ui.style()) + .inner_margin(egui::Margin::symmetric(6, 1)) + .show(ui, |ui| { + ui.label(egui::RichText::new(format!("{remote_node} · {remote_port}")).color(type_color(t))); + }) + .response + .interact(egui::Sense::click()) +} + +/// One parameter row: a touch control matching the desktop widget rule (enum → dropdown, ranged → +/// slider, plain → stepper, string → field). Mutates the value in place; dispatch happens later via +/// `check_parameter_changes`. +fn render_param_row(ui: &mut egui::Ui, name: &str, value: &mut ValueType) { + match value { + ValueType::Float { value, min, max, unit, enum_labels, .. } => { + ui.label(name); + if let Some(labels) = enum_labels { + let mut sel = (*value as usize).min(labels.len().saturating_sub(1)); + egui::ComboBox::from_id_salt(name) + .width(ui.available_width().min(240.0)) + .selected_text(labels.get(sel).copied().unwrap_or("?")) + .show_ui(ui, |ui| { + for (i, label) in labels.iter().enumerate() { + ui.selectable_value(&mut sel, i, *label); + } + }); + *value = sel as f32; + } else if *max > *min { + // Give the rail a visible color so the full track length reads (the theme's default + // inactive fill can be near-transparent); trailing_fill shows progress along it. + ui.scope(|ui| { + let rail = egui::Color32::from_gray(90); + ui.visuals_mut().widgets.inactive.bg_fill = rail; + ui.visuals_mut().widgets.inactive.weak_bg_fill = rail; + ui.visuals_mut().widgets.hovered.bg_fill = rail; + ui.add(egui::Slider::new(value, *min..=*max).suffix(*unit).trailing_fill(true)); + }); + } else { + ui.add(egui::DragValue::new(value).speed(0.1)); + } + } + ValueType::String { value } => { + ui.label(name); + ui.text_edit_singleline(value); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index 7b6bca1..0b36b85 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -6,6 +6,7 @@ pub mod actions; pub mod audio_backend; pub mod backend; pub mod graph_data; +mod mobile; use backend::{BackendNodeId, GraphBackend}; use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NamModelInfo, NodeData, NodeTemplate, PendingAmpSimLoad, ValueType}; @@ -142,6 +143,9 @@ pub struct NodeGraphPane { last_oscilloscope_poll: std::time::Instant, /// Backend track ID (u32) for oscilloscope queries backend_track_id: Option, + + /// Mobile (touch) Focus/Patch view state. + mobile: mobile::MobileNodeState, } impl NodeGraphPane { @@ -171,6 +175,7 @@ impl NodeGraphPane { pending_script_resolutions: Vec::new(), last_oscilloscope_poll: std::time::Instant::now(), backend_track_id: None, + mobile: mobile::MobileNodeState::default(), } } @@ -2365,6 +2370,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane { painter.galley(text_pos, galley, text_color); return; } + + // Mobile: touch-native Focus/Patch views instead of the desktop canvas. + if shared.is_mobile { + self.render_mobile(ui, rect, shared); + return; + } + // Poll oscilloscope data at ~20 FPS let has_oscilloscopes; if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) {