#include "graph_widget.hpp" #include "xtc/xtc_plugin.h" #include #include #include #include #include #include #include #include namespace { constexpr int node_width = 150, node_height = 74; /** * @brief Returns the display label for a node type. * * @param type Node type to describe. * @return Borrowed static string; never NULL, falling back to "Unknown". */ const char *node_name(xtc_node_type type) { switch (type) { case XTC_NODE_OSCILLATOR: return "Oscillator"; case XTC_NODE_GAIN: return "Gain / bus"; case XTC_NODE_METER: return "Meter"; case XTC_NODE_PLUGIN: return "LADSPA plugin"; case XTC_NODE_NATIVE: return "Native processor"; case XTC_NODE_INSTRUMENT: return "MIDI mix"; case XTC_NODE_AUDIO: return "Audio mix"; case XTC_NODE_OUTPUT: return "Output sum"; } return "Unknown"; } } // namespace /** * @brief Constructs the widget and initializes its cached layout from the graph. * * The graph, handler, context, and feedback box are stored without taking * ownership. Layout population is delegated to set_graph(). * * @param left Left coordinate in FLTK screen space. * @param top Top coordinate in FLTK screen space. * @param width Widget width in pixels. * @param height Widget height in pixels. * @param graph Borrowed graph snapshot; must outlive the widget's use of it. * @param handler Edit callback invoked for every graph mutation. * @param context Opaque pointer passed back to @p handler; may be NULL. * @param feedback_box Borrowed label used to report edit results. */ GraphWidget::GraphWidget(int left, int top, int width, int height, const xtc_graph *graph, EditHandler handler, void *context, Fl_Box *feedback_box) : Fl_Widget(left, top, width, height), graph_(graph), handler_(handler), context_(context), feedback_(feedback_box) { set_graph(graph); } /** * @brief Replaces the graph snapshot and rebuilds cached node positions. * * Existing positions are preserved by node ID. New nodes get grid slots that do * not overlap previously known nodes. The selection is dropped when the selected * node or wire is no longer present. The widget does not take ownership of the * graph. * * @param graph Borrowed graph that must outlive the widget's use of it. */ void GraphWidget::set_graph(const xtc_graph *graph) { graph_ = graph; routing_count_ = 0; (void)xtc_graph_analyze(graph_, routing_.data(), routing_.size(), &routing_count_); size_t nodes = 0U, edges = 0U; if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) { count_ = 0U; redraw(); return; } auto old = positions_; size_t old_count = count_; count_ = 0U; for (size_t i = 0U; i < nodes && i < positions_.size(); ++i) { xtc_node_info node{}; if (xtc_graph_get_node(graph_, i, &node) != XTC_OK) continue; Position position; position.id = node.id; bool found = false; for (size_t j = 0U; j < old_count; ++j) if (old[j].id == node.id) { position = old[j]; found = true; break; } if (!found) { for (size_t slot = 0U; slot <= positions_.size(); ++slot) { position.left = 40 + static_cast(slot % 8U) * 210; position.top = 40 + static_cast(slot / 8U) * 115; bool occupied = false; for (size_t j = 0U; j < old_count; ++j) if (std::abs(old[j].left - position.left) < node_width + 12 && std::abs(old[j].top - position.top) < node_height + 12) occupied = true; for (size_t j = 0U; j < count_; ++j) if (std::abs(positions_[j].left - position.left) < node_width + 12 && std::abs(positions_[j].top - position.top) < node_height + 12) occupied = true; if (!occupied) break; } } positions_[count_++] = position; } int left, top; if (!node_position(selected_node_, left, top)) selected_node_ = SIZE_MAX; if (edge_selected_ && !select_connection(selected_edge_.source_node, selected_edge_.destination_node)) edge_selected_ = false; grow_canvas(); redraw(); } /** * @brief Looks up the cached position of a node. * * @param id Stable node identifier. * @param left Receives the left position in widget-local coordinates. * @param top Receives the top position in widget-local coordinates. * @return True when the node has a cached position, false otherwise. */ bool GraphWidget::node_position(size_t id, int &left, int &top) const { for (size_t i = 0U; i < count_; ++i) if (positions_[i].id == id) { left = positions_[i].left; top = positions_[i].top; return true; } return false; } /** * @brief Moves a node within the widget, clamped to the visible bounds. * * The node keeps a 12-pixel margin from the widget edges. * * @param id Stable node identifier. * @param left Requested left position in widget-local coordinates. * @param top Requested top position in widget-local coordinates. * @return True when the node has a cached position, false otherwise. */ bool GraphWidget::move_node(size_t id, int left, int top) { for (size_t i = 0U; i < count_; ++i) if (positions_[i].id == id) { positions_[i].left = std::max(12, std::min(left, w() - node_width - 12)); positions_[i].top = std::max(12, std::min(top, h() - node_height - 12)); redraw(); return true; } return false; } /** * @brief Selects a node by ID and clears any wire selection. * * @param id Stable node identifier. * @return True when the node has a cached position, false otherwise. */ bool GraphWidget::select_node(size_t id) { int left, top; if (!node_position(id, left, top)) return false; selected_node_ = id; edge_selected_ = false; describe_selection(); redraw(); return true; } /** * @brief Selects a wire between two nodes and clears the node selection. * * @param source Source node identifier. * @param destination Destination node identifier. * @return True when a matching connection exists, false otherwise. */ bool GraphWidget::select_connection(size_t source, size_t destination) { size_t nodes, edges; if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) return false; for (size_t i = 0U; i < edges; ++i) { xtc_connection_info edge{}; if (xtc_graph_get_connection(graph_, i, &edge) == XTC_OK && edge.source_node == source && edge.destination_node == destination) { selected_edge_ = edge; edge_selected_ = true; selected_node_ = SIZE_MAX; describe_selection(); redraw(); return true; } } return false; } /** * @brief Shows the outcome of an edit in the feedback label. * * @param result Result returned by the edit handler. * @param success Text shown when @p result is XTC_OK; must be valid then and is ignored otherwise. */ void GraphWidget::feedback(xtc_result result, const char *success) { char text[256]; if (result == XTC_OK) std::snprintf(text, sizeof(text), "%s", success); else if (result == XTC_ERR_CAPACITY) std::snprintf( text, sizeof(text), "Graph unchanged: capacity exceeded (node limit or pending edit). Retry after the next audio block."); else if (result == XTC_ERR_GRAPH_CYCLE) std::snprintf(text, sizeof(text), "Graph unchanged: %s. Feedback loops are unsupported.", xtc_result_string(result)); else std::snprintf(text, sizeof(text), "Graph unchanged: %s. Connect an audio output (right) to an input (left); duplicate wires are rejected.", xtc_result_string(result)); feedback_->copy_label(text); } /** * @brief Requests creation of a node through the edit handler. * * Selects the new node when the edit succeeds. * * @param type Kind of node to add. * @return Result reported by the edit handler. */ xtc_result GraphWidget::add_node(xtc_node_type type) { xtc_graph_edit edit{}; edit.kind = XTC_EDIT_ADD_NODE; edit.data.add.type = type; edit.data.add.value = type == XTC_NODE_OSCILLATOR ? 440.0f : type == XTC_NODE_GAIN ? 0.1f : 0.0f; size_t added = SIZE_MAX; xtc_result result = handler_(context_, edit, &added); if (result == XTC_OK) select_node(added); feedback(result, "Node added. Drag its title to move; drag an output port to an input to connect."); return result; } /** * @brief Requests a connection between two ports through the edit handler. * * @param connection Connection describing the source and destination node/port pairs. * @return Result reported by the edit handler. */ xtc_result GraphWidget::connect_ports(const xtc_connection_info &connection) { xtc_graph_edit edit{}; edit.kind = XTC_EDIT_CONNECT; edit.data.connection = connection; xtc_result result = handler_(context_, edit, nullptr); feedback(result, "Connection added. Click a wire and press Delete to disconnect."); return result; } /** * @brief Requests removal of the selected wire or node through the edit handler. * * @return Result reported by the edit handler, or XTC_ERR_INVALID_STATE when * nothing is selected. */ xtc_result GraphWidget::delete_selected() { xtc_graph_edit edit{}; if (edge_selected_) { edit.kind = XTC_EDIT_DISCONNECT; edit.data.connection = selected_edge_; } else if (selected_node_ != SIZE_MAX) { edit.kind = XTC_EDIT_REMOVE_NODE; edit.data.node_id = selected_node_; } else { feedback_->copy_label("Select a node or wire first."); return XTC_ERR_INVALID_STATE; } xtc_result result = handler_(context_, edit, nullptr); feedback(result, "Selection deleted. Unwired inputs use external audio; select a node to inspect routing."); return result; } /** * @brief Computes the widget-local center of a node port. * * @param id Stable node identifier. * @param port Port index; 0 selects the input and 1 the output. * @param left Receives the port center x coordinate. * @param top Receives the port center y coordinate. * @return True when the node and port exist, false otherwise. */ bool GraphWidget::port_position(size_t id, size_t port, int &left, int &top) const { xtc_port_info info{}; if (xtc_graph_get_port(graph_, id, port, &info) != XTC_OK || !node_position(id, left, top)) return false; left += info.direction == XTC_PORT_OUTPUT ? node_width : 0; top += 48; return true; } /** * @brief Finds the port nearest to a widget-local point. * * Searches nodes in reverse draw order and accepts points within 9 pixels of a * port center. * * @param left Widget-local x coordinate to test. * @param top Widget-local y coordinate to test. * @param node Receives the matching node ID when true is returned. * @param port Receives the matching port index (0 input, 1 output) when true is returned. * @return True when a port is within range, false otherwise. */ bool GraphWidget::hit_port(int left, int top, size_t &node, size_t &port) const { for (size_t i = count_; i > 0U; --i) for (size_t p = 0U; p < 2U; ++p) { int px, py; if (port_position(positions_[i - 1U].id, p, px, py) && std::abs(px - left) <= 9 && std::abs(py - top) <= 9) { node = positions_[i - 1U].id; port = p; return true; } } return false; } /** * @brief Draws the background, wires, nodes, ports, and any pending wire. */ void GraphWidget::draw() { fl_push_clip(x(), y(), w(), h()); fl_color(fl_rgb_color(25, 28, 33)); fl_rectf(x(), y(), w(), h()); size_t nodes = 0U, edges = 0U; (void)xtc_graph_get_counts(graph_, &nodes, &edges); for (size_t i = 0U; i < edges; ++i) { xtc_connection_info edge{}; int sx, sy, dx, dy; if (xtc_graph_get_connection(graph_, i, &edge) != XTC_OK || !port_position(edge.source_node, 1U, sx, sy) || !port_position(edge.destination_node, 0U, dx, dy)) continue; bool selected = (edge_selected_ && selected_edge_.source_node == edge.source_node && selected_edge_.destination_node == edge.destination_node) || selected_node_ == edge.source_node || selected_node_ == edge.destination_node; xtc_routing_info route{}; bool audible_path = routing(edge.destination_node, route) && route.reaches_output; fl_color(selected ? FL_YELLOW : audible_path ? fl_rgb_color(100, 200, 190) : fl_rgb_color(110, 115, 125)); fl_line_style(FL_SOLID, selected ? 3 : 2); fl_line(x() + sx, y() + sy, x() + dx, y() + dy); double length = std::hypot(dx-sx, dy-sy); if (length > 20) { double ux = (dx-sx)/length, uy = (dy-sy)/length; int ax = x() + (sx+dx)/2, ay = y() + (sy+dy)/2; fl_line(ax, ay, ax-static_cast(ux*9-uy*5), ay-static_cast(uy*9+ux*5)); fl_line(ax, ay, ax-static_cast(ux*9+uy*5), ay-static_cast(uy*9-ux*5)); } } fl_line_style(0); for (size_t i = 0U; i < count_; ++i) { xtc_node_info node{}; if (xtc_graph_get_node(graph_, i, &node) != XTC_OK) continue; int left = x() + positions_[i].left, top = y() + positions_[i].top; fl_color(node.type == XTC_NODE_OUTPUT ? fl_rgb_color(100, 50, 55) : node.type == XTC_NODE_GAIN ? fl_rgb_color(75, 55, 95) : node.type == XTC_NODE_OSCILLATOR || node.type == XTC_NODE_AUDIO || node.type == XTC_NODE_INSTRUMENT ? fl_rgb_color(35, 80, 75) : fl_rgb_color(49, 65, 85)); fl_rectf(left, top, node_width, node_height); fl_color(selected_node_ == node.id ? FL_YELLOW : fl_rgb_color(115, 130, 145)); fl_rect(left, top, node_width, node_height); fl_color(FL_WHITE); fl_font(FL_HELVETICA_BOLD, 12); char text[96]; std::snprintf(text, sizeof(text), "%s #%zu", node_name(node.type), node.id); fl_push_clip(left+6, top+2, node_width-12, 40); fl_draw(text, left + 10, top + 20); fl_font(FL_HELVETICA, 12); if (node.type == XTC_NODE_OSCILLATOR) std::snprintf(text, sizeof(text), "%.0f Hz", static_cast(node.value)); else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast(node.value)); else if (node.type == XTC_NODE_PLUGIN) { xtc_plugin_spec plugin{}; if (xtc_graph_get_plugin(graph_,node.id,&plugin) == XTC_OK) std::snprintf(text,sizeof(text),"%.20s",plugin.label); } else std::snprintf(text, sizeof(text), "%s", node.type == XTC_NODE_METER ? "Peak pass-through" : node.type == XTC_NODE_INSTRUMENT ? "MIDI sine source" : node.type == XTC_NODE_AUDIO ? "Timeline mix source" : "Summed engine output"); fl_draw(text, left + 10, top + 37); fl_pop_clip(); xtc_routing_info route{}; if (routing(node.id, route) && !route.reaches_output) { fl_color(FL_YELLOW); fl_draw("! no output path", left + 10, top + 72); } for (size_t port = 0U; port < 2U; ++port) { int px, py; if (!port_position(node.id, port, px, py)) continue; fl_color(fl_rgb_color(110, 220, 185)); fl_pie(x() + px - 5, y() + py - 5, 10, 10, 0, 360); fl_color(FL_WHITE); fl_draw(port == 0U ? (route.external_input ? "ext in" : "sum in") : "audio out", left + (port == 0U ? 10 : 90), top + 56); } } if (wiring_) { int px, py; if (port_position(wire_node_, wire_port_, px, py)) { fl_color(FL_YELLOW); fl_line(x() + px, y() + py, x() + mouse_x_, y() + mouse_y_); } } fl_pop_clip(); } /** * @brief Handles mouse and keyboard events for selection, dragging, and wiring. * * @param event FLTK event code. * @return 1 when the event was consumed, 0 for unsupported left-button input, * or the result of Fl_Widget::handle() for unhandled events. */ int GraphWidget::handle(int event) { const int left = Fl::event_x() - x(), top = Fl::event_y() - y(); switch (event) { case FL_FOCUS: case FL_UNFOCUS: return 1; case FL_PUSH: { if (Fl::event_button() == FL_RIGHT_MOUSE) { Fl_Menu_Item items[5]{}; items[0].text = "Inspect selection"; items[1].text = "Arrange by signal flow (L)"; items[2].text = "Insert unity gain in selected wire (I)"; items[3].text = "Delete selection"; const auto *picked = items->popup(Fl::event_x(), Fl::event_y()); if (picked == items) describe_selection(); else if (picked == items+1) auto_layout(); else if (picked == items+2) insert_gain(); else if (picked == items+3) delete_selected(); return 1; } if (Fl::event_button() != FL_LEFT_MOUSE) return 0; take_focus(); mouse_x_ = left; mouse_y_ = top; dragging_ = false; wiring_ = false; if (hit_port(left, top, wire_node_, wire_port_)) { wiring_ = true; redraw(); return 1; } for (size_t i = count_; i > 0U; --i) { const Position &pos = positions_[i - 1U]; if (left >= pos.left && left <= pos.left + node_width && top >= pos.top && top <= pos.top + node_height) { select_node(pos.id); dragging_ = true; drag_x_ = left - pos.left; drag_y_ = top - pos.top; return 1; } } size_t nodes, edges; (void)xtc_graph_get_counts(graph_, &nodes, &edges); for (size_t i = 0U; i < edges; ++i) { xtc_connection_info edge{}; int sx, sy, dx, dy; (void)xtc_graph_get_connection(graph_, i, &edge); if (!port_position(edge.source_node, 1U, sx, sy) || !port_position(edge.destination_node, 0U, dx, dy)) continue; double vx = dx - sx, vy = dy - sy; double length = vx * vx + vy * vy; double t = length == 0.0 ? 0.0 : std::max(0.0, std::min(1.0, ((left - sx) * vx + (top - sy) * vy) / length)); if (std::hypot(left - sx - t * vx, top - sy - t * vy) <= 6.0) { select_connection(edge.source_node, edge.destination_node); return 1; } } selected_node_ = SIZE_MAX; edge_selected_ = false; redraw(); return 1; } case FL_DRAG: mouse_x_ = left; mouse_y_ = top; if (dragging_) move_node(selected_node_, left - drag_x_, top - drag_y_); if (wiring_) redraw(); return 1; case FL_RELEASE: dragging_ = false; if (wiring_) { size_t node, port; wiring_ = false; if (hit_port(left, top, node, port)) connect_ports({wire_node_, wire_port_, node, port}); else feedback_->copy_label("Connection cancelled. Drag an output port to an input port."); redraw(); } return 1; case FL_KEYDOWN: if (Fl::event_key() == 'l' && !Fl::event_state(FL_CTRL | FL_ALT)) { auto_layout(); return 1; } if (Fl::event_key() == 'i' && !Fl::event_state(FL_CTRL | FL_ALT)) { insert_gain(); return 1; } if ((Fl::event_key() == FL_Left || Fl::event_key() == FL_Right) && count_) { size_t selected = 0; for (size_t i = 0; i < count_; ++i) if (positions_[i].id == selected_node_) { selected = Fl::event_key() == FL_Left ? (i+count_-1)%count_ : (i+1)%count_; break; } select_node(positions_[selected].id); return 1; } if (Fl::event_key() == FL_Delete || Fl::event_key() == FL_BackSpace) { delete_selected(); return 1; } if (Fl::event_key() == FL_Escape) { wiring_ = dragging_ = false; redraw(); return 1; } break; default: break; } return Fl_Widget::handle(event); } bool GraphWidget::routing(size_t id, xtc_routing_info &info) const { for (size_t i = 0; i < routing_count_; ++i) if (routing_[i].id == id) { info = routing_[i]; return true; } return false; } void GraphWidget::grow_canvas() { int width = 2000, height = 4000; for (size_t i = 0; i < count_; ++i) { width = std::max(width, positions_[i].left + node_width + 40); height = std::max(height, positions_[i].top + node_height + 40); } size(width, height); if (parent()) parent()->redraw(); } bool GraphWidget::auto_layout() { if (routing_count_ != count_) return false; std::array rows{}; for (size_t i = 0; i < count_; ++i) { xtc_routing_info info{}; if (!routing(positions_[i].id, info) || info.level >= rows.size()) return false; positions_[i].left = 40 + static_cast(info.level) * 210; positions_[i].top = 40 + static_cast(rows[info.level]++) * 115; } wiring_ = dragging_ = false; grow_canvas(); feedback_->copy_label("Arranged left to right by signal flow. Layout is session-only; no audio or routing changes."); redraw(); return true; } xtc_result GraphWidget::insert_gain() { if (!edge_selected_) { feedback_->copy_label("Select a wire first, then insert a unity gain/bus node."); return XTC_ERR_INVALID_STATE; } xtc_graph_edit edit{}; edit.kind = XTC_EDIT_INSERT_GAIN; edit.data.connection = selected_edge_; size_t added = SIZE_MAX; xtc_result result = handler_(context_, edit, &added); if (result == XTC_OK) select_node(added); feedback(result, "Unity gain inserted into wire as one edit. Inputs sum; fan-out copies the signal without normalization."); return result; } void GraphWidget::describe_selection() { char text[320]; if (edge_selected_) { std::snprintf(text, sizeof(text), "Wire: #%zu audio out -> #%zu sum in. Delete disconnects; I inserts a unity gain. Destination sums its inputs.", selected_edge_.source_node, selected_edge_.destination_node); } else { xtc_routing_info info{}; if (!routing(selected_node_, info)) { feedback_->copy_label("Select a node or wire to inspect routing."); return; } std::snprintf(text, sizeof(text), "Node #%zu: %zu incoming, %zu outgoing | %s | %s. Ports are audio lanes; stereo uses paired independent processing.", info.id, info.inputs, info.outputs, info.reaches_output ? "path to engine output" : "NO output path", info.external_input ? "unwired input uses external audio" : "wired sum or source"); } feedback_->copy_label(text); }