#include "piano_roll.hpp" #include #include #include #include #include #include #include namespace { constexpr int key_width = 64, row_height = 16, scale = 96, header = 28; template bool parse(const char *text, Values &...values) { std::istringstream input(text); (input >> ... >> values); return !input.fail() && (input >> std::ws).eof(); } } /** * @brief Creates a piano-roll editor for a MIDI clip. * * The pre-edit guard, when non-NULL, is called before every project mutation * and can veto the edit by returning an error. * * @param left Left edge of the widget in the parent's coordinate system. * @param top Top edge of the widget in the parent's coordinate system. * @param project Borrowed project that must outlive the widget. * @param clip ID of the MIDI clip to edit. * @param before Optional pre-edit guard; NULL means no guard is used. * @param context Opaque pointer passed to @p before on every edit. */ PianoRollWidget::PianoRollWidget(int left, int top, xtc_project *project, uint64_t clip, BeforeEdit before, void *context) : Fl_Widget(left, top, 900, header + 128 * row_height), project_(project), clip_(clip), before_(before), context_(context) { set_project(project, clip); } /** * @brief Points the editor at a different project or clip. * * Clears any in-progress drag when the project or clip changes and resizes the * widget to fit the clip's source length. Selection is cleared on a new model or clip. * * @param project Borrowed project that must outlive the widget. * @param clip ID of the MIDI clip to display. */ bool PianoRollWidget::select_note(uint64_t id) { size_t count = 0; xtc_project_note_count(project_,&count); for (size_t i = 0; i < count; ++i) { xtc_note_info note{}; if (xtc_project_get_note(project_,i,¬e) == XTC_OK && note.id == id && visible(note)) { selected_ = id; all_ = false; mode_ = 0; redraw(); return true; } } return false; } void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) { if (project != project_ || clip != clip_) { mode_ = 0; selected_ = UINT64_MAX; all_ = false; } project_ = project; clip_ = clip; xtc_clip_info c{}; int width = xtc_project_find_clip(project_, clip_, &c) == XTC_OK ? static_cast(c.source_length_tick * scale / 960) + key_width + 20 : 900; size(std::max(900, width), header + 128 * row_height); redraw(); } /** * @brief Converts a horizontal pixel position to a snapped tick. * * The position is measured in absolute window coordinates and snapped down to * the current configured grid. * * @param px Horizontal position in window coordinates. * @return Tick offset within the clip source, or 0 for positions on or left of * the keyboard. */ uint64_t PianoRollWidget::tick(int px) const { return static_cast(std::max(0, px - x() - key_width)) * 960U / scale / grid_ * grid_; } /** * @brief Reports an edit result and notifies listeners on success. * * On success the widget callback runs and the editor is redrawn; on failure an * alert shows the error and the project is left unchanged. * * @param result Result of the attempted project mutation. * @return The same result code. */ xtc_result PianoRollWidget::change(xtc_result result) { if (result == XTC_OK) { do_callback(); redraw(); } else fl_alert("Note unchanged: %s", xtc_result_string(result)); return result; } /** * @brief Adds a note to the current clip and selects the new note. * * Invokes the pre-edit guard, if any, before mutating the project. * * @param start Note start in clip source ticks. * @param length Note length in ticks. * @param pitch MIDI pitch from 0 to 127. * @param velocity MIDI velocity from 1 to 127. * @param channel MIDI channel from 0 to 15. * @return XTC_OK on success, the guard's error, or the project error. */ xtc_result PianoRollWidget::add_note(uint64_t start, uint64_t length, unsigned int pitch, unsigned int velocity, unsigned int channel) { xtc_result r = before_ ? before_(context_) : XTC_OK; uint64_t id; if (r == XTC_OK) r = xtc_project_add_note(project_, clip_, start, length, pitch, velocity, channel, &id); if (r == XTC_OK) { selected_ = id; all_ = false; } return change(r); } /** * @brief Removes the selected note from the current clip. * * Invokes the pre-edit guard, if any, before mutating the project. The * selection is cleared only when the removal succeeds. * * @return XTC_OK on success, the guard's error, or the project error. */ xtc_result PianoRollWidget::delete_selected() { xtc_note_edit edit{}; edit.action = XTC_NOTES_DELETE; return apply(edit); } /** * @brief Draws the keyboard, beat grid, and notes of the current clip. * * Paints only the background when the clip is missing or is not a MIDI clip. */ void PianoRollWidget::draw() { fl_push_clip(x(), y(), w(), h()); fl_color(fl_rgb_color(30, 33, 38)); fl_rectf(x(), y(), w(), h()); xtc_clip_info clip{}; if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || !clip.midi) { fl_pop_clip(); return; } int cx, cy, cw, ch; fl_clip_box(x(), y(), w(), h(), cx, cy, cw, ch); fl_font(FL_HELVETICA, 11); for (int pitch = 0; pitch < 128; ++pitch) { int py = y() + header + (127 - pitch) * row_height; if (py + row_height < cy || py > cy + ch) continue; int pc = pitch % 12; bool black = pc == 1 || pc == 3 || pc == 6 || pc == 8 || pc == 10; fl_color(black ? fl_rgb_color(29, 31, 35) : fl_rgb_color(43, 46, 52)); fl_rectf(cx, py, cw, row_height - 1); fl_color(black ? fl_rgb_color(60, 63, 70) : fl_rgb_color(130, 135, 140)); fl_rectf(x(), py, key_width - 2, row_height - 1); if (!pc) { char text[20]; std::snprintf(text, sizeof(text), "C%d", pitch / 12 - 1); fl_color(FL_WHITE); fl_draw(text, x() + 6, py + 12); } } // At 1-tick snap, show at least a 120-tick visual grid to avoid pixel overdraw. uint64_t visual = std::max(120, grid_); int spacing = static_cast(visual * scale / 960); int first = std::max(0, (cx - x() - key_width) / spacing); int last = (cx + cw - x() - key_width) / spacing + 1; for (int step = first; step <= last; ++step) { int px = x() + key_width + static_cast(static_cast(step) * visual * scale / 960); fl_color((static_cast(step) * visual) % 3840 == 0 ? fl_rgb_color(95, 98, 105) : fl_rgb_color(52, 55, 62)); fl_line(px, cy, px, cy + ch); } size_t count = 0; xtc_project_note_count(project_, &count); for (size_t i = 0; i < count; ++i) { xtc_note_info n{}; xtc_project_get_note(project_, i, &n); if (!visible(n)) continue; int left = x() + key_width + static_cast(n.start_tick * scale / 960); int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); int py = y() + header + (127 - static_cast(n.pitch)) * row_height; int a = std::max(cx, left), b = std::min(cx + cw, right); if (a >= b || py + row_height < cy || py > cy + ch) continue; fl_color(all_ || n.id == selected_ ? fl_rgb_color(230, 190, 80) : fl_rgb_color(50, static_cast(70 + n.velocity), 110)); fl_rectf(a, py + 1, b - a, row_height - 3); } fl_color(fl_rgb_color(30, 33, 38)); fl_rectf(x(), y(), w(), header); char status[160]; std::snprintf(status, sizeof(status), "Snap %llu ticks | Channel %d (-1 all) | Pitches %u..%u | %s", static_cast(grid_), channel_filter_, low_, high_, all_ ? "All filtered notes selected" : "Click selects; Ctrl+A selects filtered notes"); fl_color(FL_WHITE); fl_draw(status, x() + 6, y() + 18); fl_pop_clip(); } /** * @brief Handles mouse, keyboard, and focus events for the editor. * * Left-click on empty space adds a note, clicking a note selects it, and * dragging moves or resizes it. Right-click and Delete remove the selected * note; Escape cancels an in-progress drag. * * @param event FLTK event code. * @return 1 when the event was handled, otherwise the result of the base * class handler. */ int PianoRollWidget::handle(int event) { if (event == FL_PUSH) { take_focus(); mode_ = 0; size_t count = 0; xtc_project_note_count(project_, &count); selected_ = UINT64_MAX; all_ = false; for (size_t i = count; i > 0; --i) { xtc_note_info n{}; xtc_project_get_note(project_, i - 1, &n); int left = x() + key_width + static_cast(n.start_tick * scale / 960); int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); int top = y() + header + (127 - static_cast(n.pitch)) * row_height; if (visible(n) && Fl::event_x() >= left && Fl::event_x() < right && Fl::event_y() >= top && Fl::event_y() < top + row_height) { selected_ = n.id; original_ = n; drag_x_ = Fl::event_x(); drag_y_ = Fl::event_y(); mode_ = right - Fl::event_x() <= 6 ? 2 : 1; break; } } if (selected_ != UINT64_MAX && Fl::event_button() == FL_RIGHT_MOUSE) { mode_ = 0; Fl_Menu_Item items[5]{}; items[0].text = "Velocity and channel (V)"; items[1].text = "Quantize (Q)"; items[2].text = "Repeat (Ctrl+D)"; items[3].text = "Delete (undoable)"; const auto *picked = items->popup(Fl::event_x(),Fl::event_y()); if (picked == items) command(2); else if (picked == items+1) command(3); else if (picked == items+2) command(5); else if (picked == items+3) delete_selected(); } else if (Fl::event_button() == FL_LEFT_MOUSE && selected_ == UINT64_MAX && Fl::event_x() >= x() + key_width && Fl::event_y() >= y() + header) { int pitch = 127 - (Fl::event_y() - y() - header) / row_height; xtc_clip_info c{}; uint64_t start = tick(Fl::event_x()); if (pitch >= static_cast(low_) && pitch <= static_cast(high_) && xtc_project_find_clip(project_, clip_, &c) == XTC_OK && start < c.source_length_tick) add_note(start, std::min(960, c.source_length_tick - start), static_cast(pitch), 100, channel_filter_ < 0 ? 0U : static_cast(channel_filter_)); } redraw(); return 1; } if (event == FL_DRAG) return mode_ != 0; if (event == FL_RELEASE && mode_) { int mode = mode_; mode_ = 0; int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale / static_cast(grid_) * static_cast(grid_); xtc_clip_info c{}; if (xtc_project_find_clip(project_, clip_, &c) != XTC_OK) return 1; int64_t start = static_cast(original_.start_tick), length = static_cast(original_.length_tick); unsigned int pitch = original_.pitch; if (mode == 1) { start = std::clamp(start + delta, 0, static_cast(c.source_length_tick) - length); pitch = static_cast( std::clamp(static_cast(pitch) - (Fl::event_y() - drag_y_) / row_height, 0, 127)); } else { int64_t maximum = static_cast(c.source_length_tick) - start; length = std::clamp(length + delta, std::min(static_cast(grid_), maximum), maximum); } xtc_result r = before_ ? before_(context_) : XTC_OK; if (r == XTC_OK) r = xtc_project_edit_note(project_, selected_, static_cast(start), static_cast(length), pitch, original_.velocity, original_.channel); change(r); return 1; } if (event == FL_KEYDOWN) { int key = Fl::event_key(); if (key == 'a' && (Fl::event_state() & FL_CTRL)) { select_filtered(); return 1; } if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'q') { command(3); return 1; } if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'v') { command(2); return 1; } if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'g') { command(0); return 1; } if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'f') { command(1); return 1; } if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 't') { command(4); return 1; } if (key == 'd' && (Fl::event_state() & FL_CTRL)) { command(5); return 1; } if (key == FL_Up || key == FL_Down) { xtc_note_edit edit{}; edit.velocity = edit.channel = -1; edit.transpose = key == FL_Up ? 1 : -1; apply(edit); return 1; } } if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) { delete_selected(); return 1; } if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { mode_ = 0; return 1; } if (event == FL_FOCUS || event == FL_UNFOCUS) return 1; return Fl_Widget::handle(event); } bool PianoRollWidget::visible(const xtc_note_info &n) const { return n.clip_id == clip_ && (channel_filter_ < 0 || n.channel == static_cast(channel_filter_)) && n.pitch >= low_ && n.pitch <= high_; } xtc_result PianoRollWidget::set_grid(uint64_t ticks) { if (ticks != 1 && ticks != 120 && ticks != 160 && ticks != 240 && ticks != 320 && ticks != 480 && ticks != 960) return XTC_ERR_INVALID_ARGUMENT; grid_ = ticks; redraw(); return XTC_OK; } xtc_result PianoRollWidget::set_filter(int channel, unsigned int low, unsigned int high) { if (channel < -1 || channel > 15 || low > high || high > 127) return XTC_ERR_INVALID_ARGUMENT; channel_filter_ = channel; low_ = low; high_ = high; selected_ = UINT64_MAX; all_ = false; mode_ = 0; redraw(); return XTC_OK; } void PianoRollWidget::select_filtered() { selected_ = UINT64_MAX; all_ = true; mode_ = 0; redraw(); } xtc_result PianoRollWidget::apply(const xtc_note_edit &edit) { if (!all_ && selected_ == UINT64_MAX) return XTC_ERR_NOT_FOUND; xtc_note_filter filter{clip_, all_ ? UINT64_MAX : selected_, channel_filter_, low_, high_}; size_t count; xtc_result r = before_ ? before_(context_) : XTC_OK; if (r == XTC_OK) r = xtc_project_edit_notes(project_, &filter, &edit, &count); if (r == XTC_OK && edit.action == XTC_NOTES_DELETE) { selected_ = UINT64_MAX; all_ = false; } return change(r); } void PianoRollWidget::command(int action) { const char *text; xtc_note_edit edit{}; edit.velocity = edit.channel = -1; if (action == 0) { text = fl_input("Snap ticks: 1, 120, 160 (1/16 triplet), 240, 320 (1/8 triplet), 480, 960", "240"); unsigned int value; if (text && (!parse(text, value) || set_grid(value) != XTC_OK)) fl_alert("Choose one of the listed grids."); return; } if (action == 1) { text = fl_input("Channel (-1 all, 0..15), lowest pitch, highest pitch", "-1 0 127"); int channel; unsigned int low, high; if (text && (!parse(text, channel, low, high) || set_filter(channel, low, high) != XTC_OK)) fl_alert("Invalid note filter."); return; } if (action == 6) { select_filtered(); return; } if (action == 7) { delete_selected(); return; } if (action == 2) { text = fl_input("Velocity (1..127, -1 preserve), channel (0..15, -1 preserve)", "100 -1"); if (!text) return; if (!parse(text, edit.velocity, edit.channel)) { fl_alert("Enter velocity and channel."); return; } } else if (action == 3) edit.grid_tick = grid_; else if (action == 4) { text = fl_input("Transpose semitones (-127..127)", "12"); if (!text) return; if (!parse(text, edit.transpose)) { fl_alert("Enter a semitone count."); return; } } else if (action == 5) { text = fl_input("Additional copies (1..4096), spacing in source ticks (clip must have room)", "1 960"); unsigned long long interval; if (!text) return; if (!parse(text, edit.copies, interval)) { fl_alert("Enter copy count and spacing."); return; } edit.action = XTC_NOTES_REPEAT; edit.interval_tick = static_cast(interval); } else return; apply(edit); }