diff --git a/src/lib/score/graphics/widgets/QGraphicsCombo.cpp b/src/lib/score/graphics/widgets/QGraphicsCombo.cpp index 954ae68b97..a0d9ec9f55 100644 --- a/src/lib/score/graphics/widgets/QGraphicsCombo.cpp +++ b/src/lib/score/graphics/widgets/QGraphicsCombo.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -92,30 +93,42 @@ struct SCORE_LIB_BASE_EXPORT ComboBoxWithEnter final : public QComboBox struct DefaultComboImpl { - static void mousePressEvent(QGraphicsCombo& self, QGraphicsSceneMouseEvent* event) + // Native combobox behavior: the popup menu opens on press; QMenu handles + // both click-then-click and press-drag-release-on-item selection. + static void popupMenu(QGraphicsCombo& self, QGraphicsSceneMouseEvent* event) { - if(event->button() == Qt::LeftButton) + QMenu menu; + for(int i = 0; i < self.array.size(); i++) + { + auto* act = menu.addAction(self.array[i]); + act->setCheckable(true); + act->setChecked(i == self.m_value); + act->setData(i); + } + if(auto* chosen = menu.exec(event->screenPos())) { - self.m_grab = true; - InfiniteScroller::start(self, double(self.m_value) / (self.array.size() - 1)); + const int idx = chosen->data().toInt(); + if(idx != self.m_value) + { + self.m_value = idx; + self.update(); + self.sliderMoved(); + self.sliderReleased(); + } } + } + static void mousePressEvent(QGraphicsCombo& self, QGraphicsSceneMouseEvent* event) + { + if(event->button() == Qt::LeftButton && !self.array.empty()) + { + popupMenu(self, event); + } event->accept(); } static void mouseMoveEvent(QGraphicsCombo& self, QGraphicsSceneMouseEvent* event) { - if((event->buttons() & Qt::LeftButton) && self.m_grab) - { - double v = InfiniteScroller::move(event); - int curPos = std::round(v * (self.array.size() - 1)); - if(curPos != self.m_value) - { - self.m_value = std::clamp(curPos, 0, int(self.array.size() - 1)); - self.sliderMoved(); - self.update(); - } - } event->accept(); } @@ -123,20 +136,7 @@ struct DefaultComboImpl { if(event->button() == Qt::LeftButton) { - InfiniteScroller::stop(self, event); - - if(self.m_grab) - { - double v = InfiniteScroller::move(event); - int curPos = std::round(v * (self.array.size() - 1)); - if(curPos != self.m_value) - { - self.m_value = std::clamp(curPos, 0, int(self.array.size() - 1)); - self.update(); - } - self.m_grab = false; - } - self.sliderReleased(); + // Selection is handled by the popup opened on press. } else if(event->button() == Qt::RightButton) { /* diff --git a/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp b/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp index fc18e5f62d..fc37a7e28a 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp @@ -1333,15 +1333,15 @@ struct ComboBox static auto make_widget( T& inlet, const score::DocumentContext& ctx, QWidget* parent, QObject* context) { - const auto& values = inlet.getValues(); auto sl = new score::ComboBox{parent}; - for(auto& e : values) + for(auto& e : inlet.getValues()) { sl->addItem(e.first); } sl->setContentsMargins(0, 0, 0, 0); - auto set_index = [values, sl](const ossia::value& val) { + auto set_index = [&inlet, sl](const ossia::value& val) { + const auto& values = inlet.getValues(); auto it = ossia::find_if(values, [&](const auto& pair) { return pair.second == val; }); if(it != values.end()) @@ -1353,14 +1353,28 @@ struct ComboBox QObject::connect( sl, SignalUtils::QComboBox_currentIndexChanged_int(), context, - [values, &inlet, &ctx](int idx) { - CommandDispatcher<>{ctx.commandStack}.submit>( - inlet, values[idx].second); + [&inlet, &ctx](int idx) { + const auto& values = inlet.getValues(); + if(idx >= 0 && idx < std::ssize(values)) + CommandDispatcher<>{ctx.commandStack}.submit>( + inlet, values[idx].second); }); QObject::connect( &inlet, &T::valueChanged, sl, [=](const ossia::value& val) { set_index(val); }); + if constexpr(requires { &T::alternativesChanged; }) + { + QObject::connect( + &inlet, &T::alternativesChanged, sl, [&inlet, sl, set_index] { + QSignalBlocker b{sl}; + sl->clear(); + for(auto& e : inlet.getValues()) + sl->addItem(e.first); + set_index(inlet.value()); + }); + } + return sl; } @@ -1380,7 +1394,8 @@ struct ComboBox auto sl = new score::QGraphicsCombo{arr, nullptr}; initWidgetProperties(inlet, *sl); - auto set_index = [values, sl](const ossia::value& val) { + auto set_index = [&slider, sl](const ossia::value& val) { + const auto& values = slider.getValues(); auto it = ossia::find_if(values, [&](const auto& pair) { return pair.second == val; }); if(it != values.end()) @@ -1391,10 +1406,12 @@ struct ComboBox set_index(inlet.value()); QObject::connect( - sl, &score::QGraphicsCombo::sliderMoved, context, [values, sl, &inlet, &ctx] { + sl, &score::QGraphicsCombo::sliderMoved, context, [&slider, sl, &inlet, &ctx] { sl->moving = true; - ctx.dispatcher.submit>( - inlet, values[sl->value()].second); + const auto& values = slider.getValues(); + if(sl->value() >= 0 && sl->value() < std::ssize(values)) + ctx.dispatcher.submit>( + inlet, values[sl->value()].second); }); QObject::connect(sl, &score::QGraphicsCombo::sliderReleased, context, [sl, &ctx] { ctx.dispatcher.commit(); @@ -1408,6 +1425,18 @@ struct ComboBox set_index(val); }); + if constexpr(requires { &Control_T::alternativesChanged; }) + { + QObject::connect( + &inlet, &Control_T::alternativesChanged, sl, [&slider, sl, set_index, &inlet] { + sl->array.clear(); + for(auto& e : slider.getValues()) + sl->array.push_back(e.first); + set_index(inlet.value()); + sl->update(); + }); + } + return sl; } }; diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp index 95427f6d9a..5cadad0c55 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp @@ -8,9 +8,12 @@ #include #include +#include +#include #include +W_OBJECT_IMPL(Process::ComboBox) W_OBJECT_IMPL(Process::FileChooserBase) W_OBJECT_IMPL(Process::FileChooser) W_OBJECT_IMPL(Process::FolderChooser) @@ -106,6 +109,72 @@ void ComboBox::setupExecution(ossia::inlet& inl, QObject* exec_context) const no port.domain = domain().get(); } +void ComboBox::repopulateFromFolder(const QString& folderPath) +{ + std::vector> alts; + if(!folderPath.isEmpty()) + { + QDir dir(folderPath); + const auto files = fileExtensions.isEmpty() + ? dir.entryList(QDir::Files, QDir::Name) + : dir.entryList(fileExtensions, QDir::Files, QDir::Name); + for(const auto& f : files) + alts.emplace_back(f, f.toStdString()); // value = the file name itself + } + if(alts.empty()) + alts.emplace_back(QStringLiteral("-"), std::string("-")); + setAlternatives(std::move(alts)); + + // Watch the folder so files created while the document is open (e.g. by an + // upstream process writing its analysis) appear in the list right away. + if(folderPath != m_watchedFolder) + { + if(m_folderWatcher) + { + delete m_folderWatcher; + m_folderWatcher = nullptr; + } + m_watchedFolder = folderPath; + if(!folderPath.isEmpty() && QDir{folderPath}.exists()) + { + m_folderWatcher = new QFileSystemWatcher{{folderPath}, this}; + connect( + m_folderWatcher, &QFileSystemWatcher::directoryChanged, this, + [this](const QString& path) { repopulateFromFolder(path); }); + } + } +} + +void ComboBox::setAlternatives(std::vector> values) +{ + if(values == alternatives) + return; + alternatives = std::move(values); + + std::vector vals; + for(auto& v : alternatives) + vals.push_back(v.second); + setDomain(State::Domain{ossia::make_domain(vals)}); + + // Keep the current value if it is still in the list; otherwise fall back to + // the init value when available. A value absent from the list is left + // untouched: for folder-backed comboboxes it may name a file that simply + // does not exist *yet* — it becomes selectable (and shows up as selected) + // once the file appears. + if(!alternatives.empty()) + { + auto in_list = [this](const ossia::value& v) { + return ossia::find_if( + alternatives, [&](const auto& pair) { return pair.second == v; }) + != alternatives.end(); + }; + if(!in_list(value()) && in_list(init())) + setValue(init()); + } + + alternativesChanged(); +} + ComboBox::~ComboBox() { } ComboBox::ComboBox(JSONObject::Deserializer& vis, QObject* parent) @@ -1328,13 +1397,13 @@ SCORE_LIB_PROCESS_EXPORT void DataStreamReader::read(const Process::ComboBox& p) { read((const Process::ControlInlet&)p); - m_stream << p.alternatives; + m_stream << p.alternatives << p.folderPortName << p.fileExtensions; } template <> SCORE_LIB_PROCESS_EXPORT void DataStreamWriter::write(Process::ComboBox& p) { - m_stream >> p.alternatives; + m_stream >> p.alternatives >> p.folderPortName >> p.fileExtensions; } template <> SCORE_LIB_PROCESS_EXPORT void @@ -1342,11 +1411,27 @@ JSONReader::read(const Process::ComboBox& p) { read((const Process::ControlInlet&)p); obj["Values"] = p.alternatives; + // Folder-backed combobox metadata (empty for a plain combobox). Persisted so + // a loaded document can re-scan the folder and re-establish live refresh; + // see ProcessModel::init_folder_comboboxes. Extensions are stored as one + // space-joined string ("*.json *.wav"), like FileChooser's filters. + obj["FolderPort"] = p.folderPortName; + obj["FileExtensions"] = p.fileExtensions.join(QChar(' ')); } template <> SCORE_LIB_PROCESS_EXPORT void JSONWriter::write(Process::ComboBox& p) { p.alternatives <<= obj["Values"]; + // Guarded for documents saved before these fields existed. + if(obj.tryGet("FolderPort")) + p.folderPortName <<= obj["FolderPort"]; + if(obj.tryGet("FileExtensions")) + { + QString exts; + exts <<= obj["FileExtensions"]; + p.fileExtensions + = exts.isEmpty() ? QStringList{} : exts.split(QChar(' '), Qt::SkipEmptyParts); + } } template <> SCORE_LIB_PROCESS_EXPORT void diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp index 5c25184e2d..9a28586015 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp @@ -5,6 +5,8 @@ #include +class QFileSystemWatcher; + namespace Process { struct FloatSlider; @@ -442,6 +444,8 @@ struct SCORE_LIB_PROCESS_EXPORT ProgramEdit : public Process::ControlInlet struct SCORE_LIB_PROCESS_EXPORT ComboBox : public Process::ControlInlet { + W_OBJECT(ComboBox) +public: MODEL_METADATA_IMPL(ComboBox) std::vector> alternatives; ComboBox( @@ -449,6 +453,25 @@ struct SCORE_LIB_PROCESS_EXPORT ComboBox : public Process::ControlInlet const QString& name, Id id, QObject* parent); ~ComboBox(); + void setAlternatives(std::vector> values); + void alternativesChanged() W_SIGNAL(alternativesChanged); + + // Folder-backed combobox (halp::folder_combobox): items are the files of the + // sibling port named folderPortName, filtered by fileExtensions ("*.wav"…). + // Empty folderPortName = plain combobox. Populated at edit/load time, and + // refreshed by a filesystem watcher while the document is open so that files + // written by other processes (an upstream analysis, a recorder…) show up + // without reloading. + QString folderPortName; + QStringList fileExtensions; + void repopulateFromFolder(const QString& folderPath); + +private: + QFileSystemWatcher* m_folderWatcher{}; + QString m_watchedFolder; + +public: + const auto& getValues() const noexcept { return alternatives; } auto count() const noexcept { return alternatives.size(); } diff --git a/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp b/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp index 06905031fb..fabec64d6d 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -319,6 +320,34 @@ make_control_in(avnd::field_index, Id&& id, QObject* parent) return new Process::LineEdit{"", qname, id, parent}; } } + else if constexpr(avnd::folder_items_parameter) + { + // Folder-backed, string-valued combobox: the value IS the selected file + // name, so an object uses it directly like a file-name string. Items are + // filled from the sibling folder port at edit time by the model. + static constexpr auto c = avnd::get_range(); + const std::string initv{c.init}; + std::vector> initAlts; + initAlts.emplace_back(QString::fromStdString(initv), initv); + auto combo = new Process::ComboBox{ + std::move(initAlts), ossia::value{initv}, qname, id, parent}; + combo->folderPortName + = QString::fromUtf8(T::folder_port().data(), T::folder_port().size()); + // extensions(): space-separated suffixes ("wav aif json") -> "*.wav" ... + const std::string_view ex = T::extensions(); + std::size_t pos = 0; + while(pos < ex.size()) + { + auto sp = ex.find(' ', pos); + if(sp == std::string_view::npos) + sp = ex.size(); + if(sp > pos) + combo->fileExtensions.push_back( + "*." + QString::fromUtf8(ex.data() + pos, int(sp - pos))); + pos = sp + 1; + } + return combo; + } else if constexpr(widg.widget == avnd::widget_type::combobox) { static constexpr auto c = avnd::get_range(); diff --git a/src/plugins/score-plugin-avnd/Crousti/Executor.hpp b/src/plugins/score-plugin-avnd/Crousti/Executor.hpp index cb3fe9b596..bb4ace47f2 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Executor.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Executor.hpp @@ -47,10 +47,13 @@ #include #include #include +#include #include #include #include +#include + namespace oscr { @@ -176,7 +179,10 @@ class Executor final if constexpr(requires { ptr->impl.effect; }) if constexpr(std::is_same_vimpl.effect)>, Node>) + { connect_message_bus(element, ctx, ptr->impl.effect); + connect_dynamic_items(element, ptr->impl.effect); + } connect_worker(ctx, ptr->impl); node->dynamic_ports = element.dynamic_ports; @@ -460,6 +466,44 @@ class Executor final }; } + void connect_dynamic_items(ProcessModel& element, Node& eff) + { + // type-only inputs: get_inputs would hard-error + if constexpr( + avnd::inputs_is_type + || avnd::control_input_introspection::size == 0) + return; + else + avnd::control_input_introspection::for_all_n2( + avnd::get_inputs(eff), + [&element]( + F& field, auto pred_index, avnd::field_index) { + if constexpr(avnd::dynamic_items_parameter) + { + auto ports = element.avnd_input_idx_to_model_ports(Idx); + if(ports.size() != 1) + return; + if(auto combo = qobject_cast(ports[0])) + { + field.update_items = [p = QPointer{combo}]( + std::vector items) { + std::vector> alts; + alts.reserve(items.size()); + for(std::size_t i = 0; i < items.size(); i++) + alts.emplace_back(QString::fromStdString(items[i]), (int)i); + QMetaObject::invokeMethod( + qApp, + [p, alts = std::move(alts)]() mutable { + if(p) + p->setAlternatives(std::move(alts)); + }, + Qt::QueuedConnection); + }; + } + } + }); + } + void connect_message_bus( ProcessModel& element, const ::Execution::Context& ctx, Node& eff) { diff --git a/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp b/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp index 5a1cd5be99..55b2353d9c 100644 --- a/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -170,7 +171,59 @@ class ProcessModel final } void init_before_port_creation() { init_dynamic_ports(); } - void init_after_port_creation() { init_controller_ports(); } + void init_after_port_creation() + { + init_controller_ports(); + init_folder_comboboxes(); + } + + // Folder-backed comboboxes (halp::folder_combobox): each carries the name of + // a sibling folder port; list that folder's files as the combobox items, at + // edit/load time, and refresh whenever the folder value changes. Runs after + // deserialization, so a loaded document's folder value is already in place. + void init_folder_comboboxes() + { + for(auto* inl : m_inlets) + { + auto combo = qobject_cast(inl); + if(!combo || combo->folderPortName.isEmpty()) + continue; + + Process::ControlInlet* folderPort = nullptr; + for(auto* other : m_inlets) + { + auto ci = qobject_cast(other); + if(ci && ci != combo && ci->name() == combo->folderPortName) + { + folderPort = ci; + break; + } + } + + auto pathOf = [](Process::ControlInlet* p) -> QString { + if(!p) + return {}; + if(auto s = p->value().target()) + { + // The sibling port may name a folder, or a file (e.g. a sound-file + // port): in the latter case list the file's containing folder. + auto path = QString::fromStdString(*s); + if(QFileInfo fi{path}; fi.isFile()) + return fi.absolutePath(); + return path; + } + return {}; + }; + + combo->repopulateFromFolder(pathOf(folderPort)); + if(folderPort) + QObject::connect( + folderPort, &Process::ControlInlet::valueChanged, combo, + [combo, folderPort, pathOf](const ossia::value&) { + combo->repopulateFromFolder(pathOf(folderPort)); + }); + } + } void check_all_ports() { if(std::ssize(m_inlets) != expected_input_ports()