diff --git a/CLAUDE.md b/CLAUDE.md index 0dcc874..027388f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ both the style and the stage for that reason. `renderChain` takes the pool as a trailing argument; the command line builds no styles and passes none. The interface builds the chain in the sidebar ("Effect chain": add from the -menu, reorder, remove, switch off with the check on the row, one stage selected +menu, reorder by dragging a row or with its arrows, remove, switch off with the check on the row, one stage selected at a time with its params below), the CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur --radius 5`, with `--disable NAME` leaving a stage in place but skipping it. Flags share one namespace across all effects, so `buildChain` diff --git a/src/gui/window.cpp b/src/gui/window.cpp index d2ffa50..95ce64c 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -1300,6 +1300,12 @@ row.stage-row:selected .stage-action, row.stage-row:focus-within .stage-action { opacity: 1; } +row.stage-row.drop-before { + box-shadow: inset 0 2px @theme_fg_color; +} +row.stage-row.drop-after { + box-shadow: inset 0 -2px @theme_fg_color; +} )css"; Gtk::Widget& EffectorWindow::buildChainSection() { @@ -1330,6 +1336,34 @@ Gtk::Widget& EffectorWindow::buildChainSection() { chainList_.signal_row_selected().connect( [this](Gtk::ListBoxRow*) { onStageSelected(); }); + // A row is dragged by its index (see rebuildChainRows) and dropped between + // two rows, which a line along the edge of the row below or above shows. + auto drop = Gtk::DropTarget::create(Glib::Value::value_type(), Gdk::DragAction::MOVE); + drop->signal_motion().connect( + [this](double, double y) { + showChainDropSlot(chainDropSlot(y)); + return Gdk::DragAction::MOVE; + }, + false); + drop->signal_leave().connect([this] { showChainDropSlot(-1); }); + drop->signal_drop().connect( + [this](const Glib::ValueBase& value, double, double y) { + showChainDropSlot(-1); + Glib::Value from; + from.init(value.gobj()); + const int index = from.get(); + const int slot = chainDropSlot(y); + // The slot counts the dragged row itself, which leaves its place + // before it takes the new one. + const int to = slot > index ? slot - 1 : slot; + // Not while the drag is still finishing: the move rebuilds the + // rows, the dragged one and its drag source with them. + Glib::signal_idle().connect_once([this, index, to] { moveStage(index, to); }); + return true; + }, + false); + chainList_.add_controller(drop); + auto* scroller = Gtk::make_managed(); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_child(chainList_); @@ -1409,12 +1443,13 @@ void EffectorWindow::removeStage(int index) { requestRender(); } -void EffectorWindow::moveStage(int index, int delta) { - const int target = index + delta; - if (index < 0 || static_cast(index) >= stages_.size()) return; - if (target < 0 || static_cast(target) >= stages_.size()) return; - std::swap(stages_[static_cast(index)], stages_[static_cast(target)]); - selectedStage_ = target; +void EffectorWindow::moveStage(int from, int to) { + const auto count = static_cast(stages_.size()); + if (from < 0 || from >= count || to < 0 || to >= count || from == to) return; + ChainStage stage = std::move(stages_[static_cast(from)]); + stages_.erase(stages_.begin() + from); + stages_.insert(stages_.begin() + to, std::move(stage)); + selectedStage_ = to; rebuildChainRows(); buildParamWidgets(); requestRender(); @@ -1488,7 +1523,7 @@ void EffectorWindow::rebuildChainRows() { if (delta == 0) { removeStage(index); } else { - moveStage(index, delta); + moveStage(index, index + delta); } }); if (delta == -1) button->set_sensitive(index > 0); @@ -1497,6 +1532,23 @@ void EffectorWindow::rebuildChainRows() { } row->set_child(*box); + + // The row itself is what is dragged, under the pointer where it was + // picked up; a press that goes nowhere is still a click on the row. + auto drag = Gtk::DragSource::create(); + drag->set_actions(Gdk::DragAction::MOVE); + drag->signal_prepare().connect( + [source = drag.get(), row, index](double x, double y) { + source->set_icon(Gtk::WidgetPaintable::create(*row), static_cast(x), + static_cast(y)); + Glib::Value value; + value.init(Glib::Value::value_type()); + value.set(index); + return Gdk::ContentProvider::create(value); + }, + false); + row->add_controller(drag); + chainList_.append(*row); } @@ -1506,6 +1558,39 @@ void EffectorWindow::rebuildChainRows() { scheduleChainSplitFit(); } +// Where a row dropped at `y` goes: before the row whose upper half `y` is in, +// or after the last row. +int EffectorWindow::chainDropSlot(double y) const { + for (int i = 0;; ++i) { + auto* row = chainList_.get_row_at_index(i); + if (row == nullptr) return i; + graphene_rect_t bounds; + if (!gtk_widget_compute_bounds(GTK_WIDGET(row->gobj()), GTK_WIDGET(chainList_.gobj()), + &bounds)) + continue; + if (y < bounds.origin.y + bounds.size.height / 2) return i; + } +} + +// Marks the edge a drop into `slot` lands on; -1 clears the mark. +void EffectorWindow::showChainDropSlot(int slot) { + const int count = static_cast(stages_.size()); + for (int i = 0; auto* row = chainList_.get_row_at_index(i); ++i) { + const bool before = i == slot; + const bool after = slot == count && i == count - 1; + if (before) { + row->add_css_class("drop-before"); + } else { + row->remove_css_class("drop-before"); + } + if (after) { + row->add_css_class("drop-after"); + } else { + row->remove_css_class("drop-after"); + } + } +} + // Once the rows are laid out, not in the middle of a rebuild or of an // allocation, where moving the split would ask for another one. void EffectorWindow::scheduleChainSplitFit() { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index c1f6f30..dbabdae 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -91,7 +91,11 @@ private: Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended void appendStage(const EffectPtr& effect); void removeStage(int index); - void moveStage(int index, int delta); + void moveStage(int from, int to); // `to` is the index it ends up at + // Dragging a row: the slot between rows it would drop into, and the line + // that shows it. + int chainDropSlot(double y) const; + void showChainDropSlot(int slot); // The check on a chain row: the stage stays where it is, and is skipped. void setStageEnabled(int index, bool enabled); void rebuildChainRows(); diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index 5070157..47f2bd8 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -122,6 +122,30 @@ bool addFromMenu(Gtk::MenuButton& add, const std::string& title) { return true; } +// The drop target the chain list reorders its rows with. +GtkDropTarget* chainDropTarget(Gtk::Widget& list) { + GListModel* controllers = gtk_widget_observe_controllers(list.gobj()); + GtkDropTarget* found = nullptr; + for (guint i = 0; i < g_list_model_get_n_items(controllers) && found == nullptr; ++i) { + auto* controller = g_list_model_get_item(controllers, i); + if (GTK_IS_DROP_TARGET(controller)) found = GTK_DROP_TARGET(controller); + g_object_unref(controller); + } + g_object_unref(controllers); + return found; +} + +// What dropping the row of stage `index` at `y` does; a real drag needs a +// pointer, which the tests do not have. +void dropStage(GtkDropTarget* drop, int index, double y) { + GValue value = G_VALUE_INIT; + g_value_init(&value, G_TYPE_INT); + g_value_set_int(&value, index); + gboolean handled = FALSE; + g_signal_emit_by_name(drop, "drop", &value, 1.0, y, &handled); + g_value_unset(&value); +} + void runTests(Gtk::Window& window) { const auto& effects = effector::effects(); @@ -239,6 +263,43 @@ void runTests(Gtk::Window& window) { "Remove drops the stage and renumbers the rest"); check(!sources->get_visible(), "and the sources hide again"); + std::cout << "drag and drop\n"; + addFromMenu(*add, "Scanlines"); + for (const auto& effect : effects) { + if (effect->title() != "Blur" && effect->title() != "Scanlines") { + addFromMenu(*add, effect->title()); + break; + } + } + GtkDropTarget* drop = chainDropTarget(*chainList); + check(drop != nullptr, "the chain list takes drops"); + if (drop != nullptr && rowCount(*chainList) == 3) { + const std::string first = rowTitle(*chainList, 0).substr(3); + const std::string second = rowTitle(*chainList, 1).substr(3); + const std::string third = rowTitle(*chainList, 2).substr(3); + const double bottom = chainList->get_row_at_index(2)->get_height() * 3.0; + GdkDragAction action = GDK_ACTION_NONE; + g_signal_emit_by_name(drop, "motion", 1.0, bottom, &action); + check(chainList->get_row_at_index(2)->has_css_class("drop-after"), + "dragging past the last row marks its bottom edge"); + dropStage(drop, 0, bottom); + pump(); + check(rowTitle(*chainList, 0) == "1. " + second && rowTitle(*chainList, 1) == "2. " + third && + rowTitle(*chainList, 2) == "3. " + first, + "dropping the first stage past the last moves it to the end"); + check(chainList->get_selected_row() == chainList->get_row_at_index(2), + "and selects it"); + check(!chainList->get_row_at_index(2)->has_css_class("drop-after"), + "and clears the mark"); + dropStage(drop, 2, 1.0); + pump(); + check(rowTitle(*chainList, 0) == "1. " + first && rowTitle(*chainList, 1) == "2. " + second, + "dropping on the top edge of the first row moves it to the front"); + } + for (int i = rowCount(*chainList) - 1; i > 0; --i) { + if (auto* removeRow = withTooltip(*chainList->get_row_at_index(i), "Remove")) press(*removeRow); + } + std::cout << "many stages\n"; for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast(i) % effects.size()]->title()); pump();