#include "sequencer_widget.hpp" #include "xtc/xtc_midi.h" #include #include #include #include #include #include #include /** * @brief Constructs the timeline widget with a borrowed project and seek callback. * * @param left Left coordinate in pixels. * @param top Top coordinate in pixels. * @param project Borrowed project; must outlive the widget's use of it. * @param seek Callback used to request a transport seek. * @param context Opaque callback context. */ SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context) : Fl_Widget(left, top, 6300, 3700), project_(project), seek_(seek), context_(context) { extent(); } /** * @brief Replaces the borrowed project and refreshes the widget's displayed state. * * @param project Borrowed project; must outlive the widget's use of it. * @param reset Whether to clear selection, cursor, and drag state. */ void SequencerWidget::set_project(xtc_project *project, bool reset) { project_ = project; if (reset) { track_ = clip_ = UINT64_MAX; cursor_ = 0; mode_ = 0; } extent(); redraw(); } /** * @brief Converts a screen x coordinate to a bounded timeline tick snapped to a sixteenth note. * * @param px Screen x coordinate in pixels. * @return Snapped tick within the supported project range. */ uint64_t SequencerWidget::tick(int px) const { return std::min(XTC_PROJECT_MAX_TICK, static_cast(std::max(0, px - x() - 120)) * 960U / static_cast(scale_) / grid_ * grid_); } /** * @brief Resizes the timeline's scrollable area to fit its tracks and clip ends. */ void SequencerWidget::extent() { size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips); uint64_t end = 32U * 3840U; for (size_t i = 0; i < clips; ++i) { xtc_clip_info c{}; xtc_project_get_clip(project_, i, &c); end = std::max(end, c.start_tick + c.length_tick + 3840U); } size(static_cast(end * static_cast(scale_) / 960U + 140U), std::max(400, static_cast(tracks) * 56 + 100)); if (parent()) parent()->redraw(); } /** * @brief Reports an edit result, refreshes the widget, and notifies listeners on success. * * @param result Result of the attempted operation. */ void SequencerWidget::changed(xtc_result result) { if (result != XTC_OK) fl_alert("%s", xtc_result_string(result)); else { extent(); do_callback(); } redraw(); } /** * @brief Creates and selects a track through the arrangement edit hook. * @return XTC_OK on success, or an error code describing the failure. */ xtc_result SequencerWidget::add_track() { if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } uint64_t id; xtc_result r = xtc_project_add_track(project_, "Track", &id); if (r == XTC_OK) { track_ = id; clip_ = UINT64_MAX; } changed(r); return r; } /** * @brief Creates and selects a one-bar silent region at the timeline cursor. * @return XTC_OK on success, or an error code describing the failure. */ xtc_result SequencerWidget::add_region() { if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips); if (track_ == UINT64_MAX && tracks) { xtc_track_info t{}; xtc_project_get_track(project_, 0, &t); track_ = t.id; } uint64_t id; xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, 3840U, &id); if (r == XTC_OK) clip_ = id; changed(r); return r; } /** * @brief Creates and selects a one-bar MIDI clip at the timeline cursor. * @return XTC_OK on success, or an error code describing the failure. */ xtc_result SequencerWidget::add_midi_clip() { xtc_result r = before_edit ? before_edit(context_) : XTC_OK; uint64_t id; if (r == XTC_OK) { if (track_ == UINT64_MAX) { xtc_track_info t{}; r = xtc_project_get_track(project_, 0, &t); track_ = r == XTC_OK ? t.id : UINT64_MAX; } if (r == XTC_OK) r = xtc_project_add_midi_clip(project_, track_, cursor_, 3840, &id); if (r == XTC_OK) clip_ = id; } changed(r); return r; } /** * @brief Removes the selected clip, or the selected track when no clip is selected. * @return XTC_OK on success, or an error code describing the failure. */ xtc_result SequencerWidget::delete_selection() { if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } xtc_result r = clip_ != UINT64_MAX ? xtc_project_remove_clip(project_, clip_) : xtc_project_remove_track(project_, track_); if (r == XTC_OK) clip_ = track_ = UINT64_MAX; changed(r); return r; } /** * @brief Adjusts timeline zoom within its supported bounds and updates the scroll extent. * * @param direction Zoom direction; positive zooms in, nonpositive zooms out. */ void SequencerWidget::zoom(int direction) { scale_ = std::clamp(direction > 0 ? scale_ * 2 : scale_ / 2, 24, 384); extent(); redraw(); } /** * @brief Draws the widget's current model, selection, and interaction feedback. */ void SequencerWidget::draw() { fl_push_clip(x(), y(), w(), h()); fl_color(fl_rgb_color(35, 38, 43)); fl_rectf(x(), y(), w(), h()); int cx, cy, cw, ch; fl_clip_box(x(), y(), w(), h(), cx, cy, cw, ch); fl_font(FL_HELVETICA, 12); int first = std::max(0, (cx - x() - 120) / (scale_ * 4)); int last = (cx + cw - x() - 120) / (scale_ * 4) + 1; for (int bar = first; bar <= last; ++bar) { int px = x() + 120 + bar * scale_ * 4; fl_color(fl_rgb_color(65, 68, 74)); fl_line(px, y(), px, y() + h()); char label[32]; std::snprintf(label, sizeof(label), "%d", bar + 1); fl_color(FL_WHITE); fl_draw(label, px + 4, y() + 19); } char tempo[32]; std::snprintf(tempo, sizeof(tempo), "%.3g BPM | 4/4", xtc_project_tempo(project_) / 1000.0); fl_color(FL_WHITE); fl_draw(tempo, x() + 4, y() + 19); size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips); for (size_t i = 0; i < tracks; ++i) { xtc_track_info t{}; xtc_project_get_track(project_, i, &t); int py = y() + 28 + static_cast(i) * 56; fl_color(t.id == track_ ? fl_rgb_color(70, 90, 110) : fl_rgb_color(48, 51, 57)); fl_rectf(x(), py, 118, 55); fl_color(FL_WHITE); fl_draw(t.name, x() + 6, py + 32); for (size_t j = 0; j < clips; ++j) { xtc_clip_info c{}; xtc_project_get_clip(project_, j, &c); if (c.track_id != t.id) continue; int left = x() + 120 + static_cast(c.start_tick * static_cast(scale_) / 960U); int right = left + std::max(4, static_cast(c.length_tick * static_cast(scale_) / 960U)); int a = std::max(left, cx), b = std::min(right, cx + cw); if (b <= a || py + 50 < cy || py > cy + ch) continue; fl_color(c.id == clip_ ? fl_rgb_color(100, 170, 205) : fl_rgb_color(65, 120, 150)); fl_rectf(a, py + 5, b - a, 44); fl_push_clip(a, py + 5, b - a, 44); fl_color(FL_WHITE); char label[96]; std::snprintf(label,sizeof(label),"%s: %s",c.midi ? "MIDI" : c.asset_path[0] ? "Audio" : "Region",c.name); fl_draw(label,std::max(a,left+6),py+31); fl_pop_clip(); } } int playhead = x() + 120 + static_cast(cursor_ * static_cast(scale_) / 960U); if (playhead >= cx && playhead <= cx + cw) { fl_color(FL_RED); fl_line(playhead, y(), playhead, y() + h()); } fl_pop_clip(); } /** * @brief Handles FLTK input events and delegates unhandled events to the base widget. * * @param event FLTK event code. * @return Nonzero if the event was handled, zero otherwise. */ int SequencerWidget::handle(int event) { if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) { take_focus(); mode_ = 0; if (Fl::event_y() < y() + 28) { uint64_t target = tick(Fl::event_x()); xtc_result r = seek_(context_, target); if (r == XTC_OK) cursor_ = target; else fl_alert("Stop playback before seeking: %s", xtc_result_string(r)); redraw(); return 1; } size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips); int row = (Fl::event_y() - y() - 28) / 56; if (row < 0 || static_cast(row) >= tracks) return 1; xtc_track_info t{}; xtc_project_get_track(project_, static_cast(row), &t); track_ = t.id; clip_ = UINT64_MAX; for (size_t j = clips; j > 0; --j) { xtc_clip_info c{}; xtc_project_get_clip(project_, j - 1, &c); int left = x() + 120 + static_cast(c.start_tick * static_cast(scale_) / 960U); int right = left + static_cast(c.length_tick * static_cast(scale_) / 960U); if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) { clip_ = c.id; original_ = c; drag_x_ = Fl::event_x(); mode_ = Fl::event_x() - left < 7 ? 2 : right - Fl::event_x() < 7 ? 3 : 1; break; } } if (Fl::event_button() == FL_RIGHT_MOUSE) { mode_ = 0; Fl_Menu_Item items[4]{}; items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y()); if (picked == items) duplicate_selection(); else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } else if (picked == items+2) delete_selection(); } 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_); int64_t start = static_cast(original_.start_tick); int64_t length = static_cast(original_.length_tick); int64_t offset = static_cast(original_.offset_tick); uint64_t target = original_.track_id; if (mode == 1) { start = std::max(0, start + delta); int row = (Fl::event_y() - y() - 28) / 56; xtc_track_info t{}; if (row >= 0 && xtc_project_get_track(project_, static_cast(row), &t) == XTC_OK) target = t.id; } else if (mode == 2) { start += delta; offset += delta; length -= delta; } else length += delta; if (start < 0 || offset < 0 || length <= 0) return 1; if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return 1; } } changed(xtc_project_edit_clip(project_, clip_, target, static_cast(start), static_cast(length), static_cast(offset))); return 1; } if (event == FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key() == 'd') { duplicate_selection(); return 1; } if (event == FL_KEYDOWN && Fl::event_key() == FL_F+2) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); return 1; } if (event == FL_KEYDOWN && Fl::event_state(FL_ALT) && (Fl::event_key() == FL_Up || Fl::event_key() == FL_Down)) { move_track(Fl::event_key() == FL_Up ? -1 : 1); return 1; } if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { mode_ = 0; return 1; } if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) { delete_selection(); return 1; } if (event == FL_FOCUS || event == FL_UNFOCUS) return 1; return Fl_Widget::handle(event); } xtc_result SequencerWidget::grid(uint64_t ticks) { if (ticks != 1 && ticks != 120 && ticks != 240 && ticks != 480 && ticks != 960) return XTC_ERR_INVALID_ARGUMENT; grid_ = ticks; redraw(); return XTC_OK; } xtc_result SequencerWidget::duplicate_selection() { xtc_clip_info c{}; uint64_t id; xtc_result r = xtc_project_find_clip(project_,clip_,&c); if (r == XTC_OK && before_edit) r = before_edit(context_); if (r == XTC_OK) r = xtc_project_duplicate_clip(project_,clip_,c.track_id,c.start_tick+c.length_tick,&id); if (r == XTC_OK) clip_ = id; changed(r); return r; } xtc_result SequencerWidget::rename_selection(const char *name) { xtc_result r = before_edit ? before_edit(context_) : XTC_OK; if (r == XTC_OK) r = clip_ != UINT64_MAX ? xtc_project_rename_clip(project_,clip_,name) : xtc_project_rename_track(project_,track_,name); changed(r); return r; } xtc_result SequencerWidget::move_track(int direction) { size_t tracks, clips, i; xtc_project_get_counts(project_,&tracks,&clips); xtc_track_info track{}; for (i = 0; i < tracks; ++i) { xtc_project_get_track(project_,i,&track); if (track.id == track_) break; } if (i == tracks || (direction < 0 ? i == 0 : i+1 == tracks)) return XTC_ERR_NOT_FOUND; xtc_result r = before_edit ? before_edit(context_) : XTC_OK; if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1); changed(r); return r; }