428 lines
18 KiB
C++
428 lines
18 KiB
C++
#include "patterneditorview.h"
|
|
using Xybrid::UI::PatternEditorView;
|
|
#include "ui/patterneditormodel.h"
|
|
using Xybrid::UI::PatternEditorModel;
|
|
#include "ui/patterneditoritemdelegate.h"
|
|
using Xybrid::UI::PatternEditorItemDelegate;
|
|
|
|
#include "util/strings.h"
|
|
#include "util/keys.h"
|
|
|
|
#include "ui/channelheaderview.h"
|
|
using Xybrid::UI::ChannelHeaderView;
|
|
|
|
#include "data/project.h"
|
|
using Xybrid::Data::Project;
|
|
using Xybrid::Data::Pattern;
|
|
|
|
#include "editing/compositecommand.h"
|
|
#include "editing/patterncommands.h"
|
|
using namespace Xybrid::Editing;
|
|
|
|
#include "audio/audioengine.h"
|
|
using namespace Xybrid::Audio;
|
|
|
|
#include "util/pattern.h"
|
|
|
|
#include <QKeyEvent>
|
|
#include <QShortcut>
|
|
#include <QTimer>
|
|
#include <QGuiApplication>
|
|
#include <QClipboard>
|
|
#include <QMimeData>
|
|
#include <QCborArray>
|
|
#include <QCborMap>
|
|
#include <QDebug>
|
|
|
|
#include <QHeaderView>
|
|
#include <QScrollBar>
|
|
#include <QHBoxLayout>
|
|
#include <QMenu>
|
|
#include <QTextEdit>
|
|
#include <QInputDialog>
|
|
#include <QMessageBox>
|
|
|
|
|
|
namespace {
|
|
std::shared_ptr<Pattern> pt = std::make_shared<Pattern>(1, 0); // fallback pattern
|
|
}
|
|
|
|
PatternEditorView::PatternEditorView(QWidget *parent) : QTableView(parent) {
|
|
hdr.reset(new ChannelHeaderView(this));
|
|
hdr->setSectionResizeMode(QHeaderView::Fixed);
|
|
hdr->setTextElideMode(Qt::ElideNone);//Middle);
|
|
hdr->setStretchLastSection(true);
|
|
hdr->setSectionsMovable(true);
|
|
hdr->setFirstSectionMovable(true);
|
|
hdr->setEditTriggers(EditTrigger::DoubleClicked);
|
|
|
|
// hook up scrolling to update header position
|
|
connect(horizontalScrollBar(), &QScrollBar::valueChanged, this, &PatternEditorView::updateHeaderOffset);
|
|
// and header swap
|
|
connect(hdr.get(), &QHeaderView::sectionMoved, this, &PatternEditorView::headerMoved);
|
|
|
|
// hook up context menu
|
|
hdr->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
connect(hdr.get(), &QHeaderView::customContextMenuRequested, this, &PatternEditorView::headerContextMenu);
|
|
|
|
connect(hdr.get(), &QHeaderView::sectionDoubleClicked, this, &PatternEditorView::headerDoubleClicked);
|
|
|
|
horizontalHeader()->setSectionResizeMode(QHeaderView::Fixed);//ResizeToContents);
|
|
horizontalHeader()->setStretchLastSection(true);
|
|
verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
|
|
verticalHeader()->setMinimumSectionSize(0);
|
|
//verticalHeader()->setStretchLastSection(true);
|
|
verticalHeader()->setSectionsClickable(false);
|
|
setCornerButtonEnabled(false);
|
|
//verticalHeader()->setDefaultAlignment(Qt::AlignTop);
|
|
|
|
cornerBoxBox.reset(new QWidget(this));
|
|
QHBoxLayout *layoutCheckBox = new QHBoxLayout(cornerBoxBox.get());
|
|
cornerBox.reset(new QCheckBox());
|
|
layoutCheckBox->addWidget(cornerBox.get());
|
|
layoutCheckBox->setAlignment(Qt::AlignCenter);
|
|
layoutCheckBox->setContentsMargins(0,0,0,0);
|
|
cornerBox->setToolTip("Expand to fit names");
|
|
cornerBox->setFocusPolicy(Qt::NoFocus);
|
|
connect(cornerBox.get(), &QCheckBox::toggled, this, [this](bool state) {
|
|
mdl->fitHeaderToName = state;
|
|
mdl->updateColumnDisplay();
|
|
});
|
|
|
|
mdl.reset(new PatternEditorModel(this));
|
|
del.reset(new PatternEditorItemDelegate(this));
|
|
setItemDelegate(&*del);
|
|
mdl->setPattern(pt);
|
|
setModel(&*mdl);
|
|
hdr->setModel(&*mdl->hprox);
|
|
|
|
// smooth scroll
|
|
setVerticalScrollMode(ScrollMode::ScrollPerPixel);
|
|
setHorizontalScrollMode(ScrollMode::ScrollPerPixel);
|
|
|
|
{ /* set up hotkeys */ } {
|
|
// transpose notes
|
|
auto transpose = [this](int amt, int key = Qt::Key_Alt) {
|
|
auto p = mdl->getPattern();
|
|
auto sel = this->selectionModel()->selection().first();
|
|
auto startCol = sel.left() % Util::colsPerChannel;
|
|
if (sel.width() == 1 && startCol != 1) {
|
|
if (startCol == 0) { // port
|
|
amt = std::clamp(amt, -16, 16);
|
|
auto cc = new CompositeCommand();
|
|
auto ch = Util::channelForColumn(sel.left());
|
|
auto last = sel.bottom();
|
|
for (auto i = sel.top(); i <= last; i++) {
|
|
if (p->rowAt(ch, i-1).port >= 0) {
|
|
auto c = new PatternDeltaCommand(p, ch, i-1);
|
|
auto op = c->row.port;
|
|
c->row.port = static_cast<int16_t>(std::clamp(op + amt, 0, 255));
|
|
if (c->row.port == op) c->cancel();
|
|
else cc->compose(c);
|
|
}
|
|
}
|
|
cc->commit("change note port(s)");
|
|
} else if (startCol >= 2 && sel.height() == 1) { // single param
|
|
amt = std::clamp(amt, -16, 16);
|
|
auto c = new PatternDeltaCommand(p, Util::channelForColumn(sel.left()), sel.top()-1);
|
|
auto& r = c->row;
|
|
auto par = static_cast<size_t>((sel.left() % Util::colsPerChannel) - 2) / 2;
|
|
if (r.numParams() > par) {
|
|
if (r.param(par)[0] != ' ') r.param(par)[1] = static_cast<uint8_t>(std::clamp(static_cast<int>(r.param(par)[1]) + amt, 0, 255));
|
|
c->commit();
|
|
} else c->cancel();
|
|
}
|
|
} else { // note(s)
|
|
amt = std::clamp(amt, -12, 12);
|
|
auto cc = new CompositeCommand();
|
|
for (auto s : sel.indexes()) { // clazy:exclude=range-loop-detach
|
|
if (s.column() % Util::colsPerChannel != 1) continue;
|
|
int ch = Util::channelForColumn(s.column());
|
|
auto c = new PatternDeltaCommand(p, ch, s.row()-1);
|
|
if (c->row.note >= 0) {
|
|
c->row.note = static_cast<int16_t>(std::max(c->row.note + amt, 0));
|
|
cc->compose(c);
|
|
} else c->cancel();
|
|
}
|
|
cc->commit("transpose note(s)");
|
|
startPreview(key);
|
|
}
|
|
|
|
|
|
};
|
|
|
|
connect(new QShortcut(QKeySequence("Alt+Up"), this), &QShortcut::activated, this, [transpose] { transpose(1, Qt::Key_Up); });
|
|
connect(new QShortcut(QKeySequence("Alt+Down"), this), &QShortcut::activated, this, [transpose] { transpose(-1, Qt::Key_Down); });
|
|
connect(new QShortcut(QKeySequence("Alt+Right"), this), &QShortcut::activated, this, [transpose] { transpose(100, Qt::Key_Right); });
|
|
connect(new QShortcut(QKeySequence("Alt+Left"), this), &QShortcut::activated, this, [transpose] { transpose(-100, Qt::Key_Left); });
|
|
|
|
// fold
|
|
connect(new QShortcut(QKeySequence("Ctrl+Space"), this), &QShortcut::activated, this, [this] {
|
|
setUpdatesEnabled(false);
|
|
mdl->toggleFold();
|
|
auto p = mdl->getPattern();
|
|
auto ind = currentIndex();
|
|
int row = ind.row() - 1;
|
|
if (mdl->folded && p->fold > 1 && row % p->fold != 0) {
|
|
ind = ind.siblingAtRow(1 + row - (row % p->fold));
|
|
setCurrentIndex(ind);
|
|
if (selectedIndexes().count() <= 1) selectionModel()->select(ind, QItemSelectionModel::SelectionFlag::ClearAndSelect);
|
|
}
|
|
scrollTo(ind, ScrollHint::PositionAtCenter);
|
|
setUpdatesEnabled(true);
|
|
//QTimer::singleShot(1, [this, ind]{ scrollTo(ind, ScrollHint::PositionAtCenter); });
|
|
});
|
|
|
|
// cut/copy/paste
|
|
auto copy = [this] {
|
|
auto* clip = QGuiApplication::clipboard();
|
|
auto* data = new QMimeData();
|
|
|
|
auto p = mdl->getPattern();
|
|
auto sel = selectionModel()->selection()[0];
|
|
auto chMin = Util::channelForColumn(sel.left());
|
|
auto chMax = Util::channelForColumn(sel.right());
|
|
|
|
// cbor data format: (may change between versions, assume a given system only has one Xybrid version installed)
|
|
// [ integer first, last, n*[ integer port, note, (param, val)... ]... ]...
|
|
QCborArray root;
|
|
|
|
for (int ch = chMin; ch <= chMax; ch++) {
|
|
QCborArray chm;
|
|
int first = 0, last = 255;
|
|
if (ch == chMin) first = Util::fieldForColumn(sel.left());
|
|
if (ch == chMax) last = Util::fieldForColumn(sel.right());
|
|
|
|
chm << first << last;
|
|
for (int r = sel.top(); r <= sel.bottom(); r++) {
|
|
QCborArray rm;
|
|
auto& row = p->rowAt(ch, r-1);
|
|
rm << row.port;
|
|
rm << row.note;
|
|
if (row.params) for (auto p : *row.params) rm << p[0] << p[1];
|
|
chm << rm;
|
|
}
|
|
root << chm;
|
|
}
|
|
|
|
data->setData("xybrid-internal/x-pattern-copy", root.toCborValue().toCbor());
|
|
clip->setMimeData(data);
|
|
};
|
|
|
|
connect(new QShortcut(QKeySequence(QKeySequence::StandardKey::Cut), this), &QShortcut::activated, this, [this, copy] {
|
|
copy();
|
|
this->edit(currentIndex(), EditTrigger::EditKeyPressed, new QKeyEvent(QKeyEvent::Type::KeyPress, Qt::Key_Delete, Qt::KeyboardModifier::NoModifier));
|
|
});
|
|
connect(new QShortcut(QKeySequence(QKeySequence::StandardKey::Copy), this), &QShortcut::activated, this, [copy] { copy(); });
|
|
connect(new QShortcut(QKeySequence(QKeySequence::StandardKey::Paste), this), &QShortcut::activated, this, [this] {
|
|
const auto* clip = QGuiApplication::clipboard();
|
|
const auto* data = clip->mimeData();
|
|
|
|
if (!data->hasFormat("xybrid-internal/x-pattern-copy")) return; // no pattern data
|
|
|
|
auto p = mdl->getPattern();
|
|
auto idx = currentIndex();
|
|
auto chMin = Util::channelForColumn(idx.column());
|
|
auto rMin = idx.row()-1;
|
|
|
|
auto root = QCborValue::fromCbor(data->data("xybrid-internal/x-pattern-copy")).toArray();
|
|
|
|
auto cc = new CompositeCommand();
|
|
|
|
for (int ch = 0; ch < static_cast<int>(p->numChannels()) - chMin && ch < root.size(); ch++) {
|
|
auto chm = root[ch].toArray();
|
|
|
|
int first = static_cast<int>(chm[0].toInteger());
|
|
int last = static_cast<int>(chm[1].toInteger());
|
|
|
|
int pOff = 0;
|
|
if (ch == 0 && first >= 2) pOff = std::max(0, Util::fieldForColumn(idx.column()) - first);
|
|
|
|
for (int r = 0; r < p->rows - rMin && r < chm.size() - 2; r++) {
|
|
auto rm = chm[r+2].toArray();
|
|
auto c = new PatternDeltaCommand(p, ch+chMin, r+rMin);
|
|
auto& row = c->row;
|
|
|
|
if (first <= 0 && last >= 0) row.port = static_cast<int16_t>(rm[0].toInteger());
|
|
if (first <= 1 && last >= 1) row.note = static_cast<int16_t>(rm[1].toInteger());
|
|
for (int p = 0; p < (rm.size() - 2) / 2 && p+pOff < Util::paramSoftCap; p++)
|
|
if (first <= p+2 && last >= p+2) row.setParam(static_cast<size_t>(p+pOff), static_cast<char>(rm[p*2+2].toInteger()), static_cast<unsigned char>(rm[p*2+3].toInteger()));
|
|
|
|
cc->compose(c);
|
|
}
|
|
}
|
|
|
|
cc->commit("paste pattern data");
|
|
});
|
|
}
|
|
}
|
|
|
|
PatternEditorView::~PatternEditorView() {
|
|
//
|
|
/*mdl.release();
|
|
del.release();
|
|
hdr.release();
|
|
cornerBoxBox.release();
|
|
cornerBox.release();*/
|
|
//horizontalHeader()->deleteLater();
|
|
}
|
|
|
|
void PatternEditorView::keyPressEvent(QKeyEvent* e) {
|
|
if (/*event->modifiers() & Qt::Modifier::CTRL &&*/ (e->key() == Qt::Key_Tab || e->key() == Qt::Key_Backtab)) { // don't block ctrl+tab
|
|
e->ignore();
|
|
return;
|
|
//QKeyEvent()
|
|
}
|
|
if (e->key() == Qt::Key_Delete || e->key() == Qt::Key_Backspace || e->key() == Qt::Key_Insert) {
|
|
if (!edit(currentIndex(), AnyKeyPressed, e)) {
|
|
e->ignore();
|
|
return;
|
|
}
|
|
}
|
|
auto prevInd = currentIndex();
|
|
QTableView::keyPressEvent(e);
|
|
if (auto ind = currentIndex(); ind != prevInd) scrollTo(ind, ScrollHint::PositionAtCenter); // cursor position changed, scroll to keep up
|
|
if (!e->isAutoRepeat()) {
|
|
if (Util::keyToNote(e->key()) >= 0 || (e->key() >= Qt::Key_0 && e->key() <= Qt::Key_9) || e->key() == Qt::Key_Space) { // note-related key
|
|
startPreview(Util::unshiftedKey(e->key()));
|
|
}
|
|
}
|
|
}
|
|
|
|
void PatternEditorView::keyReleaseEvent(QKeyEvent* e) {
|
|
QAbstractItemView::keyReleaseEvent(e);
|
|
if (!e->isAutoRepeat()) stopPreview(Util::unshiftedKey(e->key()));
|
|
}
|
|
|
|
void PatternEditorView::mouseReleaseEvent(QMouseEvent* e) {
|
|
QTableView::mouseReleaseEvent(e);
|
|
auto sm = selectionModel();
|
|
if (sm->selectedIndexes().size() <= 1) {
|
|
auto ind = indexAt(e->localPos().toPoint());
|
|
if (ind.isValid() && model()->flags(ind) & Qt::ItemFlag::ItemIsEnabled) scrollTo(ind, ScrollHint::PositionAtCenter);
|
|
}
|
|
}
|
|
|
|
void PatternEditorView::startPreview(int key) {
|
|
auto ind = currentIndex();
|
|
int cc = ind.column() % PatternEditorModel::colsPerChannel;
|
|
int ch = (ind.column() - cc) / PatternEditorModel::colsPerChannel;
|
|
if (cc == 1) { // note column
|
|
stopPreview(key); // end current preview first, if applicable
|
|
auto& r = mdl->getPattern()->rowAt(ch, ind.row()-1);
|
|
auto p = mdl->getPattern()->project->shared_from_this();
|
|
previewKey[key] = {r.port, audioEngine->preview(p, r.port, r.note)};
|
|
}
|
|
}
|
|
|
|
void PatternEditorView::stopPreview(int key) {
|
|
if (auto k = previewKey.find(key); k != previewKey.end()) {
|
|
auto p = mdl->getPattern()->project->shared_from_this();
|
|
audioEngine->preview(p, k->second.first, -2, k->second.second);
|
|
previewKey.erase(k);
|
|
}
|
|
}
|
|
|
|
void PatternEditorView::setPattern(const std::shared_ptr<Pattern>& pattern) {
|
|
setUpdatesEnabled(false);
|
|
mdl->setPattern(pattern);
|
|
|
|
//rowCountChanged(0, mdl->rowCount());
|
|
|
|
auto cur = currentIndex();
|
|
auto ind = model()->index(std::clamp(cur.row(), 1, pattern->rows), std::clamp(cur.column(), 0, std::max(0, mdl->columnCount() - 2)));
|
|
while (isColumnHidden(ind.column())) ind = ind.siblingAtColumn(ind.column()-1);
|
|
while (isRowHidden(ind.row())) ind = ind.siblingAtRow(ind.row()-1);
|
|
setCurrentIndex(ind);
|
|
selectionModel()->select(ind, QItemSelectionModel::ClearAndSelect);
|
|
scrollTo(ind, ScrollHint::PositionAtCenter);
|
|
setUpdatesEnabled(true);
|
|
}
|
|
|
|
void PatternEditorView::updateGeometries() {
|
|
if (mdl && colUpdateNeeded && horizontalHeader()->count() > 0) {
|
|
mdl->updateColumnDisplay();
|
|
colUpdateNeeded = false;
|
|
} else updateHeader(true); // do this once on every geom update
|
|
if (cornerBox) {
|
|
cornerBoxBox->setGeometry(verticalHeader()->x(), horizontalHeader()->y(), verticalHeader()->width(), horizontalHeader()->height());
|
|
//cornerBox->move(verticalHeader()->x() + verticalHeader()->width() / 2 - cornerBox->minimumWidth() / 2, horizontalHeader()->y() + cornerBox->height() / 2);
|
|
}
|
|
this->QTableView::updateGeometries();
|
|
}
|
|
|
|
void PatternEditorView::updateHeader(bool full) {
|
|
auto* bh = horizontalHeader(); // base header
|
|
if (full) {
|
|
hdr->reset(); // force section update
|
|
for (int i = 0; i < hdr->count(); i++) { // set sizes
|
|
constexpr int cpc = PatternEditorModel::colsPerChannel;
|
|
int w = 0;
|
|
for (int j = 0; j < cpc; j++) {
|
|
w += bh->sectionSize(i*cpc+j);
|
|
}
|
|
hdr->resizeSection(i, w);
|
|
}
|
|
}
|
|
hdr->setGeometry(bh->x(), bh->y(), bh->width(), bh->height());
|
|
hdr->setOffset(bh->offset());
|
|
}
|
|
|
|
void PatternEditorView::refresh() {
|
|
mdl->refresh();
|
|
}
|
|
|
|
bool PatternEditorView::isFolded() {
|
|
return mdl->folded;
|
|
}
|
|
void PatternEditorView::updateHeaderOffset(int) {
|
|
updateHeader(false);
|
|
}
|
|
|
|
void PatternEditorView::headerMoved(int logicalIndex, int oldVisualIndex, int newVisualIndex) {
|
|
if (logicalIndex == newVisualIndex) return; // assume moving back
|
|
hdr->moveSection(newVisualIndex, logicalIndex); // maintain straight-through order
|
|
if (logicalIndex >= hdr->count() - 1) return; // no dragging the endcap :|
|
|
|
|
(new PatternChannelMoveCommand(mdl->getPattern(), oldVisualIndex, newVisualIndex))->commit();
|
|
}
|
|
|
|
void PatternEditorView::headerDoubleClicked(int section) {
|
|
startRenameChannel(section);
|
|
}
|
|
|
|
void PatternEditorView::headerContextMenu(QPoint pt) {
|
|
int idx = hdr->logicalIndexAt(pt);
|
|
std::shared_ptr<Pattern> p = mdl->getPattern();
|
|
|
|
QMenu* menu = new QMenu(this);
|
|
menu->addAction("Add Channel", this, [/*this,*/ idx, p]() {
|
|
(new PatternChannelAddCommand(p, idx))->commit();
|
|
});
|
|
if (idx < hdr->count() - 1) {
|
|
menu->addAction("Delete Channel", this, [this, idx, p]() {
|
|
if (QMessageBox::warning(this, "Are you sure?", QString("Remove channel %1 from pattern %2?").arg(Util::numAndName(idx, p->channel(idx).name), Util::numAndName(p->index, p->name)), QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) return;
|
|
(new PatternChannelDeleteCommand(p, idx))->commit();
|
|
});
|
|
menu->addAction("Rename Channel...", this, [this, idx, p]() {
|
|
if (p != mdl->getPattern()) return; // swapped already
|
|
startRenameChannel(idx);
|
|
});
|
|
}
|
|
menu->setAttribute(Qt::WA_DeleteOnClose);
|
|
menu->popup(hdr->mapToGlobal(pt));
|
|
|
|
}
|
|
|
|
void PatternEditorView::startRenameChannel(int channel) {
|
|
auto p = mdl->getPattern();
|
|
if (static_cast<size_t>(channel) >= p->numChannels()) return;
|
|
auto c = &p->channel(channel);
|
|
bool ok = false;
|
|
auto capt = QString("Rename channel %1:").arg(channel);
|
|
auto n = QInputDialog::getText(this, "Rename...", capt, QLineEdit::Normal, c->name, &ok);
|
|
if (!ok) return; // canceled
|
|
if (p != mdl->getPattern() || c != &p->channel(channel)) return; // abort if this somehow isn't the channel it was before
|
|
(new PatternChannelRenameCommand(p, channel, n))->commit();
|
|
}
|