xtc/src/gui/sequencer_widget.cpp

880 lines
40 KiB
C++
Raw Normal View History

#include <sstream>
#include "sequencer_widget.hpp"
#include "theme.hpp"
#include "snap.hpp"
#include <cstring>
#include "xtc/xtc_midi.h"
#include <FL/Fl.H>
#include <FL/Fl_Scroll.H>
#include <FL/fl_draw.H>
#include <FL/fl_ask.H>
#include <FL/Fl_Menu_Item.H>
#include <algorithm>
#include <cstdio>
#include <sstream>
#include <new>
2026-10-27 01:37:21 +01:00
/**
* @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_Group(left, top, 6300, 3700), project_(project), seek_(seek), context_(context)
2026-10-27 01:37:21 +01:00
{
resizable(nullptr);
2026-10-27 01:37:21 +01:00
extent();
end();
2026-10-27 01:37:21 +01:00
}
/**
* @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.
*/
bool SequencerWidget::select(uint64_t track, uint64_t clip)
{
size_t tracks = 0, clips = 0;
xtc_project_get_counts(project_,&tracks,&clips);
bool found = false;
for (size_t i = 0; i < tracks; ++i) {
xtc_track_info info{};
if (xtc_project_get_track(project_,i,&info) == XTC_OK && info.id == track) found = true;
}
if (!found) return false;
if (clip != UINT64_MAX) {
xtc_clip_info info{};
if (xtc_project_find_clip(project_,clip,&info) != XTC_OK || info.track_id != track) return false;
}
selection_.clear();
track_ = track; clip_ = clip; mode_ = 0; redraw();
return true;
}
void SequencerWidget::set_project(xtc_project *project, bool reset)
{
project_ = project;
2026-10-27 01:37:21 +01:00
if (reset) {
waves_.clear();
selection_.clear();
2026-10-27 01:37:21 +01:00
track_ = clip_ = UINT64_MAX;
cursor_ = 0;
mode_ = 0;
}
refresh_waveforms();
2026-10-27 01:37:21 +01:00
extent();
redraw();
}
2026-10-27 01:37:21 +01:00
/**
* @brief Converts a screen x coordinate to a bounded timeline tick snapped to the selected grid.
*
* @param px Screen x coordinate in pixels.
* @return Snapped tick within the supported project range.
*/
int SequencerWidget::header_left() const
{
auto *scroll=dynamic_cast<Fl_Scroll *>(parent());return scroll ? std::max(x(),scroll->x()) : x();
}
void SequencerWidget::header_width(int width)
{
width=std::clamp(width,180,360);if(width==header_width_)return;
auto *scroll=dynamic_cast<Fl_Scroll *>(parent());int sx=scroll ? scroll->xposition() : 0,sy=scroll ? scroll->yposition() : 0;
header_width_=width;extent();if(scroll)scroll->scroll_to(sx,sy);redraw();
}
uint64_t SequencerWidget::tick(int px) const
{
int64_t raw = static_cast<int64_t>(std::max(0,px-x()-header_width_))*960/scale_;
return std::min(XTC_PROJECT_MAX_TICK,static_cast<uint64_t>(xtc_ui::snap_position(raw,grid_)));
}
2026-10-27 01:37:21 +01:00
/**
* @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) {
2026-10-27 01:37:21 +01:00
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<int>(end * static_cast<unsigned int>(scale_) / 960U + static_cast<unsigned int>(header_width_+20)),
std::max(400, static_cast<int>(tracks) * 56 + 100));
refresh_instruments();
2026-10-27 01:37:21 +01:00
if (parent())
parent()->redraw();
}
2026-10-27 01:37:21 +01:00
/**
* @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)
{
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
fl_alert("%s", xtc_result_string(result));
else {
refresh_waveforms();
2026-10-27 01:37:21 +01:00
extent();
do_callback();
}
redraw();
}
2026-10-27 01:37:21 +01:00
/**
* @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()
{
2026-10-27 01:37:21 +01:00
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;selection_.clear();
2026-10-27 01:37:21 +01:00
}
changed(r);
return r;
}
2026-10-27 01:37:21 +01:00
/**
* @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()
{
2026-10-27 01:37:21 +01:00
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) {
2026-10-27 01:37:21 +01:00
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_, new_clip_ticks, &id);
if (r == XTC_OK) {clip_ = id;selection_.clear();}
2026-10-27 01:37:21 +01:00
changed(r);
return r;
}
2026-10-27 01:37:21 +01:00
/**
* @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{};
2026-10-27 01:37:21 +01:00
r = xtc_project_get_track(project_, 0, &t);
track_ = r == XTC_OK ? t.id : UINT64_MAX;
}
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_project_add_midi_clip(project_, track_, cursor_, new_clip_ticks, &id);
if (r == XTC_OK) {clip_ = id;selection_.clear();}
}
2026-10-27 01:37:21 +01:00
changed(r);
return r;
}
2026-10-27 01:37:21 +01:00
/**
* @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(!selected_clips().empty())return transform_selection(XTC_CLIPS_DELETE,0);
xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_remove_track(project_,track_);
if(r==XTC_OK)track_=clip_=UINT64_MAX;
changed(r);return r;
}
2026-10-27 01:37:21 +01:00
/**
* @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)
2026-10-27 01:37:21 +01:00
{
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());
2026-10-27 01:37:21 +01:00
int cx, cy, cw, ch;
fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch);
fl_color(xtc_ui::background()); fl_rectf(cx,cy,cw,ch);
size_t tracks = 0, clips = 0, notes = 0;
xtc_project_get_counts(project_,&tracks,&clips); xtc_project_note_count(project_,&notes);
for (size_t i = 0; i < tracks; ++i) {
int py = y()+28+static_cast<int>(i)*56;
if (py+56 < cy || py > cy+ch) continue;
fl_color(i%2 ? fl_rgb_color(30,34,40) : fl_rgb_color(33,37,43));
fl_rectf(x()+header_width_,py,w()-header_width_,55);
}
fl_font(FL_HELVETICA,11);
uint64_t visual = grid_ == 1 ? 960 : grid_;
while (visual*static_cast<unsigned int>(scale_)/960 < 6) visual *= 2;
int spacing = static_cast<int>(visual*static_cast<unsigned int>(scale_)/960);
int first = std::max(0,(cx-x()-header_width_)/spacing);
int last = (cx+cw-x()-header_width_)/spacing+1;
for (int step = first; step <= last; ++step) {
uint64_t tick = static_cast<uint64_t>(step)*visual;
int px = x()+header_width_+static_cast<int>(tick*static_cast<unsigned int>(scale_)/960);
fl_color(tick%3840 == 0 ? fl_rgb_color(76,84,96) : tick%960 == 0 ? fl_rgb_color(56,63,73) : fl_rgb_color(42,48,56));
fl_line(px,y()+28,px,y()+h());
}
first = std::max(0,(cx-x()-header_width_)/(scale_*4));
last = (cx+cw-x()-header_width_)/(scale_*4)+1;
for (int bar = first; bar <= last; ++bar) {
char label[32]; std::snprintf(label,sizeof(label),"%d",bar+1);
fl_color(xtc_ui::muted()); fl_draw(label,x()+header_width_+bar*scale_*4+6,y()+19);
}
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<int>(i)*56;
if (py+56 < cy || py > cy+ch) continue;
Fl_Color color = xtc_ui::track(t.id);
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()+header_width_+static_cast<int>(c.start_tick*static_cast<unsigned int>(scale_)/960U);
int right = left+std::max(4,static_cast<int>(c.length_tick*static_cast<unsigned int>(scale_)/960U));
int a = std::max(left,cx), b = std::min(right,cx+cw);
if (b <= a) continue;
fl_color(fl_color_average(color,xtc_ui::background(),clip_selected(c.id) ? .75f : .45f));
fl_rectf(a,py+5,b-a,46);
fl_color(clip_selected(c.id) ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46);
fl_push_clip(a+1,py+6,std::max(0,b-a-2),44);
if (c.midi) {
/* Real note positions, pitch and duration; no placeholder waveform. */
unsigned int low = 127, high = 0;
for (size_t n = 0; n < notes; ++n) {
xtc_note_info note{}; xtc_project_get_note(project_,n,&note);
if (note.clip_id == c.id) { low = std::min(low,note.pitch); high = std::max(high,note.pitch); }
}
unsigned int range = high >= low ? std::max(12U,high-low) : 12U;
fl_color(fl_color_average(color,FL_WHITE,.55f));
for (size_t n = 0; n < notes; ++n) {
xtc_note_info note{}; xtc_project_get_note(project_,n,&note);
if (note.clip_id != c.id) continue;
uint64_t begin = std::max(note.start_tick,c.offset_tick);
uint64_t end = std::min(note.start_tick+note.length_tick,c.offset_tick+c.length_tick);
if (begin >= end) continue;
int nx = left+static_cast<int>((begin-c.offset_tick)*static_cast<unsigned int>(scale_)/960U);
int nw = std::max(2,static_cast<int>((end-begin)*static_cast<unsigned int>(scale_)/960U));
fl_rectf(nx,py+26+static_cast<int>((high-note.pitch)*18/range),nw,2);
}
} else draw_waveform(c,left,py);
fl_color(fl_color_average(color,xtc_ui::background(),.35f)); fl_rectf(a+1,py+6,std::max(0,b-a-2),15);
fl_font(FL_HELVETICA,11); fl_color(xtc_ui::text());
fl_draw(c.name,left+7,py+18);
2026-10-27 01:37:21 +01:00
fl_pop_clip();
}
}
if (!tracks) {
fl_font(FL_HELVETICA_BOLD,18); fl_color(xtc_ui::text());
fl_draw("Your next track starts here",x()+220,y()+105);
fl_font(FL_HELVETICA,13); fl_color(xtc_ui::muted());
fl_draw("Create a MIDI track or import audio from the panel on the left.",x()+220,y()+135);
fl_draw("Arrange clips here. Open the piano roll to draw and edit MIDI notes.",x()+220,y()+161);
}
{
uint64_t begin=mode_==4 ? std::min(loop_anchor_,loop_preview_) : loop_begin_;
uint64_t end=mode_==4 ? std::max(loop_anchor_,loop_preview_) : loop_end_;
int left=x()+header_width_+static_cast<int>(begin*static_cast<unsigned int>(scale_)/960);
int right=x()+header_width_+static_cast<int>(end*static_cast<unsigned int>(scale_)/960);
fl_color(loop_enabled_ ? xtc_ui::accent() : xtc_ui::muted());
fl_rectf(left,y()+23,std::max(0,right-left),4);
}
size_t markers=0;xtc_project_marker_count(project_,&markers);
for(size_t i=0;i<markers;++i) {
xtc_marker marker{};xtc_project_get_marker(project_,i,&marker);
int px=x()+header_width_+static_cast<int>(marker.tick*static_cast<unsigned int>(scale_)/960U);
if(px<cx || px>cx+cw)continue;
fl_color(xtc_ui::accent());fl_line(px,y()+24,px,y()+h());
fl_push_clip(px,y(),160,12);fl_font(FL_HELVETICA,10);fl_draw(marker.name,px+3,y()+10);fl_pop_clip();
}
int playhead = x()+header_width_+static_cast<int>(cursor_*static_cast<unsigned int>(scale_)/960U);
if (playhead >= cx && playhead <= cx+cw) {
fl_color(xtc_ui::accent()); fl_line(playhead,y(),playhead,y()+h());
fl_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6);
}
int pinned=header_left();
fl_color(xtc_ui::background());fl_rectf(pinned,y(),header_width_,28);
fl_color(xtc_ui::muted());fl_draw("TRACKS / drag edge to resize",pinned+10,y()+19);
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<int>(i)*56;
if(py+56<cy || py>cy+ch)continue;
Fl_Color color = xtc_ui::track(t.id);
fl_color(t.id == track_ ? fl_rgb_color(49,62,70) : xtc_ui::panel());
fl_rectf(pinned,py,header_width_-2,55);
fl_color(color); fl_rectf(pinned,py,3,55);
fl_push_clip(pinned+9,py,header_width_-18,55);
fl_color(xtc_ui::text()); fl_font(FL_HELVETICA_BOLD,12);
fl_draw(t.name,pinned+12,py+18);
char detail[64];
std::snprintf(detail,sizeof(detail),"%02zu %s%s%s",i+1,t.is_bus ? "BUS" : "TRACK",
t.mute ? " MUTE" : "",t.solo ? " SOLO" : "");
if (t.is_bus) { fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,pinned+12,py+42); }
if (t.mute || t.solo) { fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(t.mute ? "M" : "S",pinned+header_width_-16,py+18); }
fl_pop_clip();
}
fl_color(xtc_ui::muted());fl_rectf(pinned+header_width_-3,cy,3,ch);
for(size_t i=0;i<track_choices_.size();++i)if(track_choices_[i].widget)
track_choices_[i].widget->resize(pinned+10,y()+58+static_cast<int>(i)*56,header_width_-22,20);
draw_children();
fl_pop_clip();
}
2026-10-27 01:37:21 +01:00
/**
* @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_x()>=header_left()+header_width_-5 && Fl::event_x()<header_left()+header_width_) {
mode_=5;header_drag_x_=Fl::event_x();header_drag_original_=header_width_;return 1;
}
if(mode_==5 && (event==FL_DRAG || event==FL_RELEASE)) {
header_width(header_drag_original_+Fl::event_x()-header_drag_x_);if(event==FL_RELEASE)mode_=0;return 1;
}
if(mode_==5 && event==FL_KEYDOWN && Fl::event_key()==FL_Escape) {header_width(header_drag_original_);mode_=0;return 1;}
for (const auto &choice : track_choices_) {
if (!choice.widget || !choice.widget->visible()) continue;
if ((event == FL_PUSH && Fl::event_inside(choice.widget)) ||
((event == FL_DRAG || event == FL_RELEASE) && Fl::pushed() == choice.widget) ||
(event == FL_KEYDOWN && Fl::focus() == choice.widget))
return Fl_Group::handle(event);
}
if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) {
2026-10-27 01:37:21 +01:00
take_focus();
mode_ = 0;
if (Fl::event_y() < y() + 28 && Fl::event_x() >= header_left()+header_width_ &&
Fl::event_button()==FL_LEFT_MOUSE && Fl::event_state(FL_SHIFT)) {
loop_anchor_=loop_preview_=tick(Fl::event_x()); mode_=4; redraw(); return 1;
}
if (Fl::event_y() < y() + 28) {
if(Fl::event_x()<header_left()+header_width_)return 1;
uint64_t target = tick(Fl::event_x());
xtc_result r = seek_(context_, target);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
cursor_ = target;
else
fl_alert("Stop playback before seeking: %s", xtc_result_string(r));
redraw();
return 1;
}
2026-10-27 01:37:21 +01:00
size_t tracks, clips;
xtc_project_get_counts(project_, &tracks, &clips);
int row = (Fl::event_y() - y() - 28) / 56;
2026-10-27 01:37:21 +01:00
if (row < 0 || static_cast<size_t>(row) >= tracks)
return 1;
xtc_track_info t{};
xtc_project_get_track(project_, static_cast<size_t>(row), &t);
uint64_t previous_clip=clip_;
2026-10-27 01:37:21 +01:00
track_ = t.id;
clip_ = UINT64_MAX;
for (size_t j = clips; j > 0 && Fl::event_x()>=header_left()+header_width_; --j) {
2026-10-27 01:37:21 +01:00
xtc_clip_info c{};
xtc_project_get_clip(project_, j - 1, &c);
int left = x() + header_width_ + static_cast<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
int right = left + std::max(4, static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U));
if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) {
2026-10-27 01:37:21 +01:00
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_state(FL_SHIFT) && clip_ != UINT64_MAX) {
uint64_t hit=clip_;clip_=previous_clip;toggle_clip(hit);mode_=0;return 1;
}
selection_.clear();
if (Fl::event_button() == FL_LEFT_MOUSE && Fl::event_clicks() && clip_ != UINT64_MAX && original_.midi) {
mode_ = 0;
if (open_clip) open_clip(context_);
return 1;
}
if (Fl::event_button() == FL_RIGHT_MOUSE) {
mode_ = 0;
Fl_Menu_Item items[6]{};
items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)";
items[3].text = "Split clip at cursor (S)"; items[4].text = "Audio gain and fades (A)";
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();
else if (picked == items+3) split_selection();
else if (picked == items+4) audio_properties_dialog();
}
2026-10-27 01:37:21 +01:00
redraw();
return 1;
}
if (event==FL_DRAG && mode_==4) {loop_preview_=tick(Fl::event_x());redraw();return 1;}
if (event==FL_RELEASE && mode_==4) {
loop_preview_=tick(Fl::event_x()); mode_=0;
uint64_t begin=std::min(loop_anchor_,loop_preview_),end=std::max(loop_anchor_,loop_preview_);
if(end>begin && loop_select) {
xtc_result r=loop_select(context_,begin,end);
if(r!=XTC_OK) fl_alert("Loop unchanged: %s",xtc_result_string(r));
}
redraw();return 1;
}
2026-10-27 01:37:21 +01:00
if (event == FL_DRAG)
return mode_ != 0;
if (event == FL_RELEASE && mode_) {
2026-10-27 01:37:21 +01:00
int mode = mode_;
mode_ = 0;
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_;
int64_t start = static_cast<int64_t>(original_.start_tick);
int64_t length = static_cast<int64_t>(original_.length_tick);
int64_t offset = static_cast<int64_t>(original_.offset_tick);
uint64_t target = original_.track_id;
if (mode == 1) {
start = xtc_ui::snap_position(start+delta,grid_);
int row = (Fl::event_y() - y() - 28) / 56;
xtc_track_info t{};
2026-10-27 01:37:21 +01:00
if (row >= 0 && xtc_project_get_track(project_, static_cast<size_t>(row), &t) == XTC_OK)
target = t.id;
} else if (mode == 2) {
delta = xtc_ui::snap_position(start+delta,grid_)-start;
2026-10-27 01:37:21 +01:00
start += delta;
offset += delta;
length -= delta;
} else
length = xtc_ui::snap_position(start+length+delta,grid_)-start;
2026-10-27 01:37:21 +01:00
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<uint64_t>(start),
2026-10-27 01:37:21 +01:00
static_cast<uint64_t>(length), static_cast<uint64_t>(offset)));
return 1;
}
if (event == FL_KEYDOWN && !Fl::event_state(FL_CTRL | FL_ALT)) {
if (Fl::event_key() == 's') { split_selection(); return 1; }
if (Fl::event_key() == 'a') { audio_properties_dialog(); return 1; }
}
if(event==FL_KEYDOWN && !Fl::event_state(FL_CTRL|FL_ALT|FL_SHIFT) && (Fl::event_key()==FL_Left || Fl::event_key()==FL_Right)) {nudge_selection(Fl::event_key()==FL_Left ? -1 : 1);return 1;}
if(event==FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key()=='c'){copy_selection();return 1;}
if(event==FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key()=='v'){paste_selection();return 1;}
if (event == FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key() == 'a') {select_all_clips();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; }
2026-10-27 01:37:21 +01:00
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_Group::handle(event);
}
xtc_result SequencerWidget::merge_midi()
{
auto ids=selected_clips();uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_merge_midi(project_,ids.data(),ids.size(),&id);
if(r==XTC_OK){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);select(c.track_id,id);}
changed(r);return r;
}
xtc_result SequencerWidget::duplicate_track(const char *name)
{
const uint64_t source=track_;uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_duplicate_track(project_,source,name,&id);
if(r==XTC_OK)select(id);
changed(r);return r;
}
void SequencerWidget::duplicate_track_dialog()
{
const char *name=fl_input("Name for complete track duplicate","Track copy");if(name)duplicate_track(name);
}
xtc_result SequencerWidget::ripple_time(uint64_t length,bool remove)
{
const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_ripple_time(project_,at,length,remove);
changed(r);return r;
}
void SequencerWidget::ripple_dialog(bool remove)
{
const char *input=fl_input(remove ? "Remove time from cursor: ticks (split crossing clips first)" : "Insert time at cursor: ticks (split crossing clips first)","3840");if(!input)return;
uint64_t length=0;char extra;std::istringstream values(input);
if(!(values>>length) || (values>>extra) || !length || length>XTC_PROJECT_MAX_TICK){fl_alert("Enter a positive bounded tick duration.");return;}
ripple_time(length,remove);
}
xtc_result SequencerWidget::marker(const char *name)
{
const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=name ? xtc_project_set_marker(project_,at,name) : xtc_project_remove_marker(project_,at);
changed(r);return r;
}
void SequencerWidget::marker_dialog()
{
const char *name=fl_input("Marker at cursor (1..63 UTF-8 bytes)","Section");if(name)marker(name);
}
std::vector<uint64_t> SequencerWidget::selected_clips() const
{
std::vector<uint64_t> ids;xtc_clip_info c{};
for(auto id:selection_)if(xtc_project_find_clip(project_,id,&c)==XTC_OK)ids.push_back(id);
if(ids.empty() && clip_!=UINT64_MAX && xtc_project_find_clip(project_,clip_,&c)==XTC_OK)ids.push_back(clip_);
return ids;
}
bool SequencerWidget::clip_selected(uint64_t id) const
{
return selection_.empty() ? id==clip_ : std::find(selection_.begin(),selection_.end(),id)!=selection_.end();
}
bool SequencerWidget::toggle_clip(uint64_t id)
{
xtc_clip_info c{};if(xtc_project_find_clip(project_,id,&c)!=XTC_OK)return false;
if(selection_.empty() && clip_!=UINT64_MAX)selection_.push_back(clip_);
auto found=std::find(selection_.begin(),selection_.end(),id);
if(found==selection_.end())selection_.push_back(id);else selection_.erase(found);
clip_=selection_.empty() ? UINT64_MAX : selection_.back();
if(clip_!=UINT64_MAX)xtc_project_find_clip(project_,clip_,&c);
track_=c.track_id;redraw();return true;
}
void SequencerWidget::select_all_clips()
{
selection_.clear();size_t tracks=0,clips=0;xtc_project_get_counts(project_,&tracks,&clips);
for(size_t i=0;i<clips;++i){xtc_clip_info c{};xtc_project_get_clip(project_,i,&c);selection_.push_back(c.id);}
if(!selection_.empty()){xtc_clip_info c{};clip_=selection_.back();xtc_project_find_clip(project_,clip_,&c);track_=c.track_id;}
redraw();
}
xtc_result SequencerWidget::grid(uint64_t ticks)
{
if (xtc_ui::snap_index(ticks) < 0) return XTC_ERR_INVALID_ARGUMENT;
grid_ = ticks; redraw(); return XTC_OK;
}
xtc_result SequencerWidget::transform_selection(xtc_clip_operation op,int64_t amount)
{
auto ids=selected_clips();std::vector<uint64_t> output(ids.size());
xtc_result r=ids.empty() ? XTC_ERR_NOT_FOUND : XTC_OK;
if(r==XTC_OK && before_edit)r=before_edit(context_);
if(r==XTC_OK)r=xtc_project_transform_clips(project_,ids.data(),ids.size(),op,amount,output.data());
if(r==XTC_OK) {
selection_=op==XTC_CLIPS_DELETE ? std::vector<uint64_t>{} : output;
clip_=selection_.empty() ? UINT64_MAX : selection_.back();
}
changed(r);return r;
}
xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit)
{
auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit);
changed(r);return r;
}
void SequencerWidget::fades_dialog()
{
const char *input=fl_input("Symmetric fade length in ticks (0 removes; at most half each clip)","10");if(!input)return;
int64_t ticks=0;char extra;std::istringstream values(input);
if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast<int64_t>(XTC_PROJECT_MAX_TICK)){fl_alert("Enter a valid nonnegative tick length.");return;}
transform_selection(XTC_CLIPS_FADE,ticks);
}
void SequencerWidget::velocity_dialog()
{
const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return;
unsigned int percent=0;int offset=0;char extra;std::istringstream values(input);
if(!(values>>percent>>offset) || (values>>extra) || percent>400 || offset < -127 || offset>127){fl_alert("Enter a valid percentage and velocity offset.");return;}
xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=percent;edit.velocity_offset=offset;transform_midi(edit);
}
void SequencerWidget::scale_dialog()
{
const char *input=fl_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return;
unsigned int root=0,scale=0;char extra;std::istringstream values(input);
if(!(values>>root>>scale) || (values>>extra) || root>11 || scale>2){fl_alert("Enter a root from 0 to 11 and scale from 0 to 2.");return;}
xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=root;edit.scale=scale;transform_midi(edit);
}
void SequencerWidget::gate_dialog()
{
const char *input=fl_input("Note length percentage (1..400)","50");if(!input)return;
unsigned int percent=0;char extra;std::istringstream values(input);
if(!(values>>percent) || (values>>extra) || percent<1 || percent>400){fl_alert("Enter a whole percentage from 1 to 400.");return;}
xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_GATE;edit.gate_percent=percent;transform_midi(edit);
}
void SequencerWidget::swing_dialog()
{
const char *input=fl_input("Grid ticks (1..3840), swing (0..100), strength (0..100)","240 50 100");if(!input)return;
int grid=0,swing=-1,strength=-1;char extra;
std::istringstream values(input);
if(!(values>>grid>>swing>>strength) || (values>>extra) || grid<1 || grid>3840 || swing<0 || swing>100 || strength<0 || strength>100){fl_alert("Enter a valid grid, swing and strength.");return;}
xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SWING;edit.grid=static_cast<uint64_t>(grid);edit.swing=static_cast<unsigned int>(swing);edit.strength=static_cast<unsigned int>(strength);transform_midi(edit);
}
xtc_result SequencerWidget::repeat_selection(unsigned int copies)
{
auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_repeat_clips(project_,ids.data(),ids.size(),copies);
changed(r);return r;
}
void SequencerWidget::repeat_dialog()
{
const char *input=fl_input("Additional phrase copies (1..1024)","3");if(!input)return;
char *end=nullptr;unsigned long copies=std::strtoul(input,&end,10);
if(end==input || *end || copies<1 || copies>1024){fl_alert("Enter a whole number from 1 to 1024.");return;}
repeat_selection(static_cast<unsigned int>(copies));
}
xtc_result SequencerWidget::copy_selection()
{
auto ids=selected_clips();xtc_clipboard *copy=nullptr;
xtc_result r=xtc_clipboard_create(project_,ids.data(),ids.size(),&copy);
if(r==XTC_OK)clipboard_.reset(copy);
else fl_alert("Copy unchanged: %s",xtc_result_string(r));
return r;
}
xtc_result SequencerWidget::paste_selection()
{
const uint64_t start=cursor_,track=track_;
std::vector<uint64_t> output(xtc_clipboard_count(clipboard_.get()));
xtc_result r=output.empty() ? XTC_ERR_INVALID_STATE : XTC_OK;
if(r==XTC_OK && before_edit)r=before_edit(context_);
if(r==XTC_OK)r=xtc_clipboard_paste(clipboard_.get(),project_,track,start,output.data());
if(r==XTC_OK){selection_=output;clip_=selection_.back();xtc_clip_info c{};xtc_project_find_clip(project_,clip_,&c);track_=c.track_id;}
changed(r);return r;
}
xtc_result SequencerWidget::nudge_selection(int direction)
{
return transform_selection(XTC_CLIPS_MOVE,direction<0 ? -static_cast<int64_t>(grid_) : static_cast<int64_t>(grid_));
}
xtc_result SequencerWidget::duplicate_selection()
{
auto ids=selected_clips();uint64_t begin=XTC_PROJECT_MAX_TICK,end=0;
for(auto id:ids){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);begin=std::min(begin,c.start_tick);end=std::max(end,c.start_tick+c.length_tick);}
return transform_selection(XTC_CLIPS_DUPLICATE,static_cast<int64_t>(end)-static_cast<int64_t>(begin));
}
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;
}
xtc_result SequencerWidget::split_selection()
{
uint64_t right;
const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead.
xtc_result result = before_edit ? before_edit(context_) : XTC_OK;
if (result == XTC_OK) {
xtc_clip_info c{};result=xtc_project_find_clip(project_,clip_,&c);
if(result==XTC_OK)result=c.midi ? xtc_project_split_midi(project_,clip_,split_tick,&right) : xtc_project_split_audio(project_,clip_,split_tick,&right);
}
if (result == XTC_OK) {clip_ = right;selection_.clear();}
changed(result);
return result;
}
xtc_result SequencerWidget::audio_properties(float gain, uint64_t in, uint64_t out)
{
xtc_result result = before_edit ? before_edit(context_) : XTC_OK;
if (result == XTC_OK) result = xtc_project_set_clip_audio(project_, clip_, gain, in, out);
changed(result);
return result;
}
void SequencerWidget::audio_properties_dialog()
{
xtc_clip_info clip{};
if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || clip.midi) {
fl_alert("Select an audio clip or silent region first.");
return;
}
char defaults[128];
std::snprintf(defaults, sizeof(defaults), "%.9g %llu %llu", static_cast<double>(clip.gain),
static_cast<unsigned long long>(clip.fade_in_tick), static_cast<unsigned long long>(clip.fade_out_tick));
const char *text = fl_input("Linear gain (0..4), fade-in ticks, fade-out ticks (0 disables)", defaults);
if (!text) return;
std::istringstream input(text);
float gain; uint64_t in, out;
if (!(input >> gain >> in >> out) || !(input >> std::ws).eof()) {
fl_alert("Enter gain and two fade durations in ticks."); return;
}
audio_properties(gain, in, out);
}
void SequencerWidget::refresh_waveforms() try
{
// Control-thread loading only. Keep cached failures as well as successes;
// removed/relinked paths are evicted. Reopening rebuilds externally changed files.
size_t tracks, clips;
xtc_project_get_counts(project_, &tracks, &clips);
std::vector<Wave> next;
for (size_t i = 0; i < clips && next.size() < 64; ++i) {
xtc_clip_info c{};
xtc_project_get_clip(project_, i, &c);
if (!c.asset_path[0]) continue;
bool present = false;
for (const auto &wave : next) if (wave.path == c.asset_path) { present = true; break; }
if (present) continue;
Wave entry;
entry.path = c.asset_path;
for (auto &wave : waves_) if (wave.path == entry.path) {
entry.data = std::move(wave.data); present = true; break;
}
if (!present) {
xtc_waveform *summary = nullptr;
(void)xtc_waveform_create(c.asset_path, &summary);
entry.data.reset(summary);
}
next.push_back(std::move(entry));
}
waves_ = std::move(next);
} catch (const std::bad_alloc &) {
waves_.clear(); // Preview failure must not interrupt the model/history edit.
}
void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top)
{
const xtc_waveform *summary = nullptr;
for (const auto &wave : waves_) if (wave.path == c.asset_path) { summary = wave.data.get(); break; }
if (!summary) return;
unsigned int rate; size_t frames, bins;
if (xtc_waveform_info(summary, &rate, &frames, &bins) != XTC_OK) return;
double ticks_per_frame = static_cast<double>(xtc_project_tempo(project_)) * 960 / (rate * 60000.0);
fl_color(fl_rgb_color(30, 65, 80));
for (size_t bin = 0; bin < bins; ++bin) {
double start = static_cast<double>(bin * frames / bins) * ticks_per_frame;
double end = static_cast<double>((bin+1) * frames / bins) * ticks_per_frame;
double offset = static_cast<double>(c.offset_tick);
if (end <= offset || start >= offset + static_cast<double>(c.length_tick)) continue;
int px = left + static_cast<int>((std::max(start, offset) - offset) * scale_ / 960);
int width = std::max(1, static_cast<int>((end - std::max(start, offset)) * scale_ / 960));
float low, high;
if (xtc_waveform_peak(summary, bin, &low, &high) != XTC_OK) continue;
int y1 = top + 28 - static_cast<int>(std::clamp(high, -1.0f, 1.0f) * 19);
int y2 = top + 28 - static_cast<int>(std::clamp(low, -1.0f, 1.0f) * 19);
fl_rectf(px, y1, width, std::max(1, y2-y1+1));
}
}
Fl_Choice *SequencerWidget::instrument_choice(uint64_t track) const
{
for (const auto &entry : track_choices_) if (entry.widget && entry.track == track) return entry.widget;
return nullptr;
}
void SequencerWidget::refresh_instruments() try
{
size_t tracks = 0, clips = 0;
xtc_project_get_counts(project_,&tracks,&clips);
catalog_.clear();
2026-10-28 17:16:01 +01:00
for (unsigned int i=0;i<2;++i) { xtc_plugin_spec s{}; if (xtc_plugin_probe(XTC_BUILTIN_PATH,i,48000,&s)==XTC_OK) catalog_.push_back(s); }
for (size_t i = 0; i < tracks; ++i) {
xtc_track_info t{}; xtc_plugin_spec spec{};
xtc_project_get_track(project_,i,&t);
if (xtc_project_get_instrument(project_,t.id,&spec) != XTC_OK) continue;
bool present = false;
for (const auto &item : catalog_) if (!std::strcmp(item.path,spec.path) && item.index == spec.index) present = true;
if (!present) catalog_.push_back(spec);
}
for (size_t i = 0; i < track_choices_.size(); ++i) {
auto &entry = track_choices_[i];
xtc_track_info t{};
if (i >= tracks || xtc_project_get_track(project_,i,&t) != XTC_OK || t.is_bus) {
if (entry.widget) entry.widget->hide();
entry.track = UINT64_MAX; continue;
}
if (!entry.widget) {
struct GroupScope {
Fl_Group *previous;
explicit GroupScope(Fl_Group *group) : previous(Fl_Group::current()) { Fl_Group::current(group); }
~GroupScope() { Fl_Group::current(previous); }
} scope(this);
entry.widget = new Fl_Choice(header_left()+10,y()+58+static_cast<int>(i)*56,header_width_-22,20);
xtc_ui::style(entry.widget); entry.widget->textsize(10); entry.widget->textcolor(xtc_ui::text());
entry.widget->tooltip("Track instrument: built-in sine, an instrument used in this project, or Load plugin...");
entry.widget->callback(instrument_callback,this);
}
entry.track = t.id;
entry.widget->resize(header_left()+10,y()+58+static_cast<int>(i)*56,header_width_-22,20);
entry.widget->clear(); entry.widget->add("Built-in sine");
xtc_plugin_spec current{}; int selected = 0;
bool assigned = xtc_project_get_instrument(project_,t.id,&current) == XTC_OK;
for (size_t j = 0; j < catalog_.size(); ++j) {
std::string name = std::string(xtc_plugin_format_name(catalog_[j].format))+": "+catalog_[j].label;
int item = entry.widget->add("Instrument"); entry.widget->replace(item,name.c_str());
if (assigned && !std::strcmp(current.path,catalog_[j].path) && current.index == catalog_[j].index) selected = static_cast<int>(j)+1;
}
entry.widget->add("Load plugin..."); entry.widget->value(selected); entry.widget->show();
}
} catch (const std::bad_alloc &) {
catalog_.clear();
for (auto &entry : track_choices_) if (entry.widget) entry.widget->hide();
}
void SequencerWidget::instrument_callback(Fl_Widget *widget, void *context)
{
auto *owner = static_cast<SequencerWidget *>(context);
auto *choice = static_cast<Fl_Choice *>(widget);
for (const auto &entry : owner->track_choices_) if (entry.widget == choice) {
uint64_t track = entry.track;
int index = choice->value();
bool browse = index == static_cast<int>(owner->catalog_.size())+1;
xtc_plugin_spec spec{};
if (index > 0 && !browse) {
if (static_cast<size_t>(index) > owner->catalog_.size()) return;
spec = owner->catalog_[static_cast<size_t>(index)-1];
}
if (owner->instrument_select) owner->instrument_select(owner->context_,track,index > 0 && !browse ? &spec : nullptr,browse);
owner->refresh_instruments(); owner->redraw(); return;
}
}
xtc_result SequencerWidget::set_zoom(int scale)
{
if(scale!=24 && scale!=48 && scale!=96 && scale!=192 && scale!=384) return XTC_ERR_INVALID_ARGUMENT;
scale_=scale;extent();redraw();return XTC_OK;
}