From 68746260476ec5394bc2a86999a9766f98c2270e Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Sun, 12 Jul 2026 19:12:43 -0400 Subject: [PATCH 1/7] avnd: support comboboxes with runtime-populated item lists Lets an avendish object repopulate a combobox port's choices at run time (e.g. from the files it finds in a folder). Complements the avendish halp::dynamic_combobox port. - Process::ComboBox gains setAlternatives() + an alternativesChanged signal. setAlternatives refreshes the value domain and re-clamps the current value when the list shrinks. The alternatives were already serialized, so saved documents reopen with their last-known items. - The inspector and canvas combobox widgets read their item list live and rebuild on alternativesChanged. - The avnd executor injects the port's update_items callback, marshalling the object's runtime updates to the model inlet on the main thread (QPointer-guarded). Nodes with type-only inputs are skipped. Objects that don't use the feature are unaffected. Co-authored-by: Pia Baltazar --- .../Process/Dataflow/ControlWidgets.hpp | 49 +++++++++++++++---- .../Process/Dataflow/WidgetInlets.cpp | 25 ++++++++++ .../Process/Dataflow/WidgetInlets.hpp | 5 ++ .../score-plugin-avnd/Crousti/Executor.hpp | 44 +++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) 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..126587acb1 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp @@ -11,6 +11,7 @@ #include +W_OBJECT_IMPL(Process::ComboBox) W_OBJECT_IMPL(Process::FileChooserBase) W_OBJECT_IMPL(Process::FileChooser) W_OBJECT_IMPL(Process::FolderChooser) @@ -106,6 +107,30 @@ void ComboBox::setupExecution(ossia::inlet& inl, QObject* exec_context) const no port.domain = domain().get(); } +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)}); + + // re-clamp the current value if the list shrank past it + if(!alternatives.empty()) + { + const auto& cur = value(); + auto it = ossia::find_if( + alternatives, [&](const auto& pair) { return pair.second == cur; }); + if(it == alternatives.end()) + setValue(alternatives.back().second); + } + + alternativesChanged(); +} + ComboBox::~ComboBox() { } ComboBox::ComboBox(JSONObject::Deserializer& vis, QObject* parent) diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp index 5c25184e2d..d881fa164e 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp @@ -442,6 +442,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 +451,9 @@ 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); + const auto& getValues() const noexcept { return alternatives; } auto count() const noexcept { return alternatives.size(); } 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) { From 226de24e047ccf9b27cd0f5c390f410499f941cb Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Mon, 13 Jul 2026 13:46:49 -0400 Subject: [PATCH 2/7] avnd: folder-backed comboboxes (populate from a sibling folder port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the host side of avendish's halp::folder_combobox: a combobox whose items are the files of a sibling folder/path port, listed at edit/load time. - Process::ComboBox gains folderPortName + fileExtensions and repopulateFromFolder(): lists a folder's matching files as the items. - The avnd binding (Concepts.hpp) stamps that metadata onto the ComboBox when the field is an avnd::folder_items_parameter. - ProcessModel wires each folder-combobox to its sibling folder port after port creation (i.e. after deserialization): populates immediately and refreshes on the folder's valueChanged. Populated at document load, no execution needed — unlike the executor-pushed dynamic_combobox, this is the right shape for file pickers in an editor (FluCoMa's folder file ports, Granola's sound picker). NOT yet compile-tested (needs a full score build). Co-authored-by: Pia Baltazar --- .../Process/Dataflow/WidgetInlets.cpp | 19 ++++++++ .../Process/Dataflow/WidgetInlets.hpp | 7 +++ .../score-plugin-avnd/Crousti/Concepts.hpp | 22 ++++++++- .../Crousti/ProcessModel.hpp | 47 ++++++++++++++++++- 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp index 126587acb1..f71c03df20 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp @@ -8,6 +8,7 @@ #include #include +#include #include @@ -107,6 +108,24 @@ 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); + int i = 0; + for(const auto& f : files) + alts.emplace_back(f, i++); + } + if(alts.empty()) + alts.emplace_back(QStringLiteral("-"), 0); + setAlternatives(std::move(alts)); +} + void ComboBox::setAlternatives(std::vector> values) { if(values == alternatives) diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp index d881fa164e..bcd8d8ee85 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp @@ -454,6 +454,13 @@ struct SCORE_LIB_PROCESS_EXPORT ComboBox : public Process::ControlInlet 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. + QString folderPortName; + QStringList fileExtensions; + void repopulateFromFolder(const QString& folderPath); + 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..ee4a5bb3d1 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 @@ -328,8 +329,27 @@ make_control_in(avnd::field_index, Id&& id, QObject* parent) init = static_cast(c.init); else init = c.init; - return new Process::ComboBox{ + auto combo = new Process::ComboBox{ to_combobox_range(c.values), std::move(init), qname, id, parent}; + if constexpr(avnd::folder_items_parameter) + { + 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::choices) { diff --git a/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp b/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp index 5a1cd5be99..55c873e820 100644 --- a/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp @@ -170,7 +170,52 @@ 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()) + return QString::fromStdString(*s); + 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() From 825293f855b9497e1a2c82c4d2dd2dcd992ebec5 Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Mon, 13 Jul 2026 19:29:03 -0400 Subject: [PATCH 3/7] folder_combobox: string value = the selected file name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the folder-backed combobox string-valued (the selected file name) rather than an index, so an object consumes it directly like a file-name string — adopting it is then a pure port-type swap (lineedit -> folder_combobox) with no object-code change. The folder binding builds (name, name) alternatives. Co-authored-by: Pia Baltazar --- .../Process/Dataflow/WidgetInlets.cpp | 5 +- .../score-plugin-avnd/Crousti/Concepts.hpp | 49 +++++++++++-------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp index f71c03df20..1a482fdc33 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp @@ -117,12 +117,11 @@ void ComboBox::repopulateFromFolder(const QString& folderPath) const auto files = fileExtensions.isEmpty() ? dir.entryList(QDir::Files, QDir::Name) : dir.entryList(fileExtensions, QDir::Files, QDir::Name); - int i = 0; for(const auto& f : files) - alts.emplace_back(f, i++); + alts.emplace_back(f, f.toStdString()); // value = the file name itself } if(alts.empty()) - alts.emplace_back(QStringLiteral("-"), 0); + alts.emplace_back(QStringLiteral("-"), std::string("-")); setAlternatives(std::move(alts)); } diff --git a/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp b/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp index ee4a5bb3d1..fabec64d6d 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp @@ -320,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(); @@ -329,27 +357,8 @@ make_control_in(avnd::field_index, Id&& id, QObject* parent) init = static_cast(c.init); else init = c.init; - auto combo = new Process::ComboBox{ + return new Process::ComboBox{ to_combobox_range(c.values), std::move(init), qname, id, parent}; - if constexpr(avnd::folder_items_parameter) - { - 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::choices) { From a168e43a84582603dbfeb3e39bd67920effb33ef Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Mon, 13 Jul 2026 23:42:17 -0400 Subject: [PATCH 4/7] ComboBox: persist folder-combobox metadata so it survives document load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit folderPortName / fileExtensions were only set by the avnd port factory at creation; the deserializing ProcessModel constructor restores ports from the document instead of re-running the factory, so on load they were empty and init_folder_comboboxes skipped the combobox — no folder re-scan and no live refresh, only the stale saved alternatives. Serialize both fields with the port (extensions as one space-joined string, like FileChooser's filters; JSON reads guarded for older documents). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DDQaHG5FbEyH9D8VsjEyXe --- .../Process/Dataflow/WidgetInlets.cpp | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp index 1a482fdc33..ae48a021e8 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp @@ -1371,13 +1371,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 @@ -1385,11 +1385,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 From 6f6105e810597d9ceefccb3ddde3b2c489241bb3 Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Tue, 14 Jul 2026 00:06:51 -0400 Subject: [PATCH 5/7] QGraphicsCombo: open a popup menu on click The canvas combobox item's only interaction was an invisible drag-up/down-to-spin (InfiniteScroller); a plain click did nothing, so comboboxes looked read-only on the process panel while working fine in the inspector. The old popup attempt had been left commented out. Open a QMenu on mouse press instead, like a native combobox: items are the current alternatives, the selected one is checked, and QMenu natively supports both click-then-click and press-drag-release selection. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DDQaHG5FbEyH9D8VsjEyXe --- .../score/graphics/widgets/QGraphicsCombo.cpp | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) 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) { /* From ed979a90b42b978e62da13f6e9addffbd4c42315 Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Tue, 14 Jul 2026 00:43:13 -0400 Subject: [PATCH 6/7] ComboBox: watch the folder and keep not-yet-existing selections Folder-backed comboboxes now install a QFileSystemWatcher on their folder, so files created while the document is open (an upstream process writing its analysis, a recorder...) appear in the list without reloading. Also change the refresh policy for the current value: keep it when still listed, fall back to the port's init when that is listed, and otherwise leave it untouched instead of clamping to an arbitrary entry - it may name a file that simply does not exist yet, and becomes selected once the file appears. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DDQaHG5FbEyH9D8VsjEyXe --- .../Process/Dataflow/WidgetInlets.cpp | 38 ++++++++++++++++--- .../Process/Dataflow/WidgetInlets.hpp | 13 ++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp index ae48a021e8..5cadad0c55 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.cpp @@ -9,6 +9,7 @@ #include #include +#include #include @@ -123,6 +124,25 @@ void ComboBox::repopulateFromFolder(const QString& folderPath) 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) @@ -136,14 +156,20 @@ void ComboBox::setAlternatives(std::vector> val vals.push_back(v.second); setDomain(State::Domain{ossia::make_domain(vals)}); - // re-clamp the current value if the list shrank past it + // 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()) { - const auto& cur = value(); - auto it = ossia::find_if( - alternatives, [&](const auto& pair) { return pair.second == cur; }); - if(it == alternatives.end()) - setValue(alternatives.back().second); + 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(); diff --git a/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp b/src/plugins/score-lib-process/Process/Dataflow/WidgetInlets.hpp index bcd8d8ee85..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; @@ -456,11 +458,20 @@ struct SCORE_LIB_PROCESS_EXPORT ComboBox : public Process::ControlInlet // 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. + // 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(); } From e08c6d1d78b4b1fd58a9e49757522408927a1005 Mon Sep 17 00:00:00 2001 From: Pia Baltazar Date: Tue, 14 Jul 2026 01:03:45 -0400 Subject: [PATCH 7/7] folder combobox: accept a file-valued sibling port When the sibling port named by folder_port() holds a file path rather than a folder (e.g. a sound-file port), list the file's containing folder. Lets an object expose a picker next to an existing file port without adding a dedicated folder port. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DDQaHG5FbEyH9D8VsjEyXe --- src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp b/src/plugins/score-plugin-avnd/Crousti/ProcessModel.hpp index 55c873e820..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 @@ -203,7 +204,14 @@ class ProcessModel final if(!p) return {}; if(auto s = p->value().target()) - return QString::fromStdString(*s); + { + // 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 {}; };