From 16f8d01c44bec4109f8b8863dfd5f8dc6f1bc7b8 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 08:58:14 +0200 Subject: [PATCH 01/25] Add C++ ScriptConstructor to replace QML filter/computed-column drag-and-drop constructors Introduces a UI-agnostic model layer and a C++ QQuickItem view that together replace the JSON/QML/JS FilterConstructor and ComputedColumnsConstructor. Model (CommonData): - ScriptNode tree with JSON round-trip and R code generation matching the old QML output byte-for-byte (operators, functions, row-functions, columns, literals), including na.rm handling and ifelse return types. - ScriptConstructorRegistry as the single source of truth for available operators/functions per mode (filter / computed column / computed dataset). - ScriptConstructorModel owning the formula tree with drop-target resolution (including the gobble-left behaviour), completeness/boolean checks and snapshot-based undo. View (Desktop/qquick): - ScriptConstructorView (registered as ScriptConstructor) orchestrates layout, drag-and-drop, palettes, operator bar, inline literal editing and column type changes; leaf visuals are incubated QML (Text/Image/TextInput/CheckBox). Integration: - FilterWindow.qml now uses ScriptConstructor for the drag-and-drop filter. Tests: - Golden-R parity, 300-seed JSON round-trip fuzz, completeness, undo and DEFAULT_FILTER_JSON regression tests in testall.cpp. --- CommonData/scriptconstructormodel.cpp | 559 ++++++++++++++ CommonData/scriptconstructormodel.h | 121 +++ CommonData/scriptconstructorregistry.cpp | 267 +++++++ CommonData/scriptconstructorregistry.h | 78 ++ CommonData/scriptnode.cpp | 570 ++++++++++++++ CommonData/scriptnode.h | 220 ++++++ .../components/JASP/Widgets/FilterWindow.qml | 51 +- Desktop/mainwindow.cpp | 2 + Desktop/qquick/scriptconstructorview.cpp | 720 ++++++++++++++++++ Desktop/qquick/scriptconstructorview.h | 164 ++++ Desktop/qquick/scriptnodeitem.cpp | 585 ++++++++++++++ Desktop/qquick/scriptnodeitem.h | 106 +++ Tests/testall.cpp | 379 +++++++++ Tests/testall.h | 9 + 14 files changed, 3794 insertions(+), 37 deletions(-) create mode 100644 CommonData/scriptconstructormodel.cpp create mode 100644 CommonData/scriptconstructormodel.h create mode 100644 CommonData/scriptconstructorregistry.cpp create mode 100644 CommonData/scriptconstructorregistry.h create mode 100644 CommonData/scriptnode.cpp create mode 100644 CommonData/scriptnode.h create mode 100644 Desktop/qquick/scriptconstructorview.cpp create mode 100644 Desktop/qquick/scriptconstructorview.h create mode 100644 Desktop/qquick/scriptnodeitem.cpp create mode 100644 Desktop/qquick/scriptnodeitem.h diff --git a/CommonData/scriptconstructormodel.cpp b/CommonData/scriptconstructormodel.cpp new file mode 100644 index 0000000000..15d7f611e5 --- /dev/null +++ b/CommonData/scriptconstructormodel.cpp @@ -0,0 +1,559 @@ +#include "scriptconstructormodel.h" +#include + +// --- DropTarget --- + +bool DropTarget::accepts(ScriptNode * node) const +{ + if(!node) return false; + return ScriptConstructorModel::keysOverlap(node->dragKeys(), dropKeys); +} + +bool ScriptConstructorModel::keysOverlap(const stringvec & a, const stringvec & b) +{ + for(const std::string & ka : a) + for(const std::string & kb : b) + if(ka == kb) + return true; + return false; +} + +// --- ScriptConstructorModel --- + +ScriptConstructorModel::ScriptConstructorModel(QObject * parent) + : QObject(parent) +{ +} + +ScriptConstructorModel::~ScriptConstructorModel() +{ + deleteAllFormulas(); +} + +void ScriptConstructorModel::deleteAllFormulas() +{ + for(ScriptNode * node : _formulas) + ScriptNode::deleteTree(node); + _formulas.clear(); +} + +void ScriptConstructorModel::fromJson(const std::string & json) +{ + Json::Value root; + Json::Reader().parse(json, root); + fromJson(root); +} + +void ScriptConstructorModel::fromJson(const Json::Value & json) +{ + deleteAllFormulas(); + + const Json::Value & formulas = json.get("formulas", Json::arrayValue); + for(Json::ArrayIndex i = 0; i < formulas.size(); i++) + { + ScriptNode * node = ScriptNode::fromJson(formulas[i], nullptr); + if(node) + _formulas.push_back(node); + } + + emit reset(); + emit changed(); +} + +Json::Value ScriptConstructorModel::toJson() const +{ + Json::Value json; + json["formulas"] = Json::arrayValue; + + for(ScriptNode * node : _formulas) + json["formulas"].append(node->toJson()); + + return json; +} + +std::string ScriptConstructorModel::toString() const +{ + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + std::string out = Json::writeString(builder, toJson()); + + while(!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) + out.pop_back(); + + return out; +} + +std::string ScriptConstructorModel::toR() const +{ + std::string out; + + for(int i = 0; i < static_cast(_formulas.size()); i++) + { + if(i > 0) out += "& "; + out += _formulas[i]->toR(_typeProvider); + + if(_mode == ScriptConstructorMode::Filter) + out += "\n"; + else if(i < static_cast(_formulas.size()) - 1) + out += "\n"; + } + + return out; +} + +bool ScriptConstructorModel::checkCompleteness() const +{ + for(ScriptNode * node : _formulas) + if(!node->isComplete()) + return false; + return true; +} + +bool ScriptConstructorModel::allBoolean() const +{ + for(ScriptNode * node : _formulas) + { + bool isBool = false; + for(const std::string & key : node->dragKeys()) + if(key == "boolean") + isBool = true; + + if(!isBool) + return false; + } + return true; +} + +ScriptNode * ScriptConstructorModel::formulaAt(int index) const +{ + if(index < 0 || index >= static_cast(_formulas.size())) + return nullptr; + return _formulas[index]; +} + +int ScriptConstructorModel::rootIndexOf(ScriptNode * node) const +{ + for(int i = 0; i < static_cast(_formulas.size()); i++) + if(_formulas[i] == node) + return i; + return -1; +} + +ScriptNode * ScriptConstructorModel::rootFormulaOf(ScriptNode * node) const +{ + ScriptNode * cur = node; + while(cur && cur->parent()) + cur = cur->parent(); + return cur; +} + +bool ScriptConstructorModel::isAncestor(ScriptNode * ancestor, ScriptNode * descendant) const +{ + ScriptNode * cur = descendant; + while(cur) + { + if(cur == ancestor) return true; + cur = cur->parent(); + } + return false; +} + +void ScriptConstructorModel::detachFromParent(ScriptNode * node) +{ + if(!node) return; + + ScriptNode * par = node->parent(); + if(!par) + { + int idx = rootIndexOf(node); + if(idx >= 0) + _formulas.erase(_formulas.begin() + idx); + return; + } + + if(auto * op = dynamic_cast(par)) + { + if(op->leftChild() == node) op->setLeft(nullptr); + else op->setRight(nullptr); + } + else if(auto * func = dynamic_cast(par)) + { + for(int i = 0; i < func->childCount(); i++) + if(func->childAt(i) == node) + { + func->setArgumentValue(i, nullptr); + break; + } + } + else if(auto * rowFunc = dynamic_cast(par)) + { + for(int i = 0; i < rowFunc->childCount(); i++) + if(rowFunc->childAt(i) == node) + { + rowFunc->setChild(i, nullptr); + break; + } + } + + node->setParent(nullptr); +} + +void ScriptConstructorModel::placeAt(ScriptNode * node, const DropTarget & target) +{ + switch(target.kind) + { + case DropTarget::Kind::Root: + { + int idx = target.index; + if(idx < 0 || idx > static_cast(_formulas.size())) + idx = static_cast(_formulas.size()); + _formulas.insert(_formulas.begin() + idx, node); + node->setParent(nullptr); + break; + } + case DropTarget::Kind::OperatorLeft: + if(auto * op = dynamic_cast(target.parent)) + op->setLeft(node); + break; + case DropTarget::Kind::OperatorRight: + if(auto * op = dynamic_cast(target.parent)) + op->setRight(node); + break; + case DropTarget::Kind::FunctionArg: + if(auto * func = dynamic_cast(target.parent)) + func->setArgumentValue(target.index, node); + break; + case DropTarget::Kind::RowFunctionArg: + if(auto * rowFunc = dynamic_cast(target.parent)) + rowFunc->setChild(target.index, node); + break; + case DropTarget::Kind::None: + break; + } +} + +// --- right-most empty / filled drop spot helpers --- + +static DropTarget makeSlotTarget(ScriptNode * parent, DropTarget::Kind kind, int index, const stringvec & keys) +{ + DropTarget t; + t.kind = kind; + t.parent = parent; + t.index = index; + t.dropKeys = keys; + return t; +} + +static DropTarget rightMostEmptyDropSpotRec(ScriptNode * node) +{ + if(!node) return DropTarget::none(); + + if(auto * op = dynamic_cast(node)) + { + if(op->rightChild()) + return rightMostEmptyDropSpotRec(op->rightChild()); + return makeSlotTarget(op, DropTarget::Kind::OperatorRight, 1, op->dropKeysRight()); + } + + if(auto * func = dynamic_cast(node)) + { + for(int i = func->childCount() - 1; i >= 0; i--) + { + const auto & arg = func->arguments()[i]; + if(arg.value) + { + DropTarget sub = rightMostEmptyDropSpotRec(arg.value); + if(sub.isValid()) + return sub; + } + else + return makeSlotTarget(func, DropTarget::Kind::FunctionArg, i, arg.dropKeys); + } + return DropTarget::none(); + } + + if(auto * rowFunc = dynamic_cast(node)) + { + for(int i = rowFunc->childCount() - 1; i >= 0; i--) + { + if(rowFunc->childAt(i)) + { + DropTarget sub = rightMostEmptyDropSpotRec(rowFunc->childAt(i)); + if(sub.isValid()) + return sub; + } + else + return makeSlotTarget(rowFunc, DropTarget::Kind::RowFunctionArg, i, {"number"}); + } + return DropTarget::none(); + } + + return DropTarget::none(); +} + +static DropTarget rightMostFilledDropSpotRec(ScriptNode * node) +{ + if(!node) return DropTarget::none(); + + if(auto * op = dynamic_cast(node)) + { + if(op->rightChild()) + return makeSlotTarget(op, DropTarget::Kind::OperatorRight, 1, op->dropKeysRight()); + return DropTarget::none(); + } + + if(auto * func = dynamic_cast(node)) + { + DropTarget last; + for(int i = 0; i < func->childCount(); i++) + { + if(!func->childAt(i)) + return last.isValid() ? last : DropTarget::none(); + last = makeSlotTarget(func, DropTarget::Kind::FunctionArg, i, func->arguments()[i].dropKeys); + } + return last.isValid() ? last : DropTarget::none(); + } + + if(auto * rowFunc = dynamic_cast(node)) + { + DropTarget last; + for(int i = 0; i < rowFunc->childCount(); i++) + { + if(!rowFunc->childAt(i)) + return last.isValid() ? last : DropTarget::none(); + last = makeSlotTarget(rowFunc, DropTarget::Kind::RowFunctionArg, i, {"number"}); + } + return last.isValid() ? last : DropTarget::none(); + } + + return DropTarget::none(); +} + +DropTarget ScriptConstructorModel::findReasonableInsertionSpot(ScriptNode * node) const +{ + if(_formulas.empty()) + return DropTarget::none(); + + ScriptNode * last = _formulas.back(); + if(last == node) + { + if(_formulas.size() == 1) + return DropTarget::none(); + last = _formulas[_formulas.size() - 2]; + if(last == node) + return DropTarget::none(); + } + + return rightMostEmptyDropSpotRec(last); +} + +// --- editing operations --- + +void ScriptConstructorModel::beginEdit() +{ + _editBeforeJson = toString(); +} + +void ScriptConstructorModel::endEdit(const QString & description) +{ + emit changed(); + + if(!_undoStack) + return; + + std::string afterJson = toString(); + if(afterJson == _editBeforeJson) + return; + + _undoStack->push(new ScriptConstructorEditCommand(this, _editBeforeJson, afterJson, description)); +} + +void ScriptConstructorModel::insertNode(ScriptNode * node, DropTarget target) +{ + if(!node) return; + + beginEdit(); + + if(target.isValid()) + { + placeAt(node, target); + } + else + { + _formulas.push_back(node); + node->setParent(nullptr); + + DropTarget spot = findReasonableInsertionSpot(node); + if(spot.isValid() && keysOverlap(node->dragKeys(), spot.dropKeys)) + { + detachFromParent(node); + placeAt(node, spot); + } + else + tryGobbleLeft(node); + } + + endEdit(tr("Insert element")); +} + +void ScriptConstructorModel::removeNode(ScriptNode * node) +{ + if(!node) return; + + beginEdit(); + detachFromParent(node); + ScriptNode::deleteTree(node); + endEdit(tr("Remove element")); +} + +void ScriptConstructorModel::moveNode(ScriptNode * node, DropTarget target) +{ + if(!node) return; + + if(target.isValid() && isAncestor(node, target.parent)) + return; + + beginEdit(); + detachFromParent(node); + + if(target.isValid()) + placeAt(node, target); + else + { + _formulas.push_back(node); + node->setParent(nullptr); + } + + endEdit(tr("Move element")); +} + +void ScriptConstructorModel::setColumnTypeUser(ScriptNodeColumn * node, int columnType) +{ + if(!node) return; + beginEdit(); + node->setColumnTypeUser(columnType); + endEdit(tr("Change column type")); +} + +void ScriptConstructorModel::setLiteralNumber(ScriptNodeLiteral * node, double value) +{ + if(!node) return; + beginEdit(); + node->setNumberValue(value); + endEdit(tr("Edit number")); +} + +void ScriptConstructorModel::setLiteralBool(ScriptNodeLiteral * node, bool value) +{ + if(!node) return; + beginEdit(); + node->setBoolValue(value); + endEdit(tr("Edit logical")); +} + +void ScriptConstructorModel::setLiteralString(ScriptNodeLiteral * node, const std::string & value) +{ + if(!node) return; + beginEdit(); + node->setStringValue(value); + endEdit(tr("Edit text")); +} + +void ScriptConstructorModel::clear() +{ + beginEdit(); + deleteAllFormulas(); + endEdit(tr("Clear all")); + emit reset(); +} + +bool ScriptConstructorModel::tryGobbleLeft(ScriptNode * node) +{ + auto * op = dynamic_cast(node); + if(!op || op->leftChild() != nullptr) + return false; + + if(_formulas.size() <= 1) + return false; + + stringvec leftKeys = op->dropKeysLeft(); + + for(int i = static_cast(_formulas.size()) - 1; i >= 0; i--) + { + if(_formulas[i] == node) + continue; + + ScriptNode * gobbleMeUp = _formulas[i]; + DropTarget putResultHere = DropTarget::root(i); + bool putIsRoot = true; + + while(gobbleMeUp) + { + if(keysOverlap(gobbleMeUp->dragKeys(), leftKeys)) + { + bool iFitHere = putIsRoot || keysOverlap(node->dragKeys(), putResultHere.dropKeys); + if(iFitHere) + { + int gobbleRootIndex = rootIndexOf(gobbleMeUp); + + detachFromParent(gobbleMeUp); + op->setLeft(gobbleMeUp); + + detachFromParent(node); + if(putIsRoot) + { + int insertAt = gobbleRootIndex >= 0 ? gobbleRootIndex : static_cast(_formulas.size()); + if(insertAt > static_cast(_formulas.size())) insertAt = static_cast(_formulas.size()); + _formulas.insert(_formulas.begin() + insertAt, node); + node->setParent(nullptr); + } + else + placeAt(node, putResultHere); + + return true; + } + } + + DropTarget filled = rightMostFilledDropSpotRec(gobbleMeUp); + if(!filled.isValid()) + return false; + + gobbleMeUp = filled.parent ? filled.parent->childAt(filled.index) : nullptr; + putResultHere = filled; + putIsRoot = false; + } + + return false; + } + + return false; +} + +// --- ScriptConstructorEditCommand --- + +ScriptConstructorEditCommand::ScriptConstructorEditCommand(ScriptConstructorModel * model, const std::string & beforeJson, const std::string & afterJson, const QString & description) + : QUndoCommand(description) + , _model(model) + , _beforeJson(beforeJson) + , _afterJson(afterJson) +{ +} + +void ScriptConstructorEditCommand::undo() +{ + if(!_model) return; + _model->fromJson(_beforeJson); +} + +void ScriptConstructorEditCommand::redo() +{ + if(!_model) return; + + if(_firstRedo) + { + _firstRedo = false; + return; + } + + _model->fromJson(_afterJson); +} diff --git a/CommonData/scriptconstructormodel.h b/CommonData/scriptconstructormodel.h new file mode 100644 index 0000000000..79a26c0f1f --- /dev/null +++ b/CommonData/scriptconstructormodel.h @@ -0,0 +1,121 @@ +#ifndef SCRIPTCONSTRUCTORMODEL_H +#define SCRIPTCONSTRUCTORMODEL_H + +#include +#include +#include +#include +#include "scriptnode.h" +#include "scriptconstructorregistry.h" + +class QUndoStack; + +/// Describes a place where a ScriptNode can be (or is requested to be) inserted. +struct DropTarget +{ + enum class Kind { None, Root, OperatorLeft, OperatorRight, FunctionArg, RowFunctionArg }; + + Kind kind = Kind::None; + ScriptNode * parent = nullptr; ///< Node owning the slot (nullptr for Root) + int index = -1; ///< Formula index for Root, argument index for Function/RowFunction + stringvec dropKeys; ///< Keys accepted at this spot + + bool isValid() const { return kind != Kind::None; } + bool isRoot() const { return kind == Kind::Root; } + bool accepts(ScriptNode * node) const; + + static DropTarget none() { return {}; } + static DropTarget root(int formulaIndex = -1) { DropTarget t; t.kind = Kind::Root; t.index = formulaIndex; return t; } +}; + +/// +/// Owns the tree of ScriptNodes that make up a drag-and-drop filter / computed column formula. +/// All mutations go through this class so that undo and change-notification have a single source. +/// It is UI-agnostic: the QQuickItem view layer renders the tree and forwards user gestures here. +class ScriptConstructorModel : public QObject +{ + Q_OBJECT + +public: + explicit ScriptConstructorModel(QObject * parent = nullptr); + ~ScriptConstructorModel() override; + + // (Re)build the whole tree from stored JSON. Does not push undo. + void fromJson(const std::string & json); + void fromJson(const Json::Value & json); + + std::string toString() const; ///< {"formulas":[...]} + Json::Value toJson() const; + std::string toR() const; ///< R code for all formulas + + bool checkCompleteness() const; ///< true when every required slot is filled + bool allBoolean() const; ///< true when every root formula returns boolean + int formulaCount() const { return static_cast(_formulas.size()); } + ScriptNode * formulaAt(int index) const; + const std::vector & formulas() const { return _formulas; } + + ScriptConstructorMode mode() const { return _mode; } + void setMode(ScriptConstructorMode mode) { _mode = mode; } + + void setColumnTypeProvider(const ScriptColumnTypeProvider * provider) { _typeProvider = provider; } + const ScriptColumnTypeProvider * columnTypeProvider() const { return _typeProvider; } + + void setUndoStack(QUndoStack * stack) { _undoStack = stack; } + + // --- editing operations (each pushes an undo command when an undo stack is set) --- + void insertNode(ScriptNode * node, DropTarget target); ///< takes ownership of node + void removeNode(ScriptNode * node); ///< deletes the node subtree + void moveNode(ScriptNode * node, DropTarget target); + void setColumnTypeUser(ScriptNodeColumn * node, int columnType); + void setLiteralNumber(ScriptNodeLiteral * node, double value); + void setLiteralBool(ScriptNodeLiteral * node, bool value); + void setLiteralString(ScriptNodeLiteral * node, const std::string & value); + void clear(); + + /// Resolves where a freshly created node should go when the user did not drop it anywhere specific. + DropTarget findReasonableInsertionSpot(ScriptNode * node) const; + + /// Returns the drop keys accepted at a given target (used by the view for hover feedback). + static bool keysOverlap(const stringvec & a, const stringvec & b); + +signals: + void changed(); ///< tree contents changed (any edit) + void reset(); ///< whole tree rebuilt (fromJson/clear/undo); view must rebuild items + +private: + void deleteAllFormulas(); + void detachFromParent(ScriptNode * node); + void placeAt(ScriptNode * node, const DropTarget & target); + ScriptNode * rootFormulaOf(ScriptNode * node) const; + int rootIndexOf(ScriptNode * node) const; + bool isAncestor(ScriptNode * ancestor, ScriptNode * descendant) const; + DropTarget resolveInsertionTarget(ScriptNode * node, DropTarget requested); + bool tryGobbleLeft(ScriptNode * node); + + void beginEdit(); + void endEdit(const QString & description); + + std::vector _formulas; + ScriptConstructorMode _mode = ScriptConstructorMode::Filter; + const ScriptColumnTypeProvider * _typeProvider = nullptr; + QUndoStack * _undoStack = nullptr; + std::string _editBeforeJson; +}; + +/// Undo command that snapshots the constructor JSON before and after an edit. +class ScriptConstructorEditCommand : public QUndoCommand +{ +public: + ScriptConstructorEditCommand(ScriptConstructorModel * model, const std::string & beforeJson, const std::string & afterJson, const QString & description); + + void undo() override; + void redo() override; + +private: + ScriptConstructorModel * _model; + std::string _beforeJson, + _afterJson; + bool _firstRedo = true; +}; + +#endif // SCRIPTCONSTRUCTORMODEL_H diff --git a/CommonData/scriptconstructorregistry.cpp b/CommonData/scriptconstructorregistry.cpp new file mode 100644 index 0000000000..edd0521d76 --- /dev/null +++ b/CommonData/scriptconstructorregistry.cpp @@ -0,0 +1,267 @@ +#include "scriptconstructorregistry.h" +#include "columntype.h" + +ScriptParamDef ScriptParamDef::fromRaw(const std::string & rawName, const stringvec & rawDropKeys) +{ + ScriptParamDef out; + out.name = rawName; + out.dropKeys = rawDropKeys; + out.optional = rawName.size() > 0 && rawName[0] == '?'; + + if(out.optional) + out.name = rawName.substr(1); + + return out; +} + +stringvec ScriptFunctionDef::dragKeys() const +{ + static const stringvec booleanKeys = {"boolean"}, + numberKeys = {"number"}, + ifElseKeys = {"string", "number", "boolean"}; + + //NB: matches QML Function.qml `isIfElse: functionName === "ifelse"` (lowercase only) + if(name == "ifelse") return ifElseKeys; + if(name == "!" || name == "hasSubstring" || name == "is.na") + return booleanKeys; + return numberKeys; +} + +bool ScriptFunctionDef::addsNaRm() const +{ + static const stringset naRmFunctions = {"mean", "sd", "var", "sum", "prod", "min", "max", "median"}; + + return naRmFunctions.count(name) > 0; +} + +stringvec ScriptOperatorDef::dropKeysLeft(ScriptConstructorMode mode) const +{ + static const stringvec numberKeys = {"number"}, + booleanKeys = {"boolean"}, + everythingKeys = {"boolean", "string", "number"}, + numberCompareKeys = {"number", "ordered"}; + + if(op == "<" || op == ">" || op == "<=" || op == ">=") return numberCompareKeys; + if(op == "%|%") return mode == ScriptConstructorMode::Filter ? booleanKeys : numberKeys; + if(op == "==" || op == "!=") return everythingKeys; + if(op == "&" || op == "|") return booleanKeys; + return numberKeys; +} + +stringvec ScriptOperatorDef::dropKeysRight(ScriptConstructorMode mode) const +{ + static const stringvec numberKeys = {"number"}, + booleanKeys = {"boolean"}, + everythingKeys = {"boolean", "string", "number"}, + numberCompareKeys = {"number", "ordered"}, + conditionalRight = {"string", "boolean"}; + + if(op == "<" || op == ">" || op == "<=" || op == ">=") return numberCompareKeys; + if(op == "%|%") return conditionalRight; + if(op == "==" || op == "!=") return everythingKeys; + if(op == "&" || op == "|") return booleanKeys; + return numberKeys; +} + +bool ScriptOperatorDef::mirrorKeys() const +{ + return op == "==" || op == "!="; +} + +bool ScriptOperatorDef::returnsBoolean(ScriptConstructorMode mode) const +{ + static const stringset booleanOps = {"&", "|"}, + numberCompare = {"<", ">", "<=", ">="}, + everythingOps = {"==", "!="}; + + if(booleanOps.count(op) || numberCompare.count(op) || everythingOps.count(op)) + return true; + + if(op == "%|%") + return mode == ScriptConstructorMode::Filter; + + return false; +} + +stringvec ScriptOperatorDef::dragKeys(ScriptConstructorMode mode) const +{ + return returnsBoolean(mode) ? stringvec{"boolean"} : stringvec{"number"}; +} + +ScriptConstructorRegistry::ScriptConstructorRegistry() +{ + auto addOp = [this](const std::string & op, const std::string & toolTip, const std::string & image = "", bool vertical = false) + { + _operatorIndex[op + (vertical ? "V" : "")] = _operators.size(); + _operators.push_back({op, toolTip, image, vertical}); + }; + + addOp("+", "Addition", "plus.png"); + addOp("-", "Subtraction", "minus.png"); + addOp("*", "Multiplication", "multiply.png"); + addOp("/", "Division", "divide.png", true); + addOp("/", "Division", ""); + addOp("^", "Power (2^3 returns 8)", ""); + addOp("%%", "Modulo: returns the remainder of a division. 3%2 returns 1", "modulo.png"); + addOp("==", "Equality: returns logicals", "equal.png"); + addOp("!=", "Inequality: returns logicals", "notEqual.png"); + addOp("<", "Less than: returns logicals", "lessThan.png"); + addOp("<=", "Less than or equal to: returns logicals", "lessThanEqual.png"); + addOp(">", "Greater than: returns logicals", "greaterThan.png"); + addOp(">=", "Greater than or equal to: returns logicals", "greaterThanEqual.png"); + addOp("&", "And: returns logicals", "and.png"); + addOp("|", "Or: returns logicals", "or.png"); + addOp("%|%", "Split: applies filter separately to each subgroup", "ConditionBy.png"); + + auto addFunc = [this](const std::string & name, const std::string & friendlyName, const std::string & toolTip, const std::vector & params, const std::string & image = "") + { + _functionIndex[name] = _functions.size(); + _functions.push_back({name, friendlyName, toolTip, image, params, false, false}); + }; + + auto P = [](const std::string & name, const stringvec & keys) { return ScriptParamDef::fromRaw(name, keys); }; + + static const stringvec numKeys = {"number"}, + boolKeys = {"boolean"}, + strKeys = {"string"}, + boolStrNum = {"boolean", "string", "number"}, + strNum = {"string", "number"}, + strBoolNum = {"string", "boolean", "number"}; + + addFunc("abs", "", "absolute value", {P("values", numKeys)}); + addFunc("sd", "", "standard deviation", {P("values", numKeys)}); + addFunc("var", "", "variance", {P("values", numKeys)}); + addFunc("sum", "", "summation", {P("values", numKeys)}); + addFunc("prod", "", "product of values", {P("values", numKeys)}); + addFunc("zScores", "", "Standardizes the variable", {P("values", numKeys)}); + addFunc("min", "", "returns minimum of values", {P("values", numKeys)}); + addFunc("max", "", "returns maximum of values", {P("values", numKeys)}); + addFunc("mean", "", "mean", {P("values", numKeys)}); + addFunc("sign", "", "returns the sign of values", {P("values", numKeys)}); + addFunc("round", "", "rounds y to n decimals", {P("y", numKeys), P("n", numKeys)}); + addFunc("length", "", "returns number of elements in y", {P("y", strNum)}); + addFunc("median", "", "median", {P("values", numKeys)}); + addFunc("ifelse", "", "if-else statement", {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); + addFunc("hasSubstring", "", "returns true if string contains substring at least once", {P("string", strKeys), P("substring", strKeys)}); + addFunc("is.na", "", "Combine with not-operator to filter out rows with missing values (NA) for a column.", {P("y", strBoolNum)}); + + addFunc("log", "", "natural logarithm", {P("y", numKeys)}); + addFunc("log2", "log\u2082", "base 2 logarithm", {P("y", numKeys)}); + addFunc("log10", "log\u2081\u2080", "base 10 logarithm", {P("y", numKeys)}); + addFunc("logb", "", "logarithm of y in 'base'", {P("y", numKeys), P("base", numKeys)}); + addFunc("exp", "", "exponential", {P("y", numKeys)}); + addFunc("fishZ", "", "Fisher's Z-transform (i.e., the inverse hyperbolic tangent) to transform correlations, numbers between -1 and 1 to the real line", {P("y", numKeys)}); + addFunc("invFishZ", "fishZ\u207B\u00B9", "Inverse Fisher's Z-transform (i.e., the hyperbolic tangent) to transform real numbers to numbers between -1 and 1", {P("y", numKeys)}); + addFunc("logit", "", "Logit transform (i.e., the inverse of the standard logit function, or log-odds transform) converts numbers between 0 and 1 to the real line.", {P("y", numKeys)}); + addFunc("invLogit", "logit\u207B\u00B9", "Inverse logit transform (i.e., the standard logit function) converts numbers on the real line to numbers between 0 and 1.", {P("y", numKeys)}); + addFunc("BoxCox", "", "Two-parameter Box-Cox transform (transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like.", {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("BoxCoxAuto", "", "Two-parameter Box-Cox transform with an automatic determination of the shape parameter lambda, according to one of the three of methods:'loglik', 'sd', or 'movingRange'. The search for optimal lambda is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("method", strKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("invBoxCox", "BoxCox\u207B\u00B9", "Inverse two-parameter Box-Cox transform.", {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("powerTransform", "", "Two-parameter power transform (scale-invariant Box-Box; transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like.", {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys)}); + addFunc("powerTransformAuto", "", "Two-parameter power transform with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys)}); + addFunc("YeoJohnson", "", "Yeo-Johnson transform (transforms any real values) to stabilize variance and attempt to make the data more normal distribution-like.", {P("y", numKeys), P("lambda", numKeys)}); + addFunc("YeoJohnsonAuto", "", "Yeo-Johnson transform (transforms any real values) with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); + addFunc("Johnson", "", "Johnson transform (transforms any real values). The search for optimal parameter is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); + + addFunc("cut", "", "break your data up in numBreaks levels", {P("values", numKeys), P("numBreaks", numKeys)}); + addFunc("replaceNA", "", "replace any missing values (NA) in column by the value in replaceWith", {P("column", strBoolNum), P("replaceWith", strBoolNum)}); + addFunc("ifElse", "", "if-else statement", {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); + + addFunc("normalDist", "", "generates data from a Gaussian distribution with specified mean and standard deviation sd", {P("mean", numKeys), P("sd", numKeys)}); + addFunc("tDist", "", "generates data from t distribution with degrees of freedom df and non-centrality parameter ncp", {P("df", numKeys), P("ncp", numKeys)}); + addFunc("chiSqDist", "", "generates data from a chi-squared distribution with degrees of freedom df and non-centrality parameter ncp", {P("df", numKeys), P("ncp", numKeys)}); + addFunc("fDist", "", "generates data from an F distribution with specified degrees of freedoms df1, df2 and non-centrality parameter ncp", {P("df1", numKeys), P("df2", numKeys), P("ncp", numKeys)}); + addFunc("binomDist", "", "generates data from a binomial distribution with specified trials and probability prob", {P("trials", numKeys), P("prob", numKeys)}); + addFunc("negBinomDist", "", "generates data from a negative binomial distribution with specified trials and probability prob", {P("targetTrial", numKeys), P("prob", numKeys)}); + addFunc("geomDist", "", "generates data from a geometric distribution with specified probability prob", {P("prob", numKeys)}); + addFunc("poisDist", "", "generates data from a Poisson distribution with specified rate lambda", {P("lambda", numKeys)}); + addFunc("betaDist", "", "generates data from a beta distribution with specified shapes alpha and beta", {P("alpha", numKeys), P("beta", numKeys)}); + addFunc("unifDist", "", "generates data from a uniform distribution between min and max", {P("min", numKeys), P("max", numKeys)}); + addFunc("gammaDist", "", "generates data from a gamma distribution with specified shape and scale", {P("shape", numKeys), P("scale", numKeys)}); + addFunc("expDist", "", "generates data from an exponential distribution with specified rate", {P("rate", numKeys)}); + addFunc("logNormDist", "", "generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog", {P("meanLog", numKeys), P("sdLog", numKeys)}); + addFunc("weibullDist", "", "generates data from a Weibull distribution with specified shape and scale", {P("shape", numKeys), P("scale", numKeys)}); + + auto addRowFunc = [this](const std::string & name, const std::string & toolTip) + { + _rowFunctionIndex[name] = _rowFunctions.size(); + _rowFunctions.push_back({name, name, toolTip, "", {}, true, true}); + }; + + addRowFunc("rowMean", "Rowwise mean"); + addRowFunc("rowSum", "Rowwise sum"); + addRowFunc("rowSD", "Rowwise standard deviation"); + addRowFunc("rowVariance", "Rowwise variance"); + addRowFunc("rowMedian", "Rowwise median"); + addRowFunc("rowMin", "Rowwise minimum"); + addRowFunc("rowMax", "Rowwise maximum"); +} + +const ScriptConstructorRegistry & ScriptConstructorRegistry::instance() +{ + static ScriptConstructorRegistry registry; + return registry; +} + +const ScriptOperatorDef * ScriptConstructorRegistry::operatorDef(const std::string & op) const +{ + for(const ScriptOperatorDef & def : _operators) + if(def.op == op) + return &def; + + return nullptr; +} + +const ScriptFunctionDef * ScriptConstructorRegistry::functionDef(const std::string & name) const +{ + auto it = _functionIndex.find(name); + return it != _functionIndex.end() ? &_functions[it->second] : nullptr; +} + +const ScriptFunctionDef * ScriptConstructorRegistry::rowFunctionDef(const std::string & name) const +{ + auto it = _rowFunctionIndex.find(name); + return it != _rowFunctionIndex.end() ? &_rowFunctions[it->second] : nullptr; +} + +std::vector ScriptConstructorRegistry::functionsForMode(ScriptConstructorMode mode) const +{ + static const stringset filterOnlyFunctions = {"ifelse"}; + + std::vector out; + + for(const ScriptFunctionDef & def : _functions) + { + if(mode == ScriptConstructorMode::Filter && def.name == "ifElse") continue; + if(mode != ScriptConstructorMode::Filter && filterOnlyFunctions.count(def.name)) continue; + + out.push_back(def); + } + + return out; +} + +std::vector ScriptConstructorRegistry::operatorsForMode(ScriptConstructorMode) const +{ + return _operators; +} + +stringvec ScriptConstructorRegistry::dropKeysForColumnType(int colType) +{ + switch(colType) + { + case 1: return {"number"}; + case 2: return {"string", "ordered"}; + default: return {"string"}; + } +} + +std::string ScriptConstructorRegistry::columnTypeString(int colType) +{ + switch(colType) + { + case 1: return "scale"; + case 2: return "ordinal"; + default: return "nominal"; + } +} diff --git a/CommonData/scriptconstructorregistry.h b/CommonData/scriptconstructorregistry.h new file mode 100644 index 0000000000..dd81a3e74d --- /dev/null +++ b/CommonData/scriptconstructorregistry.h @@ -0,0 +1,78 @@ +#ifndef SCRIPTCONSTRUCTORREGISTRY_H +#define SCRIPTCONSTRUCTORREGISTRY_H + +#include +#include +#include +#include "utils.h" + +enum class ScriptConstructorMode { Filter, ComputedColumn, ComputedDataSet }; + +struct ScriptParamDef +{ + std::string name; + stringvec dropKeys; + bool optional = false; + + static ScriptParamDef fromRaw(const std::string & rawName, const stringvec & rawDropKeys); +}; + +struct ScriptFunctionDef +{ + std::string name; + std::string friendlyName; + std::string toolTip; + std::string image; + std::vector params; + bool variadic = false; + bool isRowFunction = false; + + stringvec dragKeys() const; + bool addsNaRm() const; +}; + +struct ScriptOperatorDef +{ + std::string op; + std::string toolTip; + std::string image; + bool vertical = false; + + stringvec dropKeysLeft( ScriptConstructorMode mode) const; + stringvec dropKeysRight( ScriptConstructorMode mode) const; + bool mirrorKeys() const; + bool returnsBoolean( ScriptConstructorMode mode) const; + stringvec dragKeys( ScriptConstructorMode mode) const; +}; + +class ScriptConstructorRegistry +{ +public: + static const ScriptConstructorRegistry & instance(); + + const std::vector & operators() const { return _operators; } + const std::vector & functions() const { return _functions; } + const std::vector & rowFunctions() const { return _rowFunctions; } + + const ScriptOperatorDef * operatorDef( const std::string & op) const; + const ScriptFunctionDef * functionDef( const std::string & name) const; + const ScriptFunctionDef * rowFunctionDef(const std::string & name) const; + + std::vector functionsForMode(ScriptConstructorMode mode) const; + std::vector operatorsForMode(ScriptConstructorMode mode) const; + + static stringvec dropKeysForColumnType(int columnType); + static std::string columnTypeString(int columnType); + +private: + ScriptConstructorRegistry(); + + std::vector _operators; + std::vector _functions; + std::vector _rowFunctions; + std::map _operatorIndex, + _functionIndex, + _rowFunctionIndex; +}; + +#endif // SCRIPTCONSTRUCTORREGISTRY_H diff --git a/CommonData/scriptnode.cpp b/CommonData/scriptnode.cpp new file mode 100644 index 0000000000..d9c800a3aa --- /dev/null +++ b/CommonData/scriptnode.cpp @@ -0,0 +1,570 @@ +#include "scriptnode.h" +#include +#include +#include + +static std::string numberToRString(double v) +{ + if(std::isnan(v)) return "NaN"; + if(std::isinf(v)) return v > 0 ? "Inf" : "-Inf"; + + if(v == std::floor(v) && std::abs(v) < 1e15) + return std::to_string(static_cast(v)); + + for(int prec = 1; prec <= 17; prec++) + { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*g", prec, v); + if(std::strtod(buf, nullptr) == v) + return buf; + } + + char buf[64]; + snprintf(buf, sizeof(buf), "%.17g", v); + return buf; +} + +ScriptNode::ScriptNode(ScriptNode * parent) + : QObject(nullptr) + , _parent(parent) +{ +} + +void ScriptNode::deleteTree(ScriptNode * node) +{ + if(!node) return; + + for(int i = 0; i < node->childCount(); i++) + deleteTree(node->childAt(i)); + + delete node; +} + +std::string ScriptNode::nodeTypeString() const +{ + switch(type()) + { + case Type::Operator: return "Operator"; + case Type::OperatorVertical: return "OperatorVertical"; + case Type::Function: return "Function"; + case Type::RowFunction: return "RowFunction"; + case Type::Column: return "Column"; + case Type::Number: return "Number"; + case Type::Boolean: return "Boolean"; + case Type::String: return "String"; + } + return ""; +} + +ScriptNode::Type ScriptNode::typeFromString(const std::string & str) +{ + if(str == "Operator") return Type::Operator; + if(str == "OperatorVertical") return Type::OperatorVertical; + if(str == "Function") return Type::Function; + if(str == "RowFunction") return Type::RowFunction; + if(str == "Column") return Type::Column; + if(str == "Number") return Type::Number; + if(str == "Boolean") return Type::Boolean; + if(str == "String") return Type::String; + + throw std::runtime_error("Unknown script node type: " + str); +} + +ScriptNode * ScriptNode::fromJson(const Json::Value & json, ScriptNode * parent) +{ + if(json.isNull() || !json.isObject()) + return nullptr; + + const std::string nodeType = json.get("nodeType", "").asString(); + const std::string toolTip = json.get("toolTipText", "").asString(); + + ScriptNode * result = nullptr; + + if(nodeType == "Operator" || nodeType == "OperatorVertical") + { + auto * op = new ScriptNodeOperator(json.get("operator", "+").asString(), nodeType == "OperatorVertical", parent); + + op->setLeft( fromJson(json.get("leftArgument", Json::nullValue), op)); + op->setRight( fromJson(json.get("rightArgument", Json::nullValue), op)); + + result = op; + } + else if(nodeType == "Function") + { + std::vector args; + const Json::Value & argsJson = json.get("arguments", Json::arrayValue); + + for(Json::ArrayIndex i = 0; i < argsJson.size(); i++) + { + const Json::Value & argJson = argsJson[i]; + + ScriptNodeFunction::Argument arg; + arg.name = argJson.get("name", "").asString(); + arg.optional = !arg.name.empty() && arg.name[0] == '?'; + + if(arg.optional) + arg.name = arg.name.substr(1); + + const Json::Value & keysJson = argJson.get("dropKeys", Json::arrayValue); + for(Json::ArrayIndex k = 0; k < keysJson.size(); k++) + arg.dropKeys.push_back(keysJson[k].asString()); + + args.push_back(arg); + } + + auto * func = new ScriptNodeFunction(json.get("functionName", "").asString(), args, parent); + + for(Json::ArrayIndex i = 0; i < argsJson.size(); i++) + func->setArgumentValue(static_cast(i), fromJson(argsJson[i].get("argument", Json::nullValue), func)); + + result = func; + } + else if(nodeType == "RowFunction") + { + auto * rowFunc = new ScriptNodeRowFunction(json.get("functionName", "").asString(), parent); + + const Json::Value & droppedItems = json.get("droppedItems", Json::arrayValue); + for(Json::ArrayIndex i = 0; i < droppedItems.size(); i++) + { + const std::string itemStr = droppedItems[i].asString(); + + if(itemStr == "null" || itemStr.empty()) + rowFunc->addChild(nullptr); + else + { + Json::Value itemJson; + Json::Reader().parse(itemStr, itemJson); + rowFunc->addChild(fromJson(itemJson, rowFunc)); + } + } + + if(rowFunc->childCount() == 0) + rowFunc->addChild(nullptr); + + result = rowFunc; + } + else if(nodeType == "Column") + { + result = new ScriptNodeColumn( + json.get("columnName", "").asString(), + json.get("columnTypeUser", -1).asInt(), + json.get("columnTypeDrop", -1).asInt(), + parent); + + if(json.isMember("dataSetName")) + static_cast(result)->setDataSetName(json["dataSetName"].asString()); + } + else if(nodeType == "Number") + { + auto * lit = new ScriptNodeLiteral(Type::Number, parent); + lit->setNumberValue(json.get("value", 0).asDouble()); + result = lit; + } + else if(nodeType == "Boolean") + { + auto * lit = new ScriptNodeLiteral(Type::Boolean, parent); + + const Json::Value & val = json.get("value", false); + if(val.isString()) lit->setBoolValue(val.asString() == "TRUE"); + else lit->setBoolValue(val.asBool()); + + result = lit; + } + else if(nodeType == "String") + { + auto * lit = new ScriptNodeLiteral(Type::String, parent); + lit->setStringValue(json.get("text", "").asString()); + result = lit; + } + + if(result && !toolTip.empty()) + result->setToolTip(toolTip); + + return result; +} + +// --- ScriptNodeOperator --- + +ScriptNodeOperator::ScriptNodeOperator(const std::string & op, bool vertical, ScriptNode * parent) + : ScriptNode(parent) + , _op(op) + , _vertical(vertical) +{ +} + +void ScriptNodeOperator::setLeft(ScriptNode * node) +{ + if(node) node->setParent(this); + _left = node; +} + +void ScriptNodeOperator::setRight(ScriptNode * node) +{ + if(node) node->setParent(this); + _right = node; +} + +Json::Value ScriptNodeOperator::toJson() const +{ + Json::Value json; + json["nodeType"] = nodeTypeString(); + json["operator"] = _op; + json["leftArgument"] = _left ? _left->toJson() : Json::nullValue; + json["rightArgument"] = _right ? _right->toJson() : Json::nullValue; + + if(!_toolTip.empty()) + json["toolTipText"] = _toolTip; + + return json; +} + +std::string ScriptNodeOperator::toR(const ScriptColumnTypeProvider * typeProvider) const +{ + std::string out = "("; + out += _left ? _left->toR(typeProvider) : "null"; + out += " " + _op + " "; + out += _right ? _right->toR(typeProvider) : "null"; + out += ")"; + return out; +} + +stringvec ScriptNodeOperator::dragKeys() const +{ + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op); + + if(!def) return {"number"}; + + return def->dragKeys(ScriptConstructorMode::Filter); +} + +bool ScriptNodeOperator::isComplete() const +{ + bool leftOk = _left ? _left->isComplete() : false; + bool rightOk = _right ? _right->isComplete() : false; + + return leftOk && rightOk; +} + +stringvec ScriptNodeOperator::dropKeysLeft() const +{ + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op); + return def ? def->dropKeysLeft(ScriptConstructorMode::Filter) : stringvec{"number"}; +} + +stringvec ScriptNodeOperator::dropKeysRight() const +{ + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op); + return def ? def->dropKeysRight(ScriptConstructorMode::Filter) : stringvec{"number"}; +} + +// --- ScriptNodeFunction --- + +ScriptNodeFunction::ScriptNodeFunction(const std::string & functionName, ScriptNode * parent) + : ScriptNode(parent) + , _functionName(functionName) +{ + const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(functionName); + + if(def) + for(const ScriptParamDef & param : def->params) + _arguments.push_back({param.name, param.dropKeys, param.optional, nullptr}); +} + +ScriptNodeFunction::ScriptNodeFunction(const std::string & functionName, const std::vector & args, ScriptNode * parent) + : ScriptNode(parent) + , _functionName(functionName) + , _arguments(args) +{ +} + +void ScriptNodeFunction::addArgument(const Argument & arg) +{ + _arguments.push_back(arg); +} + +void ScriptNodeFunction::setArgumentValue(int index, ScriptNode * node) +{ + if(index < 0 || index >= static_cast(_arguments.size())) + return; + + if(node) node->setParent(this); + _arguments[index].value = node; +} + +int ScriptNodeFunction::argumentIndex(const std::string & name) const +{ + for(int i = 0; i < static_cast(_arguments.size()); i++) + if(_arguments[i].name == name) + return i; + + return -1; +} + +Json::Value ScriptNodeFunction::toJson() const +{ + Json::Value json; + json["nodeType"] = "Function"; + json["functionName"] = _functionName; + json["arguments"] = Json::arrayValue; + + for(const Argument & arg : _arguments) + { + Json::Value argJson; + argJson["name"] = arg.optional ? "?" + arg.name : arg.name; + argJson["dropKeys"] = Json::arrayValue; + for(const std::string & key : arg.dropKeys) + argJson["dropKeys"].append(key); + argJson["argument"] = arg.value ? arg.value->toJson() : Json::nullValue; + + json["arguments"].append(argJson); + } + + if(!_toolTip.empty()) + json["toolTipText"] = _toolTip; + + return json; +} + +std::string ScriptNodeFunction::toR(const ScriptColumnTypeProvider * typeProvider) const +{ + std::string out = _functionName + "("; + + for(int i = 0; i < static_cast(_arguments.size()); i++) + { + if(i > 0) out += ", "; + out += _arguments[i].value ? _arguments[i].value->toR(typeProvider) : "NULL"; + } + + const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(_functionName); + if(def && def->addsNaRm()) + out += ", na.rm=TRUE"; + + out += ")"; + return out; +} + +stringvec ScriptNodeFunction::dragKeys() const +{ + const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(_functionName); + return def ? def->dragKeys() : stringvec{"number"}; +} + +bool ScriptNodeFunction::isComplete() const +{ + for(const Argument & arg : _arguments) + { + if(arg.optional) + continue; + + if(!arg.value || !arg.value->isComplete()) + return false; + } + + return true; +} + +// --- ScriptNodeRowFunction --- + +ScriptNodeRowFunction::ScriptNodeRowFunction(const std::string & functionName, ScriptNode * parent) + : ScriptNode(parent) + , _functionName(functionName) +{ +} + +void ScriptNodeRowFunction::setChild(int index, ScriptNode * node) +{ + if(index < 0 || index >= static_cast(_children.size())) + return; + + if(node) node->setParent(this); + _children[index] = node; +} + +void ScriptNodeRowFunction::addChild(ScriptNode * node) +{ + if(node) node->setParent(this); + _children.push_back(node); +} + +void ScriptNodeRowFunction::removeChildAt(int index) +{ + if(index < 0 || index >= static_cast(_children.size())) + return; + + _children.erase(_children.begin() + index); +} + +int ScriptNodeRowFunction::childCountFilled() const +{ + int count = 0; + for(ScriptNode * child : _children) + if(child) count++; + return count; +} + +Json::Value ScriptNodeRowFunction::toJson() const +{ + Json::Value json; + json["nodeType"] = "RowFunction"; + json["functionName"] = _functionName; + json["droppedItems"] = Json::arrayValue; + + for(ScriptNode * child : _children) + { + if(child) + { + Json::Value childJson = child->toJson(); + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + json["droppedItems"].append(Json::writeString(builder, childJson)); + } + else + json["droppedItems"].append("null"); + } + + if(!_toolTip.empty()) + json["toolTipText"] = _toolTip; + + return json; +} + +std::string ScriptNodeRowFunction::toR(const ScriptColumnTypeProvider * typeProvider) const +{ + std::string out = _functionName + "NaRm("; + + bool first = true; + for(ScriptNode * child : _children) + { + if(!child) continue; + + if(!first) out += ", "; + out += child->toR(typeProvider); + first = false; + } + + out += ")"; + return out; +} + +stringvec ScriptNodeRowFunction::dragKeys() const +{ + return {"number"}; +} + +bool ScriptNodeRowFunction::isComplete() const +{ + for(ScriptNode * child : _children) + if(child && child->isComplete()) + return true; + + return false; +} + +// --- ScriptNodeColumn --- + +ScriptNodeColumn::ScriptNodeColumn(const std::string & columnName, int columnTypeUser, int columnTypeDrop, ScriptNode * parent) + : ScriptNode(parent) + , _columnName(columnName) + , _columnTypeUser(columnTypeUser) + , _columnTypeDrop(columnTypeDrop) +{ +} + +void ScriptNodeColumn::setColumnTypeUser(int t) +{ + _columnTypeUser = t; +} + +void ScriptNodeColumn::setColumnTypeDrop(int t) +{ + _columnTypeDrop = t; +} + +int ScriptNodeColumn::effectiveColumnType(int actualColumnType) const +{ + if(_columnTypeDrop != -1) return _columnTypeDrop; + if(_columnTypeUser != -1) return _columnTypeUser; + return actualColumnType; +} + +Json::Value ScriptNodeColumn::toJson() const +{ + Json::Value json; + json["nodeType"] = "Column"; + json["columnName"] = _columnName; + json["columnTypeUser"] = _columnTypeUser; + json["columnTypeDrop"] = _columnTypeDrop; + + if(!_dataSetName.empty()) + json["dataSetName"] = _dataSetName; + + if(!_toolTip.empty()) + json["toolTipText"] = _toolTip; + + return json; +} + +std::string ScriptNodeColumn::toR(const ScriptColumnTypeProvider * typeProvider) const +{ + int actualType = typeProvider ? typeProvider->columnType(_columnName) : 1; + int effective = effectiveColumnType(actualType); + + return _columnName + "." + ScriptConstructorRegistry::columnTypeString(effective); +} + +stringvec ScriptNodeColumn::dragKeys() const +{ + if(_columnTypeDrop != -1) + return ScriptConstructorRegistry::dropKeysForColumnType(_columnTypeDrop); + + return {"number", "string", "ordered"}; +} + +// --- ScriptNodeLiteral --- + +ScriptNodeLiteral::ScriptNodeLiteral(Type literalType, ScriptNode * parent) + : ScriptNode(parent) + , _literalType(literalType) +{ +} + +Json::Value ScriptNodeLiteral::toJson() const +{ + Json::Value json; + json["nodeType"] = nodeTypeString(); + + switch(_literalType) + { + case Type::Number: json["value"] = _numberValue; break; + case Type::Boolean: json["value"] = _boolValue ? "TRUE" : "FALSE"; break; + case Type::String: json["text"] = _stringValue; break; + default: break; + } + + if(!_toolTip.empty()) + json["toolTipText"] = _toolTip; + + return json; +} + +std::string ScriptNodeLiteral::toR(const ScriptColumnTypeProvider *) const +{ + switch(_literalType) + { + case Type::Number: return numberToRString(_numberValue); + case Type::Boolean: return _boolValue ? "TRUE" : "FALSE"; + case Type::String: return "'" + _stringValue + "'"; + default: return ""; + } +} + +stringvec ScriptNodeLiteral::dragKeys() const +{ + switch(_literalType) + { + case Type::Number: return {"number"}; + case Type::Boolean: return {"boolean"}; + case Type::String: return {"string"}; + default: return {}; + } +} diff --git a/CommonData/scriptnode.h b/CommonData/scriptnode.h new file mode 100644 index 0000000000..5d9b016261 --- /dev/null +++ b/CommonData/scriptnode.h @@ -0,0 +1,220 @@ +#ifndef SCRIPTNODE_H +#define SCRIPTNODE_H + +#include +#include +#include +#include +#include "utils.h" +#include "scriptconstructorregistry.h" + +class ScriptNodeModel; + +/// Provides the actual column type for a column name (needed for R code generation of Column nodes). +/// The view/integration layer implements this using ColumnsModel or DataSet. +class ScriptColumnTypeProvider +{ +public: + virtual ~ScriptColumnTypeProvider() = default; + virtual int columnType(const std::string & columnName) const = 0; +}; + +class ScriptNode : public QObject +{ + Q_OBJECT + +public: + enum class Type { Operator, OperatorVertical, Function, RowFunction, Column, Number, Boolean, String }; + + explicit ScriptNode(ScriptNode * parent = nullptr); + virtual ~ScriptNode() = default; + + virtual Type type() const = 0; + virtual Json::Value toJson() const = 0; + virtual std::string toR(const ScriptColumnTypeProvider * typeProvider = nullptr) const = 0; + virtual stringvec dragKeys() const = 0; + virtual bool isComplete() const = 0; + + virtual ScriptNode * leftChild() const { return nullptr; } + virtual ScriptNode * rightChild() const { return nullptr; } + virtual int childCount() const { return 0; } + virtual ScriptNode * childAt(int) const { return nullptr; } + + ScriptNode * parent() const { return _parent; } + void setParent(ScriptNode * newParent) { _parent = newParent; } + + std::string toolTip() const { return _toolTip; } + void setToolTip(const std::string & tip) { _toolTip = tip; } + + static ScriptNode * fromJson(const Json::Value & json, ScriptNode * parent = nullptr); + static void deleteTree(ScriptNode * node); + + std::string nodeTypeString() const; + static Type typeFromString(const std::string & str); + +protected: + ScriptNode * _parent = nullptr; + std::string _toolTip; +}; + +class ScriptNodeOperator : public ScriptNode +{ + Q_OBJECT + +public: + ScriptNodeOperator(const std::string & op, bool vertical, ScriptNode * parent = nullptr); + + Type type() const override { return _vertical ? Type::OperatorVertical : Type::Operator; } + Json::Value toJson() const override; + std::string toR(const ScriptColumnTypeProvider * typeProvider = nullptr) const override; + stringvec dragKeys() const override; + bool isComplete() const override; + + ScriptNode * leftChild() const override { return _left; } + ScriptNode * rightChild() const override { return _right; } + int childCount() const override { return 2; } + ScriptNode * childAt(int i) const override { return i == 0 ? _left : _right; } + + const std::string & op() const { return _op; } + bool isVertical() const { return _vertical; } + + void setLeft(ScriptNode * node); + void setRight(ScriptNode * node); + + stringvec dropKeysLeft() const; + stringvec dropKeysRight() const; + +private: + std::string _op; + bool _vertical; + ScriptNode * _left = nullptr, + * _right = nullptr; +}; + +class ScriptNodeFunction : public ScriptNode +{ + Q_OBJECT + +public: + struct Argument + { + std::string name; + stringvec dropKeys; + bool optional = false; + ScriptNode * value = nullptr; + }; + + ScriptNodeFunction(const std::string & functionName, ScriptNode * parent = nullptr); + ScriptNodeFunction(const std::string & functionName, const std::vector & args, ScriptNode * parent = nullptr); + + Type type() const override { return Type::Function; } + Json::Value toJson() const override; + std::string toR(const ScriptColumnTypeProvider * typeProvider = nullptr) const override; + stringvec dragKeys() const override; + bool isComplete() const override; + + int childCount() const override { return static_cast(_arguments.size()); } + ScriptNode * childAt(int i) const override { return _arguments.at(i).value; } + + const std::string & functionName() const { return _functionName; } + const std::vector & arguments() const { return _arguments; } + + void addArgument(const Argument & arg); + void setArgumentValue(int index, ScriptNode * node); + int argumentIndex(const std::string & name) const; + +private: + std::string _functionName; + std::vector _arguments; +}; + +class ScriptNodeRowFunction : public ScriptNode +{ + Q_OBJECT + +public: + ScriptNodeRowFunction(const std::string & functionName, ScriptNode * parent = nullptr); + + Type type() const override { return Type::RowFunction; } + Json::Value toJson() const override; + std::string toR(const ScriptColumnTypeProvider * typeProvider = nullptr) const override; + stringvec dragKeys() const override; + bool isComplete() const override; + + int childCount() const override { return static_cast(_children.size()); } + ScriptNode * childAt(int i) const override { return _children.at(i); } + + const std::string & functionName() const { return _functionName; } + const std::vector & children() const { return _children; } + + void setChild(int index, ScriptNode * node); + void addChild(ScriptNode * node); + void removeChildAt(int index); + int childCountFilled() const; + +private: + std::string _functionName; + std::vector _children; +}; + +class ScriptNodeColumn : public ScriptNode +{ + Q_OBJECT + +public: + ScriptNodeColumn(const std::string & columnName, int columnTypeUser = -1, int columnTypeDrop = -1, ScriptNode * parent = nullptr); + + Type type() const override { return Type::Column; } + Json::Value toJson() const override; + std::string toR(const ScriptColumnTypeProvider * typeProvider = nullptr) const override; + stringvec dragKeys() const override; + bool isComplete() const override { return true; } + + const std::string & columnName() const { return _columnName; } + int columnTypeUser() const { return _columnTypeUser; } + int columnTypeDrop() const { return _columnTypeDrop; } + const std::string & dataSetName() const { return _dataSetName; } + + void setColumnName(const std::string & name) { _columnName = name; } + void setColumnTypeUser(int t); + void setColumnTypeDrop(int t); + void setDataSetName(const std::string & name) { _dataSetName = name; } + + int effectiveColumnType(int actualColumnType) const; + +private: + std::string _columnName; + int _columnTypeUser = -1, + _columnTypeDrop = -1; + std::string _dataSetName; +}; + +class ScriptNodeLiteral : public ScriptNode +{ + Q_OBJECT + +public: + ScriptNodeLiteral(Type literalType, ScriptNode * parent = nullptr); + + Type type() const override { return _literalType; } + Json::Value toJson() const override; + std::string toR(const ScriptColumnTypeProvider * typeProvider = nullptr) const override; + stringvec dragKeys() const override; + bool isComplete() const override { return true; } + + double numberValue() const { return _numberValue; } + bool boolValue() const { return _boolValue; } + const std::string & stringValue() const { return _stringValue; } + + void setNumberValue(double v) { _numberValue = v; } + void setBoolValue(bool v) { _boolValue = v; } + void setStringValue(const std::string & v) { _stringValue = v; } + +private: + Type _literalType; + double _numberValue = 0; + bool _boolValue = false; + std::string _stringValue; +}; + +#endif // SCRIPTNODE_H diff --git a/Desktop/components/JASP/Widgets/FilterWindow.qml b/Desktop/components/JASP/Widgets/FilterWindow.qml index a213d47963..abb3a06437 100644 --- a/Desktop/components/JASP/Widgets/FilterWindow.qml +++ b/Desktop/components/JASP/Widgets/FilterWindow.qml @@ -168,11 +168,13 @@ FocusScope visible: filterModel.showEasyFilter - FilterConstructor + ScriptConstructor { - id: easyFilterConstructor - onRCodeChanged: filterContainer.rCodeChanged(rScript) - clip: true + id: easyFilterConstructor + mode: ScriptConstructor.Filter + columnsModel: columnsModel + constructorJson: filterModel.filter.constructorJson + clip: true anchors { @@ -182,39 +184,14 @@ FocusScope top: parent.top } - - functionModel: ListModel + onApplyRequested: function(json, rCode) { - - ListElement { type: "function"; friendlyFunctionName: ""; /* qsTr("Abs"); */ functionName: "abs"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("absolute value") } - ListElement { type: "function"; friendlyFunctionName: ""; /* qsTr("Standard deviation"); */ functionName: "sd"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("standard deviation") } - ListElement { type: "function"; friendlyFunctionName: ""; /* qsTr("Variance"); */ functionName: "var"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("variance") } - ListElement { type: "function"; friendlyFunctionName: ""; /* qsTr("Sum"); */ functionName: "sum"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("summation") } - ListElement { type: "function"; friendlyFunctionName: ""; /* qsTr("Product"); */ functionName: "prod"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("product of values") } - ListElement { type: "function"; friendlyFunctionName: ""; /* qsTr("ZScores"); */ functionName: "zScores"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("Standardizes the variable") } - - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise mean") ; */ functionName: "rowMean"; toolTip: qsTr("Rowwise mean") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise sum") ; */ functionName: "rowSum"; toolTip: qsTr("Rowwise sum") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise standard deviation"); */ functionName: "rowSD"; toolTip: qsTr("Rowwise standard deviation") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise variance") ; */ functionName: "rowVariance"; toolTip: qsTr("Rowwise variance") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise median") ; */ functionName: "rowMedian"; toolTip: qsTr("Rowwise median") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise minimum") ; */ functionName: "rowMin"; toolTip: qsTr("Rowwise minimum") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; /* qsTr("Rowwise maximum") ; */ functionName: "rowMax"; toolTip: qsTr("Rowwise maximum") } - - - - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Min"); */ functionName: "min"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("returns minimum of values") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Max"); */ functionName: "max"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("returns maximum of values") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Mean"); */ functionName: "mean"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("mean") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Sign"); */ functionName: "sign"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("returns the sign of values") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Round"); */ functionName: "round"; functionParameters: "y,n"; functionParamTypes: "number,number"; toolTip: qsTr("rounds y to n decimals") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Length"); */ functionName: "length"; functionParameters: "y"; functionParamTypes: "string:number"; toolTip: qsTr("returns number of elements in y") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Median"); */ functionName: "median"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("median") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("IfElse"); */ functionName: "ifelse"; functionParameters: "test,then,else"; functionParamTypes: "boolean,boolean:string:number,boolean:string:number"; toolTip: qsTr("if-else statement") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("HasSubstring"); */ functionName: "hasSubstring"; functionParameters: "string,substring"; functionParamTypes: "string,string"; toolTip: qsTr("returns true if string contains substring at least once") } - ListElement { type: "function"; friendlyFunctionName: ""; /*qsTr("Is.NA"); */ functionName: "is.na"; functionParameters: "y"; functionParamTypes: "string:number:boolean"; toolTip: qsTr("Combine with not-operator to filter out rows with missing values (NA) for a column.") } + filterModel.applyConstructorJson(json) + filterModel.filter.constructorR = rCode } + onRCodeChanged: filterContainer.rCodeChanged(rScript) + function askIfChanged(closeFunc) { if(jsonChanged() || !lastCheckPassed) @@ -235,7 +212,7 @@ FocusScope property var closeFunc: undefined - onSave: if(easyFilterConstructor.checkAndApplyFilter()) closeFunc(); + onSave: if(easyFilterConstructor.checkAndApply()) closeFunc(); onDiscard: { easyFilterConstructor.initializeFromJSON(); closeFunc(); } } } @@ -304,12 +281,12 @@ FocusScope JaspControls.RectangularButton { - property bool showApplyNotApplied: easyFilterConstructor.somethingChanged || easyFilterConstructor.showStartupMsg + property bool showApplyNotApplied: easyFilterConstructor.somethingChanged id: applyEasyFilter text: showApplyNotApplied ? qsTr("Apply pass-through filter") : qsTr("Filter applied") enabled: easyFilterConstructor.somethingChanged - onClicked: easyFilterConstructor.checkAndApplyFilter() + onClicked: easyFilterConstructor.checkAndApply() toolTip: showApplyNotApplied ? qsTr("Click to apply filter") : qsTr("Filter is already applied") anchors { diff --git a/Desktop/mainwindow.cpp b/Desktop/mainwindow.cpp index f709679ee3..c198e64d76 100644 --- a/Desktop/mainwindow.cpp +++ b/Desktop/mainwindow.cpp @@ -52,6 +52,7 @@ #include "qquick/datasetview.h" #include "qquick/rcommander.h" +#include "qquick/scriptconstructorview.h" #include "resultstesting/compareresults.h" @@ -162,6 +163,7 @@ MainWindow::MainWindow(Application * application) : QObject(application), _appli qmlRegisterType ("JASP", 1, 0, "RCommander" ); qmlRegisterType ("JASP", 1, 0, "ResultsJsInterface" ); qmlRegisterType ("JASP", 1, 0, "ColumnModel" ); + qmlRegisterType ("JASP", 1, 0, "ScriptConstructor" ); qmlRegisterUncreatableType ("JASP.PlotEditor", 1, 0, "AxisModel", "Can't make it"); qmlRegisterUncreatableType ("JASP.PlotEditor", 1, 0, "PlotEditorModel", "Can't make it"); diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp new file mode 100644 index 0000000000..aa5bb56e99 --- /dev/null +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -0,0 +1,720 @@ +#include "scriptconstructorview.h" +#include "scriptnodeitem.h" +#include "jasptheme.h" +#include "qutils.h" + +#include +#include +#include +#include +#include +#include +#include + +ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) + : QQuickItem(parent) +{ + setFlag(QQuickItem::ItemHasContents, true); + setClip(true); + + connect(&_model, &ScriptConstructorModel::reset, this, [this](){ rebuildFormulaItems(); }); + connect(&_model, &ScriptConstructorModel::changed, this, [this](){ + setSomethingChanged(true); + emit rCodeChanged(rCode()); + }); +} + +ScriptConstructorView::~ScriptConstructorView() +{ + for(auto & comp : {_textComp, _imageComp, _textInputComp, _checkBoxComp, _rectComp}) + delete comp.data(); +} + +// ------------------------------------------------------------------------------------- +// Q_PROPERTY accessors +// ------------------------------------------------------------------------------------- + +void ScriptConstructorView::setModeInt(int m) +{ + ScriptConstructorMode mode = static_cast(m); + if(mode == _model.mode()) return; + + _model.setMode(mode); + emit modeChanged(); +} + +QString ScriptConstructorView::constructorJson() const +{ + return tq(_model.toString()); +} + +void ScriptConstructorView::setConstructorJson(const QString & json) +{ + std::string s = fq(json); + if(s == _model.toString()) return; + + _model.fromJson(s); + _lastAppliedJson = tq(_model.toString()); + emit constructorJsonChanged(); +} + +QString ScriptConstructorView::rCode() const +{ + return tq(_model.toR()); +} + +void ScriptConstructorView::setSomethingChanged(bool v) +{ + if(v == _somethingChanged) return; + _somethingChanged = v; + emit somethingChangedChanged(); +} + +void ScriptConstructorView::setShowGeneratedRCode(bool v) +{ + if(v == _showGeneratedRCode) return; + _showGeneratedRCode = v; + emit showGeneratedRCodeChanged(); +} + +void ScriptConstructorView::setColumnsModel(QAbstractItemModel * m) +{ + if(m == _columnsModel) return; + + if(_columnsModel) + disconnect(_columnsModel, nullptr, this, nullptr); + + _columnsModel = m; + + if(_columnsModel) + { + connect(_columnsModel, &QAbstractItemModel::modelReset, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + connect(_columnsModel, &QAbstractItemModel::rowsInserted, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + connect(_columnsModel, &QAbstractItemModel::rowsRemoved, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + } + + emit columnsModelChanged(); + + if(_chromeBuilt) + buildColumnPalette(); +} + +// ------------------------------------------------------------------------------------- +// QML-callable API +// ------------------------------------------------------------------------------------- + +QString ScriptConstructorView::returnFilterJSON() const +{ + return constructorJson(); +} + +bool ScriptConstructorView::jsonChanged() const +{ + return _model.toString() != _lastAppliedJson; +} + +void ScriptConstructorView::initializeFromJSON(const QString & json) +{ + std::string s = json.isEmpty() ? fq(_lastAppliedJson) : fq(json); + _model.fromJson(s); + setSomethingChanged(false); + rebuildFormulaItems(); +} + +bool ScriptConstructorView::checkAndApply() +{ + setSomethingChanged(false); + + bool complete = _model.checkCompleteness(); + bool isFilter = _model.mode() == ScriptConstructorMode::Filter; + bool booleanOk = !isFilter || _model.allBoolean(); + bool oneFormula = !isFilter || _model.formulaCount() <= 1; + + _lastCheckPassed = complete && booleanOk && oneFormula; + emit lastCheckPassedChanged(); + + if(_lastCheckPassed) + { + _lastAppliedJson = tq(_model.toString()); + emit applyRequested(constructorJson(), rCode()); + } + + refreshHint(); + return _lastCheckPassed; +} + +void ScriptConstructorView::nodeEdited() +{ + setSomethingChanged(true); + emit rCodeChanged(rCode()); +} + +// ------------------------------------------------------------------------------------- +// Theme metrics +// ------------------------------------------------------------------------------------- + +qreal ScriptConstructorView::blockDim() const +{ + JaspTheme * theme = JaspTheme::currentTheme(); + return 20.0 * (theme ? theme->uiScale() : 1.0); +} + +qreal ScriptConstructorView::fontPixelSize() const +{ + JaspTheme * theme = JaspTheme::currentTheme(); + return 16.0 * (theme ? theme->uiScale() : 1.0); +} + +qreal ScriptConstructorView::spacing() const +{ + return 2.0 * (JaspTheme::currentTheme() ? JaspTheme::currentTheme()->uiScale() : 1.0); +} + +// ------------------------------------------------------------------------------------- +// Leaf components (inline QML, incubated on demand) +// ------------------------------------------------------------------------------------- + +QQmlComponent * ScriptConstructorView::textComponent() +{ + if(!_textComp) + { + _textComp = new QQmlComponent(qmlEngine(this)); + _textComp->setData("import QtQuick\nText { verticalAlignment: Text.AlignVCenter }", QUrl("ScriptConstructorText")); + } + return _textComp; +} + +QQmlComponent * ScriptConstructorView::imageComponent() +{ + if(!_imageComp) + { + _imageComp = new QQmlComponent(qmlEngine(this)); + _imageComp->setData("import QtQuick\nImage { smooth: true }", QUrl("ScriptConstructorImage")); + } + return _imageComp; +} + +QQmlComponent * ScriptConstructorView::textInputComponent() +{ + if(!_textInputComp) + { + _textInputComp = new QQmlComponent(qmlEngine(this)); + _textInputComp->setData("import QtQuick\nTextInput { selectByMouse: true }", QUrl("ScriptConstructorTextInput")); + } + return _textInputComp; +} + +QQmlComponent * ScriptConstructorView::checkBoxComponent() +{ + if(!_checkBoxComp) + { + _checkBoxComp = new QQmlComponent(qmlEngine(this)); + _checkBoxComp->setData("import QtQuick\nimport QtQuick.Controls\nCheckBox {}", QUrl("ScriptConstructorCheckBox")); + } + return _checkBoxComp; +} + +QQmlComponent * ScriptConstructorView::rectangleComponent() +{ + if(!_rectComp) + { + _rectComp = new QQmlComponent(qmlEngine(this)); + _rectComp->setData("import QtQuick\nRectangle {}", QUrl("ScriptConstructorRectangle")); + } + return _rectComp; +} + +QQuickItem * ScriptConstructorView::newLeaf(QQmlComponent * comp) +{ + if(!comp || comp->isError()) + return nullptr; + + QQmlIncubator incubator(QQmlIncubator::Synchronous); + comp->create(incubator); + + if(incubator.isError()) + return nullptr; + + return qobject_cast(incubator.object()); +} + +// ------------------------------------------------------------------------------------- +// Chrome + item tree +// ------------------------------------------------------------------------------------- + +void ScriptConstructorView::componentComplete() +{ + QQuickItem::componentComplete(); + + if(!_chromeBuilt) + { + buildChrome(); + _chromeBuilt = true; + } + + rebuildFormulaItems(); +} + +void ScriptConstructorView::buildChrome() +{ + JaspTheme * theme = JaspTheme::currentTheme(); + + _background = newLeaf(rectangleComponent()); + if(_background) + { + _background->setParentItem(this); + _background->setZ(-3); + _background->setProperty("color", theme ? theme->white() : QColor("white")); + } + + _operatorBar = new QQuickItem(this); + _operatorBar->setParentItem(this); + _operatorBar->setZ(3); + + _columnPalette = new QQuickItem(this); + _columnPalette->setParentItem(this); + + _functionPalette = new QQuickItem(this); + _functionPalette->setParentItem(this); + + _scriptArea = new QQuickItem(this); + _scriptArea->setClip(true); + + _scriptColumn = new QQuickItem(_scriptArea); + _scriptColumn->setParentItem(_scriptArea); + + _trash = newLeaf(rectangleComponent()); + if(_trash) + { + _trash->setParentItem(_scriptArea); + _trash->setProperty("color", QColor(0, 0, 0, 0)); + _trash->setProperty("border.color", theme ? theme->gray() : QColor("gray")); + _trash->setProperty("border.width", 1); + _trash->setProperty("radius", 6.0); + _trash->setZ(10); + } + + buildOperatorBar(); + buildColumnPalette(); + buildFunctionPalette(); +} + +ScriptNodeItem * ScriptConstructorView::makeNodeItem(ScriptNode * node, QQuickItem * parent) +{ + ScriptNodeItem * item = new ScriptNodeItem(this, node, parent); + item->rebuild(); + _nodeItems[node] = item; + return item; +} + +void ScriptConstructorView::clearFormulaItems() +{ + for(auto & pair : _nodeItems) + if(pair.second) + pair.second->deleteLater(); + + _nodeItems.clear(); + _rootItems.clear(); +} + +void ScriptConstructorView::rebuildFormulaItems() +{ + if(!_chromeBuilt || !_scriptColumn) + return; + + clearFormulaItems(); + + for(ScriptNode * formula : _model.formulas()) + { + ScriptNodeItem * item = makeNodeItem(formula, _scriptColumn); + _rootItems.append(item); + } + + layoutAll(); +} + +void ScriptConstructorView::layoutAll() +{ + qreal w = width(), h = height(); + qreal barH = blockDim() * 1.75; + qreal paletteW = blockDim() * 6; + + if(_background) + { + _background->setWidth(w); + _background->setHeight(h); + } + + if(_operatorBar) + { + _operatorBar->setX(0); + _operatorBar->setY(0); + _operatorBar->setWidth(w); + _operatorBar->setHeight(barH); + } + + if(_columnPalette) + { + _columnPalette->setX(0); + _columnPalette->setY(barH); + _columnPalette->setWidth(paletteW); + _columnPalette->setHeight(h - barH); + } + + if(_functionPalette) + { + _functionPalette->setX(w - paletteW); + _functionPalette->setY(barH); + _functionPalette->setWidth(paletteW); + _functionPalette->setHeight(h - barH); + } + + if(_scriptArea) + { + _scriptArea->setX(paletteW); + _scriptArea->setY(barH); + _scriptArea->setWidth(w - 2 * paletteW); + _scriptArea->setHeight(h - barH); + } + + if(_trash) + { + qreal trashDim = blockDim() * 3; + _trash->setWidth(trashDim); + _trash->setHeight(trashDim); + _trash->setX(_scriptArea->width() - trashDim - spacing()); + _trash->setY(_scriptArea->height() - trashDim - spacing()); + } + + layoutScriptArea(); +} + +void ScriptConstructorView::layoutScriptArea() +{ + if(!_scriptColumn) return; + + qreal y = spacing(); + qreal x = spacing(); + + for(ScriptNodeItem * item : _rootItems) + { + item->layout(); + item->setX(x); + item->setY(y); + y += item->preferredHeight() + spacing() * 2; + } + + _scriptColumn->setWidth(width()); + _scriptColumn->setHeight(y); +} + +void ScriptConstructorView::geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) +{ + QQuickItem::geometryChange(newGeometry, oldGeometry); + + if(newGeometry.size() != oldGeometry.size()) + layoutAll(); +} + +QSGNode * ScriptConstructorView::updatePaintNode(QSGNode * oldNode, UpdatePaintNodeData *) +{ + QSGRectangleNode * rect = static_cast(oldNode); + + if(!rect) + { + rect = window()->createRectangleNode(); + QSGFlatColorMaterial * material = new QSGFlatColorMaterial(); + material->setColor(JaspTheme::currentTheme() ? JaspTheme::currentTheme()->white() : QColor("white")); + rect->setMaterial(material); + rect->setFlag(QSGNode::OwnsMaterial); + } + + rect->setRect(boundingRect()); + return rect; +} + +void ScriptConstructorView::refreshHint() +{ + // Hint text rendering is handled by the surrounding window (as before); kept as a hook. +} + +// ===================================================================================== +// Palettes + operator bar +// ===================================================================================== + +void ScriptConstructorView::buildOperatorBar() +{ + if(!_operatorBar) return; + + qreal x = spacing(); + for(const ScriptOperatorDef & def : ScriptConstructorRegistry::instance().operatorsForMode(_model.mode())) + { + ScriptNode * proto = new ScriptNodeOperator(def.op, def.vertical); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBar); + item->setAcceptsDrops(false); + item->rebuild(); + item->setX(x); + item->setY(0); + x += item->preferredWidth() + spacing() * 2; + } + + _operatorBar->setWidth(x); + _operatorBar->setHeight(blockDim()); +} + +void ScriptConstructorView::buildFunctionPalette() +{ + if(!_functionPalette) return; + + qreal y = spacing(); + for(const ScriptFunctionDef & def : ScriptConstructorRegistry::instance().functionsForMode(_model.mode())) + { + ScriptNode * proto = new ScriptNodeFunction(def.name); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _functionPalette); + item->setAcceptsDrops(false); + item->rebuild(); + item->setX(spacing()); + item->setY(y); + y += item->preferredHeight() + spacing(); + } + + for(const ScriptFunctionDef & def : ScriptConstructorRegistry::instance().rowFunctions()) + { + ScriptNode * proto = new ScriptNodeRowFunction(def.name); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _functionPalette); + item->setAcceptsDrops(false); + item->rebuild(); + item->setX(spacing()); + item->setY(y); + y += item->preferredHeight() + spacing(); + } + + _functionPalette->setWidth(blockDim() * 6); + _functionPalette->setHeight(y); +} + +void ScriptConstructorView::buildColumnPalette() +{ + if(!_columnPalette) return; + + // Clear any previously built column prototypes (rebuilt when the dataset changes). + for(QQuickItem * child : _columnPalette->childItems()) + child->deleteLater(); + + // Columns come from _columnsModel (set from QML). Rendered as prototype Column nodes. + if(!_columnsModel) + return; + + qreal y = spacing(); + int rows = _columnsModel->rowCount(); + + for(int r = 0; r < rows; r++) + { + QModelIndex idx = _columnsModel->index(r, 0); + QString name = _columnsModel->data(idx, static_cast(_columnsModel->roleNames().key("columnName"))).toString(); + + if(name.isEmpty()) + continue; + + ScriptNode * proto = new ScriptNodeColumn(fq(name)); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _columnPalette); + item->setAcceptsDrops(false); + item->rebuild(); + item->setX(spacing()); + item->setY(y); + y += item->preferredHeight() + spacing(); + } + + _columnPalette->setWidth(blockDim() * 6); + _columnPalette->setHeight(y); +} + +// ===================================================================================== +// Drag & drop orchestration +// ===================================================================================== + +static ScriptNode * clonePrototype(ScriptNode * proto) +{ + if(!proto) return nullptr; + + switch(proto->type()) + { + case ScriptNode::Type::Operator: + case ScriptNode::Type::OperatorVertical: + { + auto * op = static_cast(proto); + return new ScriptNodeOperator(op->op(), op->isVertical()); + } + case ScriptNode::Type::Function: + { + auto * func = static_cast(proto); + return new ScriptNodeFunction(func->functionName()); + } + case ScriptNode::Type::RowFunction: + { + auto * rowFunc = static_cast(proto); + auto * out = new ScriptNodeRowFunction(rowFunc->functionName()); + out->addChild(nullptr); + return out; + } + case ScriptNode::Type::Column: + { + auto * col = static_cast(proto); + return new ScriptNodeColumn(col->columnName()); + } + case ScriptNode::Type::Number: + return new ScriptNodeLiteral(ScriptNode::Type::Number); + case ScriptNode::Type::Boolean: + return new ScriptNodeLiteral(ScriptNode::Type::Boolean); + case ScriptNode::Type::String: + return new ScriptNodeLiteral(ScriptNode::Type::String); + } + + return nullptr; +} + +void ScriptConstructorView::spawnFromPrototype(ScriptNode * proto, const QPointF & scenePos) +{ + ScriptNode * newNode = clonePrototype(proto); + if(!newNode) return; + + startDragNew(newNode, scenePos); +} + +void ScriptConstructorView::startDragExisting(ScriptNodeItem * item, const QPointF & scenePos) +{ + if(!item) return; + + _draggedItem = item; + _dragIsNew = false; + _draggedNewNode = nullptr; + + QPointF local = item->mapFromScene(scenePos); + _dragOffset = local; + + item->setParentItem(this); + item->setZ(100); + item->setPosition(scenePos - _dragOffset); + + setSomethingChanged(true); +} + +void ScriptConstructorView::startDragNew(ScriptNode * newNode, const QPointF & scenePos) +{ + if(!newNode) return; + + ScriptNodeItem * item = makeNodeItem(newNode, this); + item->setZ(100); + item->setPosition(scenePos); + + _draggedItem = item; + _dragIsNew = true; + _draggedNewNode = newNode; + _dragOffset = QPointF(0, 0); + + setSomethingChanged(true); +} + +void ScriptConstructorView::collectDropSpots(QList & out) const +{ + for(const auto & pair : _nodeItems) + if(pair.second) + out.append(pair.second->dropSpots()); +} + +ScriptDropSpot * ScriptConstructorView::dropSpotAt(const QPointF & scenePos) const +{ + QList spots; + const_cast(this)->collectDropSpots(spots); + + for(ScriptDropSpot * spot : spots) + { + if(!spot) continue; + QPointF local = spot->mapFromScene(scenePos); + if(spot->contains(local)) + return spot; + } + + return nullptr; +} + +void ScriptConstructorView::clearHover() +{ + if(_hoveredSpot) + { + _hoveredSpot->setHoverState(false, false); + _hoveredSpot = nullptr; + } +} + +void ScriptConstructorView::dragMove(const QPointF & scenePos) +{ + if(!_draggedItem) return; + + _draggedItem->setPosition(scenePos - _dragOffset); + + ScriptDropSpot * spot = dropSpotAt(scenePos); + + if(spot != _hoveredSpot) + { + clearHover(); + _hoveredSpot = spot; + } + + if(_hoveredSpot) + { + bool accepted = _draggedItem && _hoveredSpot->target().accepts(_draggedItem->node()); + _hoveredSpot->setHoverState(true, accepted); + } +} + +void ScriptConstructorView::endDrag(const QPointF & scenePos) +{ + if(!_draggedItem) return; + + ScriptNode * node = _draggedItem->node(); + ScriptDropSpot * spot = dropSpotAt(scenePos); + + clearHover(); + + // Trash zone: bottom-right of the script area. + bool overTrash = _trash && _trash->contains(_trash->mapFromScene(scenePos)); + + if(overTrash) + { + if(_dragIsNew) + ScriptNode::deleteTree(node); + else + _model.removeNode(node); + + _draggedItem = nullptr; + _draggedNewNode = nullptr; + rebuildFormulaItems(); + nodeEdited(); + return; + } + + if(spot && spot->target().accepts(node)) + { + DropTarget target = spot->target(); + + if(_dragIsNew) + _model.insertNode(node, target); + else + _model.moveNode(node, target); + } + else + { + // No valid spot: drop at root (model resolves a reasonable insertion point). + if(_dragIsNew) + _model.insertNode(node, DropTarget::root()); + else + _model.moveNode(node, DropTarget::root()); + } + + _draggedItem = nullptr; + _draggedNewNode = nullptr; + + rebuildFormulaItems(); + nodeEdited(); +} diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h new file mode 100644 index 0000000000..2cd70c2565 --- /dev/null +++ b/Desktop/qquick/scriptconstructorview.h @@ -0,0 +1,164 @@ +#ifndef SCRIPTCONSTRUCTORVIEW_H +#define SCRIPTCONSTRUCTORVIEW_H + +#include +#include +#include +#include "scriptconstructormodel.h" + +class ScriptNodeItem; +class ScriptDropSpot; +class QQmlComponent; +class QAbstractItemModel; + +/// +/// C++ replacement for the old QML FilterConstructor / ComputedColumnsConstructor. +/// +/// Owns a ScriptConstructorModel (the single source of truth for the formula tree, JSON and R +/// code) and renders it as a tree of QQuickItems. All formula logic lives in the model; this +/// class only renders, lays out, and forwards user gestures (drag/drop, inline editing, column +/// type changes) to the model. +class ScriptConstructorView : public QQuickItem +{ + Q_OBJECT + + Q_PROPERTY( int mode READ modeInt WRITE setModeInt NOTIFY modeChanged ) + Q_PROPERTY( QString constructorJson READ constructorJson WRITE setConstructorJson NOTIFY constructorJsonChanged ) + Q_PROPERTY( QString rCode READ rCode NOTIFY rCodeChanged ) + Q_PROPERTY( bool somethingChanged READ somethingChanged WRITE setSomethingChanged NOTIFY somethingChangedChanged ) + Q_PROPERTY( bool lastCheckPassed READ lastCheckPassed NOTIFY lastCheckPassedChanged ) + Q_PROPERTY( bool isColumnConstructor READ isColumnConstructor NOTIFY modeChanged ) + Q_PROPERTY( bool showGeneratedRCode READ showGeneratedRCode WRITE setShowGeneratedRCode NOTIFY showGeneratedRCodeChanged) + Q_PROPERTY( QAbstractItemModel* columnsModel READ columnsModel WRITE setColumnsModel NOTIFY columnsModelChanged ) + +public: + enum Mode { Filter = 0, ComputedColumn = 1, ComputedDataSet = 2 }; + Q_ENUM(Mode) + + explicit ScriptConstructorView(QQuickItem * parent = nullptr); + ~ScriptConstructorView() override; + + ScriptConstructorModel * model() { return &_model; } + + int modeInt() const { return static_cast(_model.mode()); } + void setModeInt(int m); + + QString constructorJson() const; + void setConstructorJson(const QString & json); + + QString rCode() const; + + bool somethingChanged() const { return _somethingChanged; } + void setSomethingChanged(bool v); + + bool lastCheckPassed() const { return _lastCheckPassed; } + bool isColumnConstructor() const { return _model.mode() != ScriptConstructorMode::Filter; } + + bool showGeneratedRCode() const { return _showGeneratedRCode; } + void setShowGeneratedRCode(bool v); + + QAbstractItemModel* columnsModel() const { return _columnsModel; } + void setColumnsModel(QAbstractItemModel * m); + + void setColumnTypeProvider(const ScriptColumnTypeProvider * p) { _model.setColumnTypeProvider(p); } + void setUndoStack(QUndoStack * s) { _model.setUndoStack(s); } + + // --- QML-callable API mirroring the old constructors --- + Q_INVOKABLE bool checkAndApply(); + Q_INVOKABLE void initializeFromJSON(const QString & json = QString()); + Q_INVOKABLE bool jsonChanged() const; + Q_INVOKABLE QString returnFilterJSON() const; + + // --- used by ScriptNodeItem / ScriptDropSpot --- + QQmlComponent * textComponent(); + QQmlComponent * imageComponent(); + QQmlComponent * textInputComponent(); + QQmlComponent * checkBoxComponent(); + QQmlComponent * rectangleComponent(); + + qreal blockDim() const; + qreal fontPixelSize() const; + qreal spacing() const; + + QQuickItem * scriptArea() const { return _scriptArea; } + QQuickItem * newLeaf(QQmlComponent * comp); + + void nodeEdited(); + + // --- drag & drop orchestration (called by ScriptNodeItem / palette items) --- + void startDragExisting(ScriptNodeItem * item, const QPointF & scenePos); + void startDragNew(ScriptNode * newNode, const QPointF & scenePos); + void spawnFromPrototype(ScriptNode * proto, const QPointF & scenePos); + void dragMove(const QPointF & scenePos); + void endDrag(const QPointF & scenePos); + ScriptDropSpot * dropSpotAt(const QPointF & scenePos) const; + void collectDropSpots(QList & out) const; + +signals: + void modeChanged(); + void constructorJsonChanged(); + void rCodeChanged(QString rScript); + void somethingChangedChanged(); + void lastCheckPassedChanged(); + void showGeneratedRCodeChanged(); + void columnsModelChanged(); + + /// Emitted when the user applies a valid formula. The surrounding window persists it + /// (FilterModel::applyConstructorJson or Column::setConstructorJson/setRCode). + void applyRequested(QString json, QString rCode); + +protected: + void componentComplete() override; + void geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) override; + QSGNode * updatePaintNode(QSGNode * oldNode, UpdatePaintNodeData *) override; + +private: + void buildChrome(); + void buildOperatorBar(); + void buildColumnPalette(); + void buildFunctionPalette(); + void rebuildFormulaItems(); + void clearFormulaItems(); + void layoutAll(); + void layoutScriptArea(); + void refreshHint(); + + ScriptNodeItem * makeNodeItem(ScriptNode * node, QQuickItem * parent); + void clearHover(); + + ScriptConstructorModel _model; + + QPointer _background, + _operatorBar, + _columnPalette, + _functionPalette, + _scriptArea, + _scriptColumn, + _trash, + _hint; + std::map _nodeItems; + QList _rootItems; + + QPointer _textComp, + _imageComp, + _textInputComp, + _checkBoxComp, + _rectComp; + + QAbstractItemModel * _columnsModel = nullptr; + + // drag state + QPointer _draggedItem; + ScriptNode * _draggedNewNode = nullptr; + QPointF _dragOffset; + QPointer _hoveredSpot; + bool _dragIsNew = false; + + bool _somethingChanged = false, + _lastCheckPassed = true, + _showGeneratedRCode = false, + _chromeBuilt = false; + QString _lastAppliedJson; +}; + +#endif // SCRIPTCONSTRUCTORVIEW_H diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp new file mode 100644 index 0000000000..f2f6f5dee7 --- /dev/null +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -0,0 +1,585 @@ +#include "scriptnodeitem.h" +#include "scriptconstructorview.h" +#include "jasptheme.h" +#include "qutils.h" +#include +#include +#include +#include +#include + +// ===================================================================================== +// ScriptDropSpot +// ===================================================================================== + +ScriptDropSpot::ScriptDropSpot(ScriptConstructorView * view, QQuickItem * parent) + : QQuickItem(parent) + , _view(view) +{ + setImplicitWidth(_view ? _view->blockDim() * 3 : 60); + setImplicitHeight(_view ? _view->blockDim() : 20); +} + +void ScriptDropSpot::setTarget(const DropTarget & target) +{ + _target = target; +} + +QQuickItem * ScriptDropSpot::ensurePlaceholder() +{ + if(_placeholder) + return _placeholder; + + _placeholder = _view->newLeaf(_view->textComponent()); + if(_placeholder) + { + _placeholder->setParentItem(this); + _placeholder->setProperty("verticalAlignment", 0); // Text.AlignVCenter? set below via anchors + } + return _placeholder; +} + +QQuickItem * ScriptDropSpot::ensureMarker() +{ + if(_marker) + return _marker; + + _marker = _view->newLeaf(_view->rectangleComponent()); + if(_marker) + { + _marker->setParentItem(this); + _marker->setZ(-3); + _marker->setProperty("color", QColor("transparent")); + _marker->setProperty("radius", 4.0); + _marker->setProperty("border.width", 2.0); + _marker->setProperty("border.color", JaspTheme::currentTheme()->blue()); + _marker->setVisible(false); + } + return _marker; +} + +void ScriptDropSpot::setDefaultText(const QString & text) +{ + _defaultText = text; + if(_placeholder) + _placeholder->setProperty("text", _defaultText); +} + +void ScriptDropSpot::setAcceptsDrops(bool accepts) +{ + _acceptsDrops = accepts; +} + +void ScriptDropSpot::setFilledItem(ScriptNodeItem * item) +{ + _filled = item; + if(item) + { + item->setParentItem(this); + item->setX(0); + item->setY(0); + if(_placeholder) _placeholder->setVisible(false); + setImplicitWidth(item->preferredWidth()); + setImplicitHeight(item->preferredHeight()); + } +} + +void ScriptDropSpot::clearFilled() +{ + _filled = nullptr; + if(_placeholder) _placeholder->setVisible(_acceptsDrops); + setImplicitWidth(_view ? _view->blockDim() * 3 : 60); + setImplicitHeight(_view ? _view->blockDim() : 20); +} + +void ScriptDropSpot::setHoverState(bool hovered, bool accepted) +{ + QQuickItem * m = ensureMarker(); + if(!m) return; + + m->setVisible(hovered); + if(hovered) + { + JaspTheme * theme = JaspTheme::currentTheme(); + m->setProperty("border.color", accepted ? theme->green() : theme->red()); + m->setWidth(width()); + m->setHeight(height()); + } +} + +void ScriptDropSpot::setError(bool error) +{ + QQuickItem * m = ensureMarker(); + if(!m) return; + + if(error) + { + m->setVisible(true); + m->setProperty("border.color", QColor("#BB0000")); + m->setWidth(width()); + m->setHeight(height()); + } + else if(!_filled) + m->setVisible(false); +} + +void ScriptDropSpot::layout() +{ + if(_marker) + { + _marker->setWidth(width()); + _marker->setHeight(height()); + } + + if(_filled) + { + _filled->layout(); + setImplicitWidth(_filled->preferredWidth()); + setImplicitHeight(_filled->preferredHeight()); + } + else if(_placeholder) + { + _placeholder->setProperty("text", _defaultText); + qreal w = _placeholder->property("implicitWidth").toReal(); + qreal minW = _acceptsDrops && _view ? _view->blockDim() * 3 : 0; + setImplicitWidth(std::max(w, minW)); + setImplicitHeight(_view ? _view->blockDim() : 20); + } +} + +// ===================================================================================== +// ScriptNodeItem +// ===================================================================================== + +ScriptNodeItem::ScriptNodeItem(ScriptConstructorView * view, ScriptNode * node, QQuickItem * parent) + : QQuickItem(parent) + , _view(view) + , _node(node) +{ + setAcceptedMouseButtons(Qt::LeftButton | Qt::RightButton); +} + +ScriptNodeItem::~ScriptNodeItem() +{ + clearLeaves(); +} + +void ScriptNodeItem::clearLeaves() +{ + for(QQuickItem * leaf : _leaves) + if(leaf) + leaf->deleteLater(); + _leaves.clear(); + + for(ScriptDropSpot * spot : _dropSpots) + if(spot) + spot->deleteLater(); + _dropSpots.clear(); +} + +QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) +{ + QQuickItem * item = _view->newLeaf(_view->textComponent()); + if(!item) return nullptr; + + item->setParentItem(this); + item->setProperty("text", text); + + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + f.setBold(bold); + item->setProperty("font", f); + item->setProperty("color", theme->textEnabled()); + + addLeaf(item); + return item; +} + +QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) +{ + QQuickItem * item = _view->newLeaf(_view->imageComponent()); + if(!item) return nullptr; + + item->setParentItem(this); + item->setProperty("source", JaspTheme::currentTheme()->iconPath() + "/" + iconFile); + item->setProperty("fillMode", 1); // Image.PreserveAspectFit + + qreal dim = _view->blockDim(); + item->setWidth(dim); + item->setHeight(dim); + + addLeaf(item); + return item; +} + +ScriptDropSpot * ScriptNodeItem::makeDropSpot(const DropTarget & target, const QString & placeholder) +{ + ScriptDropSpot * spot = new ScriptDropSpot(_view, this); + spot->setTarget(target); + spot->setDefaultText(placeholder); + spot->setAcceptsDrops(_acceptsDrops); + _dropSpots.append(spot); + return spot; +} + +void ScriptNodeItem::addLeaf(QQuickItem * leaf) +{ + _leaves.append(leaf); +} + +qreal ScriptNodeItem::textWidth(QQuickItem * textItem) const +{ + if(!textItem) return 0; + return textItem->property("implicitWidth").toReal(); +} + +void ScriptNodeItem::setAcceptsDrops(bool accepts) +{ + _acceptsDrops = accepts; + for(ScriptDropSpot * spot : _dropSpots) + spot->setAcceptsDrops(accepts); +} + +void ScriptNodeItem::setNested(bool nested) +{ + _nested = nested; +} + +bool ScriptNodeItem::shouldDrag(qreal x, qreal) const +{ + // For columns the icon (leftmost blockDim) is a click target for changing the type, not a drag handle. + if(_node && _node->type() == ScriptNode::Type::Column && _acceptsDrops) + return x >= _view->blockDim(); + + return true; +} + +void ScriptNodeItem::rebuild() +{ + clearLeaves(); + + if(!_node) return; + + JaspTheme * theme = JaspTheme::currentTheme(); + qreal block = _view->blockDim(); + + switch(_node->type()) + { + case ScriptNode::Type::Number: + { + auto * lit = static_cast(_node); + QQuickItem * input = _view->newLeaf(_view->textInputComponent()); + if(input) + { + input->setParentItem(this); + input->setProperty("text", QString::number(lit->numberValue())); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + input->setProperty("font", f); + input->setProperty("color", theme->textEnabled()); + addLeaf(input); + connect(input, SIGNAL(editingFinished()), this, SLOT(onLiteralEditFinished())); + } + break; + } + case ScriptNode::Type::String: + { + auto * lit = static_cast(_node); + QQuickItem * input = _view->newLeaf(_view->textInputComponent()); + if(input) + { + input->setParentItem(this); + input->setProperty("text", QString::fromStdString(lit->stringValue())); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + input->setProperty("font", f); + input->setProperty("color", theme->textEnabled()); + addLeaf(input); + connect(input, SIGNAL(editingFinished()), this, SLOT(onLiteralEditFinished())); + } + break; + } + case ScriptNode::Type::Boolean: + { + auto * lit = static_cast(_node); + QQuickItem * box = _view->newLeaf(_view->checkBoxComponent()); + if(box) + { + box->setParentItem(this); + box->setProperty("checked", lit->boolValue()); + addLeaf(box); + connect(box, SIGNAL(toggled()), this, SLOT(onBooleanToggled())); + } + break; + } + case ScriptNode::Type::Column: + { + auto * col = static_cast(_node); + + int actual = 1; + if(_view->model()->columnTypeProvider()) + actual = _view->model()->columnTypeProvider()->columnType(col->columnName()); + int effective = col->effectiveColumnType(actual); + + makeImage(getIconFilename(static_cast(effective), varIconType::DefaultIconType)); + makeText(QString::fromStdString(col->columnName())); + break; + } + case ScriptNode::Type::Operator: + case ScriptNode::Type::OperatorVertical: + { + auto * op = static_cast(_node); + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op->op()); + + makeDropSpot(DropTarget{op->isVertical() ? DropTarget::Kind::OperatorLeft : DropTarget::Kind::OperatorLeft, op, 0, op->dropKeysLeft()}, "..."); + + if(def && !def->image.empty()) + makeImage(tq(def->image)); + else + makeText(QString::fromStdString(op->op()), true); + + makeDropSpot(DropTarget{DropTarget::Kind::OperatorRight, op, 1, op->dropKeysRight()}, "..."); + break; + } + case ScriptNode::Type::Function: + { + auto * func = static_cast(_node); + makeText(QString::fromStdString(func->functionName())); + + for(int i = 0; i < func->childCount(); i++) + { + const auto & arg = func->arguments()[i]; + makeDropSpot(DropTarget{DropTarget::Kind::FunctionArg, func, i, arg.dropKeys}, QString::fromStdString(arg.name)); + } + break; + } + case ScriptNode::Type::RowFunction: + { + auto * rowFunc = static_cast(_node); + makeText(QString::fromStdString(rowFunc->functionName())); + + for(int i = 0; i < rowFunc->childCount(); i++) + makeDropSpot(DropTarget{DropTarget::Kind::RowFunctionArg, rowFunc, i, {"number"}}, "..."); + break; + } + } + + layout(); +} + +void ScriptNodeItem::layout() +{ + if(!_node) return; + + qreal block = _view->blockDim(); + qreal spacing = _view->spacing(); + qreal x = 0, maxH = block; + + // Lay out leaves and drop spots left-to-right in creation order. + auto placeNext = [&](QQuickItem * item) + { + if(!item) return; + qreal w = item->property("implicitWidth").toReal(); + qreal h = item->property("implicitHeight").toReal(); + if(w <= 0) w = item->width(); + if(h <= 0) h = item->height(); + if(h <= 0) h = block; + + item->setX(x); + item->setY((maxH > h ? (maxH - h) / 2 : 0)); + x += w + spacing; + maxH = std::max(maxH, h); + }; + + // Interleave leaves and drop spots: for operators/function the drop spots were created + // between leaves. We simply walk both lists by their visual order stored during rebuild. + // To keep it simple we lay out leaves first that precede drops; the rebuild order is + // preserved by construction (leaves and spots appended in visual order is not guaranteed), + // so we reconstruct order by type. + + // For a robust visual order we re-derive from the node structure: + ScriptNode::Type t = _node->type(); + + if(t == ScriptNode::Type::Operator || t == ScriptNode::Type::OperatorVertical) + { + auto * op = static_cast(_node); + ScriptDropSpot * left = _dropSpots.size() > 0 ? _dropSpots[0] : nullptr; + ScriptDropSpot * right = _dropSpots.size() > 1 ? _dropSpots[1] : nullptr; + + if(left) { left->layout(); placeNext(left); } + + QQuickItem * opVisual = _leaves.isEmpty() ? nullptr : _leaves.first(); + if(opVisual) + { + qreal w = opVisual->width() > 0 ? opVisual->width() : opVisual->property("implicitWidth").toReal(); + qreal h = opVisual->height() > 0 ? opVisual->height() : block; + opVisual->setX(x); + opVisual->setY((maxH > h ? (maxH - h) / 2 : 0)); + x += w + spacing; + maxH = std::max(maxH, h); + } + + if(right) { right->layout(); placeNext(right); } + (void)op; + } + else if(t == ScriptNode::Type::Function || t == ScriptNode::Type::RowFunction) + { + QQuickItem * nameVisual = _leaves.isEmpty() ? nullptr : _leaves.first(); + if(nameVisual) + { + qreal w = nameVisual->property("implicitWidth").toReal(); + qreal h = block; + nameVisual->setX(x); + nameVisual->setY(0); + x += w; + } + + // opening paren + x += 2; + + for(int i = 0; i < _dropSpots.size(); i++) + { + ScriptDropSpot * spot = _dropSpots[i]; + spot->layout(); + placeNext(spot); + } + + x += 2; // closing paren + } + else + { + // Leaves (column, number, string, boolean) + for(QQuickItem * leaf : _leaves) + { + if(!leaf) continue; + qreal w = leaf->property("implicitWidth").toReal(); + if(w <= 0) w = leaf->width(); + qreal h = leaf->property("implicitHeight").toReal(); + if(h <= 0) h = leaf->height(); + if(h <= 0) h = block; + + leaf->setX(x); + leaf->setY((maxH > h ? (maxH - h) / 2 : 0)); + x += w + spacing; + maxH = std::max(maxH, h); + } + } + + _preferredWidth = x > 0 ? x - spacing : 0; + _preferredHeight = maxH; + setImplicitWidth(_preferredWidth); + setImplicitHeight(_preferredHeight); + setWidth(_preferredWidth); + setHeight(_preferredHeight); +} + +// ------------------------------------------------------------------------------------- +// Mouse handling -> forwards to the view's drag orchestration +// ------------------------------------------------------------------------------------- + +void ScriptNodeItem::mousePressEvent(QMouseEvent * event) +{ + if(!_acceptsDrops) + { + // Palette / operator-bar prototype: spawn a fresh draggable node. + if(event->button() == Qt::LeftButton) + { + grabMouse(); + _view->spawnFromPrototype(_node, event->scenePosition()); + event->accept(); + } + else + event->ignore(); + return; + } + + if(event->button() == Qt::RightButton) + { + // Right-click deletes the node (matches old DragGeneric behaviour). + _view->model()->removeNode(_node); + _view->nodeEdited(); + event->accept(); + return; + } + + if(!shouldDrag(event->position().x(), event->position().y())) + { + // Clicking a column's icon cycles its requested type (scale -> ordinal -> nominal -> scale). + if(_node && _node->type() == ScriptNode::Type::Column) + { + auto * col = static_cast(_node); + int cur = col->columnTypeUser(); + int next = (cur < 1 || cur >= 3) ? 1 : cur + 1; + + _view->model()->setColumnTypeUser(col, next); + _view->nodeEdited(); + rebuild(); + event->accept(); + return; + } + + event->ignore(); + return; + } + + grabMouse(); + _view->startDragExisting(this, event->scenePosition()); + event->accept(); +} + +void ScriptNodeItem::mouseMoveEvent(QMouseEvent * event) +{ + if(_view) + _view->dragMove(event->scenePosition()); + event->accept(); +} + +void ScriptNodeItem::mouseReleaseEvent(QMouseEvent * event) +{ + ungrabMouse(); + if(_view) + _view->endDrag(event->scenePosition()); + event->accept(); +} + +void ScriptNodeItem::mouseDoubleClickEvent(QMouseEvent * event) +{ + event->accept(); +} + +void ScriptNodeItem::onLiteralEditFinished() +{ + QQuickItem * input = qobject_cast(sender()); + if(!input || !_node) return; + + QString text = input->property("text").toString(); + + if(_node->type() == ScriptNode::Type::Number) + { + bool ok = false; + double value = text.toDouble(&ok); + + if(ok) + _view->model()->setLiteralNumber(static_cast(_node), value); + else + input->setProperty("text", QString::number(static_cast(_node)->numberValue())); + } + else if(_node->type() == ScriptNode::Type::String) + { + if(!text.isEmpty()) + _view->model()->setLiteralString(static_cast(_node), fq(text)); + } + + _view->nodeEdited(); +} + +void ScriptNodeItem::onBooleanToggled() +{ + QQuickItem * box = qobject_cast(sender()); + if(!box || !_node || _node->type() != ScriptNode::Type::Boolean) return; + + bool checked = box->property("checked").toBool(); + _view->model()->setLiteralBool(static_cast(_node), checked); + _view->nodeEdited(); +} diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h new file mode 100644 index 0000000000..c7ebf4d89f --- /dev/null +++ b/Desktop/qquick/scriptnodeitem.h @@ -0,0 +1,106 @@ +#ifndef SCRIPTNODEITEM_H +#define SCRIPTNODEITEM_H + +#include +#include +#include "scriptconstructormodel.h" + +class ScriptConstructorView; +class ScriptNodeItem; +class QQmlComponent; + +/// +/// A single drop slot in the constructor. Visually a rounded placeholder that can hold one +/// ScriptNodeItem. All drop-validation logic lives in the model; this class only renders state. +class ScriptDropSpot : public QQuickItem +{ + Q_OBJECT + +public: + explicit ScriptDropSpot(ScriptConstructorView * view, QQuickItem * parent = nullptr); + + void setTarget(const DropTarget & target); + const DropTarget & target() const { return _target; } + + void setFilledItem(ScriptNodeItem * item); + ScriptNodeItem * filledItem() const { return _filled; } + void clearFilled(); + + void setHoverState(bool hovered, bool accepted); + void setError(bool error); + void setAcceptsDrops(bool accepts); + void setDefaultText(const QString & text); + + void layout(); + +private: + QQuickItem * ensurePlaceholder(); + QQuickItem * ensureMarker(); + + ScriptConstructorView * _view = nullptr; + DropTarget _target; + QPointer _filled; + QPointer _placeholder; + QPointer _marker; + QString _defaultText = "..."; + bool _acceptsDrops = true; +}; + +/// +/// Visual representation of a single ScriptNode. Creates incubated QML leaves (Text, Image, +/// TextInput, CheckBox) for its content plus ScriptDropSpot children for its slots, and lays +/// them out. Contains no formula logic: it only renders and forwards gestures to the view. +class ScriptNodeItem : public QQuickItem +{ + Q_OBJECT + +public: + explicit ScriptNodeItem(ScriptConstructorView * view, ScriptNode * node, QQuickItem * parent = nullptr); + ~ScriptNodeItem() override; + + ScriptNode * node() const { return _node; } + + void rebuild(); + void layout(); + + qreal preferredWidth() const { return _preferredWidth; } + qreal preferredHeight() const { return _preferredHeight; } + + bool shouldDrag(qreal x, qreal y) const; + QList dropSpots() const { return _dropSpots; } + + void setAcceptsDrops(bool accepts); + bool acceptsDrops() const { return _acceptsDrops; } + + void setNested(bool nested); + +protected: + void mousePressEvent(QMouseEvent * event) override; + void mouseMoveEvent(QMouseEvent * event) override; + void mouseReleaseEvent(QMouseEvent * event) override; + void mouseDoubleClickEvent(QMouseEvent * event) override; + +private slots: + void onLiteralEditFinished(); + void onBooleanToggled(); + +private: + QQuickItem * makeText(const QString & text, bool bold = false); + QQuickItem * makeImage(const QString & iconFile); + ScriptDropSpot* makeDropSpot(const DropTarget & target, const QString & placeholder); + void clearLeaves(); + void addLeaf(QQuickItem * leaf); + + qreal textWidth(QQuickItem * textItem) const; + + ScriptConstructorView * _view = nullptr; + ScriptNode * _node = nullptr; + QList _leaves; + QList _dropSpots; + qreal _preferredWidth = 0, + _preferredHeight = 0; + bool _acceptsDrops = true, + _nested = false; +}; + +#endif // SCRIPTNODEITEM_H diff --git a/Tests/testall.cpp b/Tests/testall.cpp index a176cc5e82..b8cc0d309e 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -20,10 +20,15 @@ #include "workspace.h" #include "undostack.h" #include "data/asyncloader.h" +#include "scriptconstructormodel.h" +#include "scriptnode.h" +#include "scriptconstructorregistry.h" #include #include #include +#include +#include #include #include "data/asyncloader.h" @@ -1356,5 +1361,379 @@ bool TestAll::_checkDoSyncFake() return true; } +// ===================================================================================== +// ScriptConstructor regression tests +// ===================================================================================== + +namespace +{ + struct FixedColumnTypeProvider : public ScriptColumnTypeProvider + { + strintmap types; + int columnType(const std::string & name) const override + { + auto it = types.find(name); + return it == types.end() ? 1 : it->second; + } + }; + + Json::Value colNode(const std::string & name, int typeUser = -1, int typeDrop = -1) + { + Json::Value v; + v["nodeType"] = "Column"; + v["columnName"] = name; + v["columnTypeUser"] = typeUser; + v["columnTypeDrop"] = typeDrop; + return v; + } + + Json::Value numNode(double val) + { + Json::Value v; + v["nodeType"] = "Number"; + v["value"] = val; + return v; + } + + Json::Value boolNode(bool val) + { + Json::Value v; + v["nodeType"] = "Boolean"; + v["value"] = val ? "TRUE" : "FALSE"; + return v; + } + + Json::Value strNode(const std::string & text) + { + Json::Value v; + v["nodeType"] = "String"; + v["text"] = text; + return v; + } + + Json::Value opNode(const std::string & op, const Json::Value & left, const Json::Value & right, bool vertical = false) + { + Json::Value v; + v["nodeType"] = vertical ? "OperatorVertical" : "Operator"; + v["operator"] = op; + v["leftArgument"] = left; + v["rightArgument"] = right; + return v; + } + + Json::Value funcArg(const std::string & name, const stringvec & keys, const Json::Value & argument) + { + Json::Value a; + a["name"] = name; + a["dropKeys"] = Json::arrayValue; + for(const std::string & k : keys) + a["dropKeys"].append(k); + a["argument"] = argument; + return a; + } + + Json::Value funcNode(const std::string & name, std::initializer_list args) + { + Json::Value v; + v["nodeType"] = "Function"; + v["functionName"] = name; + v["arguments"] = Json::arrayValue; + for(const Json::Value & a : args) + v["arguments"].append(a); + return v; + } + + Json::Value rowFuncNode(const std::string & name, std::initializer_list droppedJsonStrings) + { + Json::Value v; + v["nodeType"] = "RowFunction"; + v["functionName"] = name; + v["droppedItems"] = Json::arrayValue; + for(const std::string & s : droppedJsonStrings) + v["droppedItems"].append(s); + return v; + } + + Json::Value formulas(std::initializer_list nodes) + { + Json::Value v; + v["formulas"] = Json::arrayValue; + for(const Json::Value & n : nodes) + v["formulas"].append(n); + return v; + } + + std::string compact(const Json::Value & v) + { + Json::StreamWriterBuilder b; + b["indentation"] = ""; + std::string s = Json::writeString(b, v); + while(!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) + s.pop_back(); + return s; + } +} + +void TestAll::testScriptConstructorDefaultFilterJson() +{ + QVERIFY(_newPkgWithDataSet()); + + ScriptConstructorModel model; + model.fromJson(std::string(DEFAULT_FILTER_JSON)); + + QCOMPARE(model.formulaCount(), 0); + QCOMPARE(model.toString(), std::string(DEFAULT_FILTER_JSON)); + QVERIFY(model.checkCompleteness()); +} + +void TestAll::testScriptConstructorGoldenR() +{ + QVERIFY(_newPkgWithDataSet()); + + FixedColumnTypeProvider provider; + provider.types["contNormal"] = 1; // scale + provider.types["contBinom"] = 1; + provider.types["group"] = 3; // nominal + provider.types["ord"] = 2; // ordinal + + ScriptConstructorModel model; + model.setColumnTypeProvider(&provider); + model.setMode(ScriptConstructorMode::Filter); + + auto checkR = [&](const Json::Value & tree, const std::string & expected) + { + model.fromJson(tree); + QCOMPARE(model.toR(), expected); + }; + + // Column resolves through the provider (no user/drop override) -> ".scale" + checkR(formulas({colNode("contNormal")}), "contNormal.scale\n"); + + // Arithmetic operator with a literal + checkR(formulas({opNode("+", colNode("contNormal"), numNode(5))}), "(contNormal.scale + 5)\n"); + + // Empty right slot becomes "null" + checkR(formulas({opNode("+", colNode("contNormal"), Json::nullValue)}), "(contNormal.scale + null)\n"); + + // mean() gains ", na.rm=TRUE" + checkR(formulas({funcNode("mean", {funcArg("values", {"number"}, colNode("contNormal"))})}), "mean(contNormal.scale, na.rm=TRUE)\n"); + + // abs() has no na.rm + checkR(formulas({funcNode("abs", {funcArg("values", {"number"}, colNode("contNormal"))})}), "abs(contNormal.scale)\n"); + + // Empty function argument becomes "NULL" + checkR(formulas({funcNode("round", {funcArg("y", {"number"}, colNode("contNormal")), funcArg("n", {"number"}, Json::nullValue)})}), "round(contNormal.scale, NULL)\n"); + + // ifelse with boolean + numbers + checkR(formulas({funcNode("ifelse", {funcArg("test", {"boolean"}, boolNode(true)), funcArg("then", {"boolean","string","number"}, numNode(1)), funcArg("else", {"boolean","string","number"}, numNode(2))})}), "ifelse(TRUE, 1, 2)\n"); + + // String literal is single-quoted + checkR(formulas({strNode("hello")}), "'hello'\n"); + + // Nested boolean expression + checkR(formulas({opNode("&", opNode(">", colNode("contNormal"), numNode(0)), opNode("<", colNode("contBinom"), numNode(10)))}), "((contNormal.scale > 0) & (contBinom.scale < 10))\n"); + + // Vertical (fraction) operator serialises differently but generates the same R shape + checkR(formulas({opNode("/", colNode("contNormal"), numNode(2), true)}), "(contNormal.scale / 2)\n"); + + // RowFunction only emits filled entries and appends NaRm + { + Json::StreamWriterBuilder b; b["indentation"] = ""; + std::string aJson = compact(colNode("contNormal")); + std::string bJson = compact(colNode("contBinom")); + checkR(formulas({rowFuncNode("rowMean", {aJson, "null", bJson})}), "rowMeanNaRm(contNormal.scale, contBinom.scale)\n"); + } + + // Column with an explicit user type override ignores the provider + checkR(formulas({colNode("group", 2)}), "group.ordinal\n"); + + // %|% conditional operator in filter mode + checkR(formulas({opNode("%|%", opNode(">", colNode("contNormal"), numNode(0)), colNode("group"))}), "((contNormal.scale > 0) %|% group.nominal)\n"); +} + +void TestAll::testScriptConstructorCompleteness() +{ + QVERIFY(_newPkgWithDataSet()); + + ScriptConstructorModel model; + model.setMode(ScriptConstructorMode::Filter); + + // Complete boolean formula passes both checks + model.fromJson(formulas({opNode(">", colNode("a"), numNode(0))})); + QVERIFY(model.checkCompleteness()); + QVERIFY(model.allBoolean()); + + // Missing right operand -> incomplete + model.fromJson(formulas({opNode(">", colNode("a"), Json::nullValue)})); + QVERIFY(!model.checkCompleteness()); + + // Arithmetic root is complete but not boolean -> cannot be a filter root + model.fromJson(formulas({opNode("+", colNode("a"), numNode(1))})); + QVERIFY(model.checkCompleteness()); + QVERIFY(!model.allBoolean()); + + // Optional ("?") parameters do not block completeness + { + Json::Value box = funcNode("BoxCoxAuto", { + funcArg("y", {"number"}, colNode("a")), + funcArg("?predictor", {"number"}, Json::nullValue), + funcArg("?groupSize", {"number"}, Json::nullValue), + funcArg("method", {"string"}, strNode("loglik")), + funcArg("lower", {"number"}, numNode(0)), + funcArg("upper", {"number"}, numNode(1)), + funcArg("shift", {"number"}, numNode(0)), + funcArg("continuityAdjustment", {"boolean"}, boolNode(true))}); + model.fromJson(formulas({box})); + QVERIFY(model.checkCompleteness()); + } + + // RowFunction is complete when at least one slot is filled + { + std::string aJson = compact(colNode("a")); + model.fromJson(formulas({rowFuncNode("rowSum", {"null", aJson})})); + QVERIFY(model.checkCompleteness()); + + model.fromJson(formulas({rowFuncNode("rowSum", {"null"})})); + QVERIFY(!model.checkCompleteness()); + } +} + +void TestAll::testScriptConstructorRoundTrip() +{ + QVERIFY(_newPkgWithDataSet()); + + // Deterministic pseudo-random trees: fromJson -> toString -> fromJson -> toString must be idempotent. + // This guarantees that loading a stored constructor JSON and saving it again never loses or reorders + // information, which is what .jasp file round-trips rely on. + + const std::vector columnNames = {"contNormal", "contBinom", "group", "ord", "text"}; + const std::vector operators = {"+", "-", "*", "/", "^", "%%", "==", "!=", "<", "<=", ">", ">=", "&", "|", "%|%"}; + const std::vector functions = {"abs", "sd", "var", "sum", "prod", "zScores", "min", "max", "mean", "sign", "round", "length", "median", "ifelse", "hasSubstring", "is.na", "log", "exp", "BoxCox", "cut", "replaceNA"}; + const std::vector rowFunctions = {"rowMean", "rowSum", "rowSD", "rowVariance", "rowMedian", "rowMin", "rowMax"}; + + std::function makeNode = [&](std::mt19937 & rng, int depth) -> Json::Value + { + std::uniform_int_distribution pick(0, 99); + std::uniform_int_distribution colPick(0, static_cast(columnNames.size()) - 1); + std::uniform_int_distribution opPick(0, static_cast(operators.size()) - 1); + std::uniform_int_distribution funcPick(0, static_cast(functions.size()) - 1); + std::uniform_int_distribution rowPick(0, static_cast(rowFunctions.size()) - 1); + std::uniform_real_distribution numDist(-100.0, 100.0); + + int kind = pick(rng); + + if(depth <= 0) + { + // Leaves only at max depth + int leaf = kind % 4; + if(leaf == 0) return colNode(columnNames[colPick(rng)]); + if(leaf == 1) return numNode(numDist(rng)); + if(leaf == 2) return boolNode(kind % 2 == 0); + return strNode("s" + std::to_string(kind)); + } + + if(kind < 30) + return colNode(columnNames[colPick(rng)]); + if(kind < 45) + return numNode(numDist(rng)); + if(kind < 52) + return boolNode(kind % 2 == 0); + if(kind < 58) + return strNode("s" + std::to_string(kind)); + if(kind < 78) + return opNode(operators[opPick(rng)], makeNode(rng, depth - 1), makeNode(rng, depth - 1)); + if(kind < 90) + { + const std::string & fn = functions[funcPick(rng)]; + const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(fn); + Json::Value args = Json::arrayValue; + if(def) + for(const ScriptParamDef & p : def->params) + { + bool fill = (pick(rng) % 10) < 7; + args.append(funcArg(p.optional ? "?" + p.name : p.name, p.dropKeys, fill ? makeNode(rng, depth - 1) : Json::nullValue)); + } + Json::Value v; + v["nodeType"] = "Function"; + v["functionName"] = fn; + v["arguments"] = args; + return v; + } + + // RowFunction with a couple of filled slots serialised as embedded JSON strings + int slotCount = 1 + (kind % 3); + std::vector dropped; + for(int i = 0; i < slotCount; i++) + dropped.push_back((pick(rng) % 10) < 6 ? compact(makeNode(rng, depth - 1)) : std::string("null")); + if(std::all_of(dropped.begin(), dropped.end(), [](const std::string & s){ return s == "null"; })) + dropped[0] = compact(colNode(columnNames[colPick(rng)])); + + Json::Value v; + v["nodeType"] = "RowFunction"; + v["functionName"] = rowFunctions[rowPick(rng)]; + v["droppedItems"] = Json::arrayValue; + for(const std::string & s : dropped) + v["droppedItems"].append(s); + return v; + }; + + ScriptConstructorModel model; + + for(int seed = 0; seed < 300; seed++) + { + std::mt19937 rng(seed); + std::uniform_int_distribution formulaCount(0, 3); + + Json::Value tree; + tree["formulas"] = Json::arrayValue; + int n = formulaCount(rng); + for(int i = 0; i < n; i++) + tree["formulas"].append(makeNode(rng, 3)); + + model.fromJson(tree); + std::string first = model.toString(); + + model.fromJson(first); + std::string second = model.toString(); + + if(first != second) + QFAIL(("Round-trip not idempotent for seed " + std::to_string(seed) + ":\nfirst: " + first + "\nsecond: " + second).c_str()); + + // The re-parsed tree must also be parseable without throwing and keep the same formula count. + QCOMPARE(model.formulaCount(), n); + } +} + +void TestAll::testScriptConstructorUndo() +{ + QVERIFY(_newPkgWithDataSet()); + + ScriptConstructorModel model; + QUndoStack stack; + model.setUndoStack(&stack); + + QCOMPARE(model.formulaCount(), 0); + + // Insert a node -> one undo command + ScriptNode * node = new ScriptNodeOperator(">", false); + model.insertNode(node, DropTarget::root()); + QCOMPARE(model.formulaCount(), 1); + QCOMPARE(stack.count(), 1); + + // Undo removes it, redo brings it back + stack.undo(); + QCOMPARE(model.formulaCount(), 0); + stack.redo(); + QCOMPARE(model.formulaCount(), 1); + + // Clear -> another command + model.clear(); + QCOMPARE(model.formulaCount(), 0); + QCOMPARE(stack.count(), 2); + + stack.undo(); + QCOMPARE(model.formulaCount(), 1); +} + QTEST_MAIN(TestAll) diff --git a/Tests/testall.h b/Tests/testall.h index 9886bd2c0a..9bd0d6d474 100644 --- a/Tests/testall.h +++ b/Tests/testall.h @@ -71,6 +71,15 @@ private slots: // and the workspace teardown paths). void testCloseWorkspaceAndDataSets(); + // ScriptConstructor (drag-and-drop filter / computed column model) regression tests. + // These replace the old QML FilterConstructor and must stay byte/behaviour compatible with the + // JSON stored in .jasp files and the R code that gets sent to the engine. + void testScriptConstructorRoundTrip(); + void testScriptConstructorGoldenR(); + void testScriptConstructorCompleteness(); + void testScriptConstructorUndo(); + void testScriptConstructorDefaultFilterJson(); + private: DataSetPackage * _pkg = nullptr; Importer * _importer = nullptr; From 242510e4a29e270d0779c7416f0937e71e37d93d Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 11:11:23 +0200 Subject: [PATCH 02/25] Fix ScriptConstructor view bugs found in review - Column palette now falls back to ColumnsModel::singleton() (the columnsModel Q_PROPERTY shadowed the QML context property, so the palette was empty). - Add ScriptPalette with mouse-wheel and drag-to-scroll for the column and function palettes. - Drop spots accept inline typing: clicking an empty slot focuses a TextInput that parses number -> string -> boolean on edit-finish. - rebuild() now recursively renders child nodes into filled drop spots, and drop spots/leaves get explicit sizes so they are visible/clickable. - View implements ScriptColumnTypeProvider, resolving real column types from the columns model for R generation (previously always fell back to scale); columnTypeDrop is resolved on drop and re-resolved when the user changes a column's type while it sits in a slot. - Drag/drop hardening: scene->local coordinate mapping, skip filled and own-subtree drop spots, prefer deepest spot, clean up the dragged item. - Add hint text area, error markers on incomplete slots, trash icon, and filterErrorMsg property. - Add headless QML smoke test (tst_scriptconstructor.qml) verifying the view instantiates and the JSON->R/apply flow works. --- CommonData/scriptconstructormodel.cpp | 67 ++++ CommonData/scriptconstructormodel.h | 3 +- .../components/JASP/Widgets/FilterWindow.qml | 2 +- Desktop/qquick/scriptconstructorview.cpp | 272 +++++++++++++--- Desktop/qquick/scriptconstructorview.h | 25 +- Desktop/qquick/scriptnodeitem.cpp | 291 ++++++++++++++++-- Desktop/qquick/scriptnodeitem.h | 39 +++ Tests/qmlTests/tst_scriptconstructor.qml | 70 +++++ Tests/testqml.cpp | 2 + 9 files changed, 701 insertions(+), 70 deletions(-) create mode 100644 Tests/qmlTests/tst_scriptconstructor.qml diff --git a/CommonData/scriptconstructormodel.cpp b/CommonData/scriptconstructormodel.cpp index 15d7f611e5..cfd765c5bf 100644 --- a/CommonData/scriptconstructormodel.cpp +++ b/CommonData/scriptconstructormodel.cpp @@ -230,10 +230,74 @@ void ScriptConstructorModel::placeAt(ScriptNode * node, const DropTarget & targe case DropTarget::Kind::None: break; } + + if(auto * col = dynamic_cast(node)) + resolveColumnTypeDrop(col, target.dropKeys); +} + +void ScriptConstructorModel::resolveColumnTypeDrop(ScriptNodeColumn * col, const stringvec & slotKeys) +{ + // Mirrors the old JASPColumn.qml dropHandler.onWasDroppedOn(): + // prefer the user-selected type if the slot accepts it, then the actual + // column type, then scale/ordinal/nominal in order. + col->setColumnTypeDrop(-1); + + if(slotKeys.empty()) + return; + + auto accepts = [&slotKeys](int colType) + { + return keysOverlap(ScriptConstructorRegistry::dropKeysForColumnType(colType), slotKeys); + }; + + int userType = col->columnTypeUser(); + if(userType != -1 && accepts(userType)) + { + col->setColumnTypeDrop(userType); + return; + } + + int actualType = _typeProvider ? _typeProvider->columnType(col->columnName()) : 1; + if(accepts(actualType)) + { + col->setColumnTypeDrop(actualType); + return; + } + + for(int t : {1, 2, 3}) // scale, ordinal, nominal + { + if(accepts(t)) + { + col->setColumnTypeDrop(t); + return; + } + } } // --- right-most empty / filled drop spot helpers --- +static stringvec containingSlotKeys(ScriptNode * node) +{ + ScriptNode * par = node ? node->parent() : nullptr; + if(!par) return {}; + + if(auto * op = dynamic_cast(par)) + return op->leftChild() == node ? op->dropKeysLeft() : op->dropKeysRight(); + + if(auto * func = dynamic_cast(par)) + { + for(int i = 0; i < func->childCount(); i++) + if(func->childAt(i) == node) + return func->arguments()[i].dropKeys; + return {}; + } + + if(dynamic_cast(par)) + return {"number"}; + + return {}; +} + static DropTarget makeSlotTarget(ScriptNode * parent, DropTarget::Kind kind, int index, const stringvec & keys) { DropTarget t; @@ -432,6 +496,9 @@ void ScriptConstructorModel::setColumnTypeUser(ScriptNodeColumn * node, int colu if(!node) return; beginEdit(); node->setColumnTypeUser(columnType); + // Re-resolve the drop-time type, like the old JASPColumn.qml did when the + // user clicked the type icon while the column was inside a drop spot. + resolveColumnTypeDrop(node, containingSlotKeys(node)); endEdit(tr("Change column type")); } diff --git a/CommonData/scriptconstructormodel.h b/CommonData/scriptconstructormodel.h index 79a26c0f1f..5515f55b6d 100644 --- a/CommonData/scriptconstructormodel.h +++ b/CommonData/scriptconstructormodel.h @@ -19,6 +19,7 @@ struct DropTarget ScriptNode * parent = nullptr; ///< Node owning the slot (nullptr for Root) int index = -1; ///< Formula index for Root, argument index for Function/RowFunction stringvec dropKeys; ///< Keys accepted at this spot + bool optional = false; ///< Empty spot does not fail completeness checks bool isValid() const { return kind != Kind::None; } bool isRoot() const { return kind == Kind::Root; } @@ -86,10 +87,10 @@ class ScriptConstructorModel : public QObject void deleteAllFormulas(); void detachFromParent(ScriptNode * node); void placeAt(ScriptNode * node, const DropTarget & target); + void resolveColumnTypeDrop(ScriptNodeColumn * col, const stringvec & slotKeys); ScriptNode * rootFormulaOf(ScriptNode * node) const; int rootIndexOf(ScriptNode * node) const; bool isAncestor(ScriptNode * ancestor, ScriptNode * descendant) const; - DropTarget resolveInsertionTarget(ScriptNode * node, DropTarget requested); bool tryGobbleLeft(ScriptNode * node); void beginEdit(); diff --git a/Desktop/components/JASP/Widgets/FilterWindow.qml b/Desktop/components/JASP/Widgets/FilterWindow.qml index abb3a06437..9eef92ca0e 100644 --- a/Desktop/components/JASP/Widgets/FilterWindow.qml +++ b/Desktop/components/JASP/Widgets/FilterWindow.qml @@ -172,8 +172,8 @@ FocusScope { id: easyFilterConstructor mode: ScriptConstructor.Filter - columnsModel: columnsModel constructorJson: filterModel.filter.constructorJson + filterErrorMsg: filterModel.filter.filterErrorMsg clip: true anchors diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index aa5bb56e99..ba6ddf3d72 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -2,6 +2,7 @@ #include "scriptnodeitem.h" #include "jasptheme.h" #include "qutils.h" +#include "data/columnsmodel.h" #include #include @@ -17,6 +18,9 @@ ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) setFlag(QQuickItem::ItemHasContents, true); setClip(true); + // The view resolves actual column types from the columns model for R generation. + _model.setColumnTypeProvider(this); + connect(&_model, &ScriptConstructorModel::reset, this, [this](){ rebuildFormulaItems(); }); connect(&_model, &ScriptConstructorModel::changed, this, [this](){ setSomethingChanged(true); @@ -55,6 +59,7 @@ void ScriptConstructorView::setConstructorJson(const QString & json) _model.fromJson(s); _lastAppliedJson = tq(_model.toString()); + setSomethingChanged(false); emit constructorJsonChanged(); } @@ -99,6 +104,44 @@ void ScriptConstructorView::setColumnsModel(QAbstractItemModel * m) buildColumnPalette(); } +int ScriptConstructorView::columnType(const std::string & columnName) const +{ + QAbstractItemModel * model = _columnsModel ? _columnsModel : ColumnsModel::singleton(); + if(!model) + return 1; // scale + + int nameRole = static_cast(model->roleNames().key("columnName")); + int typeRole = static_cast(model->roleNames().key("columnType")); + + QString wanted = tq(columnName); + for(int r = 0; r < model->rowCount(); r++) + { + QModelIndex idx = model->index(r, 0); + if(model->data(idx, nameRole).toString() == wanted) + { + int t = model->data(idx, typeRole).toInt(); + return t > 0 ? t : 1; + } + } + + return 1; // scale +} + +void ScriptConstructorView::setFilterErrorMsg(const QString & msg) +{ + if(msg == _filterErrorMsg) return; + _filterErrorMsg = msg; + emit filterErrorMsgChanged(); + refreshHint(); +} + +void ScriptConstructorView::setHintText(const QString & text) +{ + if(text == _hintText) return; + _hintText = text; + refreshHint(); +} + // ------------------------------------------------------------------------------------- // QML-callable API // ------------------------------------------------------------------------------------- @@ -133,19 +176,39 @@ bool ScriptConstructorView::checkAndApply() _lastCheckPassed = complete && booleanOk && oneFormula; emit lastCheckPassedChanged(); + // Mark empty required drop spots in red after a failed check (mirrors old iWasChecked behaviour). + for(auto & pair : _nodeItems) + { + if(!pair.second) continue; + for(ScriptDropSpot * spot : pair.second->dropSpots()) + spot->setError(!complete && !spot->target().optional && spot->filledItem() == nullptr); + } + + QString hint; + if(complete && booleanOk && oneFormula) + hint = _model.formulaCount() == 0 ? tr("Filter cleared\n") : (isFilter ? tr("Filter applied\n") : tr("Computed columns code applied")); + if(!complete) + hint += tr("Please enter all arguments - see fields marked in red.\n"); + if(!booleanOk) + hint += (!complete ? "\n" : QString()) + tr("Formula does not return a set of logical values, and therefore cannot be used in the filter.\n"); + if(!oneFormula) + hint += (!complete ? "
" : QString()) + tr("Only one formula per computed column allowed."); + setHintText(hint.trimmed()); + if(_lastCheckPassed) { _lastAppliedJson = tq(_model.toString()); emit applyRequested(constructorJson(), rCode()); } - refreshHint(); return _lastCheckPassed; } void ScriptConstructorView::nodeEdited() { setSomethingChanged(true); + _hintText = ""; + refreshHint(); emit rCodeChanged(rCode()); } @@ -252,6 +315,20 @@ void ScriptConstructorView::componentComplete() _chromeBuilt = true; } + // If no columns model was bound from QML (e.g. the property name shadows the + // `columnsModel` context property), fall back to the ColumnsModel singleton and + // keep the palette in sync with dataset changes. + if(!_columnsModel) + { + if(ColumnsModel * singleton = ColumnsModel::singleton()) + { + connect(singleton, &QAbstractItemModel::modelReset, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + connect(singleton, &QAbstractItemModel::rowsInserted, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + connect(singleton, &QAbstractItemModel::rowsRemoved, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + } + buildColumnPalette(); + } + rebuildFormulaItems(); } @@ -271,10 +348,10 @@ void ScriptConstructorView::buildChrome() _operatorBar->setParentItem(this); _operatorBar->setZ(3); - _columnPalette = new QQuickItem(this); + _columnPalette = new ScriptPalette(this); _columnPalette->setParentItem(this); - _functionPalette = new QQuickItem(this); + _functionPalette = new ScriptPalette(this); _functionPalette->setParentItem(this); _scriptArea = new QQuickItem(this); @@ -292,11 +369,39 @@ void ScriptConstructorView::buildChrome() _trash->setProperty("border.width", 1); _trash->setProperty("radius", 6.0); _trash->setZ(10); + + // Trash icon centred inside the drop zone. + QQuickItem * icon = newLeaf(imageComponent()); + if(icon) + { + icon->setParentItem(_trash); + icon->setProperty("source", (theme ? theme->iconPath() : QString()) + "/trashcan.png"); + icon->setProperty("fillMode", 1); // Image.PreserveAspectFit + qreal dim = blockDim() * 1.6; + icon->setWidth(dim); + icon->setHeight(dim); + icon->setX((blockDim() * 3 - dim) / 2); + icon->setY((blockDim() * 3 - dim) / 2); + } + } + + _hint = newLeaf(textComponent()); + if(_hint) + { + _hint->setParentItem(this); + _hint->setProperty("wrapMode", 4); // Text.WordWrap + _hint->setProperty("horizontalAlignment", 4); // Text.AlignHCenter + _hint->setProperty("color", theme ? theme->textEnabled() : QColor("black")); + QFont f = theme ? theme->font() : QFont(); + f.setPixelSize(static_cast(fontPixelSize())); + _hint->setProperty("font", f); + _hint->setZ(5); } buildOperatorBar(); buildColumnPalette(); buildFunctionPalette(); + refreshHint(); } ScriptNodeItem * ScriptConstructorView::makeNodeItem(ScriptNode * node, QQuickItem * parent) @@ -309,14 +414,30 @@ ScriptNodeItem * ScriptConstructorView::makeNodeItem(ScriptNode * node, QQuickIt void ScriptConstructorView::clearFormulaItems() { - for(auto & pair : _nodeItems) - if(pair.second) - pair.second->deleteLater(); + // Delete only root items; child node items are QQuickItem children of their + // parent node/drop-spot and are destroyed transitively. Deleting every entry in + // _nodeItems would double-free the children. + for(ScriptNodeItem * item : _rootItems) + if(item) + item->deleteLater(); _nodeItems.clear(); _rootItems.clear(); } +void ScriptConstructorView::_clearPaletteChildren(QQuickItem * palette) +{ + if(!palette) return; + for(QQuickItem * child : palette->childItems()) + { + // Palette prototype items own their ScriptNode prototype; free it too. + if(auto * ni = qobject_cast(child)) + if(ni->node()) + ni->node()->deleteLater(); + child->deleteLater(); + } +} + void ScriptConstructorView::rebuildFormulaItems() { if(!_chromeBuilt || !_scriptColumn) @@ -338,6 +459,7 @@ void ScriptConstructorView::layoutAll() qreal w = width(), h = height(); qreal barH = blockDim() * 1.75; qreal paletteW = blockDim() * 6; + qreal hintH = _hint ? fontPixelSize() + 2 * spacing() : 0; if(_background) { @@ -358,7 +480,7 @@ void ScriptConstructorView::layoutAll() _columnPalette->setX(0); _columnPalette->setY(barH); _columnPalette->setWidth(paletteW); - _columnPalette->setHeight(h - barH); + _columnPalette->setHeight(h - barH - hintH); } if(_functionPalette) @@ -366,7 +488,7 @@ void ScriptConstructorView::layoutAll() _functionPalette->setX(w - paletteW); _functionPalette->setY(barH); _functionPalette->setWidth(paletteW); - _functionPalette->setHeight(h - barH); + _functionPalette->setHeight(h - barH - hintH); } if(_scriptArea) @@ -374,7 +496,15 @@ void ScriptConstructorView::layoutAll() _scriptArea->setX(paletteW); _scriptArea->setY(barH); _scriptArea->setWidth(w - 2 * paletteW); - _scriptArea->setHeight(h - barH); + _scriptArea->setHeight(h - barH - hintH); + } + + if(_hint) + { + _hint->setX(paletteW); + _hint->setY(h - hintH); + _hint->setWidth(w - 2 * paletteW); + _hint->setHeight(hintH); } if(_trash) @@ -435,7 +565,28 @@ QSGNode * ScriptConstructorView::updatePaintNode(QSGNode * oldNode, UpdatePaintN void ScriptConstructorView::refreshHint() { - // Hint text rendering is handled by the surrounding window (as before); kept as a hook. + if(!_hint) return; + + JaspTheme * theme = JaspTheme::currentTheme(); + + if(!_filterErrorMsg.isEmpty()) + { + _hint->setProperty("text", _filterErrorMsg); + _hint->setProperty("color", theme ? theme->redDarker() : QColor("darkred")); + } + else + { + QString text = _hintText.isEmpty() ? defaultHintText() : _hintText; + _hint->setProperty("text", text); + _hint->setProperty("color", theme ? theme->textEnabled() : QColor("black")); + } +} + +QString ScriptConstructorView::defaultHintText() const +{ + return _model.mode() == ScriptConstructorMode::Filter + ? tr("Welcome to the drag and drop filter!") + : tr("Welcome to the drag and drop computed column constructor!"); } // ===================================================================================== @@ -466,11 +617,14 @@ void ScriptConstructorView::buildFunctionPalette() { if(!_functionPalette) return; + QQuickItem * content = _functionPalette->content(); + _clearPaletteChildren(content); + qreal y = spacing(); for(const ScriptFunctionDef & def : ScriptConstructorRegistry::instance().functionsForMode(_model.mode())) { ScriptNode * proto = new ScriptNodeFunction(def.name); - ScriptNodeItem * item = new ScriptNodeItem(this, proto, _functionPalette); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, content); item->setAcceptsDrops(false); item->rebuild(); item->setX(spacing()); @@ -481,7 +635,7 @@ void ScriptConstructorView::buildFunctionPalette() for(const ScriptFunctionDef & def : ScriptConstructorRegistry::instance().rowFunctions()) { ScriptNode * proto = new ScriptNodeRowFunction(def.name); - ScriptNodeItem * item = new ScriptNodeItem(this, proto, _functionPalette); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, content); item->setAcceptsDrops(false); item->rebuild(); item->setX(spacing()); @@ -489,35 +643,37 @@ void ScriptConstructorView::buildFunctionPalette() y += item->preferredHeight() + spacing(); } - _functionPalette->setWidth(blockDim() * 6); - _functionPalette->setHeight(y); + _functionPalette->setContentHeight(y); } void ScriptConstructorView::buildColumnPalette() { if(!_columnPalette) return; + QQuickItem * content = _columnPalette->content(); + // Clear any previously built column prototypes (rebuilt when the dataset changes). - for(QQuickItem * child : _columnPalette->childItems()) - child->deleteLater(); + _clearPaletteChildren(content); - // Columns come from _columnsModel (set from QML). Rendered as prototype Column nodes. - if(!_columnsModel) + // Columns come from the bound model, falling back to the ColumnsModel singleton. + QAbstractItemModel * model = _columnsModel ? _columnsModel : ColumnsModel::singleton(); + if(!model) return; qreal y = spacing(); - int rows = _columnsModel->rowCount(); + int rows = model->rowCount(); + int nameRole = static_cast(model->roleNames().key("columnName")); for(int r = 0; r < rows; r++) { - QModelIndex idx = _columnsModel->index(r, 0); - QString name = _columnsModel->data(idx, static_cast(_columnsModel->roleNames().key("columnName"))).toString(); + QModelIndex idx = model->index(r, 0); + QString name = model->data(idx, nameRole).toString(); if(name.isEmpty()) continue; ScriptNode * proto = new ScriptNodeColumn(fq(name)); - ScriptNodeItem * item = new ScriptNodeItem(this, proto, _columnPalette); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, content); item->setAcceptsDrops(false); item->rebuild(); item->setX(spacing()); @@ -525,8 +681,7 @@ void ScriptConstructorView::buildColumnPalette() y += item->preferredHeight() + spacing(); } - _columnPalette->setWidth(blockDim() * 6); - _columnPalette->setHeight(y); + _columnPalette->setContentHeight(y); } // ===================================================================================== @@ -589,12 +744,11 @@ void ScriptConstructorView::startDragExisting(ScriptNodeItem * item, const QPoin _dragIsNew = false; _draggedNewNode = nullptr; - QPointF local = item->mapFromScene(scenePos); - _dragOffset = local; + _dragOffset = item->mapFromScene(scenePos); item->setParentItem(this); item->setZ(100); - item->setPosition(scenePos - _dragOffset); + item->setPosition(mapFromScene(scenePos) - _dragOffset); setSomethingChanged(true); } @@ -605,7 +759,7 @@ void ScriptConstructorView::startDragNew(ScriptNode * newNode, const QPointF & s ScriptNodeItem * item = makeNodeItem(newNode, this); item->setZ(100); - item->setPosition(scenePos); + item->setPosition(mapFromScene(scenePos)); _draggedItem = item; _dragIsNew = true; @@ -622,20 +776,54 @@ void ScriptConstructorView::collectDropSpots(QList & out) const out.append(pair.second->dropSpots()); } -ScriptDropSpot * ScriptConstructorView::dropSpotAt(const QPointF & scenePos) const +ScriptDropSpot * ScriptConstructorView::dropSpotAt(const QPointF & scenePos, ScriptNodeItem * dragged) const { QList spots; const_cast(this)->collectDropSpots(spots); + ScriptDropSpot * bestSpot = nullptr; + int bestDepth = -1; + for(ScriptDropSpot * spot : spots) { if(!spot) continue; + + // Skip spots that are already filled, unless filled by the item being dragged + // (dropping back into its own spot is a no-op move). + if(spot->filledItem() && spot->filledItem() != dragged) + continue; + + // Skip spots that live inside the dragged item's subtree. + if(dragged) + { + bool insideDragged = false; + for(QQuickItem * p = spot->parentItem(); p; p = p->parentItem()) + { + if(p == dragged) + { + insideDragged = true; + break; + } + } + if(insideDragged) continue; + } + QPointF local = spot->mapFromScene(scenePos); - if(spot->contains(local)) - return spot; + if(!spot->contains(local)) continue; + + // Prefer the deepest spot under the cursor. + int depth = 0; + for(QQuickItem * p = spot->parentItem(); p; p = p->parentItem()) + depth++; + + if(depth > bestDepth) + { + bestDepth = depth; + bestSpot = spot; + } } - return nullptr; + return bestSpot; } void ScriptConstructorView::clearHover() @@ -651,9 +839,9 @@ void ScriptConstructorView::dragMove(const QPointF & scenePos) { if(!_draggedItem) return; - _draggedItem->setPosition(scenePos - _dragOffset); + _draggedItem->setPosition(mapFromScene(scenePos) - _dragOffset); - ScriptDropSpot * spot = dropSpotAt(scenePos); + ScriptDropSpot * spot = dropSpotAt(scenePos, _draggedItem); if(spot != _hoveredSpot) { @@ -673,7 +861,7 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) if(!_draggedItem) return; ScriptNode * node = _draggedItem->node(); - ScriptDropSpot * spot = dropSpotAt(scenePos); + ScriptDropSpot * spot = dropSpotAt(scenePos, _draggedItem); clearHover(); @@ -686,15 +874,8 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) ScriptNode::deleteTree(node); else _model.removeNode(node); - - _draggedItem = nullptr; - _draggedNewNode = nullptr; - rebuildFormulaItems(); - nodeEdited(); - return; } - - if(spot && spot->target().accepts(node)) + else if(spot && spot->target().accepts(node)) { DropTarget target = spot->target(); @@ -712,6 +893,11 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) _model.moveNode(node, DropTarget::root()); } + // The dragged item was reparented to the view root (or created there), so it + // is not cleaned up by clearFormulaItems(); remove it explicitly. + if(_draggedItem) + _draggedItem->deleteLater(); + _draggedItem = nullptr; _draggedNewNode = nullptr; diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index 2cd70c2565..b382260f41 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -8,6 +8,7 @@ class ScriptNodeItem; class ScriptDropSpot; +class ScriptPalette; class QQmlComponent; class QAbstractItemModel; @@ -18,7 +19,7 @@ class QAbstractItemModel; /// code) and renders it as a tree of QQuickItems. All formula logic lives in the model; this /// class only renders, lays out, and forwards user gestures (drag/drop, inline editing, column /// type changes) to the model. -class ScriptConstructorView : public QQuickItem +class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider { Q_OBJECT @@ -30,6 +31,7 @@ class ScriptConstructorView : public QQuickItem Q_PROPERTY( bool isColumnConstructor READ isColumnConstructor NOTIFY modeChanged ) Q_PROPERTY( bool showGeneratedRCode READ showGeneratedRCode WRITE setShowGeneratedRCode NOTIFY showGeneratedRCodeChanged) Q_PROPERTY( QAbstractItemModel* columnsModel READ columnsModel WRITE setColumnsModel NOTIFY columnsModelChanged ) + Q_PROPERTY( QString filterErrorMsg READ filterErrorMsg WRITE setFilterErrorMsg NOTIFY filterErrorMsgChanged ) public: enum Mode { Filter = 0, ComputedColumn = 1, ComputedDataSet = 2 }; @@ -60,9 +62,15 @@ class ScriptConstructorView : public QQuickItem QAbstractItemModel* columnsModel() const { return _columnsModel; } void setColumnsModel(QAbstractItemModel * m); + QString filterErrorMsg() const { return _filterErrorMsg; } + void setFilterErrorMsg(const QString & msg); + void setColumnTypeProvider(const ScriptColumnTypeProvider * p) { _model.setColumnTypeProvider(p); } void setUndoStack(QUndoStack * s) { _model.setUndoStack(s); } + // ScriptColumnTypeProvider: resolve a column's actual type from the columns model. + int columnType(const std::string & columnName) const override; + // --- QML-callable API mirroring the old constructors --- Q_INVOKABLE bool checkAndApply(); Q_INVOKABLE void initializeFromJSON(const QString & json = QString()); @@ -84,6 +92,8 @@ class ScriptConstructorView : public QQuickItem QQuickItem * newLeaf(QQmlComponent * comp); void nodeEdited(); + void refresh() { rebuildFormulaItems(); } + ScriptNodeItem * makeNodeItem(ScriptNode * node, QQuickItem * parent); // --- drag & drop orchestration (called by ScriptNodeItem / palette items) --- void startDragExisting(ScriptNodeItem * item, const QPointF & scenePos); @@ -91,7 +101,7 @@ class ScriptConstructorView : public QQuickItem void spawnFromPrototype(ScriptNode * proto, const QPointF & scenePos); void dragMove(const QPointF & scenePos); void endDrag(const QPointF & scenePos); - ScriptDropSpot * dropSpotAt(const QPointF & scenePos) const; + ScriptDropSpot * dropSpotAt(const QPointF & scenePos, ScriptNodeItem * dragged = nullptr) const; void collectDropSpots(QList & out) const; signals: @@ -102,6 +112,7 @@ class ScriptConstructorView : public QQuickItem void lastCheckPassedChanged(); void showGeneratedRCodeChanged(); void columnsModelChanged(); + void filterErrorMsgChanged(); /// Emitted when the user applies a valid formula. The surrounding window persists it /// (FilterModel::applyConstructorJson or Column::setConstructorJson/setRCode). @@ -119,23 +130,25 @@ class ScriptConstructorView : public QQuickItem void buildFunctionPalette(); void rebuildFormulaItems(); void clearFormulaItems(); + void _clearPaletteChildren(QQuickItem * palette); void layoutAll(); void layoutScriptArea(); void refreshHint(); + void setHintText(const QString & text); + QString defaultHintText() const; - ScriptNodeItem * makeNodeItem(ScriptNode * node, QQuickItem * parent); void clearHover(); ScriptConstructorModel _model; QPointer _background, _operatorBar, - _columnPalette, - _functionPalette, _scriptArea, _scriptColumn, _trash, _hint; + QPointer _columnPalette, + _functionPalette; std::map _nodeItems; QList _rootItems; @@ -159,6 +172,8 @@ class ScriptConstructorView : public QQuickItem _showGeneratedRCode = false, _chromeBuilt = false; QString _lastAppliedJson; + QString _filterErrorMsg; + QString _hintText; }; #endif // SCRIPTCONSTRUCTORVIEW_H diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index f2f6f5dee7..ea896d2bfe 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -7,6 +7,99 @@ #include #include #include +#include +#include + +// ===================================================================================== +// ScriptPalette +// ===================================================================================== + +ScriptPalette::ScriptPalette(QQuickItem * parent) + : QQuickItem(parent) +{ + setClip(true); + setAcceptedMouseButtons(Qt::LeftButton); + _content = new QQuickItem(this); + _content->setParentItem(this); + _content->setX(0); + _content->setY(0); +} + +void ScriptPalette::setContentHeight(qreal height) +{ + if(_content) + _content->setHeight(height); + clampScroll(); +} + +void ScriptPalette::clampScroll() +{ + if(!_content) return; + + qreal maxScroll = std::max(qreal(0), _content->height() - height()); + if(_scrollY < 0) _scrollY = 0; + if(_scrollY > maxScroll) _scrollY = maxScroll; + _content->setY(-_scrollY); +} + +void ScriptPalette::wheelEvent(QWheelEvent * event) +{ + qreal step = event->angleDelta().y() / 120.0; + _scrollY -= step * 3.0 * 20.0; // a few lines per notch + clampScroll(); + event->accept(); +} + +void ScriptPalette::mousePressEvent(QMouseEvent * event) +{ + // Only reached when the press lands on empty palette background (child + // prototype items accept their own presses to start drags). + if(event->button() == Qt::LeftButton) + { + _dragScrolling = true; + _dragStartY = event->scenePosition().y(); + _dragStartScroll = _scrollY; + grabMouse(); + event->accept(); + } + else + event->ignore(); +} + +void ScriptPalette::mouseMoveEvent(QMouseEvent * event) +{ + if(_dragScrolling) + { + _scrollY = _dragStartScroll - (event->scenePosition().y() - _dragStartY); + clampScroll(); + event->accept(); + } + else + event->ignore(); +} + +void ScriptPalette::mouseReleaseEvent(QMouseEvent * event) +{ + if(_dragScrolling) + { + _dragScrolling = false; + ungrabMouse(); + event->accept(); + } + else + event->ignore(); +} + +void ScriptPalette::geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) +{ + QQuickItem::geometryChange(newGeometry, oldGeometry); + if(newGeometry.size() != oldGeometry.size()) + { + if(_content) + _content->setWidth(newGeometry.width()); + clampScroll(); + } +} // ===================================================================================== // ScriptDropSpot @@ -18,6 +111,7 @@ ScriptDropSpot::ScriptDropSpot(ScriptConstructorView * view, QQuickItem * parent { setImplicitWidth(_view ? _view->blockDim() * 3 : 60); setImplicitHeight(_view ? _view->blockDim() : 20); + setAcceptedMouseButtons(Qt::LeftButton); } void ScriptDropSpot::setTarget(const DropTarget & target) @@ -34,7 +128,15 @@ QQuickItem * ScriptDropSpot::ensurePlaceholder() if(_placeholder) { _placeholder->setParentItem(this); - _placeholder->setProperty("verticalAlignment", 0); // Text.AlignVCenter? set below via anchors + _placeholder->setProperty("verticalAlignment", 128); // Text.AlignVCenter + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + _placeholder->setProperty("font", f); + _placeholder->setProperty("color", theme->textDisabled()); + _placeholder->setProperty("text", _defaultText); + _placeholder->setX(0); + _placeholder->setY(0); } return _placeholder; } @@ -61,6 +163,8 @@ QQuickItem * ScriptDropSpot::ensureMarker() void ScriptDropSpot::setDefaultText(const QString & text) { _defaultText = text; + if(_acceptsDrops) + ensurePlaceholder(); if(_placeholder) _placeholder->setProperty("text", _defaultText); } @@ -79,8 +183,11 @@ void ScriptDropSpot::setFilledItem(ScriptNodeItem * item) item->setX(0); item->setY(0); if(_placeholder) _placeholder->setVisible(false); + if(_input) _input->setVisible(false); setImplicitWidth(item->preferredWidth()); setImplicitHeight(item->preferredHeight()); + setWidth(item->preferredWidth()); + setHeight(item->preferredHeight()); } } @@ -88,8 +195,12 @@ void ScriptDropSpot::clearFilled() { _filled = nullptr; if(_placeholder) _placeholder->setVisible(_acceptsDrops); - setImplicitWidth(_view ? _view->blockDim() * 3 : 60); - setImplicitHeight(_view ? _view->blockDim() : 20); + qreal w = _view ? _view->blockDim() * 3 : 60; + qreal h = _view ? _view->blockDim() : 20; + setImplicitWidth(w); + setImplicitHeight(h); + setWidth(w); + setHeight(h); } void ScriptDropSpot::setHoverState(bool hovered, bool accepted) @@ -125,26 +236,150 @@ void ScriptDropSpot::setError(bool error) void ScriptDropSpot::layout() { - if(_marker) - { - _marker->setWidth(width()); - _marker->setHeight(height()); - } + qreal w, h; if(_filled) { _filled->layout(); - setImplicitWidth(_filled->preferredWidth()); - setImplicitHeight(_filled->preferredHeight()); + w = _filled->preferredWidth(); + h = _filled->preferredHeight(); } - else if(_placeholder) + else { - _placeholder->setProperty("text", _defaultText); - qreal w = _placeholder->property("implicitWidth").toReal(); + if(_placeholder) + _placeholder->setProperty("text", _defaultText); + qreal pw = _placeholder ? _placeholder->property("implicitWidth").toReal() : 0; qreal minW = _acceptsDrops && _view ? _view->blockDim() * 3 : 0; - setImplicitWidth(std::max(w, minW)); - setImplicitHeight(_view ? _view->blockDim() : 20); + w = std::max(pw, minW); + h = _view ? _view->blockDim() : 20; + } + + setImplicitWidth(w); + setImplicitHeight(h); + setWidth(w); + setHeight(h); + + if(_marker) + { + _marker->setWidth(w); + _marker->setHeight(h); + } + if(_placeholder) + { + _placeholder->setWidth(w); + _placeholder->setHeight(h); + } + if(_input) + { + _input->setWidth(w); + _input->setHeight(h); + } +} + +QQuickItem * ScriptDropSpot::ensureInput() +{ + if(_input) + return _input; + + _input = _view->newLeaf(_view->textInputComponent()); + if(_input) + { + _input->setParentItem(this); + _input->setProperty("text", _defaultText); + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + _input->setProperty("font", f); + _input->setProperty("color", theme->textEnabled()); + _input->setX(0); + _input->setY(0); + _input->setVisible(false); + connect(_input, SIGNAL(editingFinished()), this, SLOT(onInputEditingFinished())); + } + return _input; +} + +void ScriptDropSpot::mousePressEvent(QMouseEvent * event) +{ + // Clicking an empty drop spot lets the user type a literal value directly. + if(event->button() == Qt::LeftButton && _acceptsDrops && !_filled) + { + QQuickItem * input = ensureInput(); + if(input) + { + input->setProperty("text", ""); + if(_placeholder) _placeholder->setVisible(false); + input->setVisible(true); + input->forceActiveFocus(); + event->accept(); + return; + } + } + event->ignore(); +} + +void ScriptDropSpot::onInputEditingFinished() +{ + parseAndCreateLiteral(); + + if(_input) + { + _input->setVisible(false); + _input->setFocus(false); + } + if(_placeholder && !_filled) _placeholder->setVisible(true); +} + +void ScriptDropSpot::parseAndCreateLiteral() +{ + QString text = _input ? _input->property("text").toString().trimmed() : QString(); + + bool isNum = false; + double numVal = text.toDouble(&isNum); + + const stringvec & keys = _target.dropKeys; + auto has = [&keys](const char * k) { return std::find(keys.begin(), keys.end(), std::string(k)) != keys.end(); }; + + ScriptNodeLiteral * lit = nullptr; + + // match the old DropSpot.tryConvertToObject order: number, then string, then boolean + if(isNum && has("number")) + { + lit = new ScriptNodeLiteral(ScriptNode::Type::Number); + lit->setNumberValue(numVal); + } + else if(has("string") && !text.isEmpty()) + { + lit = new ScriptNodeLiteral(ScriptNode::Type::String); + lit->setStringValue(fq(text)); + } + else if(has("boolean")) + { + if(isNum) + { + lit = new ScriptNodeLiteral(ScriptNode::Type::Boolean); + lit->setBoolValue(numVal != 0); + } + else if(text.compare("true", Qt::CaseInsensitive) == 0) + { + lit = new ScriptNodeLiteral(ScriptNode::Type::Boolean); + lit->setBoolValue(true); + } + else if(text.compare("false", Qt::CaseInsensitive) == 0) + { + lit = new ScriptNodeLiteral(ScriptNode::Type::Boolean); + lit->setBoolValue(false); + } + } + + if(lit) + { + _view->model()->insertNode(lit, _target); + _view->nodeEdited(); + _view->refresh(); } + else if(_input) + _input->setProperty("text", _defaultText); } // ===================================================================================== @@ -217,8 +452,8 @@ ScriptDropSpot * ScriptNodeItem::makeDropSpot(const DropTarget & target, const Q { ScriptDropSpot * spot = new ScriptDropSpot(_view, this); spot->setTarget(target); - spot->setDefaultText(placeholder); spot->setAcceptsDrops(_acceptsDrops); + spot->setDefaultText(placeholder); _dropSpots.append(spot); return spot; } @@ -264,6 +499,15 @@ void ScriptNodeItem::rebuild() JaspTheme * theme = JaspTheme::currentTheme(); qreal block = _view->blockDim(); + // Create a child item for an existing model child and place it into the drop spot. + auto fillSpot = [&](ScriptDropSpot * spot, ScriptNode * child) + { + if(!child) return; + ScriptNodeItem * childItem = _view->makeNodeItem(child, spot); + childItem->setNested(true); + spot->setFilledItem(childItem); + }; + switch(_node->type()) { case ScriptNode::Type::Number: @@ -332,14 +576,17 @@ void ScriptNodeItem::rebuild() auto * op = static_cast(_node); const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op->op()); - makeDropSpot(DropTarget{op->isVertical() ? DropTarget::Kind::OperatorLeft : DropTarget::Kind::OperatorLeft, op, 0, op->dropKeysLeft()}, "..."); + ScriptDropSpot * leftSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorLeft, op, 0, op->dropKeysLeft()}, "..."); if(def && !def->image.empty()) makeImage(tq(def->image)); else makeText(QString::fromStdString(op->op()), true); - makeDropSpot(DropTarget{DropTarget::Kind::OperatorRight, op, 1, op->dropKeysRight()}, "..."); + ScriptDropSpot * rightSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorRight, op, 1, op->dropKeysRight()}, "..."); + + fillSpot(leftSpot, op->leftChild()); + fillSpot(rightSpot, op->rightChild()); break; } case ScriptNode::Type::Function: @@ -350,7 +597,8 @@ void ScriptNodeItem::rebuild() for(int i = 0; i < func->childCount(); i++) { const auto & arg = func->arguments()[i]; - makeDropSpot(DropTarget{DropTarget::Kind::FunctionArg, func, i, arg.dropKeys}, QString::fromStdString(arg.name)); + ScriptDropSpot * spot = makeDropSpot(DropTarget{DropTarget::Kind::FunctionArg, func, i, arg.dropKeys, arg.optional}, QString::fromStdString(arg.name)); + fillSpot(spot, arg.value); } break; } @@ -360,7 +608,10 @@ void ScriptNodeItem::rebuild() makeText(QString::fromStdString(rowFunc->functionName())); for(int i = 0; i < rowFunc->childCount(); i++) - makeDropSpot(DropTarget{DropTarget::Kind::RowFunctionArg, rowFunc, i, {"number"}}, "..."); + { + ScriptDropSpot * spot = makeDropSpot(DropTarget{DropTarget::Kind::RowFunctionArg, rowFunc, i, {"number"}, true}, "..."); + fillSpot(spot, rowFunc->childAt(i)); + } break; } } diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index c7ebf4d89f..8c3b8e1aaf 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -33,15 +33,24 @@ class ScriptDropSpot : public QQuickItem void layout(); +protected: + void mousePressEvent(QMouseEvent * event) override; + +private slots: + void onInputEditingFinished(); + private: QQuickItem * ensurePlaceholder(); QQuickItem * ensureMarker(); + QQuickItem * ensureInput(); + void parseAndCreateLiteral(); ScriptConstructorView * _view = nullptr; DropTarget _target; QPointer _filled; QPointer _placeholder; QPointer _marker; + QPointer _input; QString _defaultText = "..."; bool _acceptsDrops = true; }; @@ -103,4 +112,34 @@ private slots: _nested = false; }; +/// +/// Scrollable container for palette items (columns/functions/operators). +/// Supports mouse-wheel scrolling and drag-to-scroll on empty background areas. +class ScriptPalette : public QQuickItem +{ + Q_OBJECT + +public: + explicit ScriptPalette(QQuickItem * parent = nullptr); + + QQuickItem * content() const { return _content; } + void setContentHeight(qreal height); + +protected: + void wheelEvent(QWheelEvent * event) override; + void mousePressEvent(QMouseEvent * event) override; + void mouseMoveEvent(QMouseEvent * event) override; + void mouseReleaseEvent(QMouseEvent * event) override; + void geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) override; + +private: + void clampScroll(); + + QQuickItem * _content = nullptr; + qreal _scrollY = 0; + bool _dragScrolling = false; + qreal _dragStartY = 0, + _dragStartScroll = 0; +}; + #endif // SCRIPTNODEITEM_H diff --git a/Tests/qmlTests/tst_scriptconstructor.qml b/Tests/qmlTests/tst_scriptconstructor.qml new file mode 100644 index 0000000000..f889cced1e --- /dev/null +++ b/Tests/qmlTests/tst_scriptconstructor.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtTest +import JASP + +TestCase +{ + name: "TestScriptConstructor" + width: 900 + height: 600 + when: windowShown + + ScriptConstructor + { + id: sc + mode: ScriptConstructor.Filter + width: 900 + height: 600 + } + + // In the headless test there is no ColumnsModel, so column types resolve to the + // scale fallback. The exact per-type R output is covered by the golden tests in + // testall.cpp which use a real column-type provider. + + function test_load_json_generates_r() + { + sc.constructorJson = '{"formulas":[{"nodeType":"Operator","operator":">","leftArgument":{"nodeType":"Column","columnName":"TestInts","columnTypeUser":-1,"columnTypeDrop":-1},"rightArgument":{"nodeType":"Number","value":2}}]}' + + compare(sc.rCode, "(TestInts.scale > 2)\n") + compare(sc.somethingChanged, false) + compare(sc.jsonChanged(), false) + compare(sc.checkAndApply(), true) + compare(sc.lastCheckPassed, true) + } + + function test_check_and_apply_emits() + { + sc.constructorJson = '{"formulas":[{"nodeType":"Operator","operator":"==","leftArgument":{"nodeType":"Column","columnName":"TestLetters","columnTypeUser":-1,"columnTypeDrop":-1},"rightArgument":{"nodeType":"String","text":"A"}}]}' + + var appliedR = "" + var handler = function(json, rCode) { appliedR = rCode; } + sc.applyRequested.connect(handler) + + compare(sc.checkAndApply(), true) + compare(appliedR, "(TestLetters.scale == 'A')\n") + + sc.applyRequested.disconnect(handler) + } + + function test_incomplete_formula_fails_check() + { + sc.constructorJson = '{"formulas":[{"nodeType":"Operator","operator":"+","leftArgument":{"nodeType":"Column","columnName":"TestInts","columnTypeUser":-1,"columnTypeDrop":-1},"rightArgument":null}]}' + + compare(sc.checkAndApply(), false) + compare(sc.lastCheckPassed, false) + } + + function test_non_boolean_root_fails_filter_check() + { + sc.constructorJson = '{"formulas":[{"nodeType":"Operator","operator":"+","leftArgument":{"nodeType":"Number","value":1},"rightArgument":{"nodeType":"Number","value":2}}]}' + + compare(sc.checkAndApply(), false) + } + + function test_empty_filter_applies() + { + sc.constructorJson = '{"formulas":[]}' + compare(sc.checkAndApply(), true) + compare(sc.rCode, "") + } +} diff --git a/Tests/testqml.cpp b/Tests/testqml.cpp index 5d3fd9573c..9d31ca8d8d 100644 --- a/Tests/testqml.cpp +++ b/Tests/testqml.cpp @@ -5,6 +5,7 @@ #include "datasetprovider.h" #include "utilities/qmlutils.h" #include "utilities/settings.h" +#include "qquick/scriptconstructorview.h" TestQml::TestQml(QObject *parent) : QObject{parent} @@ -35,6 +36,7 @@ void TestQml::qmlEngineAvailable(QQmlEngine *engine) // Initialization requiring the QQmlEngine to be constructed QmlUtils::setupQMLEngine(engine); + qmlRegisterType("JASP", 1, 0, "ScriptConstructor"); } void TestQml::cleanupTestCase() From bb9b27afc6adf886a22aea823675a8103b2b2a61 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 13:39:27 +0200 Subject: [PATCH 03/25] Finish ScriptConstructor rewrite: R display, sqrt/!, fuzz test - Wire the generated-R code display for computed columns (reserve bottom space, show/update on edit, hide in filter mode). - Add sqrt and ! to the operator bar as operator-bar-only functions. - Render function math symbols (sum/prod/sd/var and row variants) as images. - Wrap nested operator/function args in parentheses. - Fix operator "gobble left" on drop into empty space. - Integrate ScriptConstructor into ComputeColumnWindow. - Add engine-execution fuzz test that runs generated R through the engine. --- CommonData/scriptconstructormodel.h | 1 + CommonData/scriptconstructorregistry.cpp | 28 ++-- CommonData/scriptconstructorregistry.h | 1 + .../JASP/Widgets/ComputeColumnWindow.qml | 97 ++---------- Desktop/qquick/scriptconstructorview.cpp | 95 ++++++++++- Desktop/qquick/scriptconstructorview.h | 7 +- Desktop/qquick/scriptnodeitem.cpp | 69 +++++++- Desktop/qquick/scriptnodeitem.h | 3 + Tests/testall.cpp | 41 +++++ Tests/testall.h | 1 + Tests/testengine.cpp | 148 ++++++++++++++++++ Tests/testengine.h | 1 + 12 files changed, 378 insertions(+), 114 deletions(-) diff --git a/CommonData/scriptconstructormodel.h b/CommonData/scriptconstructormodel.h index 5515f55b6d..e2582f505c 100644 --- a/CommonData/scriptconstructormodel.h +++ b/CommonData/scriptconstructormodel.h @@ -20,6 +20,7 @@ struct DropTarget int index = -1; ///< Formula index for Root, argument index for Function/RowFunction stringvec dropKeys; ///< Keys accepted at this spot bool optional = false; ///< Empty spot does not fail completeness checks + bool dropsNested = false; ///< Content dropped here renders nested (e.g. operators get parentheses) bool isValid() const { return kind != Kind::None; } bool isRoot() const { return kind == Kind::Root; } diff --git a/CommonData/scriptconstructorregistry.cpp b/CommonData/scriptconstructorregistry.cpp index edd0521d76..61962cfb0c 100644 --- a/CommonData/scriptconstructorregistry.cpp +++ b/CommonData/scriptconstructorregistry.cpp @@ -113,10 +113,10 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() addOp("|", "Or: returns logicals", "or.png"); addOp("%|%", "Split: applies filter separately to each subgroup", "ConditionBy.png"); - auto addFunc = [this](const std::string & name, const std::string & friendlyName, const std::string & toolTip, const std::vector & params, const std::string & image = "") + auto addFunc = [this](const std::string & name, const std::string & friendlyName, const std::string & toolTip, const std::vector & params, const std::string & image = "", bool operatorBarOnly = false) { _functionIndex[name] = _functions.size(); - _functions.push_back({name, friendlyName, toolTip, image, params, false, false}); + _functions.push_back({name, friendlyName, toolTip, image, params, false, false, operatorBarOnly}); }; auto P = [](const std::string & name, const stringvec & keys) { return ScriptParamDef::fromRaw(name, keys); }; @@ -129,10 +129,10 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() strBoolNum = {"string", "boolean", "number"}; addFunc("abs", "", "absolute value", {P("values", numKeys)}); - addFunc("sd", "", "standard deviation", {P("values", numKeys)}); - addFunc("var", "", "variance", {P("values", numKeys)}); - addFunc("sum", "", "summation", {P("values", numKeys)}); - addFunc("prod", "", "product of values", {P("values", numKeys)}); + addFunc("sd", "", "standard deviation", {P("values", numKeys)}, "sigma.png"); + addFunc("var", "", "variance", {P("values", numKeys)}, "variance.png"); + addFunc("sum", "", "summation", {P("values", numKeys)}, "sum.png"); + addFunc("prod", "", "product of values", {P("values", numKeys)}, "product.png"); addFunc("zScores", "", "Standardizes the variable", {P("values", numKeys)}); addFunc("min", "", "returns minimum of values", {P("values", numKeys)}); addFunc("max", "", "returns maximum of values", {P("values", numKeys)}); @@ -145,6 +145,11 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() addFunc("hasSubstring", "", "returns true if string contains substring at least once", {P("string", strKeys), P("substring", strKeys)}); addFunc("is.na", "", "Combine with not-operator to filter out rows with missing values (NA) for a column.", {P("y", strBoolNum)}); + // sqrt and ! live only in the operator bar (interspersed with the operators), not in the + // right-hand function palette. + addFunc("sqrt", "", "Square root", {P("value(s)", numKeys)}, "rootHead.png", true); + addFunc("!", "", "Not", {P("logical(s)", boolKeys)}, "negative.png", true); + addFunc("log", "", "natural logarithm", {P("y", numKeys)}); addFunc("log2", "log\u2082", "base 2 logarithm", {P("y", numKeys)}); addFunc("log10", "log\u2081\u2080", "base 10 logarithm", {P("y", numKeys)}); @@ -182,16 +187,16 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() addFunc("logNormDist", "", "generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog", {P("meanLog", numKeys), P("sdLog", numKeys)}); addFunc("weibullDist", "", "generates data from a Weibull distribution with specified shape and scale", {P("shape", numKeys), P("scale", numKeys)}); - auto addRowFunc = [this](const std::string & name, const std::string & toolTip) + auto addRowFunc = [this](const std::string & name, const std::string & toolTip, const std::string & image = "") { _rowFunctionIndex[name] = _rowFunctions.size(); - _rowFunctions.push_back({name, name, toolTip, "", {}, true, true}); + _rowFunctions.push_back({name, name, toolTip, image, {}, true, true, false}); }; addRowFunc("rowMean", "Rowwise mean"); - addRowFunc("rowSum", "Rowwise sum"); - addRowFunc("rowSD", "Rowwise standard deviation"); - addRowFunc("rowVariance", "Rowwise variance"); + addRowFunc("rowSum", "Rowwise sum", "sum.png"); + addRowFunc("rowSD", "Rowwise standard deviation", "sigma.png"); + addRowFunc("rowVariance", "Rowwise variance", "variance.png"); addRowFunc("rowMedian", "Rowwise median"); addRowFunc("rowMin", "Rowwise minimum"); addRowFunc("rowMax", "Rowwise maximum"); @@ -232,6 +237,7 @@ std::vector ScriptConstructorRegistry::functionsForMode(Scrip for(const ScriptFunctionDef & def : _functions) { + if(def.operatorBarOnly) continue; if(mode == ScriptConstructorMode::Filter && def.name == "ifElse") continue; if(mode != ScriptConstructorMode::Filter && filterOnlyFunctions.count(def.name)) continue; diff --git a/CommonData/scriptconstructorregistry.h b/CommonData/scriptconstructorregistry.h index dd81a3e74d..ffedd834a8 100644 --- a/CommonData/scriptconstructorregistry.h +++ b/CommonData/scriptconstructorregistry.h @@ -26,6 +26,7 @@ struct ScriptFunctionDef std::vector params; bool variadic = false; bool isRowFunction = false; + bool operatorBarOnly = false; stringvec dragKeys() const; bool addsNaRm() const; diff --git a/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml b/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml index 80d4ab6e04..a0bde2b27b 100644 --- a/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml +++ b/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml @@ -63,10 +63,11 @@ FocusScope else { computedColumnConstructor.forceActiveFocus(); - computedColumnConstructor.checkAndApplyFilter() - - columnModel.column.constructorJson = computedColumnConstructor.jsonConstructed - columnModel.column.rCode = computedColumnConstructor.rCode + if(computedColumnConstructor.checkAndApply()) + { + columnModel.column.constructorJson = computedColumnConstructor.returnFilterJSON() + columnModel.column.rCode = computedColumnConstructor.rCode + } } } @@ -175,96 +176,16 @@ FocusScope } } - ComputedColumnsConstructor + ScriptConstructor { id: computedColumnConstructor + mode: ScriptConstructor.ComputedColumn anchors.fill: parent - anchors.leftMargin: 1 + anchors.leftMargin: 1 visible: !isRCode - + showGeneratedRCode: false KeyNavigation.tab: applyComputedColumnButton - - - functionModel: ListModel - { - - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "abs"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("absolute value") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "sd"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("standard deviation") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "var"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("variance") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "sum"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("summation") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "prod"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("product of values") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "zScores"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("Standardizes the variable") } - - - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "min"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("returns minimum of values") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "max"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("returns maximum of values") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "mean"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("mean") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "sign"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("returns the sign of values") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "round"; functionParameters: "y,n"; functionParamTypes: "number,number"; toolTip: qsTr("rounds y to n decimals") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "length"; functionParameters: "y"; functionParamTypes: "string:number:boolean"; toolTip: qsTr("returns number of elements in y") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "median"; functionParameters: "values"; functionParamTypes: "number"; toolTip: qsTr("median") } - - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowMean"; toolTip: qsTr("Rowwise mean") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowSum"; toolTip: qsTr("Rowwise sum") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowSD"; toolTip: qsTr("Rowwise standard deviation") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowVariance"; toolTip: qsTr("Rowwise variance") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowMedian"; toolTip: qsTr("Rowwise median") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowMin"; toolTip: qsTr("Rowwise minimum") } - ListElement { type: "rowfunction"; friendlyFunctionName: ""; functionName: "rowMax"; toolTip: qsTr("Rowwise maximum") } - - - ListElement { type: "separator" } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "log"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("natural logarithm") } - ListElement { type: "function"; friendlyFunctionName: "log\u2082"; functionName: "log2"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("base 2 logarithm") } - ListElement { type: "function"; friendlyFunctionName: "log\u2081\u2080"; functionName: "log10"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("base 10 logarithm") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "logb"; functionParameters: "y,base"; functionParamTypes: "number,number"; toolTip: qsTr("logarithm of y in 'base'") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "exp"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("exponential") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "fishZ"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("Fisher's Z-transform (i.e., the inverse hyperbolic tangent) to transform correlations, numbers between -1 and 1 to the real line") } - ListElement { type: "function"; friendlyFunctionName: "fishZ\u207B\u00B9"; functionName: "invFishZ"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("Inverse Fisher's Z-transform (i.e., the hyperbolic tangent) to transform real numbers to numbers between -1 and 1") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "logit"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("Logit transform (i.e., the inverse of the standard logit function, or log-odds transform) converts numbers between 0 and 1 to the real line.") } - ListElement { type: "function"; friendlyFunctionName: "logit\u207B\u00B9"; functionName: "invLogit"; functionParameters: "y"; functionParamTypes: "number"; toolTip: qsTr("Inverse logit transform (i.e., the standard logit function) converts numbers on the real line to numbers between 0 and 1.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "BoxCox"; functionParameters: "y,lambda,shift,continuityAdjustment"; functionParamTypes: "number,number,number,boolean"; toolTip: qsTr("Two-parameter Box-Cox transform (transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "BoxCoxAuto"; functionParameters: "y,?predictor,?groupSize,method,lower,upper,shift,continuityAdjustment"; functionParamTypes: "number,number,number,string,number,number,number,boolean"; toolTip: qsTr("Two-parameter Box-Cox transform with an automatic determination of the shape parameter lambda, according to one of the three of methods:'loglik', 'sd', or 'movingRange'. The search for optimal lambda is bounded within 'lower' and 'upper' limits.") } - ListElement { type: "function"; friendlyFunctionName: "BoxCox\u207B\u00B9"; functionName: "invBoxCox"; functionParameters: "y,lambda,shift,continuityAdjustment"; functionParamTypes: "number,number,number,boolean"; toolTip: qsTr("Inverse two-parameter Box-Cox transform.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "powerTransform"; functionParameters: "y,lambda,shift"; functionParamTypes: "number,number,number"; toolTip: qsTr("Two-parameter power transform (scale-invariant Box-Box; transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "powerTransformAuto"; functionParameters: "y,?predictor,?groupSize,lower,upper,shift"; functionParamTypes: "number,number,number,number,number,number"; toolTip: qsTr("Two-parameter power transform with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "YeoJohnson"; functionParameters: "y,lambda"; functionParamTypes: "number,number"; toolTip: qsTr("Yeo-Johnson transform (transforms any real values) to stabilize variance and attempt to make the data more normal distribution-like.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "YeoJohnsonAuto"; functionParameters: "y,lower,upper"; functionParamTypes: "number,number,number"; toolTip: qsTr("Yeo-Johnson transform (transforms any real values) with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits.") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "Johnson"; functionParameters: "y,lower,upper"; functionParamTypes: "number,number,number"; toolTip: qsTr("Johnson transform (transforms any real values). The search for optimal parameter is bounded within 'lower' and 'upper' limits.") } - - - ListElement { type: "separator" } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "cut"; functionParameters: "values,numBreaks"; functionParamTypes: "number,number"; toolTip: qsTr("break your data up in numBreaks levels") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "replaceNA"; functionParameters: "column,replaceWith"; functionParamTypes: "string:boolean:number,string:boolean:number"; toolTip: qsTr("replace any missing values (NA) in column by the value in replaceWith") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "ifElse"; functionParameters: "test,then,else"; functionParamTypes: "boolean,boolean:string:number,boolean:string:number"; toolTip: qsTr("if-else statement") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "hasSubstring"; functionParameters: "string,substring"; functionParamTypes: "string,string"; toolTip: qsTr("returns true if string contains substring at least once") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "is.na"; functionParameters: "y"; functionParamTypes: "string:number:boolean"; toolTip: qsTr("returns a boolean vector with TRUE for each missing value in y") } - - ListElement { type: "separator" } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "normalDist"; functionParameters: "mean,sd"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a Gaussian distribution with specified mean and standard deviation sd") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "tDist"; functionParameters: "df,ncp"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from t distribution with degrees of freedom df and non-centrality parameter ncp") } - - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "chiSqDist"; functionParameters: "df,ncp"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a chi-squared distribution with degrees of freedom df and non-centrality parameter ncp") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "fDist"; functionParameters: "df1,df2,ncp"; functionParamTypes: "number,number,number"; toolTip: qsTr("generates data from an F distribution with specified degrees of freedoms df1, df2 and non-centrality parameter ncp") } - - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "binomDist"; functionParameters: "trials,prob"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a binomial distribution with specified trials and probability prob") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "negBinomDist"; functionParameters: "targetTrial,prob"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a negative binomial distribution with specified trials and probability prob") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "geomDist"; functionParameters: "prob"; functionParamTypes: "number"; toolTip: qsTr("generates data from a geometric distribution with specified probability prob") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "poisDist"; functionParameters: "lambda"; functionParamTypes: "number"; toolTip: qsTr("generates data from a Poisson distribution with specified rate lambda") } - //ListElement { type: "function"; friendlyFunctionName: ""; functionName: "integerDist"; functionParameters: "categories,replace,prob"; functionParamTypes: "number,bool,number"; toolTip: qsTr("generates data between 1 and the specified number of categories either with replacement or without and a vector of specified probabilities prob") } - - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "betaDist"; functionParameters: "alpha,beta"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a beta distribution with specified shapes alpha and beta") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "unifDist"; functionParameters: "min,max"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a uniform distribution between min and max") } - - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "gammaDist"; functionParameters: "shape,scale"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a gamma distribution with specified shape and scale") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "expDist"; functionParameters: "rate"; functionParamTypes: "number"; toolTip: qsTr("generates data from an exponential distribution with specified rate") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "logNormDist"; functionParameters: "meanLog,sdLog"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog") } - ListElement { type: "function"; friendlyFunctionName: ""; functionName: "weibullDist"; functionParameters: "shape,scale"; functionParamTypes: "number,number"; toolTip: qsTr("generates data from a Weibull distribution with specified shape and scale") } - - //cut? - //match? - } } } diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index ba6ddf3d72..4d2a911ffc 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -25,6 +25,9 @@ ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) connect(&_model, &ScriptConstructorModel::changed, this, [this](){ setSomethingChanged(true); emit rCodeChanged(rCode()); + // Keep the generated-R display (computed-column mode) in sync with the model. + if(_rCodeDisplay && _showGeneratedRCode) + _rCodeDisplay->setProperty("text", rCode()); }); } @@ -44,7 +47,15 @@ void ScriptConstructorView::setModeInt(int m) if(mode == _model.mode()) return; _model.setMode(mode); + + // The generated-R display only makes sense for computed columns. + if(_rCodeDisplay) + _rCodeDisplay->setVisible(_showGeneratedRCode && mode != ScriptConstructorMode::Filter); + emit modeChanged(); + + if(_chromeBuilt) + layoutAll(); } QString ScriptConstructorView::constructorJson() const @@ -79,7 +90,17 @@ void ScriptConstructorView::setShowGeneratedRCode(bool v) { if(v == _showGeneratedRCode) return; _showGeneratedRCode = v; + + if(_rCodeDisplay) + { + _rCodeDisplay->setProperty("text", rCode()); + _rCodeDisplay->setVisible(v && _model.mode() != ScriptConstructorMode::Filter); + } + emit showGeneratedRCodeChanged(); + + if(_chromeBuilt) + layoutAll(); } void ScriptConstructorView::setColumnsModel(QAbstractItemModel * m) @@ -233,6 +254,12 @@ qreal ScriptConstructorView::spacing() const return 2.0 * (JaspTheme::currentTheme() ? JaspTheme::currentTheme()->uiScale() : 1.0); } +qreal ScriptConstructorView::desiredMinimumHeight() const +{ + // Operator bar + hint line + a little breathing room (mirrors the old constructors). + return blockDim() * 1.75 + fontPixelSize() * 2 + blockDim() * 3; +} + // ------------------------------------------------------------------------------------- // Leaf components (inline QML, incubated on demand) // ------------------------------------------------------------------------------------- @@ -398,6 +425,19 @@ void ScriptConstructorView::buildChrome() _hint->setZ(5); } + // Generated R code display (computed-column mode, toggled via showGeneratedRCode). + _rCodeDisplay = newLeaf(textComponent()); + if(_rCodeDisplay) + { + _rCodeDisplay->setParentItem(this); + _rCodeDisplay->setProperty("wrapMode", 4); // Text.WordWrap + _rCodeDisplay->setProperty("color", theme ? theme->textEnabled() : QColor("black")); + QFont rf = theme ? theme->fontRCode() : QFont(); + _rCodeDisplay->setProperty("font", rf); + _rCodeDisplay->setVisible(false); + _rCodeDisplay->setZ(5); + } + buildOperatorBar(); buildColumnPalette(); buildFunctionPalette(); @@ -461,6 +501,10 @@ void ScriptConstructorView::layoutAll() qreal paletteW = blockDim() * 6; qreal hintH = _hint ? fontPixelSize() + 2 * spacing() : 0; + // Reserve space at the bottom for the generated-R display (computed columns only). + bool showRCode = _showGeneratedRCode && _model.mode() != ScriptConstructorMode::Filter; + qreal rCodeH = (showRCode && _rCodeDisplay) ? fontPixelSize() * 2 + spacing() * 2 : 0; + if(_background) { _background->setWidth(w); @@ -480,7 +524,7 @@ void ScriptConstructorView::layoutAll() _columnPalette->setX(0); _columnPalette->setY(barH); _columnPalette->setWidth(paletteW); - _columnPalette->setHeight(h - barH - hintH); + _columnPalette->setHeight(h - barH - hintH - rCodeH); } if(_functionPalette) @@ -488,7 +532,7 @@ void ScriptConstructorView::layoutAll() _functionPalette->setX(w - paletteW); _functionPalette->setY(barH); _functionPalette->setWidth(paletteW); - _functionPalette->setHeight(h - barH - hintH); + _functionPalette->setHeight(h - barH - hintH - rCodeH); } if(_scriptArea) @@ -496,17 +540,26 @@ void ScriptConstructorView::layoutAll() _scriptArea->setX(paletteW); _scriptArea->setY(barH); _scriptArea->setWidth(w - 2 * paletteW); - _scriptArea->setHeight(h - barH - hintH); + _scriptArea->setHeight(h - barH - hintH - rCodeH); } if(_hint) { _hint->setX(paletteW); - _hint->setY(h - hintH); + _hint->setY(h - hintH - rCodeH); _hint->setWidth(w - 2 * paletteW); _hint->setHeight(hintH); } + if(_rCodeDisplay) + { + _rCodeDisplay->setVisible(showRCode); + _rCodeDisplay->setX(paletteW); + _rCodeDisplay->setY(h - rCodeH); + _rCodeDisplay->setWidth(w - 2 * paletteW); + _rCodeDisplay->setHeight(rCodeH); + } + if(_trash) { qreal trashDim = blockDim() * 3; @@ -597,8 +650,7 @@ void ScriptConstructorView::buildOperatorBar() { if(!_operatorBar) return; - qreal x = spacing(); - for(const ScriptOperatorDef & def : ScriptConstructorRegistry::instance().operatorsForMode(_model.mode())) + auto placeOperator = [this](qreal & x, const ScriptOperatorDef & def) { ScriptNode * proto = new ScriptNodeOperator(def.op, def.vertical); ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBar); @@ -607,7 +659,30 @@ void ScriptConstructorView::buildOperatorBar() item->setX(x); item->setY(0); x += item->preferredWidth() + spacing() * 2; + }; + + // sqrt and ! are functions interspersed among the operators (they belong only in the bar). + auto placeFunction = [this](qreal & x, const std::string & name) + { + ScriptNode * proto = new ScriptNodeFunction(name); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBar); + item->setAcceptsDrops(false); + item->rebuild(); + item->setX(x); + item->setY(0); + x += item->preferredWidth() + spacing() * 2; + }; + + const ScriptConstructorRegistry & registry = ScriptConstructorRegistry::instance(); + + qreal x = spacing(); + for(const ScriptOperatorDef & def : registry.operatorsForMode(_model.mode())) + { + placeOperator(x, def); + if(def.op == "^") + placeFunction(x, "sqrt"); } + placeFunction(x, "!"); _operatorBar->setWidth(x); _operatorBar->setHeight(blockDim()); @@ -886,9 +961,13 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) } else { - // No valid spot: drop at root (model resolves a reasonable insertion point). + // No specific spot under the cursor. if(_dragIsNew) - _model.insertNode(node, DropTarget::root()); + { + // A brand-new node resolves a reasonable insertion point and, for operators + // with a free left slot, absorbs ("gobbles") the preceding formula. + _model.insertNode(node, DropTarget::none()); + } else _model.moveNode(node, DropTarget::root()); } diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index b382260f41..b7e3ee765f 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -32,6 +32,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider Q_PROPERTY( bool showGeneratedRCode READ showGeneratedRCode WRITE setShowGeneratedRCode NOTIFY showGeneratedRCodeChanged) Q_PROPERTY( QAbstractItemModel* columnsModel READ columnsModel WRITE setColumnsModel NOTIFY columnsModelChanged ) Q_PROPERTY( QString filterErrorMsg READ filterErrorMsg WRITE setFilterErrorMsg NOTIFY filterErrorMsgChanged ) + Q_PROPERTY( qreal desiredMinimumHeight READ desiredMinimumHeight NOTIFY desiredMinimumHeightChanged ) public: enum Mode { Filter = 0, ComputedColumn = 1, ComputedDataSet = 2 }; @@ -65,6 +66,8 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider QString filterErrorMsg() const { return _filterErrorMsg; } void setFilterErrorMsg(const QString & msg); + qreal desiredMinimumHeight() const; + void setColumnTypeProvider(const ScriptColumnTypeProvider * p) { _model.setColumnTypeProvider(p); } void setUndoStack(QUndoStack * s) { _model.setUndoStack(s); } @@ -113,6 +116,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void showGeneratedRCodeChanged(); void columnsModelChanged(); void filterErrorMsgChanged(); + void desiredMinimumHeightChanged(); /// Emitted when the user applies a valid formula. The surrounding window persists it /// (FilterModel::applyConstructorJson or Column::setConstructorJson/setRCode). @@ -146,7 +150,8 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider _scriptArea, _scriptColumn, _trash, - _hint; + _hint, + _rCodeDisplay; QPointer _columnPalette, _functionPalette; std::map _nodeItems; diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index ea896d2bfe..7236b46644 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -410,6 +410,9 @@ void ScriptNodeItem::clearLeaves() if(spot) spot->deleteLater(); _dropSpots.clear(); + + if(_openParen) { _openParen->deleteLater(); _openParen = nullptr; } + if(_closeParen) { _closeParen->deleteLater(); _closeParen = nullptr; } } QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) @@ -448,6 +451,24 @@ QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) return item; } +QQuickItem * ScriptNodeItem::makeParenText(const QString & text) +{ + QQuickItem * item = _view->newLeaf(_view->textComponent()); + if(!item) return nullptr; + + item->setParentItem(this); + item->setProperty("text", text); + + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + item->setProperty("font", f); + item->setProperty("color", theme->textEnabled()); + item->setVisible(false); // shown only when the node is nested + + return item; +} + ScriptDropSpot * ScriptNodeItem::makeDropSpot(const DropTarget & target, const QString & placeholder) { ScriptDropSpot * spot = new ScriptDropSpot(_view, this); @@ -504,7 +525,7 @@ void ScriptNodeItem::rebuild() { if(!child) return; ScriptNodeItem * childItem = _view->makeNodeItem(child, spot); - childItem->setNested(true); + childItem->setNested(spot->target().dropsNested); spot->setFilledItem(childItem); }; @@ -576,14 +597,21 @@ void ScriptNodeItem::rebuild() auto * op = static_cast(_node); const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op->op()); - ScriptDropSpot * leftSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorLeft, op, 0, op->dropKeysLeft()}, "..."); + // Horizontal operators wrap their children in parentheses; vertical (division) does not. + bool nest = !op->isVertical(); + + ScriptDropSpot * leftSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorLeft, op, 0, op->dropKeysLeft(), false, nest}, "..."); if(def && !def->image.empty()) makeImage(tq(def->image)); else makeText(QString::fromStdString(op->op()), true); - ScriptDropSpot * rightSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorRight, op, 1, op->dropKeysRight()}, "..."); + ScriptDropSpot * rightSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorRight, op, 1, op->dropKeysRight(), false, nest}, "..."); + + // Parentheses shown when this operator is nested inside another node's drop spot. + _openParen = makeParenText("("); + _closeParen = makeParenText(")"); fillSpot(leftSpot, op->leftChild()); fillSpot(rightSpot, op->rightChild()); @@ -592,12 +620,23 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::Function: { auto * func = static_cast(_node); - makeText(QString::fromStdString(func->functionName())); + const ScriptFunctionDef * funcDef = ScriptConstructorRegistry::instance().functionDef(func->functionName()); + + if(funcDef && !funcDef->image.empty()) + makeImage(tq(funcDef->image)); + else + { + QString displayName = (funcDef && !funcDef->friendlyName.empty()) ? tq(funcDef->friendlyName) : QString::fromStdString(func->functionName()); + makeText(displayName); + } + + // Single-argument (non-abs) functions wrap their child in parentheses. + bool nest = (func->childCount() == 1 && func->functionName() != "abs"); for(int i = 0; i < func->childCount(); i++) { const auto & arg = func->arguments()[i]; - ScriptDropSpot * spot = makeDropSpot(DropTarget{DropTarget::Kind::FunctionArg, func, i, arg.dropKeys, arg.optional}, QString::fromStdString(arg.name)); + ScriptDropSpot * spot = makeDropSpot(DropTarget{DropTarget::Kind::FunctionArg, func, i, arg.dropKeys, arg.optional, nest}, QString::fromStdString(arg.name)); fillSpot(spot, arg.value); } break; @@ -605,7 +644,17 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::RowFunction: { auto * rowFunc = static_cast(_node); - makeText(QString::fromStdString(rowFunc->functionName())); + const ScriptFunctionDef * rowDef = ScriptConstructorRegistry::instance().rowFunctionDef(rowFunc->functionName()); + + // Row functions with a math-symbol image render as "row" + the symbol (e.g. rowSum -> row Σ). + // Others render their name as text. + if(rowDef && !rowDef->image.empty()) + { + makeText("row"); + makeImage(tq(rowDef->image)); + } + else + makeText(QString::fromStdString(rowFunc->functionName())); for(int i = 0; i < rowFunc->childCount(); i++) { @@ -658,6 +707,12 @@ void ScriptNodeItem::layout() ScriptDropSpot * left = _dropSpots.size() > 0 ? _dropSpots[0] : nullptr; ScriptDropSpot * right = _dropSpots.size() > 1 ? _dropSpots[1] : nullptr; + bool showParens = _nested && _openParen && _closeParen; + if(_openParen) _openParen->setVisible(showParens); + if(_closeParen) _closeParen->setVisible(showParens); + + if(showParens) placeNext(_openParen); + if(left) { left->layout(); placeNext(left); } QQuickItem * opVisual = _leaves.isEmpty() ? nullptr : _leaves.first(); @@ -672,6 +727,8 @@ void ScriptNodeItem::layout() } if(right) { right->layout(); placeNext(right); } + + if(showParens) placeNext(_closeParen); (void)op; } else if(t == ScriptNode::Type::Function || t == ScriptNode::Type::RowFunction) diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index 8c3b8e1aaf..fc665250bf 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -96,6 +96,7 @@ private slots: private: QQuickItem * makeText(const QString & text, bool bold = false); QQuickItem * makeImage(const QString & iconFile); + QQuickItem * makeParenText(const QString & text); ScriptDropSpot* makeDropSpot(const DropTarget & target, const QString & placeholder); void clearLeaves(); void addLeaf(QQuickItem * leaf); @@ -106,6 +107,8 @@ private slots: ScriptNode * _node = nullptr; QList _leaves; QList _dropSpots; + QPointer _openParen, + _closeParen; qreal _preferredWidth = 0, _preferredHeight = 0; bool _acceptsDrops = true, diff --git a/Tests/testall.cpp b/Tests/testall.cpp index b8cc0d309e..7944347c02 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -1549,6 +1549,12 @@ void TestAll::testScriptConstructorGoldenR() // %|% conditional operator in filter mode checkR(formulas({opNode("%|%", opNode(">", colNode("contNormal"), numNode(0)), colNode("group"))}), "((contNormal.scale > 0) %|% group.nominal)\n"); + + // sqrt (operator-bar-only function) wraps a single number argument + checkR(formulas({funcNode("sqrt", {funcArg("value(s)", {"number"}, colNode("contNormal"))})}), "sqrt(contNormal.scale)\n"); + + // ! (operator-bar-only function) wraps a single boolean argument + checkR(formulas({funcNode("!", {funcArg("logical(s)", {"boolean"}, boolNode(true))})}), "!(TRUE)\n"); } void TestAll::testScriptConstructorCompleteness() @@ -1735,5 +1741,40 @@ void TestAll::testScriptConstructorUndo() QCOMPARE(model.formulaCount(), 1); } +void TestAll::testScriptConstructorGobble() +{ + QVERIFY(_newPkgWithDataSet()); + + FixedColumnTypeProvider provider; + provider.types["contNormal"] = 1; // scale + + ScriptConstructorModel model; + model.setColumnTypeProvider(&provider); + model.setMode(ScriptConstructorMode::Filter); + + // Start with a single column formula at the root: contNormal + model.fromJson(formulas({colNode("contNormal")})); + QCOMPARE(model.formulaCount(), 1); + + // Drop a ">" operator with no specific target. It should absorb ("gobble") + // the existing column as its left operand, leaving the right slot empty. + ScriptNode * op = new ScriptNodeOperator(">", false); + model.insertNode(op, DropTarget::none()); + + QCOMPARE(model.formulaCount(), 1); + + auto * rootOp = dynamic_cast(model.formulaAt(0)); + QVERIFY(rootOp != nullptr); + QCOMPARE(rootOp->op(), std::string(">")); + + auto * leftCol = dynamic_cast(rootOp->leftChild()); + QVERIFY(leftCol != nullptr); + QCOMPARE(leftCol->columnName(), std::string("contNormal")); + QVERIFY(rootOp->rightChild() == nullptr); + + // R code reflects the gobble: (contNormal.scale > null) + QCOMPARE(model.toR(), std::string("(contNormal.scale > null)\n")); +} + QTEST_MAIN(TestAll) diff --git a/Tests/testall.h b/Tests/testall.h index 9bd0d6d474..3075735f9e 100644 --- a/Tests/testall.h +++ b/Tests/testall.h @@ -78,6 +78,7 @@ private slots: void testScriptConstructorGoldenR(); void testScriptConstructorCompleteness(); void testScriptConstructorUndo(); + void testScriptConstructorGobble(); void testScriptConstructorDefaultFilterJson(); private: diff --git a/Tests/testengine.cpp b/Tests/testengine.cpp index d9170d6c3d..05fd0d085a 100644 --- a/Tests/testengine.cpp +++ b/Tests/testengine.cpp @@ -14,6 +14,13 @@ #include "utilities/settings.h" #include "filter.h" #include "variableinfo.h" +#include "scriptconstructormodel.h" +#include "scriptnode.h" +#include "scriptconstructorregistry.h" + +#include +#include +#include void TestEngine::initTestCase() { @@ -444,5 +451,146 @@ void TestEngine::testVariableInfoPerFilter() QCOMPARE(info.rowCount(), rowCount); } +void TestEngine::testScriptConstructorFuzz() +{ + QVERIFY2(_data, "No dataset!"); + QVERIFY2(_engines, "No EngineSync!"); + QVERIFY2(_engineRep, "No EngineRepresentation!"); + + _engines->startStoppedEngine(_engineRep); + + QSignalSpy spy(_engineRep, &EngineRepresentation::rCodeReturned); + QVERIFY2(spy.isValid(), "Spy is broken!"); + + // JSON node builders (mirror Tests/testall.cpp) restricted to complete, type-valid trees + // made from numeric/boolean literals only. Leaves are literals so the generated R depends + // only on base R (no columns, no JASP-only helper functions) and always evaluates without + // error: R turns bad arithmetic (x/0, log of a negative, sqrt of a negative) into Inf/NaN, + // not an error. + auto numNode = [](double v) + { + Json::Value j; j["nodeType"] = "Number"; j["value"] = v; return j; + }; + auto boolNode = [](bool b) + { + Json::Value j; j["nodeType"] = "Boolean"; j["value"] = b ? "TRUE" : "FALSE"; return j; + }; + auto opNode = [](const std::string & op, const Json::Value & l, const Json::Value & r) + { + Json::Value j; j["nodeType"] = "Operator"; j["operator"] = op; + j["leftArgument"] = l; j["rightArgument"] = r; return j; + }; + auto funcNode = [](const std::string & name, const std::vector & args) + { + Json::Value j; j["nodeType"] = "Function"; j["functionName"] = name; j["arguments"] = Json::arrayValue; + for(const Json::Value & a : args) + { + Json::Value arg; arg["name"] = "x"; arg["dropKeys"] = Json::arrayValue; arg["argument"] = a; + j["arguments"].append(arg); + } + return j; + }; + + static const std::vector arithOps = {"+", "-", "*", "/", "^", "%%"}; + static const std::vector compareOps = {"==", "!=", "<", "<=", ">", ">="}; + // Base-R numeric functions that accept a single numeric argument and return a number. + static const std::vector unaryNumFuncs = {"abs", "sqrt", "sign", "exp", "log", "log2", "log10", "mean", "sd", "var", "sum", "prod", "min", "max", "median"}; + + std::function makeNumber = [&](std::mt19937 & rng, int depth) -> Json::Value + { + std::uniform_int_distribution kind(0, 99); + std::uniform_real_distribution val(1.0, 50.0); + std::uniform_int_distribution arithPick(0, static_cast(arithOps.size()) - 1); + std::uniform_int_distribution funcPick(0, static_cast(unaryNumFuncs.size()) - 1); + + if(depth <= 0) + return numNode(val(rng)); + + int k = kind(rng); + if(k < 30) return numNode(val(rng)); + if(k < 65) return opNode(arithOps[arithPick(rng)], makeNumber(rng, depth - 1), makeNumber(rng, depth - 1)); + if(k < 80) + { + const std::string & fn = unaryNumFuncs[funcPick(rng)]; + return funcNode(fn, {makeNumber(rng, depth - 1)}); + } + // round needs a numeric "n" as well. + return funcNode("round", {makeNumber(rng, depth - 1), numNode(std::uniform_int_distribution(0, 3)(rng))}); + }; + + std::function makeBoolean = [&](std::mt19937 & rng, int depth) -> Json::Value + { + std::uniform_int_distribution kind(0, 99); + std::uniform_int_distribution cmpPick(0, static_cast(compareOps.size()) - 1); + + if(depth <= 0) + return boolNode(kind(rng) % 2 == 0); + + int k = kind(rng); + if(k < 25) return boolNode(k % 2 == 0); + if(k < 60) return opNode(compareOps[cmpPick(rng)], makeNumber(rng, depth - 1), makeNumber(rng, depth - 1)); + if(k < 85) return opNode(k % 2 == 0 ? "&" : "|", makeBoolean(rng, depth - 1), makeBoolean(rng, depth - 1)); + return funcNode("!", {makeBoolean(rng, depth - 1)}); + }; + + ScriptConstructorModel model; + model.setMode(ScriptConstructorMode::ComputedColumn); + + int requestId = 0; + const int cases = 40; + + for(int seed = 0; seed < cases; seed++) + { + std::mt19937 rng(seed); + + Json::Value tree; + tree["formulas"] = Json::arrayValue; + tree["formulas"].append(seed % 2 == 0 ? makeNumber(rng, 3) : makeBoolean(rng, 3)); + + model.fromJson(tree); + const std::string rCode = model.toR(); + + // jaspRCPP_evalRCode returns "null" for any non-string R result, which the engine treats + // as an error. Wrap the generated expression so it always yields a string: "OK" when it + // evaluates without error, "ERR" when R raises. This makes the distinction between a real + // evaluation error and a merely non-string (e.g. numeric) result explicit. + const std::string wrapped = "tryCatch({" + rCode + "; 'OK'}, error = function(e) 'ERR')"; + + _engines->sendRCode(_data->id(), tq(wrapped), requestId, false, ""); + + // Wait for the reply for our request id and confirm the R evaluated without error. + bool gotIt = false; + QString result; + const int target = requestId; + + while(!gotIt && spy.wait(120000)) + { + while(spy.count() > 0) + { + QVariantList response = spy.takeFirst(); + if(response[1].toInt() == target) + { + gotIt = true; + result = response[0].toString(); + } + } + } + + if(!gotIt) + { + const std::string msg = "No R reply for request " + std::to_string(target) + " (seed " + std::to_string(seed) + "), rCode: " + rCode; + QFAIL(msg.c_str()); + } + + if(result != "OK") + { + const std::string msg = "Generated R evaluated with error (seed " + std::to_string(seed) + "): " + rCode + " (result: " + fq(result) + ")"; + QFAIL(msg.c_str()); + } + + requestId++; + } +} + QTEST_MAIN(TestEngine) diff --git a/Tests/testengine.h b/Tests/testengine.h index 886e2b58ae..c60c1bf50d 100644 --- a/Tests/testengine.h +++ b/Tests/testengine.h @@ -24,6 +24,7 @@ private slots: void testComputedColumnCascade(); void testComputedDataSet(); void testVariableInfoPerFilter(); + void testScriptConstructorFuzz(); private: EngineSync * _engines = nullptr; From 38a1397efab112bdbe0876fecb7a3ee034b22c94 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 14:07:47 +0200 Subject: [PATCH 04/25] Fix ScriptConstructor column type cycling and right-click removal - Right-clicking an element now rebuilds the view after removing the node, preventing a crash from dangling ScriptNode pointers in stale items. - Cycling a column's type now refreshes the whole view so the icon and parent layout update to the newly selected type. --- Desktop/qquick/scriptnodeitem.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 7236b46644..81283f2916 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -805,7 +805,10 @@ void ScriptNodeItem::mousePressEvent(QMouseEvent * event) if(event->button() == Qt::RightButton) { // Right-click deletes the node (matches old DragGeneric behaviour). + // Remove the node from the model and rebuild the view so no item keeps a dangling + // ScriptNode pointer (the model deletes the node subtree immediately). _view->model()->removeNode(_node); + _view->refresh(); _view->nodeEdited(); event->accept(); return; @@ -821,8 +824,8 @@ void ScriptNodeItem::mousePressEvent(QMouseEvent * event) int next = (cur < 1 || cur >= 3) ? 1 : cur + 1; _view->model()->setColumnTypeUser(col, next); + _view->refresh(); _view->nodeEdited(); - rebuild(); event->accept(); return; } From 76a6c822c1e43fe218f01cba7550ab32e2b9477b Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 14:31:02 +0200 Subject: [PATCH 05/25] Improve ScriptConstructor column type and row-function UX - Column icons use the "transformed" variant (asterisk) when the column's effective type differs from its dataset type. - Clicking a column icon in a restrictive drop slot shows the allowed types instead of silently cycling to a type that would be reverted. - Row functions lay out their "row" text + symbol image correctly, and always keep a trailing free drop slot so more columns can be added. --- CommonData/scriptconstructormodel.cpp | 17 +++++ CommonData/scriptconstructormodel.h | 4 ++ CommonData/scriptnode.cpp | 9 +++ CommonData/scriptnode.h | 1 + Desktop/qquick/scriptnodeitem.cpp | 38 +++++++++-- Tests/testall.cpp | 98 +++++++++++++++++++++++++++ Tests/testall.h | 2 + 7 files changed, 164 insertions(+), 5 deletions(-) diff --git a/CommonData/scriptconstructormodel.cpp b/CommonData/scriptconstructormodel.cpp index cfd765c5bf..7f6436e672 100644 --- a/CommonData/scriptconstructormodel.cpp +++ b/CommonData/scriptconstructormodel.cpp @@ -225,7 +225,10 @@ void ScriptConstructorModel::placeAt(ScriptNode * node, const DropTarget & targe break; case DropTarget::Kind::RowFunctionArg: if(auto * rowFunc = dynamic_cast(target.parent)) + { rowFunc->setChild(target.index, node); + rowFunc->ensureTrailingEmptySlot(); + } break; case DropTarget::Kind::None: break; @@ -411,6 +414,20 @@ DropTarget ScriptConstructorModel::findReasonableInsertionSpot(ScriptNode * node return rightMostEmptyDropSpotRec(last); } +std::vector ScriptConstructorModel::allowedColumnTypes(ScriptNode * node) const +{ + if(!node || !node->parent()) + return {1, 2, 3}; // root: unconstrained + + const stringvec keys = containingSlotKeys(node); + + std::vector out; + for(int t : {1, 2, 3}) // scale, ordinal, nominal + if(keysOverlap(ScriptConstructorRegistry::dropKeysForColumnType(t), keys)) + out.push_back(t); + return out; +} + // --- editing operations --- void ScriptConstructorModel::beginEdit() diff --git a/CommonData/scriptconstructormodel.h b/CommonData/scriptconstructormodel.h index e2582f505c..828d479d40 100644 --- a/CommonData/scriptconstructormodel.h +++ b/CommonData/scriptconstructormodel.h @@ -77,6 +77,10 @@ class ScriptConstructorModel : public QObject /// Resolves where a freshly created node should go when the user did not drop it anywhere specific. DropTarget findReasonableInsertionSpot(ScriptNode * node) const; + /// Returns the column types (1=scale, 2=ordinal, 3=nominal) that the containing drop slot + /// accepts. At the root (no parent) all three are returned. + std::vector allowedColumnTypes(ScriptNode * node) const; + /// Returns the drop keys accepted at a given target (used by the view for hover feedback). static bool keysOverlap(const stringvec & a, const stringvec & b); diff --git a/CommonData/scriptnode.cpp b/CommonData/scriptnode.cpp index d9c800a3aa..735194a9a4 100644 --- a/CommonData/scriptnode.cpp +++ b/CommonData/scriptnode.cpp @@ -402,6 +402,15 @@ int ScriptNodeRowFunction::childCountFilled() const return count; } +void ScriptNodeRowFunction::ensureTrailingEmptySlot() +{ + // Keep a free (null) child at the end so the user can always drop another column. + for(ScriptNode * child : _children) + if(!child) + return; + _children.push_back(nullptr); +} + Json::Value ScriptNodeRowFunction::toJson() const { Json::Value json; diff --git a/CommonData/scriptnode.h b/CommonData/scriptnode.h index 5d9b016261..0e006ad12b 100644 --- a/CommonData/scriptnode.h +++ b/CommonData/scriptnode.h @@ -151,6 +151,7 @@ class ScriptNodeRowFunction : public ScriptNode void addChild(ScriptNode * node); void removeChildAt(int index); int childCountFilled() const; + void ensureTrailingEmptySlot(); private: std::string _functionName; diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 81283f2916..5e6bc9900a 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -2,6 +2,7 @@ #include "scriptconstructorview.h" #include "jasptheme.h" #include "qutils.h" +#include "utilities/messageforwarder.h" #include #include #include @@ -587,7 +588,10 @@ void ScriptNodeItem::rebuild() actual = _view->model()->columnTypeProvider()->columnType(col->columnName()); int effective = col->effectiveColumnType(actual); - makeImage(getIconFilename(static_cast(effective), varIconType::DefaultIconType)); + // A column whose effective type differs from its dataset type gets the "transformed" + // icon (the one with the asterisk). + varIconType iconType = (effective == actual) ? varIconType::DefaultIconType : varIconType::TransformedIconType; + makeImage(getIconFilename(static_cast(effective), iconType)); makeText(QString::fromStdString(col->columnName())); break; } @@ -733,14 +737,21 @@ void ScriptNodeItem::layout() } else if(t == ScriptNode::Type::Function || t == ScriptNode::Type::RowFunction) { - QQuickItem * nameVisual = _leaves.isEmpty() ? nullptr : _leaves.first(); - if(nameVisual) + // Lay out all name leaves left-to-right. Row functions with a math symbol render as + // "row" text + image (e.g. rowSum -> "row" + Σ), so there can be more than one leaf. + for(QQuickItem * nameVisual : _leaves) { + if(!nameVisual) continue; qreal w = nameVisual->property("implicitWidth").toReal(); - qreal h = block; + if(w <= 0) w = nameVisual->width(); + qreal h = nameVisual->property("implicitHeight").toReal(); + if(h <= 0) h = nameVisual->height(); + if(h <= 0) h = block; + nameVisual->setX(x); - nameVisual->setY(0); + nameVisual->setY((maxH > h ? (maxH - h) / 2 : 0)); x += w; + maxH = std::max(maxH, h); } // opening paren @@ -820,6 +831,23 @@ void ScriptNodeItem::mousePressEvent(QMouseEvent * event) if(_node && _node->type() == ScriptNode::Type::Column) { auto * col = static_cast(_node); + + // When the column sits in a restrictive drop slot (e.g. a numeric argument of a + // function) its type is constrained to what the slot accepts; tell the user instead + // of silently cycling to a type that would just be reverted. + const std::vector allowed = _view->model()->allowedColumnTypes(_node); + if(allowed.size() < 3) + { + QStringList names; + for(int t : allowed) + names << QColumnUtils::getTypeFriendly(static_cast(t)); + + MessageForwarder::showWarning(tr("Cannot change column type"), + tr("Only %1 allowed in this context.").arg(names.join(tr("/")))); + event->accept(); + return; + } + int cur = col->columnTypeUser(); int next = (cur < 1 || cur >= 3) ? 1 : cur + 1; diff --git a/Tests/testall.cpp b/Tests/testall.cpp index 7944347c02..a100af17f8 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -1776,5 +1776,103 @@ void TestAll::testScriptConstructorGobble() QCOMPARE(model.toR(), std::string("(contNormal.scale > null)\n")); } +void TestAll::testScriptConstructorAllowedColumnTypes() +{ + QVERIFY(_newPkgWithDataSet()); + + ScriptConstructorModel model; + model.setMode(ScriptConstructorMode::Filter); + + auto isAllowed = [&model](ScriptNode * node, int type) + { + const std::vector allowed = model.allowedColumnTypes(node); + return std::find(allowed.begin(), allowed.end(), type) != allowed.end(); + }; + + // Root column: unconstrained, all three types allowed. + model.fromJson(formulas({colNode("contNormal")})); + { + const std::vector allowed = model.allowedColumnTypes(model.formulaAt(0)); + QCOMPARE(allowed.size(), size_t(3)); + QVERIFY(isAllowed(model.formulaAt(0), 1)); + QVERIFY(isAllowed(model.formulaAt(0), 2)); + QVERIFY(isAllowed(model.formulaAt(0), 3)); + } + + // Column in a numeric operator slot (+): only scale. + model.fromJson(formulas({opNode("+", colNode("contNormal"), numNode(1))})); + { + auto * op = dynamic_cast(model.formulaAt(0)); + QVERIFY(op); + const std::vector allowed = model.allowedColumnTypes(op->leftChild()); + QCOMPARE(allowed.size(), size_t(1)); + QVERIFY(isAllowed(op->leftChild(), 1)); + } + + // Column in a comparison operator slot (>): scale and ordinal. + model.fromJson(formulas({opNode(">", colNode("contNormal"), numNode(0))})); + { + auto * op = dynamic_cast(model.formulaAt(0)); + QVERIFY(op); + const std::vector allowed = model.allowedColumnTypes(op->leftChild()); + QCOMPARE(allowed.size(), size_t(2)); + QVERIFY(isAllowed(op->leftChild(), 1)); + QVERIFY(isAllowed(op->leftChild(), 2)); + } + + // Column in a numeric function argument (mean): only scale. + model.fromJson(formulas({funcNode("mean", {funcArg("values", {"number"}, colNode("contNormal"))})})); + { + auto * func = dynamic_cast(model.formulaAt(0)); + QVERIFY(func); + const std::vector allowed = model.allowedColumnTypes(func->childAt(0)); + QCOMPARE(allowed.size(), size_t(1)); + QVERIFY(isAllowed(func->childAt(0), 1)); + } + + // Column in a string function argument (hasSubstring): ordinal and nominal. + model.fromJson(formulas({funcNode("hasSubstring", {funcArg("string", {"string"}, colNode("text")), funcArg("substring", {"string"}, strNode("a"))})})); + { + auto * func = dynamic_cast(model.formulaAt(0)); + QVERIFY(func); + const std::vector allowed = model.allowedColumnTypes(func->childAt(0)); + QCOMPARE(allowed.size(), size_t(2)); + QVERIFY(isAllowed(func->childAt(0), 2)); + QVERIFY(isAllowed(func->childAt(0), 3)); + } +} + +void TestAll::testScriptConstructorRowFunctionFreeSlot() +{ + QVERIFY(_newPkgWithDataSet()); + + ScriptConstructorModel model; + model.setMode(ScriptConstructorMode::ComputedColumn); + + // Add a row function at the root (a freshly created one starts with a single empty slot). + auto * rowFunc = new ScriptNodeRowFunction("rowMean"); + rowFunc->addChild(nullptr); + model.insertNode(rowFunc, DropTarget::root()); + QCOMPARE(rowFunc->childCount(), 1); + QVERIFY(rowFunc->childAt(0) == nullptr); + + // Fill the empty slot; a trailing empty slot must appear so more columns can be added. + auto * col = new ScriptNodeColumn("contNormal"); + model.insertNode(col, DropTarget{DropTarget::Kind::RowFunctionArg, rowFunc, 0, {"number"}, false, false}); + QCOMPARE(rowFunc->childCount(), 2); + QVERIFY(rowFunc->childAt(0) != nullptr); + QVERIFY(rowFunc->childAt(1) == nullptr); + + // Fill that one too; another trailing empty slot appears. + auto * col2 = new ScriptNodeColumn("contBinom"); + model.insertNode(col2, DropTarget{DropTarget::Kind::RowFunctionArg, rowFunc, 1, {"number"}, false, false}); + QCOMPARE(rowFunc->childCount(), 3); + QVERIFY(rowFunc->childAt(1) != nullptr); + QVERIFY(rowFunc->childAt(2) == nullptr); + + // The trailing empty slot must not leak into the generated R code. + QCOMPARE(model.toR(), std::string("rowMeanNaRm(contNormal.scale, contBinom.scale)")); +} + QTEST_MAIN(TestAll) diff --git a/Tests/testall.h b/Tests/testall.h index 3075735f9e..41f45219ee 100644 --- a/Tests/testall.h +++ b/Tests/testall.h @@ -80,6 +80,8 @@ private slots: void testScriptConstructorUndo(); void testScriptConstructorGobble(); void testScriptConstructorDefaultFilterJson(); + void testScriptConstructorAllowedColumnTypes(); + void testScriptConstructorRowFunctionFreeSlot(); private: DataSetPackage * _pkg = nullptr; From c13c0e7edb0d5e7c428691ea5ef0b30f4cb46f0a Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 14:35:38 +0200 Subject: [PATCH 06/25] Use a non-modal tooltip for the column-type restriction message Replace the modal MessageForwarder dialog with QToolTip::showText so the "Only allowed" notice appears transiently near the clicked icon and does not block interaction. The message remains translatable via tr(). --- Desktop/qquick/scriptnodeitem.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 5e6bc9900a..ea613543fd 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -2,13 +2,13 @@ #include "scriptconstructorview.h" #include "jasptheme.h" #include "qutils.h" -#include "utilities/messageforwarder.h" #include #include #include #include #include #include +#include #include // ===================================================================================== @@ -842,8 +842,10 @@ void ScriptNodeItem::mousePressEvent(QMouseEvent * event) for(int t : allowed) names << QColumnUtils::getTypeFriendly(static_cast(t)); - MessageForwarder::showWarning(tr("Cannot change column type"), - tr("Only %1 allowed in this context.").arg(names.join(tr("/")))); + // Non-modal, transient tooltip near the clicked icon (translatable via tr()). + const QString message = tr("Only %1 allowed in this context.").arg(names.join(tr("/"))); + QToolTip::showText(event->globalPosition().toPoint(), message, nullptr, QRect(), 3000); + event->accept(); return; } From dd8131470dec9ddf519f304fbf0ac712da0ca74d Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 16:09:58 +0200 Subject: [PATCH 07/25] Add commas/parentheses, centre operator bar, autosize palettes - Separate function and row-function arguments with commas. - Wrap function arguments in parentheses (single-arg math symbols like sum/SD omit them). - Centre the operator row within the top bar. - Autosize the column/function palettes to their widest entry (capped at a third of the view). - Show the column-type restriction tooltip ~10x longer. --- Desktop/qquick/scriptconstructorview.cpp | 38 ++++++++++++--- Desktop/qquick/scriptconstructorview.h | 3 ++ Desktop/qquick/scriptnodeitem.cpp | 61 +++++++++++++++++++++--- Desktop/qquick/scriptnodeitem.h | 5 +- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 4d2a911ffc..f078e06aea 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -11,6 +11,7 @@ #include #include #include +#include ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) : QQuickItem(parent) @@ -375,6 +376,9 @@ void ScriptConstructorView::buildChrome() _operatorBar->setParentItem(this); _operatorBar->setZ(3); + // The operator prototypes live in a content item that is centred within the top bar. + _operatorBarContent = new QQuickItem(_operatorBar); + _columnPalette = new ScriptPalette(this); _columnPalette->setParentItem(this); @@ -498,7 +502,13 @@ void ScriptConstructorView::layoutAll() { qreal w = width(), h = height(); qreal barH = blockDim() * 1.75; - qreal paletteW = blockDim() * 6; + + // Widen the palettes to fit their widest entry (autosize), but never beyond a third of the + // view so the script area keeps enough room. + qreal paletteW = blockDim() * 8; + paletteW = std::max(paletteW, std::max(_columnPaletteContentWidth, _functionPaletteContentWidth)); + paletteW = std::min(paletteW, w / 3.0); + qreal hintH = _hint ? fontPixelSize() + 2 * spacing() : 0; // Reserve space at the bottom for the generated-R display (computed columns only). @@ -517,6 +527,13 @@ void ScriptConstructorView::layoutAll() _operatorBar->setY(0); _operatorBar->setWidth(w); _operatorBar->setHeight(barH); + + // Centre the operator row within the top bar. + if(_operatorBarContent) + { + _operatorBarContent->setX((w - _operatorBarContent->width()) / 2); + _operatorBarContent->setY((barH - _operatorBarContent->height()) / 2); + } } if(_columnPalette) @@ -653,7 +670,7 @@ void ScriptConstructorView::buildOperatorBar() auto placeOperator = [this](qreal & x, const ScriptOperatorDef & def) { ScriptNode * proto = new ScriptNodeOperator(def.op, def.vertical); - ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBar); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBarContent); item->setAcceptsDrops(false); item->rebuild(); item->setX(x); @@ -665,7 +682,7 @@ void ScriptConstructorView::buildOperatorBar() auto placeFunction = [this](qreal & x, const std::string & name) { ScriptNode * proto = new ScriptNodeFunction(name); - ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBar); + ScriptNodeItem * item = new ScriptNodeItem(this, proto, _operatorBarContent); item->setAcceptsDrops(false); item->rebuild(); item->setX(x); @@ -675,7 +692,7 @@ void ScriptConstructorView::buildOperatorBar() const ScriptConstructorRegistry & registry = ScriptConstructorRegistry::instance(); - qreal x = spacing(); + qreal x = 0; for(const ScriptOperatorDef & def : registry.operatorsForMode(_model.mode())) { placeOperator(x, def); @@ -684,8 +701,8 @@ void ScriptConstructorView::buildOperatorBar() } placeFunction(x, "!"); - _operatorBar->setWidth(x); - _operatorBar->setHeight(blockDim()); + _operatorBarContent->setWidth(x); + _operatorBarContent->setHeight(blockDim()); } void ScriptConstructorView::buildFunctionPalette() @@ -695,6 +712,7 @@ void ScriptConstructorView::buildFunctionPalette() QQuickItem * content = _functionPalette->content(); _clearPaletteChildren(content); + qreal maxW = 0; qreal y = spacing(); for(const ScriptFunctionDef & def : ScriptConstructorRegistry::instance().functionsForMode(_model.mode())) { @@ -705,6 +723,7 @@ void ScriptConstructorView::buildFunctionPalette() item->setX(spacing()); item->setY(y); y += item->preferredHeight() + spacing(); + maxW = std::max(maxW, item->preferredWidth()); } for(const ScriptFunctionDef & def : ScriptConstructorRegistry::instance().rowFunctions()) @@ -716,9 +735,12 @@ void ScriptConstructorView::buildFunctionPalette() item->setX(spacing()); item->setY(y); y += item->preferredHeight() + spacing(); + maxW = std::max(maxW, item->preferredWidth()); } + _functionPaletteContentWidth = maxW + spacing() * 2; _functionPalette->setContentHeight(y); + if(_chromeBuilt) layoutAll(); } void ScriptConstructorView::buildColumnPalette() @@ -735,6 +757,7 @@ void ScriptConstructorView::buildColumnPalette() if(!model) return; + qreal maxW = 0; qreal y = spacing(); int rows = model->rowCount(); int nameRole = static_cast(model->roleNames().key("columnName")); @@ -754,9 +777,12 @@ void ScriptConstructorView::buildColumnPalette() item->setX(spacing()); item->setY(y); y += item->preferredHeight() + spacing(); + maxW = std::max(maxW, item->preferredWidth()); } + _columnPaletteContentWidth = maxW + spacing() * 2; _columnPalette->setContentHeight(y); + if(_chromeBuilt) layoutAll(); } // ===================================================================================== diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index b7e3ee765f..9f7620ac63 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -147,6 +147,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider QPointer _background, _operatorBar, + _operatorBarContent, _scriptArea, _scriptColumn, _trash, @@ -156,6 +157,8 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider _functionPalette; std::map _nodeItems; QList _rootItems; + qreal _columnPaletteContentWidth = 0, + _functionPaletteContentWidth = 0; QPointer _textComp, _imageComp, diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index ea613543fd..d8112b4ee9 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -407,6 +407,11 @@ void ScriptNodeItem::clearLeaves() leaf->deleteLater(); _leaves.clear(); + for(QQuickItem * comma : _argumentCommas) + if(comma) + comma->deleteLater(); + _argumentCommas.clear(); + for(ScriptDropSpot * spot : _dropSpots) if(spot) spot->deleteLater(); @@ -465,8 +470,27 @@ QQuickItem * ScriptNodeItem::makeParenText(const QString & text) f.setPixelSize(static_cast(_view->fontPixelSize())); item->setProperty("font", f); item->setProperty("color", theme->textEnabled()); - item->setVisible(false); // shown only when the node is nested + item->setVisible(false); // visibility controlled in layout() + + return item; +} + +QQuickItem * ScriptNodeItem::makeComma() +{ + // Argument separator text (", ") — rendered between function/row-function arguments. + QQuickItem * item = _view->newLeaf(_view->textComponent()); + if(!item) return nullptr; + + item->setParentItem(this); + item->setProperty("text", ", "); + + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + item->setProperty("font", f); + item->setProperty("color", theme->textEnabled()); + _argumentCommas.append(item); return item; } @@ -625,8 +649,9 @@ void ScriptNodeItem::rebuild() { auto * func = static_cast(_node); const ScriptFunctionDef * funcDef = ScriptConstructorRegistry::instance().functionDef(func->functionName()); + const bool hasImage = funcDef && !funcDef->image.empty(); - if(funcDef && !funcDef->image.empty()) + if(hasImage) makeImage(tq(funcDef->image)); else { @@ -637,22 +662,32 @@ void ScriptNodeItem::rebuild() // Single-argument (non-abs) functions wrap their child in parentheses. bool nest = (func->childCount() == 1 && func->functionName() != "abs"); + // Functions show parentheses around their arguments unless they are a single-argument + // math symbol rendered as an image (e.g. sum -> Σ). + _showParens = (func->childCount() > 1) || !hasImage; + for(int i = 0; i < func->childCount(); i++) { const auto & arg = func->arguments()[i]; ScriptDropSpot * spot = makeDropSpot(DropTarget{DropTarget::Kind::FunctionArg, func, i, arg.dropKeys, arg.optional, nest}, QString::fromStdString(arg.name)); fillSpot(spot, arg.value); } + + _openParen = makeParenText("("); + _closeParen = makeParenText(")"); + for(int i = 0; i + 1 < func->childCount(); i++) + makeComma(); break; } case ScriptNode::Type::RowFunction: { auto * rowFunc = static_cast(_node); const ScriptFunctionDef * rowDef = ScriptConstructorRegistry::instance().rowFunctionDef(rowFunc->functionName()); + const bool hasImage = rowDef && !rowDef->image.empty(); // Row functions with a math-symbol image render as "row" + the symbol (e.g. rowSum -> row Σ). // Others render their name as text. - if(rowDef && !rowDef->image.empty()) + if(hasImage) { makeText("row"); makeImage(tq(rowDef->image)); @@ -660,11 +695,18 @@ void ScriptNodeItem::rebuild() else makeText(QString::fromStdString(rowFunc->functionName())); + _showParens = (rowFunc->childCount() > 1) || !hasImage; + for(int i = 0; i < rowFunc->childCount(); i++) { ScriptDropSpot * spot = makeDropSpot(DropTarget{DropTarget::Kind::RowFunctionArg, rowFunc, i, {"number"}, true}, "..."); fillSpot(spot, rowFunc->childAt(i)); } + + _openParen = makeParenText("("); + _closeParen = makeParenText(")"); + for(int i = 0; i + 1 < rowFunc->childCount(); i++) + makeComma(); break; } } @@ -754,17 +796,22 @@ void ScriptNodeItem::layout() maxH = std::max(maxH, h); } - // opening paren - x += 2; + if(_openParen) _openParen->setVisible(_showParens); + if(_closeParen) _closeParen->setVisible(_showParens); + + if(_showParens) placeNext(_openParen); for(int i = 0; i < _dropSpots.size(); i++) { ScriptDropSpot * spot = _dropSpots[i]; spot->layout(); placeNext(spot); + + if(i + 1 < _dropSpots.size() && i < _argumentCommas.size()) + placeNext(_argumentCommas[i]); } - x += 2; // closing paren + if(_showParens) placeNext(_closeParen); } else { @@ -844,7 +891,7 @@ void ScriptNodeItem::mousePressEvent(QMouseEvent * event) // Non-modal, transient tooltip near the clicked icon (translatable via tr()). const QString message = tr("Only %1 allowed in this context.").arg(names.join(tr("/"))); - QToolTip::showText(event->globalPosition().toPoint(), message, nullptr, QRect(), 3000); + QToolTip::showText(event->globalPosition().toPoint(), message, nullptr, QRect(), 30000); event->accept(); return; diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index fc665250bf..2d375cbb53 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -97,6 +97,7 @@ private slots: QQuickItem * makeText(const QString & text, bool bold = false); QQuickItem * makeImage(const QString & iconFile); QQuickItem * makeParenText(const QString & text); + QQuickItem * makeComma(); ScriptDropSpot* makeDropSpot(const DropTarget & target, const QString & placeholder); void clearLeaves(); void addLeaf(QQuickItem * leaf); @@ -106,13 +107,15 @@ private slots: ScriptConstructorView * _view = nullptr; ScriptNode * _node = nullptr; QList _leaves; + QList _argumentCommas; QList _dropSpots; QPointer _openParen, _closeParen; qreal _preferredWidth = 0, _preferredHeight = 0; bool _acceptsDrops = true, - _nested = false; + _nested = false, + _showParens = false; }; /// From 03a22e07087164072aeba339862037d4af772225 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 16:27:26 +0200 Subject: [PATCH 08/25] Fix sqrt radical rendering and add constructor background watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin image implicit size to blockDim so icon images no longer blow up the layout to their source resolution (this made sqrt extremely wide). - Render sqrt as a proper radical: a tall √ head plus an overline drawn above the argument; the operator bar shows the plain square-root symbol. - Add the faint centred background decoration (filter vs computed-column). --- Desktop/qquick/scriptconstructorview.cpp | 39 ++++++++++++ Desktop/qquick/scriptconstructorview.h | 3 + Desktop/qquick/scriptnodeitem.cpp | 79 +++++++++++++++++++++++- Desktop/qquick/scriptnodeitem.h | 3 +- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index f078e06aea..7569d9f52d 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -53,6 +53,8 @@ void ScriptConstructorView::setModeInt(int m) if(_rCodeDisplay) _rCodeDisplay->setVisible(_showGeneratedRCode && mode != ScriptConstructorMode::Filter); + updateBackgroundDecoration(); + emit modeChanged(); if(_chromeBuilt) @@ -372,6 +374,16 @@ void ScriptConstructorView::buildChrome() _background->setProperty("color", theme ? theme->white() : QColor("white")); } + // Faint centred decoration distinguishing a filter from a computed-column constructor. + _backgroundDecoration = newLeaf(imageComponent()); + if(_backgroundDecoration) + { + _backgroundDecoration->setParentItem(this); + _backgroundDecoration->setZ(-2); + _backgroundDecoration->setProperty("fillMode", 1); // Image.PreserveAspectFit + } + updateBackgroundDecoration(); + _operatorBar = new QQuickItem(this); _operatorBar->setParentItem(this); _operatorBar->setZ(3); @@ -521,6 +533,22 @@ void ScriptConstructorView::layoutAll() _background->setHeight(h); } + if(_backgroundDecoration) + { + const qreal iw = _backgroundDecoration->property("implicitWidth").toReal(); + const qreal ih = _backgroundDecoration->property("implicitHeight").toReal(); + if(iw > 0 && ih > 0) + { + // Fit within half the view, centred (matches the old fadeCollector watermark). + const qreal ratio = std::min(std::min(w / iw, h / ih), qreal(1.0)) * 0.5; + const qreal dw = iw * ratio, dh = ih * ratio; + _backgroundDecoration->setWidth(dw); + _backgroundDecoration->setHeight(dh); + _backgroundDecoration->setX((w - dw) / 2); + _backgroundDecoration->setY((h - dh) / 2); + } + } + if(_operatorBar) { _operatorBar->setX(0); @@ -659,6 +687,17 @@ QString ScriptConstructorView::defaultHintText() const : tr("Welcome to the drag and drop computed column constructor!"); } +void ScriptConstructorView::updateBackgroundDecoration() +{ + if(!_backgroundDecoration) return; + + const QString file = _model.mode() == ScriptConstructorMode::Filter + ? QString("filterConstructorBackground.png") + : QString("columnConstructorBackground.png"); + + _backgroundDecoration->setProperty("source", JaspTheme::currentTheme()->iconPath() + "/" + file); +} + // ===================================================================================== // Palettes + operator bar // ===================================================================================== diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index 9f7620ac63..1ae006b8d1 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -143,9 +143,12 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void clearHover(); + void updateBackgroundDecoration(); + ScriptConstructorModel _model; QPointer _background, + _backgroundDecoration, _operatorBar, _operatorBarContent, _scriptArea, diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index d8112b4ee9..af8082c57b 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -419,6 +419,7 @@ void ScriptNodeItem::clearLeaves() if(_openParen) { _openParen->deleteLater(); _openParen = nullptr; } if(_closeParen) { _closeParen->deleteLater(); _closeParen = nullptr; } + if(_overline) { _overline->deleteLater(); _overline = nullptr; } } QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) @@ -452,6 +453,10 @@ QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) qreal dim = _view->blockDim(); item->setWidth(dim); item->setHeight(dim); + // Also pin the implicit size, otherwise the Image reports its source image's (huge) + // intrinsic dimensions and blows up the layout. + item->setImplicitWidth(dim); + item->setImplicitHeight(dim); addLeaf(item); return item; @@ -650,8 +655,33 @@ void ScriptNodeItem::rebuild() auto * func = static_cast(_node); const ScriptFunctionDef * funcDef = ScriptConstructorRegistry::instance().functionDef(func->functionName()); const bool hasImage = funcDef && !funcDef->image.empty(); + const bool isSqrt = func->functionName() == "sqrt"; - if(hasImage) + if(isSqrt && _acceptsDrops) + { + // Radical: a √ head (drawn tall) with an overline layered above the argument in layout(). + QQuickItem * head = _view->newLeaf(_view->imageComponent()); + if(head) + { + head->setParentItem(this); + head->setProperty("source", theme->iconPath() + "/rootHead.png"); + head->setProperty("fillMode", 1); // Image.PreserveAspectFit + head->setWidth(block); + head->setHeight(block); + addLeaf(head); + } + + _overline = _view->newLeaf(_view->rectangleComponent()); + if(_overline) + { + _overline->setParentItem(this); + _overline->setProperty("color", theme->textEnabled()); + _overline->setVisible(false); + } + } + else if(isSqrt) // operator bar: plain square-root symbol + makeImage(QString("sqrtSelector.png")); + else if(hasImage) makeImage(tq(funcDef->image)); else { @@ -747,7 +777,52 @@ void ScriptNodeItem::layout() // For a robust visual order we re-derive from the node structure: ScriptNode::Type t = _node->type(); - if(t == ScriptNode::Type::Operator || t == ScriptNode::Type::OperatorVertical) + if(t == ScriptNode::Type::Function && _acceptsDrops + && static_cast(_node)->functionName() == "sqrt") + { + // Radical: √ head on the left, an overline above the argument, the argument below it. + QQuickItem * head = _leaves.isEmpty() ? nullptr : _leaves.first(); + ScriptDropSpot * arg = _dropSpots.isEmpty() ? nullptr : _dropSpots.first(); + + const qreal overlineH = std::max(qreal(2.0), block * 0.15); + + qreal argW = 0, argH = block; + if(arg) + { + arg->layout(); + argW = arg->width(); + argH = arg->height(); + } + + const qreal totalH = std::max(block, overlineH + argH); + + if(head) + { + head->setX(0); + head->setY(0); + head->setWidth(block); + head->setHeight(totalH); + } + + if(_overline) + { + _overline->setVisible(true); + _overline->setX(block); + _overline->setY(0); + _overline->setWidth(argW); + _overline->setHeight(overlineH); + } + + if(arg) + { + arg->setX(block); + arg->setY(overlineH); + } + + x = block + argW; + maxH = totalH; + } + else if(t == ScriptNode::Type::Operator || t == ScriptNode::Type::OperatorVertical) { auto * op = static_cast(_node); ScriptDropSpot * left = _dropSpots.size() > 0 ? _dropSpots[0] : nullptr; diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index 2d375cbb53..6439ed87a3 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -110,7 +110,8 @@ private slots: QList _argumentCommas; QList _dropSpots; QPointer _openParen, - _closeParen; + _closeParen, + _overline; qreal _preferredWidth = 0, _preferredHeight = 0; bool _acceptsDrops = true, From a42a003801af6f414a8bbe45ac4ae97b0b4d8f5a Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 17:19:41 +0200 Subject: [PATCH 09/25] stretch --- Desktop/qquick/scriptnodeitem.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index af8082c57b..0025c0d901 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -665,7 +665,7 @@ void ScriptNodeItem::rebuild() { head->setParentItem(this); head->setProperty("source", theme->iconPath() + "/rootHead.png"); - head->setProperty("fillMode", 1); // Image.PreserveAspectFit + head->setProperty("fillMode", 0); // Image.Stretch (fill the box so the head's right edge is exact) head->setWidth(block); head->setHeight(block); addLeaf(head); From e809dabab76508898b0de7a441faef26de2c48d8 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Wed, 26 Aug 2026 18:16:02 +0200 Subject: [PATCH 10/25] Restore translatable tooltips on ScriptConstructor elements - Make ScriptConstructorRegistry a QObject and wrap every operator/function/ row-function tooltip in tr(), restoring the translatable strings. - Restore the mode-dependent "returns logicals..." suffix for comparison and logical operators (and the "!" function) via ScriptOperatorDef::toolTipForMode. - Add a ToolTip overlay (hover MouseArea, acceptedButtons: NoButton) to each ScriptNodeItem, showing the element's tooltip on hover without breaking drag & drop. - Columns show "Click icon to change column type" plus description and transformed-type preview; the trash shows its old "Dump unwanted snippets" tooltip. --- CommonData/scriptconstructorregistry.cpp | 192 +++++++++++++---------- CommonData/scriptconstructorregistry.h | 16 +- Desktop/qquick/scriptconstructorview.cpp | 29 +++- Desktop/qquick/scriptconstructorview.h | 4 +- Desktop/qquick/scriptnodeitem.cpp | 81 ++++++++++ Desktop/qquick/scriptnodeitem.h | 9 ++ 6 files changed, 239 insertions(+), 92 deletions(-) diff --git a/CommonData/scriptconstructorregistry.cpp b/CommonData/scriptconstructorregistry.cpp index 61962cfb0c..631215303b 100644 --- a/CommonData/scriptconstructorregistry.cpp +++ b/CommonData/scriptconstructorregistry.cpp @@ -88,35 +88,55 @@ stringvec ScriptOperatorDef::dragKeys(ScriptConstructorMode mode) const return returnsBoolean(mode) ? stringvec{"boolean"} : stringvec{"number"}; } +QString ScriptOperatorDef::toolTipForMode(ScriptConstructorMode mode) const +{ + if(!logicalSuffix) + return toolTip; + + return toolTip.arg(mode == ScriptConstructorMode::Filter + ? ScriptConstructorRegistry::tr("returns logicals and can be the root of a filter formula") + : ScriptConstructorRegistry::tr("returns logicals")); +} + +QString ScriptFunctionDef::toolTipForMode(ScriptConstructorMode mode) const +{ + if(!logicalSuffix) + return toolTip; + + return toolTip.arg(mode == ScriptConstructorMode::Filter + ? ScriptConstructorRegistry::tr("returns logicals and can be the root of a filter formula") + : ScriptConstructorRegistry::tr("returns logicals")); +} + ScriptConstructorRegistry::ScriptConstructorRegistry() { - auto addOp = [this](const std::string & op, const std::string & toolTip, const std::string & image = "", bool vertical = false) + auto addOp = [this](const std::string & op, const QString & toolTip, const std::string & image = "", bool vertical = false, bool logicalSuffix = false) { _operatorIndex[op + (vertical ? "V" : "")] = _operators.size(); - _operators.push_back({op, toolTip, image, vertical}); + _operators.push_back({op, toolTip, image, vertical, logicalSuffix}); }; - addOp("+", "Addition", "plus.png"); - addOp("-", "Subtraction", "minus.png"); - addOp("*", "Multiplication", "multiply.png"); - addOp("/", "Division", "divide.png", true); - addOp("/", "Division", ""); - addOp("^", "Power (2^3 returns 8)", ""); - addOp("%%", "Modulo: returns the remainder of a division. 3%2 returns 1", "modulo.png"); - addOp("==", "Equality: returns logicals", "equal.png"); - addOp("!=", "Inequality: returns logicals", "notEqual.png"); - addOp("<", "Less than: returns logicals", "lessThan.png"); - addOp("<=", "Less than or equal to: returns logicals", "lessThanEqual.png"); - addOp(">", "Greater than: returns logicals", "greaterThan.png"); - addOp(">=", "Greater than or equal to: returns logicals", "greaterThanEqual.png"); - addOp("&", "And: returns logicals", "and.png"); - addOp("|", "Or: returns logicals", "or.png"); - addOp("%|%", "Split: applies filter separately to each subgroup", "ConditionBy.png"); - - auto addFunc = [this](const std::string & name, const std::string & friendlyName, const std::string & toolTip, const std::vector & params, const std::string & image = "", bool operatorBarOnly = false) + addOp("+", tr("Addition"), "plus.png"); + addOp("-", tr("Subtraction"), "minus.png"); + addOp("*", tr("Multiplication"), "multiply.png"); + addOp("/", tr("Division"), "divide.png", true); + addOp("/", tr("Division"), ""); + addOp("^", tr("Power (2^3 returns 8)"), ""); + addOp("%%", tr("Modulo: returns the remainder of a division. 3%2 returns 1"), "modulo.png"); + addOp("==", tr("Equality: %1"), "equal.png", false, true); + addOp("!=", tr("Inequality: %1"), "notEqual.png", false, true); + addOp("<", tr("Less than: %1"), "lessThan.png", false, true); + addOp("<=", tr("Less than or equal to: %1"), "lessThanEqual.png", false, true); + addOp(">", tr("Greater than: %1"), "greaterThan.png", false, true); + addOp(">=", tr("Greater than or equal to: %1"), "greaterThanEqual.png", false, true); + addOp("&", tr("And: %1"), "and.png", false, true); + addOp("|", tr("Or: %1"), "or.png", false, true); + addOp("%|%", tr("Split: applies filter separately to each subgroup"), "ConditionBy.png"); + + auto addFunc = [this](const std::string & name, const std::string & friendlyName, const QString & toolTip, const std::vector & params, const std::string & image = "", bool operatorBarOnly = false, bool logicalSuffix = false) { _functionIndex[name] = _functions.size(); - _functions.push_back({name, friendlyName, toolTip, image, params, false, false, operatorBarOnly}); + _functions.push_back({name, friendlyName, toolTip, image, params, false, false, operatorBarOnly, logicalSuffix}); }; auto P = [](const std::string & name, const stringvec & keys) { return ScriptParamDef::fromRaw(name, keys); }; @@ -128,78 +148,78 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() strNum = {"string", "number"}, strBoolNum = {"string", "boolean", "number"}; - addFunc("abs", "", "absolute value", {P("values", numKeys)}); - addFunc("sd", "", "standard deviation", {P("values", numKeys)}, "sigma.png"); - addFunc("var", "", "variance", {P("values", numKeys)}, "variance.png"); - addFunc("sum", "", "summation", {P("values", numKeys)}, "sum.png"); - addFunc("prod", "", "product of values", {P("values", numKeys)}, "product.png"); - addFunc("zScores", "", "Standardizes the variable", {P("values", numKeys)}); - addFunc("min", "", "returns minimum of values", {P("values", numKeys)}); - addFunc("max", "", "returns maximum of values", {P("values", numKeys)}); - addFunc("mean", "", "mean", {P("values", numKeys)}); - addFunc("sign", "", "returns the sign of values", {P("values", numKeys)}); - addFunc("round", "", "rounds y to n decimals", {P("y", numKeys), P("n", numKeys)}); - addFunc("length", "", "returns number of elements in y", {P("y", strNum)}); - addFunc("median", "", "median", {P("values", numKeys)}); - addFunc("ifelse", "", "if-else statement", {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); - addFunc("hasSubstring", "", "returns true if string contains substring at least once", {P("string", strKeys), P("substring", strKeys)}); - addFunc("is.na", "", "Combine with not-operator to filter out rows with missing values (NA) for a column.", {P("y", strBoolNum)}); + addFunc("abs", "", tr("absolute value"), {P("values", numKeys)}); + addFunc("sd", "", tr("standard deviation"), {P("values", numKeys)}, "sigma.png"); + addFunc("var", "", tr("variance"), {P("values", numKeys)}, "variance.png"); + addFunc("sum", "", tr("summation"), {P("values", numKeys)}, "sum.png"); + addFunc("prod", "", tr("product of values"), {P("values", numKeys)}, "product.png"); + addFunc("zScores", "", tr("Standardizes the variable"), {P("values", numKeys)}); + addFunc("min", "", tr("returns minimum of values"), {P("values", numKeys)}); + addFunc("max", "", tr("returns maximum of values"), {P("values", numKeys)}); + addFunc("mean", "", tr("mean"), {P("values", numKeys)}); + addFunc("sign", "", tr("returns the sign of values"), {P("values", numKeys)}); + addFunc("round", "", tr("rounds y to n decimals"), {P("y", numKeys), P("n", numKeys)}); + addFunc("length", "", tr("returns number of elements in y"), {P("y", strNum)}); + addFunc("median", "", tr("median"), {P("values", numKeys)}); + addFunc("ifelse", "", tr("if-else statement"), {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); + addFunc("hasSubstring", "", tr("returns true if string contains substring at least once"), {P("string", strKeys), P("substring", strKeys)}); + addFunc("is.na", "", tr("Combine with not-operator to filter out rows with missing values (NA) for a column."), {P("y", strBoolNum)}); // sqrt and ! live only in the operator bar (interspersed with the operators), not in the // right-hand function palette. - addFunc("sqrt", "", "Square root", {P("value(s)", numKeys)}, "rootHead.png", true); - addFunc("!", "", "Not", {P("logical(s)", boolKeys)}, "negative.png", true); - - addFunc("log", "", "natural logarithm", {P("y", numKeys)}); - addFunc("log2", "log\u2082", "base 2 logarithm", {P("y", numKeys)}); - addFunc("log10", "log\u2081\u2080", "base 10 logarithm", {P("y", numKeys)}); - addFunc("logb", "", "logarithm of y in 'base'", {P("y", numKeys), P("base", numKeys)}); - addFunc("exp", "", "exponential", {P("y", numKeys)}); - addFunc("fishZ", "", "Fisher's Z-transform (i.e., the inverse hyperbolic tangent) to transform correlations, numbers between -1 and 1 to the real line", {P("y", numKeys)}); - addFunc("invFishZ", "fishZ\u207B\u00B9", "Inverse Fisher's Z-transform (i.e., the hyperbolic tangent) to transform real numbers to numbers between -1 and 1", {P("y", numKeys)}); - addFunc("logit", "", "Logit transform (i.e., the inverse of the standard logit function, or log-odds transform) converts numbers between 0 and 1 to the real line.", {P("y", numKeys)}); - addFunc("invLogit", "logit\u207B\u00B9", "Inverse logit transform (i.e., the standard logit function) converts numbers on the real line to numbers between 0 and 1.", {P("y", numKeys)}); - addFunc("BoxCox", "", "Two-parameter Box-Cox transform (transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like.", {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); - addFunc("BoxCoxAuto", "", "Two-parameter Box-Cox transform with an automatic determination of the shape parameter lambda, according to one of the three of methods:'loglik', 'sd', or 'movingRange'. The search for optimal lambda is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("method", strKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); - addFunc("invBoxCox", "BoxCox\u207B\u00B9", "Inverse two-parameter Box-Cox transform.", {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); - addFunc("powerTransform", "", "Two-parameter power transform (scale-invariant Box-Box; transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like.", {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys)}); - addFunc("powerTransformAuto", "", "Two-parameter power transform with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys)}); - addFunc("YeoJohnson", "", "Yeo-Johnson transform (transforms any real values) to stabilize variance and attempt to make the data more normal distribution-like.", {P("y", numKeys), P("lambda", numKeys)}); - addFunc("YeoJohnsonAuto", "", "Yeo-Johnson transform (transforms any real values) with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); - addFunc("Johnson", "", "Johnson transform (transforms any real values). The search for optimal parameter is bounded within 'lower' and 'upper' limits.", {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); - - addFunc("cut", "", "break your data up in numBreaks levels", {P("values", numKeys), P("numBreaks", numKeys)}); - addFunc("replaceNA", "", "replace any missing values (NA) in column by the value in replaceWith", {P("column", strBoolNum), P("replaceWith", strBoolNum)}); - addFunc("ifElse", "", "if-else statement", {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); - - addFunc("normalDist", "", "generates data from a Gaussian distribution with specified mean and standard deviation sd", {P("mean", numKeys), P("sd", numKeys)}); - addFunc("tDist", "", "generates data from t distribution with degrees of freedom df and non-centrality parameter ncp", {P("df", numKeys), P("ncp", numKeys)}); - addFunc("chiSqDist", "", "generates data from a chi-squared distribution with degrees of freedom df and non-centrality parameter ncp", {P("df", numKeys), P("ncp", numKeys)}); - addFunc("fDist", "", "generates data from an F distribution with specified degrees of freedoms df1, df2 and non-centrality parameter ncp", {P("df1", numKeys), P("df2", numKeys), P("ncp", numKeys)}); - addFunc("binomDist", "", "generates data from a binomial distribution with specified trials and probability prob", {P("trials", numKeys), P("prob", numKeys)}); - addFunc("negBinomDist", "", "generates data from a negative binomial distribution with specified trials and probability prob", {P("targetTrial", numKeys), P("prob", numKeys)}); - addFunc("geomDist", "", "generates data from a geometric distribution with specified probability prob", {P("prob", numKeys)}); - addFunc("poisDist", "", "generates data from a Poisson distribution with specified rate lambda", {P("lambda", numKeys)}); - addFunc("betaDist", "", "generates data from a beta distribution with specified shapes alpha and beta", {P("alpha", numKeys), P("beta", numKeys)}); - addFunc("unifDist", "", "generates data from a uniform distribution between min and max", {P("min", numKeys), P("max", numKeys)}); - addFunc("gammaDist", "", "generates data from a gamma distribution with specified shape and scale", {P("shape", numKeys), P("scale", numKeys)}); - addFunc("expDist", "", "generates data from an exponential distribution with specified rate", {P("rate", numKeys)}); - addFunc("logNormDist", "", "generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog", {P("meanLog", numKeys), P("sdLog", numKeys)}); - addFunc("weibullDist", "", "generates data from a Weibull distribution with specified shape and scale", {P("shape", numKeys), P("scale", numKeys)}); - - auto addRowFunc = [this](const std::string & name, const std::string & toolTip, const std::string & image = "") + addFunc("sqrt", "", tr("Square root"), {P("value(s)", numKeys)}, "rootHead.png", true); + addFunc("!", "", tr("Not: %1"), {P("logical(s)", boolKeys)}, "negative.png", true, true); + + addFunc("log", "", tr("natural logarithm"), {P("y", numKeys)}); + addFunc("log2", "log\u2082", tr("base 2 logarithm"), {P("y", numKeys)}); + addFunc("log10", "log\u2081\u2080", tr("base 10 logarithm"), {P("y", numKeys)}); + addFunc("logb", "", tr("logarithm of y in 'base'"), {P("y", numKeys), P("base", numKeys)}); + addFunc("exp", "", tr("exponential"), {P("y", numKeys)}); + addFunc("fishZ", "", tr("Fisher's Z-transform (i.e., the inverse hyperbolic tangent) to transform correlations, numbers between -1 and 1 to the real line"), {P("y", numKeys)}); + addFunc("invFishZ", "fishZ\u207B\u00B9", tr("Inverse Fisher's Z-transform (i.e., the hyperbolic tangent) to transform real numbers to numbers between -1 and 1"), {P("y", numKeys)}); + addFunc("logit", "", tr("Logit transform (i.e., the inverse of the standard logit function, or log-odds transform) converts numbers between 0 and 1 to the real line."), {P("y", numKeys)}); + addFunc("invLogit", "logit\u207B\u00B9", tr("Inverse logit transform (i.e., the standard logit function) converts numbers on the real line to numbers between 0 and 1."), {P("y", numKeys)}); + addFunc("BoxCox", "", tr("Two-parameter Box-Cox transform (transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("BoxCoxAuto", "", tr("Two-parameter Box-Cox transform with an automatic determination of the shape parameter lambda, according to one of the three of methods:'loglik', 'sd', or 'movingRange'. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("method", strKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("invBoxCox", "BoxCox\u207B\u00B9", tr("Inverse two-parameter Box-Cox transform."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("powerTransform", "", tr("Two-parameter power transform (scale-invariant Box-Box; transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys)}); + addFunc("powerTransformAuto", "", tr("Two-parameter power transform with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys)}); + addFunc("YeoJohnson", "", tr("Yeo-Johnson transform (transforms any real values) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys)}); + addFunc("YeoJohnsonAuto", "", tr("Yeo-Johnson transform (transforms any real values) with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); + addFunc("Johnson", "", tr("Johnson transform (transforms any real values). The search for optimal parameter is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); + + addFunc("cut", "", tr("break your data up in numBreaks levels"), {P("values", numKeys), P("numBreaks", numKeys)}); + addFunc("replaceNA", "", tr("replace any missing values (NA) in column by the value in replaceWith"), {P("column", strBoolNum), P("replaceWith", strBoolNum)}); + addFunc("ifElse", "", tr("if-else statement"), {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); + + addFunc("normalDist", "", tr("generates data from a Gaussian distribution with specified mean and standard deviation sd"), {P("mean", numKeys), P("sd", numKeys)}); + addFunc("tDist", "", tr("generates data from t distribution with degrees of freedom df and non-centrality parameter ncp"), {P("df", numKeys), P("ncp", numKeys)}); + addFunc("chiSqDist", "", tr("generates data from a chi-squared distribution with degrees of freedom df and non-centrality parameter ncp"), {P("df", numKeys), P("ncp", numKeys)}); + addFunc("fDist", "", tr("generates data from an F distribution with specified degrees of freedoms df1, df2 and non-centrality parameter ncp"), {P("df1", numKeys), P("df2", numKeys), P("ncp", numKeys)}); + addFunc("binomDist", "", tr("generates data from a binomial distribution with specified trials and probability prob"), {P("trials", numKeys), P("prob", numKeys)}); + addFunc("negBinomDist", "", tr("generates data from a negative binomial distribution with specified trials and probability prob"), {P("targetTrial", numKeys), P("prob", numKeys)}); + addFunc("geomDist", "", tr("generates data from a geometric distribution with specified probability prob"), {P("prob", numKeys)}); + addFunc("poisDist", "", tr("generates data from a Poisson distribution with specified rate lambda"), {P("lambda", numKeys)}); + addFunc("betaDist", "", tr("generates data from a beta distribution with specified shapes alpha and beta"), {P("alpha", numKeys), P("beta", numKeys)}); + addFunc("unifDist", "", tr("generates data from a uniform distribution between min and max"), {P("min", numKeys), P("max", numKeys)}); + addFunc("gammaDist", "", tr("generates data from a gamma distribution with specified shape and scale"), {P("shape", numKeys), P("scale", numKeys)}); + addFunc("expDist", "", tr("generates data from an exponential distribution with specified rate"), {P("rate", numKeys)}); + addFunc("logNormDist", "", tr("generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog"), {P("meanLog", numKeys), P("sdLog", numKeys)}); + addFunc("weibullDist", "", tr("generates data from a Weibull distribution with specified shape and scale"), {P("shape", numKeys), P("scale", numKeys)}); + + auto addRowFunc = [this](const std::string & name, const QString & toolTip, const std::string & image = "") { _rowFunctionIndex[name] = _rowFunctions.size(); - _rowFunctions.push_back({name, name, toolTip, image, {}, true, true, false}); + _rowFunctions.push_back({name, name, toolTip, image, {}, true, true, false, false}); }; - addRowFunc("rowMean", "Rowwise mean"); - addRowFunc("rowSum", "Rowwise sum", "sum.png"); - addRowFunc("rowSD", "Rowwise standard deviation", "sigma.png"); - addRowFunc("rowVariance", "Rowwise variance", "variance.png"); - addRowFunc("rowMedian", "Rowwise median"); - addRowFunc("rowMin", "Rowwise minimum"); - addRowFunc("rowMax", "Rowwise maximum"); + addRowFunc("rowMean", tr("Rowwise mean")); + addRowFunc("rowSum", tr("Rowwise sum"), "sum.png"); + addRowFunc("rowSD", tr("Rowwise standard deviation"), "sigma.png"); + addRowFunc("rowVariance", tr("Rowwise variance"), "variance.png"); + addRowFunc("rowMedian", tr("Rowwise median")); + addRowFunc("rowMin", tr("Rowwise minimum")); + addRowFunc("rowMax", tr("Rowwise maximum")); } const ScriptConstructorRegistry & ScriptConstructorRegistry::instance() diff --git a/CommonData/scriptconstructorregistry.h b/CommonData/scriptconstructorregistry.h index ffedd834a8..f0ab9e98a8 100644 --- a/CommonData/scriptconstructorregistry.h +++ b/CommonData/scriptconstructorregistry.h @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include "utils.h" enum class ScriptConstructorMode { Filter, ComputedColumn, ComputedDataSet }; @@ -21,33 +23,39 @@ struct ScriptFunctionDef { std::string name; std::string friendlyName; - std::string toolTip; + QString toolTip; std::string image; std::vector params; bool variadic = false; bool isRowFunction = false; bool operatorBarOnly = false; + bool logicalSuffix = false; stringvec dragKeys() const; bool addsNaRm() const; + QString toolTipForMode(ScriptConstructorMode mode) const; }; struct ScriptOperatorDef { std::string op; - std::string toolTip; + QString toolTip; std::string image; - bool vertical = false; + bool vertical = false; + bool logicalSuffix = false; stringvec dropKeysLeft( ScriptConstructorMode mode) const; stringvec dropKeysRight( ScriptConstructorMode mode) const; bool mirrorKeys() const; bool returnsBoolean( ScriptConstructorMode mode) const; stringvec dragKeys( ScriptConstructorMode mode) const; + QString toolTipForMode( ScriptConstructorMode mode) const; }; -class ScriptConstructorRegistry +class ScriptConstructorRegistry : public QObject { + Q_OBJECT + public: static const ScriptConstructorRegistry & instance(); diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 7569d9f52d..1145349034 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -34,7 +34,7 @@ ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) ScriptConstructorView::~ScriptConstructorView() { - for(auto & comp : {_textComp, _imageComp, _textInputComp, _checkBoxComp, _rectComp}) + for(auto & comp : {_textComp, _imageComp, _textInputComp, _checkBoxComp, _rectComp, _tooltipAreaComp}) delete comp.data(); } @@ -317,6 +317,28 @@ QQmlComponent * ScriptConstructorView::rectangleComponent() return _rectComp; } +QQmlComponent * ScriptConstructorView::tooltipAreaComponent() +{ + if(!_tooltipAreaComp) + { + _tooltipAreaComp = new QQmlComponent(qmlEngine(this)); + _tooltipAreaComp->setData( + "import QtQuick\n" + "import QtQuick.Controls\n" + "MouseArea {\n" + " anchors.fill: parent\n" + " z: 5\n" + " acceptedButtons: Qt.NoButton\n" + " hoverEnabled: true\n" + " ToolTip.delay: 500\n" + " ToolTip.text: parent.toolTip\n" + " ToolTip.visible: ToolTip.text !== '' && containsMouse\n" + " ToolTip.toolTip.background: Rectangle { color: jaspTheme.tooltipBackgroundColor; radius: jaspTheme.borderRadius }\n" + "}\n", QUrl("ScriptConstructorToolTipArea")); + } + return _tooltipAreaComp; +} + QQuickItem * ScriptConstructorView::newLeaf(QQmlComponent * comp) { if(!comp || comp->isError()) @@ -413,6 +435,11 @@ void ScriptConstructorView::buildChrome() _trash->setProperty("radius", 6.0); _trash->setZ(10); + // Hover tooltip (mirrors the old DropTrash.qml). + _trash->setProperty("toolTip", tr("Dump unwanted snippets here; double-click to erase the entire slate")); + if(QQuickItem * overlay = newLeaf(tooltipAreaComponent())) + overlay->setParentItem(_trash); + // Trash icon centred inside the drop zone. QQuickItem * icon = newLeaf(imageComponent()); if(icon) diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index 1ae006b8d1..496a68bbaa 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -86,6 +86,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider QQmlComponent * textInputComponent(); QQmlComponent * checkBoxComponent(); QQmlComponent * rectangleComponent(); + QQmlComponent * tooltipAreaComponent(); qreal blockDim() const; qreal fontPixelSize() const; @@ -167,7 +168,8 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider _imageComp, _textInputComp, _checkBoxComp, - _rectComp; + _rectComp, + _tooltipAreaComp; QAbstractItemModel * _columnsModel = nullptr; diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 0025c0d901..92450d78d1 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -2,6 +2,7 @@ #include "scriptconstructorview.h" #include "jasptheme.h" #include "qutils.h" +#include "data/columnsmodel.h" #include #include #include @@ -393,6 +394,26 @@ ScriptNodeItem::ScriptNodeItem(ScriptConstructorView * view, ScriptNode * node, , _node(node) { setAcceptedMouseButtons(Qt::LeftButton | Qt::RightButton); + + // Transparent tooltip overlay: a MouseArea that only reports hover (acceptedButtons: + // Qt.NoButton) so a QtQuick ToolTip can show on hover without breaking drag & drop. + if(view) + { + QQuickItem * overlay = view->newLeaf(view->tooltipAreaComponent()); + if(overlay) + { + overlay->setParentItem(this); + overlay->setZ(5); + } + } +} + +void ScriptNodeItem::setToolTip(const QString & toolTip) +{ + if(_toolTip == toolTip) + return; + _toolTip = toolTip; + emit toolTipChanged(); } ScriptNodeItem::~ScriptNodeItem() @@ -741,6 +762,66 @@ void ScriptNodeItem::rebuild() } } + // Compute the hover tooltip for this element. + QString tip; + + switch(_node->type()) + { + case ScriptNode::Type::Operator: + case ScriptNode::Type::OperatorVertical: + { + const std::string & op = static_cast(_node)->op(); + if(const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op)) + tip = def->toolTipForMode(_view->model()->mode()); + break; + } + case ScriptNode::Type::Function: + { + const std::string & fn = static_cast(_node)->functionName(); + if(const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(fn)) + tip = def->toolTipForMode(_view->model()->mode()); + break; + } + case ScriptNode::Type::RowFunction: + { + const std::string & fn = static_cast(_node)->functionName(); + if(const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().rowFunctionDef(fn)) + tip = def->toolTipForMode(_view->model()->mode()); + break; + } + case ScriptNode::Type::Column: + { + auto * col = static_cast(_node); + + const int actual = _view->columnType(col->columnName()); + const int effective = col->effectiveColumnType(actual); + + QStringList parts; + parts << tr("Click icon to change column type"); + + if(ColumnsModel * cols = ColumnsModel::singleton()) + { + const QString description = cols->getColumnDescription(tq(col->columnName())); + if(!description.isEmpty()) + parts << tr("Column description: ") + description; + + if(effective != actual) + { + const QString preview = cols->getColumnTransformedToolTip(tq(col->columnName()), col->columnTypeUser()); + if(!preview.isEmpty()) + parts << preview; + } + } + + tip = parts.join("\n\n"); + break; + } + default: + break; + } + + setToolTip(tip); + layout(); } diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index 6439ed87a3..ba62ddd1cc 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -63,6 +63,8 @@ class ScriptNodeItem : public QQuickItem { Q_OBJECT + Q_PROPERTY(QString toolTip READ toolTip WRITE setToolTip NOTIFY toolTipChanged) + public: explicit ScriptNodeItem(ScriptConstructorView * view, ScriptNode * node, QQuickItem * parent = nullptr); ~ScriptNodeItem() override; @@ -83,6 +85,12 @@ class ScriptNodeItem : public QQuickItem void setNested(bool nested); + QString toolTip() const { return _toolTip; } + void setToolTip(const QString & toolTip); + +signals: + void toolTipChanged(); + protected: void mousePressEvent(QMouseEvent * event) override; void mouseMoveEvent(QMouseEvent * event) override; @@ -117,6 +125,7 @@ private slots: bool _acceptsDrops = true, _nested = false, _showParens = false; + QString _toolTip; }; /// From 52c74d918a5d6e78fb8550c9186a8102c8356893 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 00:01:54 +0200 Subject: [PATCH 11/25] Translate ScriptConstructor tooltips at display time Store the untranslated source strings (via QT_TRANSLATE_NOOP) in the registry and look them up with QCoreApplication::translate in toolTipForMode(), instead of freezing them with tr() at singleton-construction time. This makes the tooltips re-translate correctly after a live language change. --- CommonData/scriptconstructorregistry.cpp | 177 ++++++++++++----------- CommonData/scriptconstructorregistry.h | 5 +- 2 files changed, 92 insertions(+), 90 deletions(-) diff --git a/CommonData/scriptconstructorregistry.cpp b/CommonData/scriptconstructorregistry.cpp index 631215303b..d200638d2c 100644 --- a/CommonData/scriptconstructorregistry.cpp +++ b/CommonData/scriptconstructorregistry.cpp @@ -1,5 +1,6 @@ #include "scriptconstructorregistry.h" #include "columntype.h" +#include ScriptParamDef ScriptParamDef::fromRaw(const std::string & rawName, const stringvec & rawDropKeys) { @@ -90,22 +91,26 @@ stringvec ScriptOperatorDef::dragKeys(ScriptConstructorMode mode) const QString ScriptOperatorDef::toolTipForMode(ScriptConstructorMode mode) const { + const QString translated = QCoreApplication::translate("ScriptConstructorRegistry", toolTip.toUtf8().constData()); + if(!logicalSuffix) - return toolTip; + return translated; - return toolTip.arg(mode == ScriptConstructorMode::Filter - ? ScriptConstructorRegistry::tr("returns logicals and can be the root of a filter formula") - : ScriptConstructorRegistry::tr("returns logicals")); + return translated.arg(mode == ScriptConstructorMode::Filter + ? QCoreApplication::translate("ScriptConstructorRegistry", "returns logicals and can be the root of a filter formula") + : QCoreApplication::translate("ScriptConstructorRegistry", "returns logicals")); } QString ScriptFunctionDef::toolTipForMode(ScriptConstructorMode mode) const { + const QString translated = QCoreApplication::translate("ScriptConstructorRegistry", toolTip.toUtf8().constData()); + if(!logicalSuffix) - return toolTip; + return translated; - return toolTip.arg(mode == ScriptConstructorMode::Filter - ? ScriptConstructorRegistry::tr("returns logicals and can be the root of a filter formula") - : ScriptConstructorRegistry::tr("returns logicals")); + return translated.arg(mode == ScriptConstructorMode::Filter + ? QCoreApplication::translate("ScriptConstructorRegistry", "returns logicals and can be the root of a filter formula") + : QCoreApplication::translate("ScriptConstructorRegistry", "returns logicals")); } ScriptConstructorRegistry::ScriptConstructorRegistry() @@ -116,22 +121,22 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() _operators.push_back({op, toolTip, image, vertical, logicalSuffix}); }; - addOp("+", tr("Addition"), "plus.png"); - addOp("-", tr("Subtraction"), "minus.png"); - addOp("*", tr("Multiplication"), "multiply.png"); - addOp("/", tr("Division"), "divide.png", true); - addOp("/", tr("Division"), ""); - addOp("^", tr("Power (2^3 returns 8)"), ""); - addOp("%%", tr("Modulo: returns the remainder of a division. 3%2 returns 1"), "modulo.png"); - addOp("==", tr("Equality: %1"), "equal.png", false, true); - addOp("!=", tr("Inequality: %1"), "notEqual.png", false, true); - addOp("<", tr("Less than: %1"), "lessThan.png", false, true); - addOp("<=", tr("Less than or equal to: %1"), "lessThanEqual.png", false, true); - addOp(">", tr("Greater than: %1"), "greaterThan.png", false, true); - addOp(">=", tr("Greater than or equal to: %1"), "greaterThanEqual.png", false, true); - addOp("&", tr("And: %1"), "and.png", false, true); - addOp("|", tr("Or: %1"), "or.png", false, true); - addOp("%|%", tr("Split: applies filter separately to each subgroup"), "ConditionBy.png"); + addOp("+", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Addition"), "plus.png"); + addOp("-", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Subtraction"), "minus.png"); + addOp("*", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Multiplication"), "multiply.png"); + addOp("/", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Division"), "divide.png", true); + addOp("/", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Division"), ""); + addOp("^", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Power (2^3 returns 8)"), ""); + addOp("%%", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Modulo: returns the remainder of a division. 3%2 returns 1"), "modulo.png"); + addOp("==", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Equality: %1"), "equal.png", false, true); + addOp("!=", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Inequality: %1"), "notEqual.png", false, true); + addOp("<", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Less than: %1"), "lessThan.png", false, true); + addOp("<=", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Less than or equal to: %1"), "lessThanEqual.png", false, true); + addOp(">", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Greater than: %1"), "greaterThan.png", false, true); + addOp(">=", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Greater than or equal to: %1"), "greaterThanEqual.png", false, true); + addOp("&", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "And: %1"), "and.png", false, true); + addOp("|", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Or: %1"), "or.png", false, true); + addOp("%|%", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Split: applies filter separately to each subgroup"), "ConditionBy.png"); auto addFunc = [this](const std::string & name, const std::string & friendlyName, const QString & toolTip, const std::vector & params, const std::string & image = "", bool operatorBarOnly = false, bool logicalSuffix = false) { @@ -148,64 +153,64 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() strNum = {"string", "number"}, strBoolNum = {"string", "boolean", "number"}; - addFunc("abs", "", tr("absolute value"), {P("values", numKeys)}); - addFunc("sd", "", tr("standard deviation"), {P("values", numKeys)}, "sigma.png"); - addFunc("var", "", tr("variance"), {P("values", numKeys)}, "variance.png"); - addFunc("sum", "", tr("summation"), {P("values", numKeys)}, "sum.png"); - addFunc("prod", "", tr("product of values"), {P("values", numKeys)}, "product.png"); - addFunc("zScores", "", tr("Standardizes the variable"), {P("values", numKeys)}); - addFunc("min", "", tr("returns minimum of values"), {P("values", numKeys)}); - addFunc("max", "", tr("returns maximum of values"), {P("values", numKeys)}); - addFunc("mean", "", tr("mean"), {P("values", numKeys)}); - addFunc("sign", "", tr("returns the sign of values"), {P("values", numKeys)}); - addFunc("round", "", tr("rounds y to n decimals"), {P("y", numKeys), P("n", numKeys)}); - addFunc("length", "", tr("returns number of elements in y"), {P("y", strNum)}); - addFunc("median", "", tr("median"), {P("values", numKeys)}); - addFunc("ifelse", "", tr("if-else statement"), {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); - addFunc("hasSubstring", "", tr("returns true if string contains substring at least once"), {P("string", strKeys), P("substring", strKeys)}); - addFunc("is.na", "", tr("Combine with not-operator to filter out rows with missing values (NA) for a column."), {P("y", strBoolNum)}); + addFunc("abs", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "absolute value"), {P("values", numKeys)}); + addFunc("sd", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "standard deviation"), {P("values", numKeys)}, "sigma.png"); + addFunc("var", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "variance"), {P("values", numKeys)}, "variance.png"); + addFunc("sum", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "summation"), {P("values", numKeys)}, "sum.png"); + addFunc("prod", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "product of values"), {P("values", numKeys)}, "product.png"); + addFunc("zScores", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Standardizes the variable"), {P("values", numKeys)}); + addFunc("min", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "returns minimum of values"), {P("values", numKeys)}); + addFunc("max", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "returns maximum of values"), {P("values", numKeys)}); + addFunc("mean", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "mean"), {P("values", numKeys)}); + addFunc("sign", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "returns the sign of values"), {P("values", numKeys)}); + addFunc("round", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "rounds y to n decimals"), {P("y", numKeys), P("n", numKeys)}); + addFunc("length", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "returns number of elements in y"), {P("y", strNum)}); + addFunc("median", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "median"), {P("values", numKeys)}); + addFunc("ifelse", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "if-else statement"), {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); + addFunc("hasSubstring", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "returns true if string contains substring at least once"), {P("string", strKeys), P("substring", strKeys)}); + addFunc("is.na", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Combine with not-operator to filter out rows with missing values (NA) for a column."), {P("y", strBoolNum)}); // sqrt and ! live only in the operator bar (interspersed with the operators), not in the // right-hand function palette. - addFunc("sqrt", "", tr("Square root"), {P("value(s)", numKeys)}, "rootHead.png", true); - addFunc("!", "", tr("Not: %1"), {P("logical(s)", boolKeys)}, "negative.png", true, true); - - addFunc("log", "", tr("natural logarithm"), {P("y", numKeys)}); - addFunc("log2", "log\u2082", tr("base 2 logarithm"), {P("y", numKeys)}); - addFunc("log10", "log\u2081\u2080", tr("base 10 logarithm"), {P("y", numKeys)}); - addFunc("logb", "", tr("logarithm of y in 'base'"), {P("y", numKeys), P("base", numKeys)}); - addFunc("exp", "", tr("exponential"), {P("y", numKeys)}); - addFunc("fishZ", "", tr("Fisher's Z-transform (i.e., the inverse hyperbolic tangent) to transform correlations, numbers between -1 and 1 to the real line"), {P("y", numKeys)}); - addFunc("invFishZ", "fishZ\u207B\u00B9", tr("Inverse Fisher's Z-transform (i.e., the hyperbolic tangent) to transform real numbers to numbers between -1 and 1"), {P("y", numKeys)}); - addFunc("logit", "", tr("Logit transform (i.e., the inverse of the standard logit function, or log-odds transform) converts numbers between 0 and 1 to the real line."), {P("y", numKeys)}); - addFunc("invLogit", "logit\u207B\u00B9", tr("Inverse logit transform (i.e., the standard logit function) converts numbers on the real line to numbers between 0 and 1."), {P("y", numKeys)}); - addFunc("BoxCox", "", tr("Two-parameter Box-Cox transform (transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); - addFunc("BoxCoxAuto", "", tr("Two-parameter Box-Cox transform with an automatic determination of the shape parameter lambda, according to one of the three of methods:'loglik', 'sd', or 'movingRange'. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("method", strKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); - addFunc("invBoxCox", "BoxCox\u207B\u00B9", tr("Inverse two-parameter Box-Cox transform."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); - addFunc("powerTransform", "", tr("Two-parameter power transform (scale-invariant Box-Box; transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys)}); - addFunc("powerTransformAuto", "", tr("Two-parameter power transform with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys)}); - addFunc("YeoJohnson", "", tr("Yeo-Johnson transform (transforms any real values) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys)}); - addFunc("YeoJohnsonAuto", "", tr("Yeo-Johnson transform (transforms any real values) with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); - addFunc("Johnson", "", tr("Johnson transform (transforms any real values). The search for optimal parameter is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); - - addFunc("cut", "", tr("break your data up in numBreaks levels"), {P("values", numKeys), P("numBreaks", numKeys)}); - addFunc("replaceNA", "", tr("replace any missing values (NA) in column by the value in replaceWith"), {P("column", strBoolNum), P("replaceWith", strBoolNum)}); - addFunc("ifElse", "", tr("if-else statement"), {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); - - addFunc("normalDist", "", tr("generates data from a Gaussian distribution with specified mean and standard deviation sd"), {P("mean", numKeys), P("sd", numKeys)}); - addFunc("tDist", "", tr("generates data from t distribution with degrees of freedom df and non-centrality parameter ncp"), {P("df", numKeys), P("ncp", numKeys)}); - addFunc("chiSqDist", "", tr("generates data from a chi-squared distribution with degrees of freedom df and non-centrality parameter ncp"), {P("df", numKeys), P("ncp", numKeys)}); - addFunc("fDist", "", tr("generates data from an F distribution with specified degrees of freedoms df1, df2 and non-centrality parameter ncp"), {P("df1", numKeys), P("df2", numKeys), P("ncp", numKeys)}); - addFunc("binomDist", "", tr("generates data from a binomial distribution with specified trials and probability prob"), {P("trials", numKeys), P("prob", numKeys)}); - addFunc("negBinomDist", "", tr("generates data from a negative binomial distribution with specified trials and probability prob"), {P("targetTrial", numKeys), P("prob", numKeys)}); - addFunc("geomDist", "", tr("generates data from a geometric distribution with specified probability prob"), {P("prob", numKeys)}); - addFunc("poisDist", "", tr("generates data from a Poisson distribution with specified rate lambda"), {P("lambda", numKeys)}); - addFunc("betaDist", "", tr("generates data from a beta distribution with specified shapes alpha and beta"), {P("alpha", numKeys), P("beta", numKeys)}); - addFunc("unifDist", "", tr("generates data from a uniform distribution between min and max"), {P("min", numKeys), P("max", numKeys)}); - addFunc("gammaDist", "", tr("generates data from a gamma distribution with specified shape and scale"), {P("shape", numKeys), P("scale", numKeys)}); - addFunc("expDist", "", tr("generates data from an exponential distribution with specified rate"), {P("rate", numKeys)}); - addFunc("logNormDist", "", tr("generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog"), {P("meanLog", numKeys), P("sdLog", numKeys)}); - addFunc("weibullDist", "", tr("generates data from a Weibull distribution with specified shape and scale"), {P("shape", numKeys), P("scale", numKeys)}); + addFunc("sqrt", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Square root"), {P("value(s)", numKeys)}, "rootHead.png", true); + addFunc("!", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Not: %1"), {P("logical(s)", boolKeys)}, "negative.png", true, true); + + addFunc("log", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "natural logarithm"), {P("y", numKeys)}); + addFunc("log2", "log\u2082", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "base 2 logarithm"), {P("y", numKeys)}); + addFunc("log10", "log\u2081\u2080", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "base 10 logarithm"), {P("y", numKeys)}); + addFunc("logb", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "logarithm of y in 'base'"), {P("y", numKeys), P("base", numKeys)}); + addFunc("exp", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "exponential"), {P("y", numKeys)}); + addFunc("fishZ", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Fisher's Z-transform (i.e., the inverse hyperbolic tangent) to transform correlations, numbers between -1 and 1 to the real line"), {P("y", numKeys)}); + addFunc("invFishZ", "fishZ\u207B\u00B9", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Inverse Fisher's Z-transform (i.e., the hyperbolic tangent) to transform real numbers to numbers between -1 and 1"), {P("y", numKeys)}); + addFunc("logit", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Logit transform (i.e., the inverse of the standard logit function, or log-odds transform) converts numbers between 0 and 1 to the real line."), {P("y", numKeys)}); + addFunc("invLogit", "logit\u207B\u00B9", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Inverse logit transform (i.e., the standard logit function) converts numbers on the real line to numbers between 0 and 1."), {P("y", numKeys)}); + addFunc("BoxCox", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Two-parameter Box-Cox transform (transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("BoxCoxAuto", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Two-parameter Box-Cox transform with an automatic determination of the shape parameter lambda, according to one of the three of methods:'loglik', 'sd', or 'movingRange'. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("method", strKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("invBoxCox", "BoxCox\u207B\u00B9", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Inverse two-parameter Box-Cox transform."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys), P("continuityAdjustment", boolKeys)}); + addFunc("powerTransform", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Two-parameter power transform (scale-invariant Box-Box; transforms values greater than -shift) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys), P("shift", numKeys)}); + addFunc("powerTransformAuto", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Two-parameter power transform with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("?predictor", numKeys), P("?groupSize", numKeys), P("lower", numKeys), P("upper", numKeys), P("shift", numKeys)}); + addFunc("YeoJohnson", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Yeo-Johnson transform (transforms any real values) to stabilize variance and attempt to make the data more normal distribution-like."), {P("y", numKeys), P("lambda", numKeys)}); + addFunc("YeoJohnsonAuto", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Yeo-Johnson transform (transforms any real values) with an automatic determination of the shape parameter lambda. The search for optimal lambda is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); + addFunc("Johnson", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Johnson transform (transforms any real values). The search for optimal parameter is bounded within 'lower' and 'upper' limits."), {P("y", numKeys), P("lower", numKeys), P("upper", numKeys)}); + + addFunc("cut", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "break your data up in numBreaks levels"), {P("values", numKeys), P("numBreaks", numKeys)}); + addFunc("replaceNA", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "replace any missing values (NA) in column by the value in replaceWith"), {P("column", strBoolNum), P("replaceWith", strBoolNum)}); + addFunc("ifElse", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "if-else statement"), {P("test", boolKeys), P("then", boolStrNum), P("else", boolStrNum)}); + + addFunc("normalDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a Gaussian distribution with specified mean and standard deviation sd"), {P("mean", numKeys), P("sd", numKeys)}); + addFunc("tDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from t distribution with degrees of freedom df and non-centrality parameter ncp"), {P("df", numKeys), P("ncp", numKeys)}); + addFunc("chiSqDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a chi-squared distribution with degrees of freedom df and non-centrality parameter ncp"), {P("df", numKeys), P("ncp", numKeys)}); + addFunc("fDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from an F distribution with specified degrees of freedoms df1, df2 and non-centrality parameter ncp"), {P("df1", numKeys), P("df2", numKeys), P("ncp", numKeys)}); + addFunc("binomDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a binomial distribution with specified trials and probability prob"), {P("trials", numKeys), P("prob", numKeys)}); + addFunc("negBinomDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a negative binomial distribution with specified trials and probability prob"), {P("targetTrial", numKeys), P("prob", numKeys)}); + addFunc("geomDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a geometric distribution with specified probability prob"), {P("prob", numKeys)}); + addFunc("poisDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a Poisson distribution with specified rate lambda"), {P("lambda", numKeys)}); + addFunc("betaDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a beta distribution with specified shapes alpha and beta"), {P("alpha", numKeys), P("beta", numKeys)}); + addFunc("unifDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a uniform distribution between min and max"), {P("min", numKeys), P("max", numKeys)}); + addFunc("gammaDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a gamma distribution with specified shape and scale"), {P("shape", numKeys), P("scale", numKeys)}); + addFunc("expDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from an exponential distribution with specified rate"), {P("rate", numKeys)}); + addFunc("logNormDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a log-normal distribution with specified logarithmic mean meanLog and standard deviation sdLog"), {P("meanLog", numKeys), P("sdLog", numKeys)}); + addFunc("weibullDist", "", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "generates data from a Weibull distribution with specified shape and scale"), {P("shape", numKeys), P("scale", numKeys)}); auto addRowFunc = [this](const std::string & name, const QString & toolTip, const std::string & image = "") { @@ -213,13 +218,13 @@ ScriptConstructorRegistry::ScriptConstructorRegistry() _rowFunctions.push_back({name, name, toolTip, image, {}, true, true, false, false}); }; - addRowFunc("rowMean", tr("Rowwise mean")); - addRowFunc("rowSum", tr("Rowwise sum"), "sum.png"); - addRowFunc("rowSD", tr("Rowwise standard deviation"), "sigma.png"); - addRowFunc("rowVariance", tr("Rowwise variance"), "variance.png"); - addRowFunc("rowMedian", tr("Rowwise median")); - addRowFunc("rowMin", tr("Rowwise minimum")); - addRowFunc("rowMax", tr("Rowwise maximum")); + addRowFunc("rowMean", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise mean")); + addRowFunc("rowSum", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise sum"), "sum.png"); + addRowFunc("rowSD", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise standard deviation"), "sigma.png"); + addRowFunc("rowVariance", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise variance"), "variance.png"); + addRowFunc("rowMedian", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise median")); + addRowFunc("rowMin", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise minimum")); + addRowFunc("rowMax", QT_TRANSLATE_NOOP("ScriptConstructorRegistry", "Rowwise maximum")); } const ScriptConstructorRegistry & ScriptConstructorRegistry::instance() diff --git a/CommonData/scriptconstructorregistry.h b/CommonData/scriptconstructorregistry.h index f0ab9e98a8..7901c81f64 100644 --- a/CommonData/scriptconstructorregistry.h +++ b/CommonData/scriptconstructorregistry.h @@ -4,7 +4,6 @@ #include #include #include -#include #include #include "utils.h" @@ -52,10 +51,8 @@ struct ScriptOperatorDef QString toolTipForMode( ScriptConstructorMode mode) const; }; -class ScriptConstructorRegistry : public QObject +class ScriptConstructorRegistry { - Q_OBJECT - public: static const ScriptConstructorRegistry & instance(); From abcb2ae5693a182f1d875e6569e1055b974ce6e9 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 09:44:51 +0200 Subject: [PATCH 12/25] Fix division operators and filter watermark in ScriptConstructor Distinguish the two division operators by making the operator lookup vertical-aware (operatorDef now resolves via the vertical flag), so the operator bar shows the fraction (\u00f7) and diagonal (/) distinctly instead of two identical images. Render vertical division as a stacked fraction (numerator over a horizontal bar over denominator) in the script area. Make the constructor background watermark resize reactively once its source image loads, fixing the filter constructor's missing watermark. --- CommonData/scriptconstructorregistry.cpp | 9 +-- CommonData/scriptconstructorregistry.h | 2 +- CommonData/scriptnode.cpp | 6 +- Desktop/qquick/scriptconstructorview.cpp | 5 ++ Desktop/qquick/scriptnodeitem.cpp | 91 +++++++++++++++++++----- Desktop/qquick/scriptnodeitem.h | 3 +- 6 files changed, 86 insertions(+), 30 deletions(-) diff --git a/CommonData/scriptconstructorregistry.cpp b/CommonData/scriptconstructorregistry.cpp index d200638d2c..101d592db9 100644 --- a/CommonData/scriptconstructorregistry.cpp +++ b/CommonData/scriptconstructorregistry.cpp @@ -233,13 +233,10 @@ const ScriptConstructorRegistry & ScriptConstructorRegistry::instance() return registry; } -const ScriptOperatorDef * ScriptConstructorRegistry::operatorDef(const std::string & op) const +const ScriptOperatorDef * ScriptConstructorRegistry::operatorDef(const std::string & op, bool vertical) const { - for(const ScriptOperatorDef & def : _operators) - if(def.op == op) - return &def; - - return nullptr; + auto it = _operatorIndex.find(op + (vertical ? "V" : "")); + return it != _operatorIndex.end() ? &_operators[it->second] : nullptr; } const ScriptFunctionDef * ScriptConstructorRegistry::functionDef(const std::string & name) const diff --git a/CommonData/scriptconstructorregistry.h b/CommonData/scriptconstructorregistry.h index 7901c81f64..657d331fe5 100644 --- a/CommonData/scriptconstructorregistry.h +++ b/CommonData/scriptconstructorregistry.h @@ -60,7 +60,7 @@ class ScriptConstructorRegistry const std::vector & functions() const { return _functions; } const std::vector & rowFunctions() const { return _rowFunctions; } - const ScriptOperatorDef * operatorDef( const std::string & op) const; + const ScriptOperatorDef * operatorDef( const std::string & op, bool vertical = false) const; const ScriptFunctionDef * functionDef( const std::string & name) const; const ScriptFunctionDef * rowFunctionDef(const std::string & name) const; diff --git a/CommonData/scriptnode.cpp b/CommonData/scriptnode.cpp index 735194a9a4..5017ff4dfe 100644 --- a/CommonData/scriptnode.cpp +++ b/CommonData/scriptnode.cpp @@ -230,7 +230,7 @@ std::string ScriptNodeOperator::toR(const ScriptColumnTypeProvider * typeProvide stringvec ScriptNodeOperator::dragKeys() const { - const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op); + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op, _vertical); if(!def) return {"number"}; @@ -247,13 +247,13 @@ bool ScriptNodeOperator::isComplete() const stringvec ScriptNodeOperator::dropKeysLeft() const { - const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op); + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op, _vertical); return def ? def->dropKeysLeft(ScriptConstructorMode::Filter) : stringvec{"number"}; } stringvec ScriptNodeOperator::dropKeysRight() const { - const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op); + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(_op, _vertical); return def ? def->dropKeysRight(ScriptConstructorMode::Filter) : stringvec{"number"}; } diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 1145349034..f52a156870 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -403,6 +403,11 @@ void ScriptConstructorView::buildChrome() _backgroundDecoration->setParentItem(this); _backgroundDecoration->setZ(-2); _backgroundDecoration->setProperty("fillMode", 1); // Image.PreserveAspectFit + + // The source image loads asynchronously; re-layout once its intrinsic size is known so the + // watermark gets sized (it is otherwise left at 0x0 until an unrelated relayout happens). + connect(_backgroundDecoration, &QQuickItem::implicitWidthChanged, this, [this](){ if(_chromeBuilt) layoutAll(); }); + connect(_backgroundDecoration, &QQuickItem::implicitHeightChanged, this, [this](){ if(_chromeBuilt) layoutAll(); }); } updateBackgroundDecoration(); diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 92450d78d1..fd8e74a462 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -441,6 +441,7 @@ void ScriptNodeItem::clearLeaves() if(_openParen) { _openParen->deleteLater(); _openParen = nullptr; } if(_closeParen) { _closeParen->deleteLater(); _closeParen = nullptr; } if(_overline) { _overline->deleteLater(); _overline = nullptr; } + if(_fractionBar){ _fractionBar->deleteLater(); _fractionBar = nullptr; } } QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) @@ -649,14 +650,25 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::OperatorVertical: { auto * op = static_cast(_node); - const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op->op()); + const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op->op(), op->isVertical()); // Horizontal operators wrap their children in parentheses; vertical (division) does not. bool nest = !op->isVertical(); ScriptDropSpot * leftSpot = makeDropSpot(DropTarget{DropTarget::Kind::OperatorLeft, op, 0, op->dropKeysLeft(), false, nest}, "..."); - if(def && !def->image.empty()) + if(op->isVertical() && _acceptsDrops) + { + // Fraction: the horizontal line is drawn in layout(); the ÷ image is only the bar prototype. + _fractionBar = _view->newLeaf(_view->rectangleComponent()); + if(_fractionBar) + { + _fractionBar->setParentItem(this); + _fractionBar->setProperty("color", JaspTheme::currentTheme()->textEnabled()); + _fractionBar->setVisible(false); + } + } + else if(def && !def->image.empty()) makeImage(tq(def->image)); else makeText(QString::fromStdString(op->op()), true); @@ -771,7 +783,7 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::OperatorVertical: { const std::string & op = static_cast(_node)->op(); - if(const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op)) + if(const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op, static_cast(_node)->isVertical())) tip = def->toolTipForMode(_view->model()->mode()); break; } @@ -909,28 +921,69 @@ void ScriptNodeItem::layout() ScriptDropSpot * left = _dropSpots.size() > 0 ? _dropSpots[0] : nullptr; ScriptDropSpot * right = _dropSpots.size() > 1 ? _dropSpots[1] : nullptr; - bool showParens = _nested && _openParen && _closeParen; - if(_openParen) _openParen->setVisible(showParens); - if(_closeParen) _closeParen->setVisible(showParens); + // Vertical (fraction) division stacks numerator above a horizontal line above denominator. + if(op->isVertical() && _acceptsDrops) + { + if(left) left->layout(); + if(right) right->layout(); - if(showParens) placeNext(_openParen); + qreal leftW = left ? left->width() : 0; + qreal leftH = left ? left->height() : block; + qreal rightW = right ? right->width() : 0; + qreal rightH = right ? right->height() : block; - if(left) { left->layout(); placeNext(left); } + const qreal barH = std::max(qreal(2.0), block * 0.1); + const qreal barW = std::max(std::max(leftW, rightW), block); - QQuickItem * opVisual = _leaves.isEmpty() ? nullptr : _leaves.first(); - if(opVisual) - { - qreal w = opVisual->width() > 0 ? opVisual->width() : opVisual->property("implicitWidth").toReal(); - qreal h = opVisual->height() > 0 ? opVisual->height() : block; - opVisual->setX(x); - opVisual->setY((maxH > h ? (maxH - h) / 2 : 0)); - x += w + spacing; - maxH = std::max(maxH, h); + if(left) + { + left->setX((barW - leftW) / 2); + left->setY(0); + } + + if(_fractionBar) + { + _fractionBar->setVisible(true); + _fractionBar->setX(0); + _fractionBar->setY(leftH); + _fractionBar->setWidth(barW); + _fractionBar->setHeight(barH); + } + + if(right) + { + right->setX((barW - rightW) / 2); + right->setY(leftH + barH); + } + + x = barW; + maxH = leftH + barH + rightH; } + else + { + bool showParens = _nested && _openParen && _closeParen; + if(_openParen) _openParen->setVisible(showParens); + if(_closeParen) _closeParen->setVisible(showParens); - if(right) { right->layout(); placeNext(right); } + if(showParens) placeNext(_openParen); - if(showParens) placeNext(_closeParen); + if(left) { left->layout(); placeNext(left); } + + QQuickItem * opVisual = _leaves.isEmpty() ? nullptr : _leaves.first(); + if(opVisual) + { + qreal w = opVisual->width() > 0 ? opVisual->width() : opVisual->property("implicitWidth").toReal(); + qreal h = opVisual->height() > 0 ? opVisual->height() : block; + opVisual->setX(x); + opVisual->setY((maxH > h ? (maxH - h) / 2 : 0)); + x += w + spacing; + maxH = std::max(maxH, h); + } + + if(right) { right->layout(); placeNext(right); } + + if(showParens) placeNext(_closeParen); + } (void)op; } else if(t == ScriptNode::Type::Function || t == ScriptNode::Type::RowFunction) diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index ba62ddd1cc..88d354edfc 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -119,7 +119,8 @@ private slots: QList _dropSpots; QPointer _openParen, _closeParen, - _overline; + _overline, + _fractionBar; qreal _preferredWidth = 0, _preferredHeight = 0; bool _acceptsDrops = true, From a648b5b4e6740fcc5065991b845613875273c767 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 11:17:41 +0200 Subject: [PATCH 13/25] Fix constructor watermark, trash double-click, and drop-spot centering Remove the redundant white paint from ScriptConstructorView's updatePaintNode so the background watermark (filter/column) is no longer hidden behind it; both modes now show their image. Double-clicking the trash zone now clears the constructor (undoable), mirroring the old DropTrash.qml. Centre drop-spot placeholder text so the vertical-division dots line up under the fraction bar. --- Desktop/qquick/scriptconstructorview.cpp | 25 +++++++++++------------- Desktop/qquick/scriptconstructorview.h | 2 +- Desktop/qquick/scriptnodeitem.cpp | 3 ++- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index f52a156870..980810e1f7 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -9,14 +9,11 @@ #include #include #include -#include -#include #include ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) : QQuickItem(parent) { - setFlag(QQuickItem::ItemHasContents, true); setClip(true); // The view resolves actual column types from the columns model for R generation. @@ -440,6 +437,10 @@ void ScriptConstructorView::buildChrome() _trash->setProperty("radius", 6.0); _trash->setZ(10); + // Double-click erases the entire slate (handled via eventFilter). + _trash->setAcceptedMouseButtons(Qt::LeftButton); + _trash->installEventFilter(this); + // Hover tooltip (mirrors the old DropTrash.qml). _trash->setProperty("toolTip", tr("Dump unwanted snippets here; double-click to erase the entire slate")); if(QQuickItem * overlay = newLeaf(tooltipAreaComponent())) @@ -452,6 +453,7 @@ void ScriptConstructorView::buildChrome() icon->setParentItem(_trash); icon->setProperty("source", (theme ? theme->iconPath() : QString()) + "/trashcan.png"); icon->setProperty("fillMode", 1); // Image.PreserveAspectFit + icon->setAcceptedMouseButtons(Qt::NoButton); qreal dim = blockDim() * 1.6; icon->setWidth(dim); icon->setHeight(dim); @@ -676,21 +678,16 @@ void ScriptConstructorView::geometryChange(const QRectF & newGeometry, const QRe layoutAll(); } -QSGNode * ScriptConstructorView::updatePaintNode(QSGNode * oldNode, UpdatePaintNodeData *) +bool ScriptConstructorView::eventFilter(QObject * obj, QEvent * event) { - QSGRectangleNode * rect = static_cast(oldNode); - - if(!rect) + // Double-clicking the trash zone erases the whole slate (mirrors the old DropTrash.qml). + if(obj == _trash && event->type() == QEvent::MouseButtonDblClick) { - rect = window()->createRectangleNode(); - QSGFlatColorMaterial * material = new QSGFlatColorMaterial(); - material->setColor(JaspTheme::currentTheme() ? JaspTheme::currentTheme()->white() : QColor("white")); - rect->setMaterial(material); - rect->setFlag(QSGNode::OwnsMaterial); + _model.clear(); + return true; } - rect->setRect(boundingRect()); - return rect; + return QQuickItem::eventFilter(obj, event); } void ScriptConstructorView::refreshHint() diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index 496a68bbaa..387eb7740e 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -126,7 +126,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider protected: void componentComplete() override; void geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) override; - QSGNode * updatePaintNode(QSGNode * oldNode, UpdatePaintNodeData *) override; + bool eventFilter(QObject * obj, QEvent * event) override; private: void buildChrome(); diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index fd8e74a462..9f108f1dc1 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -130,7 +130,8 @@ QQuickItem * ScriptDropSpot::ensurePlaceholder() if(_placeholder) { _placeholder->setParentItem(this); - _placeholder->setProperty("verticalAlignment", 128); // Text.AlignVCenter + _placeholder->setProperty("verticalAlignment", 128); // Text.AlignVCenter + _placeholder->setProperty("horizontalAlignment", 4); // Text.AlignHCenter JaspTheme * theme = JaspTheme::currentTheme(); QFont f = theme->font(); f.setPixelSize(static_cast(_view->fontPixelSize())); From 24d7d2118425cc23f8fb47281ee0de4ebb6a09e4 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 13:20:20 +0200 Subject: [PATCH 14/25] Wire ScriptConstructor undo and make computed-column apply undoable Add local per-step undo to the constructor (own QUndoStack, focus-scoped Ctrl+Z/Shift+Z, cleared when re-seeded from another filter/column), so in-progress editing is undoable without touching the dataset stack. Route computed-column code application through SetComputedColumnCodeCommand and column creation through CreateComputedColumnCommand, so the data-mode undo/redo buttons cover computed columns the same way they already cover filters. Replace magic 1/2/3 column-type constants with columnType enum values, and drop the redundant columnTypeString in favour of the DECLARE_ENUM-generated columnTypeToString. --- CommonData/scriptconstructormodel.cpp | 7 ++-- CommonData/scriptconstructorregistry.cpp | 18 ++------- CommonData/scriptconstructorregistry.h | 1 - CommonData/scriptnode.cpp | 3 +- .../JASP/Widgets/ComputeColumnWindow.qml | 7 +--- Desktop/data/columnmodel.cpp | 24 ++++++++---- Desktop/data/columnmodel.h | 1 + Desktop/qquick/scriptconstructorview.cpp | 38 +++++++++++++++++++ Desktop/qquick/scriptconstructorview.h | 15 ++++++++ 9 files changed, 83 insertions(+), 31 deletions(-) diff --git a/CommonData/scriptconstructormodel.cpp b/CommonData/scriptconstructormodel.cpp index 7f6436e672..3670320783 100644 --- a/CommonData/scriptconstructormodel.cpp +++ b/CommonData/scriptconstructormodel.cpp @@ -1,4 +1,5 @@ #include "scriptconstructormodel.h" +#include "columntype.h" #include // --- DropTarget --- @@ -267,7 +268,7 @@ void ScriptConstructorModel::resolveColumnTypeDrop(ScriptNodeColumn * col, const return; } - for(int t : {1, 2, 3}) // scale, ordinal, nominal + for(int t : {int(columnType::scale), int(columnType::ordinal), int(columnType::nominal)}) { if(accepts(t)) { @@ -417,12 +418,12 @@ DropTarget ScriptConstructorModel::findReasonableInsertionSpot(ScriptNode * node std::vector ScriptConstructorModel::allowedColumnTypes(ScriptNode * node) const { if(!node || !node->parent()) - return {1, 2, 3}; // root: unconstrained + return {int(columnType::scale), int(columnType::ordinal), int(columnType::nominal)}; // root: unconstrained const stringvec keys = containingSlotKeys(node); std::vector out; - for(int t : {1, 2, 3}) // scale, ordinal, nominal + for(int t : {int(columnType::scale), int(columnType::ordinal), int(columnType::nominal)}) if(keysOverlap(ScriptConstructorRegistry::dropKeysForColumnType(t), keys)) out.push_back(t); return out; diff --git a/CommonData/scriptconstructorregistry.cpp b/CommonData/scriptconstructorregistry.cpp index 101d592db9..5362218615 100644 --- a/CommonData/scriptconstructorregistry.cpp +++ b/CommonData/scriptconstructorregistry.cpp @@ -276,20 +276,10 @@ std::vector ScriptConstructorRegistry::operatorsForMode(Scrip stringvec ScriptConstructorRegistry::dropKeysForColumnType(int colType) { - switch(colType) + switch(static_cast(colType)) { - case 1: return {"number"}; - case 2: return {"string", "ordered"}; - default: return {"string"}; - } -} - -std::string ScriptConstructorRegistry::columnTypeString(int colType) -{ - switch(colType) - { - case 1: return "scale"; - case 2: return "ordinal"; - default: return "nominal"; + case columnType::scale: return {"number"}; + case columnType::ordinal: return {"string", "ordered"}; + default: return {"string"}; // nominal (and anything else) } } diff --git a/CommonData/scriptconstructorregistry.h b/CommonData/scriptconstructorregistry.h index 657d331fe5..6171ee1417 100644 --- a/CommonData/scriptconstructorregistry.h +++ b/CommonData/scriptconstructorregistry.h @@ -68,7 +68,6 @@ class ScriptConstructorRegistry std::vector operatorsForMode(ScriptConstructorMode mode) const; static stringvec dropKeysForColumnType(int columnType); - static std::string columnTypeString(int columnType); private: ScriptConstructorRegistry(); diff --git a/CommonData/scriptnode.cpp b/CommonData/scriptnode.cpp index 5017ff4dfe..3911090cd5 100644 --- a/CommonData/scriptnode.cpp +++ b/CommonData/scriptnode.cpp @@ -1,4 +1,5 @@ #include "scriptnode.h" +#include "columntype.h" #include #include #include @@ -518,7 +519,7 @@ std::string ScriptNodeColumn::toR(const ScriptColumnTypeProvider * typeProvider) int actualType = typeProvider ? typeProvider->columnType(_columnName) : 1; int effective = effectiveColumnType(actualType); - return _columnName + "." + ScriptConstructorRegistry::columnTypeString(effective); + return _columnName + "." + columnTypeToString(static_cast(effective)); } stringvec ScriptNodeColumn::dragKeys() const diff --git a/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml b/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml index a0bde2b27b..c4f923adfc 100644 --- a/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml +++ b/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml @@ -59,15 +59,12 @@ FocusScope return if(isRCode) - columnModel.column.rCode = computeColumnEdit.text + columnModel.setComputedColumnCode(computeColumnEdit.text, columnModel.column.constructorJson) else { computedColumnConstructor.forceActiveFocus(); if(computedColumnConstructor.checkAndApply()) - { - columnModel.column.constructorJson = computedColumnConstructor.returnFilterJSON() - columnModel.column.rCode = computedColumnConstructor.rCode - } + columnModel.setComputedColumnCode(computedColumnConstructor.rCode, computedColumnConstructor.returnFilterJSON()) } } diff --git a/Desktop/data/columnmodel.cpp b/Desktop/data/columnmodel.cpp index d5ad414a48..4131cb43c2 100644 --- a/Desktop/data/columnmodel.cpp +++ b/Desktop/data/columnmodel.cpp @@ -934,13 +934,23 @@ void ColumnModel::createComputedColumn(const QString & name, int colType, bool u if(!dataSet || !isColumnNameFree(name)) return; - Column * column = Workspace::singleton()->createComputedColumn( - fq(name), - dataSet->id(), - -1, + // Undoable: the command's redo() creates the column, its undo() removes it. + undoStack()->pushCommand(new CreateComputedColumnCommand( + dataSet, + name, columnType(colType), - useJsonConstructor ? computedColumnType::constructorCode : computedColumnType::rCode); + useJsonConstructor ? computedColumnType::constructorCode : computedColumnType::rCode)); - if(column) - openComputedColumn(name); + openComputedColumn(name); +} + +void ColumnModel::setComputedColumnCode(const QString & rCode, const QString & json) +{ + if(!column() || _beingRefreshed) + return; + + if(column()->rCodeQ() == rCode && column()->constructorJsonQ() == json) + return; + + undoStack()->pushCommand(new SetComputedColumnCodeCommand(DataSetPackage::filter(), column(), rCode, json)); } diff --git a/Desktop/data/columnmodel.h b/Desktop/data/columnmodel.h index f895e7c5e2..88119cee46 100644 --- a/Desktop/data/columnmodel.h +++ b/Desktop/data/columnmodel.h @@ -103,6 +103,7 @@ class ColumnModel : public QIdentityProxyModel Q_INVOKABLE bool isColumnNameFree( const QString & name); Q_INVOKABLE void createComputedColumn( const QString & name, int columnType, bool useJsonConstructor); + Q_INVOKABLE void setComputedColumnCode( const QString & rCode, const QString & json); ///< Via UndoStack UndoStack * undoStack(); diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 980810e1f7..a98b9b707e 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -9,16 +9,24 @@ #include #include #include +#include +#include #include ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) : QQuickItem(parent) { setClip(true); + setFlag(QQuickItem::ItemIsFocusScope); // The view resolves actual column types from the columns model for R generation. _model.setColumnTypeProvider(this); + // Constructor editing is undone locally (Ctrl+Z while focused), independently of the dataset. + _model.setUndoStack(&_localUndoStack); + connect(&_localUndoStack, &QUndoStack::canUndoChanged, this, &ScriptConstructorView::canUndoChanged); + connect(&_localUndoStack, &QUndoStack::canRedoChanged, this, &ScriptConstructorView::canRedoChanged); + connect(&_model, &ScriptConstructorModel::reset, this, [this](){ rebuildFormulaItems(); }); connect(&_model, &ScriptConstructorModel::changed, this, [this](){ setSomethingChanged(true); @@ -68,6 +76,7 @@ void ScriptConstructorView::setConstructorJson(const QString & json) std::string s = fq(json); if(s == _model.toString()) return; + _localUndoStack.clear(); _model.fromJson(s); _lastAppliedJson = tq(_model.toString()); setSomethingChanged(false); @@ -179,12 +188,23 @@ bool ScriptConstructorView::jsonChanged() const void ScriptConstructorView::initializeFromJSON(const QString & json) { + _localUndoStack.clear(); std::string s = json.isEmpty() ? fq(_lastAppliedJson) : fq(json); _model.fromJson(s); setSomethingChanged(false); rebuildFormulaItems(); } +void ScriptConstructorView::undo() +{ + _localUndoStack.undo(); +} + +void ScriptConstructorView::redo() +{ + _localUndoStack.redo(); +} + bool ScriptConstructorView::checkAndApply() { setSomethingChanged(false); @@ -678,6 +698,24 @@ void ScriptConstructorView::geometryChange(const QRectF & newGeometry, const QRe layoutAll(); } +void ScriptConstructorView::keyPressEvent(QKeyEvent * event) +{ + if(event->matches(QKeySequence::Undo)) + { + _localUndoStack.undo(); + event->accept(); + return; + } + if(event->matches(QKeySequence::Redo)) + { + _localUndoStack.redo(); + event->accept(); + return; + } + + QQuickItem::keyPressEvent(event); +} + bool ScriptConstructorView::eventFilter(QObject * obj, QEvent * event) { // Double-clicking the trash zone erases the whole slate (mirrors the old DropTrash.qml). diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index 387eb7740e..d3654ecc9e 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "scriptconstructormodel.h" @@ -11,6 +12,7 @@ class ScriptDropSpot; class ScriptPalette; class QQmlComponent; class QAbstractItemModel; +class QKeyEvent; /// /// C++ replacement for the old QML FilterConstructor / ComputedColumnsConstructor. @@ -33,6 +35,8 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider Q_PROPERTY( QAbstractItemModel* columnsModel READ columnsModel WRITE setColumnsModel NOTIFY columnsModelChanged ) Q_PROPERTY( QString filterErrorMsg READ filterErrorMsg WRITE setFilterErrorMsg NOTIFY filterErrorMsgChanged ) Q_PROPERTY( qreal desiredMinimumHeight READ desiredMinimumHeight NOTIFY desiredMinimumHeightChanged ) + Q_PROPERTY( bool canUndo READ canUndo NOTIFY canUndoChanged ) + Q_PROPERTY( bool canRedo READ canRedo NOTIFY canRedoChanged ) public: enum Mode { Filter = 0, ComputedColumn = 1, ComputedDataSet = 2 }; @@ -79,6 +83,11 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider Q_INVOKABLE void initializeFromJSON(const QString & json = QString()); Q_INVOKABLE bool jsonChanged() const; Q_INVOKABLE QString returnFilterJSON() const; + Q_INVOKABLE void undo(); + Q_INVOKABLE void redo(); + + bool canUndo() const { return _localUndoStack.canUndo(); } + bool canRedo() const { return _localUndoStack.canRedo(); } // --- used by ScriptNodeItem / ScriptDropSpot --- QQmlComponent * textComponent(); @@ -118,6 +127,8 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void columnsModelChanged(); void filterErrorMsgChanged(); void desiredMinimumHeightChanged(); + void canUndoChanged(); + void canRedoChanged(); /// Emitted when the user applies a valid formula. The surrounding window persists it /// (FilterModel::applyConstructorJson or Column::setConstructorJson/setRCode). @@ -126,6 +137,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider protected: void componentComplete() override; void geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) override; + void keyPressEvent(QKeyEvent * event) override; bool eventFilter(QObject * obj, QEvent * event) override; private: @@ -148,6 +160,9 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider ScriptConstructorModel _model; + // Local undo for in-progress constructor editing (separate from the dataset's UndoStack). + QUndoStack _localUndoStack; + QPointer _background, _backgroundDecoration, _operatorBar, From dfb308d258c32be8e66f3e666977241f11b197c9 Mon Sep 17 00:00:00 2001 From: Joris Goosen Date: Thu, 27 Aug 2026 12:21:09 +0200 Subject: [PATCH 15/25] add 1 pixel space above args of sqrt and make background image actually show up and be smooth --- Desktop/qquick/scriptconstructorview.cpp | 29 ++++++++++++++++-------- Desktop/qquick/scriptnodeitem.cpp | 4 ++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index a98b9b707e..2acfc9f8f9 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -299,7 +299,7 @@ QQmlComponent * ScriptConstructorView::imageComponent() if(!_imageComp) { _imageComp = new QQmlComponent(qmlEngine(this)); - _imageComp->setData("import QtQuick\nImage { smooth: true }", QUrl("ScriptConstructorImage")); + _imageComp->setData("import QtQuick\nImage { smooth: true; sourceSize.width: width * 2; sourceSize.height: height * 2; }", QUrl("ScriptConstructorImage")); } return _imageComp; } @@ -423,8 +423,8 @@ void ScriptConstructorView::buildChrome() // The source image loads asynchronously; re-layout once its intrinsic size is known so the // watermark gets sized (it is otherwise left at 0x0 until an unrelated relayout happens). - connect(_backgroundDecoration, &QQuickItem::implicitWidthChanged, this, [this](){ if(_chromeBuilt) layoutAll(); }); - connect(_backgroundDecoration, &QQuickItem::implicitHeightChanged, this, [this](){ if(_chromeBuilt) layoutAll(); }); + connect(_backgroundDecoration, &QQuickItem::implicitWidthChanged, this, [this](){ if(_chromeBuilt) layoutAll(); }); + connect(_backgroundDecoration, &QQuickItem::implicitHeightChanged, this, [this](){ if(_chromeBuilt) layoutAll(); }); } updateBackgroundDecoration(); @@ -443,6 +443,7 @@ void ScriptConstructorView::buildChrome() _scriptArea = new QQuickItem(this); _scriptArea->setClip(true); + _scriptArea->setParentItem(this); _scriptColumn = new QQuickItem(_scriptArea); _scriptColumn->setParentItem(_scriptArea); @@ -593,13 +594,21 @@ void ScriptConstructorView::layoutAll() const qreal ih = _backgroundDecoration->property("implicitHeight").toReal(); if(iw > 0 && ih > 0) { - // Fit within half the view, centred (matches the old fadeCollector watermark). - const qreal ratio = std::min(std::min(w / iw, h / ih), qreal(1.0)) * 0.5; - const qreal dw = iw * ratio, dh = ih * ratio; - _backgroundDecoration->setWidth(dw); - _backgroundDecoration->setHeight(dh); - _backgroundDecoration->setX((w - dw) / 2); - _backgroundDecoration->setY((h - dh) / 2); + if(w > 0 && h > 0) + { + // Fit within half the view, centred (matches the old fadeCollector watermark). + const qreal ratio = std::min(std::min(w / iw, h / ih), qreal(1.0)) * 0.5; + const qreal dw = iw * ratio, dh = ih * ratio; + _backgroundDecoration->setWidth(dw); + _backgroundDecoration->setHeight(dh); + _backgroundDecoration->setX((w - dw) / 2); + _backgroundDecoration->setY((h - dh) / 2); + } + else + { + _backgroundDecoration->setWidth(iw); + _backgroundDecoration->setHeight(ih); + } } } diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 9f108f1dc1..a1d65f61f7 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -910,11 +910,11 @@ void ScriptNodeItem::layout() if(arg) { arg->setX(block); - arg->setY(overlineH); + arg->setY(overlineH + 1); } x = block + argW; - maxH = totalH; + maxH = totalH + 1; } else if(t == ScriptNode::Type::Operator || t == ScriptNode::Type::OperatorVertical) { From 1958f38e76fd0a3f6efe18a59030b55be2f8976d Mon Sep 17 00:00:00 2001 From: Joris Goosen Date: Thu, 27 Aug 2026 13:53:50 +0200 Subject: [PATCH 16/25] also make a bit of space above the lower division arg --- Desktop/qquick/scriptnodeitem.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index a1d65f61f7..a20e7b6316 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -954,11 +954,11 @@ void ScriptNodeItem::layout() if(right) { right->setX((barW - rightW) / 2); - right->setY(leftH + barH); + right->setY(leftH + barH + 1); } x = barW; - maxH = leftH + barH + rightH; + maxH = leftH + barH + rightH + 1; } else { From 8f81fda0f69dc0d6b2c23ea71955add5004bccd4 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 14:02:57 +0200 Subject: [PATCH 17/25] Fix drop-spot hover outlines and watermark resize staleness Replace the setProperty("border.*") calls (which silently fail on QML group properties) with QQmlProperty writes, restoring the green/red hover outline, the red incomplete-check outline and the trash-zone border. Cache the watermark image's natural size on load and use it for the scaling ratio, so the background scales back up after shrinking instead of staying stuck at its smallest size (the sourceSize = width*2 binding was making implicitWidth follow width). --- Desktop/qquick/scriptconstructorview.cpp | 24 +++++++++++++++++++----- Desktop/qquick/scriptconstructorview.h | 5 +++++ Desktop/qquick/scriptnodeitem.cpp | 9 +++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 2acfc9f8f9..d66df7bc68 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -453,8 +454,8 @@ void ScriptConstructorView::buildChrome() { _trash->setParentItem(_scriptArea); _trash->setProperty("color", QColor(0, 0, 0, 0)); - _trash->setProperty("border.color", theme ? theme->gray() : QColor("gray")); - _trash->setProperty("border.width", 1); + QQmlProperty(_trash, "border.color").write(theme ? theme->gray() : QColor("gray")); + QQmlProperty(_trash, "border.width").write(1); _trash->setProperty("radius", 6.0); _trash->setZ(10); @@ -590,10 +591,22 @@ void ScriptConstructorView::layoutAll() if(_backgroundDecoration) { - const qreal iw = _backgroundDecoration->property("implicitWidth").toReal(); - const qreal ih = _backgroundDecoration->property("implicitHeight").toReal(); - if(iw > 0 && ih > 0) + // Cache the image's natural size on the first layout where it is known (the load). + // After that the Image's `sourceSize = width * 2` binding makes implicitWidth follow + // width, so we must use the cached natural size rather than re-reading implicitWidth. + if(_backgroundImageSize.isEmpty()) { + const qreal iw = _backgroundDecoration->property("implicitWidth").toReal(); + const qreal ih = _backgroundDecoration->property("implicitHeight").toReal(); + if(iw > 0 && ih > 0) + _backgroundImageSize = QSizeF(iw, ih); + } + + if(!_backgroundImageSize.isEmpty()) + { + const qreal iw = _backgroundImageSize.width(); + const qreal ih = _backgroundImageSize.height(); + if(w > 0 && h > 0) { // Fit within half the view, centred (matches the old fadeCollector watermark). @@ -771,6 +784,7 @@ void ScriptConstructorView::updateBackgroundDecoration() ? QString("filterConstructorBackground.png") : QString("columnConstructorBackground.png"); + _backgroundImageSize = QSizeF(); _backgroundDecoration->setProperty("source", JaspTheme::currentTheme()->iconPath() + "/" + file); } diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index d3654ecc9e..fd84280498 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "scriptconstructormodel.h" @@ -188,6 +189,10 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider QAbstractItemModel * _columnsModel = nullptr; + // Natural size of the background watermark image, cached on load (the Image's + // sourceSize = 2x binding makes implicitWidth follow width afterwards). + QSizeF _backgroundImageSize; + // drag state QPointer _draggedItem; ScriptNode * _draggedNewNode = nullptr; diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index a20e7b6316..efb01f00f5 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -156,8 +157,8 @@ QQuickItem * ScriptDropSpot::ensureMarker() _marker->setZ(-3); _marker->setProperty("color", QColor("transparent")); _marker->setProperty("radius", 4.0); - _marker->setProperty("border.width", 2.0); - _marker->setProperty("border.color", JaspTheme::currentTheme()->blue()); + QQmlProperty(_marker, "border.width").write(2.0); + QQmlProperty(_marker, "border.color").write(JaspTheme::currentTheme()->blue()); _marker->setVisible(false); } return _marker; @@ -215,7 +216,7 @@ void ScriptDropSpot::setHoverState(bool hovered, bool accepted) if(hovered) { JaspTheme * theme = JaspTheme::currentTheme(); - m->setProperty("border.color", accepted ? theme->green() : theme->red()); + QQmlProperty(m, "border.color").write(accepted ? theme->green() : theme->red()); m->setWidth(width()); m->setHeight(height()); } @@ -229,7 +230,7 @@ void ScriptDropSpot::setError(bool error) if(error) { m->setVisible(true); - m->setProperty("border.color", QColor("#BB0000")); + QQmlProperty(m, "border.color").write(QColor("#BB0000")); m->setWidth(width()); m->setHeight(height()); } From 5f006a04ea3c361e66be714ef7731ccf75c4b823 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 15:34:38 +0200 Subject: [PATCH 18/25] Speed up ScriptConstructor load, silence QML warnings, add profiling timers - Fix ScriptConstructorToolTipArea TypeError by guarding null parent in the tooltip overlay binding (fired once per node item build) - Fix FilterWindow tab anchoring: anchor the Flickable content to its grandparent was dropped by Qt (parent/sibling only), so the RowLayout instead stretches via a width binding; use dataSetModel.columnsLabelFilteredCount instead of the stale workspace reference - Make column palette/tooltip builds O(N): cache column type/index/description per view (single pass via new ColumnsModel::provideInfoAt) and coalesce model-driven palette rebuilds through a 0ms timer - Add JASPTIMER scopes across the constructor pipeline (enabled with JASP_TIMER_USED=ON) and print all timers on exit --- CommonData/scriptconstructormodel.cpp | 4 + CommonData/scriptnode.cpp | 3 + .../components/JASP/Widgets/FilterWindow.qml | 13 +- Desktop/data/columnsmodel.cpp | 21 +++ Desktop/data/columnsmodel.h | 1 + Desktop/mainwindow.cpp | 3 + Desktop/qquick/scriptconstructorview.cpp | 159 ++++++++++++++++-- Desktop/qquick/scriptconstructorview.h | 19 +++ Desktop/qquick/scriptnodeitem.cpp | 22 +-- 9 files changed, 214 insertions(+), 31 deletions(-) diff --git a/CommonData/scriptconstructormodel.cpp b/CommonData/scriptconstructormodel.cpp index 3670320783..7ee2fe9c27 100644 --- a/CommonData/scriptconstructormodel.cpp +++ b/CommonData/scriptconstructormodel.cpp @@ -1,5 +1,6 @@ #include "scriptconstructormodel.h" #include "columntype.h" +#include "timers.h" #include // --- DropTarget --- @@ -40,6 +41,7 @@ void ScriptConstructorModel::deleteAllFormulas() void ScriptConstructorModel::fromJson(const std::string & json) { + JASPTIMER_SCOPE(ScriptConstructorModel fromJson); Json::Value root; Json::Reader().parse(json, root); fromJson(root); @@ -74,6 +76,7 @@ Json::Value ScriptConstructorModel::toJson() const std::string ScriptConstructorModel::toString() const { + JASPTIMER_SCOPE(ScriptConstructorModel toString); Json::StreamWriterBuilder builder; builder["indentation"] = ""; std::string out = Json::writeString(builder, toJson()); @@ -86,6 +89,7 @@ std::string ScriptConstructorModel::toString() const std::string ScriptConstructorModel::toR() const { + JASPTIMER_SCOPE(ScriptConstructorModel toR); std::string out; for(int i = 0; i < static_cast(_formulas.size()); i++) diff --git a/CommonData/scriptnode.cpp b/CommonData/scriptnode.cpp index 3911090cd5..446e6cc790 100644 --- a/CommonData/scriptnode.cpp +++ b/CommonData/scriptnode.cpp @@ -1,5 +1,6 @@ #include "scriptnode.h" #include "columntype.h" +#include "timers.h" #include #include #include @@ -73,6 +74,8 @@ ScriptNode::Type ScriptNode::typeFromString(const std::string & str) ScriptNode * ScriptNode::fromJson(const Json::Value & json, ScriptNode * parent) { + JASPTIMER_SCOPE(ScriptNode fromJson); + if(json.isNull() || !json.isObject()) return nullptr; diff --git a/Desktop/components/JASP/Widgets/FilterWindow.qml b/Desktop/components/JASP/Widgets/FilterWindow.qml index 9eef92ca0e..5078cbe457 100644 --- a/Desktop/components/JASP/Widgets/FilterWindow.qml +++ b/Desktop/components/JASP/Widgets/FilterWindow.qml @@ -62,6 +62,7 @@ FocusScope Flickable { + id: filtersScroller anchors { top: backgroundFiltersTabs.top @@ -80,13 +81,7 @@ FocusScope { id: filtersTabs z: 2 - anchors - { - top: backgroundFiltersTabs.top - left: backgroundFiltersTabs.left - right: backgroundFiltersTabs.right - margins: jaspTheme.generalAnchorMargin - } + width: Math.max(implicitWidth, filtersScroller.width) Repeater { @@ -389,10 +384,10 @@ FocusScope JaspControls.RectangularButton { id: resetAllGeneratedFilters - width: (workspace.shownDataSet && workspace.shownDataSet.columnsLabelFilteredCount > 0) ? height : 0 + width: (dataSetModel.columnsLabelFilteredCount > 0) ? height : 0 height: filterGeneratedBox.height iconSource: jaspTheme.iconPath + "eraser_all.png" - visible: workspace.shownDataSet && workspace.shownDataSet.columnsLabelFilteredCount > 0 + visible: dataSetModel.columnsLabelFilteredCount > 0 toolTip: qsTr("Reset all checkmarks on all labels") onClicked: dataSetModel.resetAllFilters() diff --git a/Desktop/data/columnsmodel.cpp b/Desktop/data/columnsmodel.cpp index 76f1b36dbc..1e0a29c4d7 100644 --- a/Desktop/data/columnsmodel.cpp +++ b/Desktop/data/columnsmodel.cpp @@ -4,6 +4,7 @@ #include "dataenums.h" #include "mainwindow.h" #include "columnsmodel.h" +#include "timers.h" ColumnsModel * ColumnsModel::_singleton = nullptr; @@ -157,6 +158,26 @@ QVariant ColumnsModel::provideInfo(varInfoType info, const QString& colName, int if (colIndex < 0) return QVariant(); + return provideInfoAt(info, colIndex, row); + } + catch(std::exception & e) + { + Log::log() << "AnalysisForm::requestInfo had an exception! " << e.what() << std::flush; + throw e; + } + + return QVariant(); +} + +QVariant ColumnsModel::provideInfoAt(varInfoType info, int colIndex, int row) const +{ + JASPTIMER_SCOPE(ColumnsModel provideInfoAt); + + if (!ColumnsModel::singleton()) + return QVariant(); + + try + { QModelIndex qColIndex = index(colIndex, 0), tableCIndex = _tableModel->index(0, colIndex), tableVIndex = _tableModel->index(row, colIndex); diff --git a/Desktop/data/columnsmodel.h b/Desktop/data/columnsmodel.h index 363fe0cbee..58396f5379 100644 --- a/Desktop/data/columnsmodel.h +++ b/Desktop/data/columnsmodel.h @@ -40,6 +40,7 @@ class ColumnsModel : public QAbstractTableModel, public VariableInfoProvider Q_INVOKABLE QString getColumnTransformedToolTip(const QString & name, int transformedTo) const; QVariant provideInfo(varInfoType info, const QString& colName = "", int row = 0) const override; + Q_INVOKABLE QVariant provideInfoAt(varInfoType info, int colIndex, int row = 0) const; bool absorbInfo( varInfoType info, const QString& name, int row, QVariant value) override; QAbstractItemModel * providerModel() override { return this; } diff --git a/Desktop/mainwindow.cpp b/Desktop/mainwindow.cpp index c198e64d76..e7115f4f86 100644 --- a/Desktop/mainwindow.cpp +++ b/Desktop/mainwindow.cpp @@ -277,6 +277,9 @@ MainWindow::~MainWindow() delete _resultsJsInterface; } catch(...) {} + + // Only logs when PROFILE_JASP is defined (JASP_TIMER_USED=ON). + JASPTIMER_PRINTALL(); } QString MainWindow::windowTitle() const diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index d66df7bc68..cc3f932011 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -3,6 +3,9 @@ #include "jasptheme.h" #include "qutils.h" #include "data/columnsmodel.h" +#include "variableinfo.h" +#include "timers.h" +#include "log.h" #include #include @@ -12,11 +15,14 @@ #include #include #include +#include #include ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) : QQuickItem(parent) { + JASPTIMER_START(ScriptConstructorView ctorToComponentComplete); + setClip(true); setFlag(QQuickItem::ItemIsFocusScope); @@ -74,6 +80,7 @@ QString ScriptConstructorView::constructorJson() const void ScriptConstructorView::setConstructorJson(const QString & json) { + JASPTIMER_SCOPE(ScriptConstructor setConstructorJson); std::string s = fq(json); if(s == _model.toString()) return; @@ -124,9 +131,11 @@ void ScriptConstructorView::setColumnsModel(QAbstractItemModel * m) if(_columnsModel) { - connect(_columnsModel, &QAbstractItemModel::modelReset, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); - connect(_columnsModel, &QAbstractItemModel::rowsInserted, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); - connect(_columnsModel, &QAbstractItemModel::rowsRemoved, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + connect(_columnsModel, &QAbstractItemModel::modelReset, this, [this](){ schedulePaletteRebuild(); }); + connect(_columnsModel, &QAbstractItemModel::rowsInserted, this, [this](){ schedulePaletteRebuild(); }); + connect(_columnsModel, &QAbstractItemModel::rowsRemoved, this, [this](){ schedulePaletteRebuild(); }); + connect(_columnsModel, &QAbstractItemModel::dataChanged, this, [this](){ schedulePaletteRebuild(); }); + connect(_columnsModel, &QAbstractItemModel::headerDataChanged, this, [this](){ schedulePaletteRebuild(); }); } emit columnsModelChanged(); @@ -137,14 +146,21 @@ void ScriptConstructorView::setColumnsModel(QAbstractItemModel * m) int ScriptConstructorView::columnType(const std::string & columnName) const { + JASPTIMER_SCOPE(ScriptConstructor columnType lookup); + QString wanted = tq(columnName); + + // Fast path: O(1) cache built in rebuildColumnCache(). + if(_columnTypesByName.contains(wanted)) + return _columnTypesByName.value(wanted); + + // Fallback when the model changed without a cache rebuild (should be rare). QAbstractItemModel * model = _columnsModel ? _columnsModel : ColumnsModel::singleton(); if(!model) return 1; // scale - int nameRole = static_cast(model->roleNames().key("columnName")); - int typeRole = static_cast(model->roleNames().key("columnType")); + int nameRole = _nameRole >= 0 ? _nameRole : static_cast(model->roleNames().key("columnName")); + int typeRole = _typeRole >= 0 ? _typeRole : static_cast(model->roleNames().key("columnType")); - QString wanted = tq(columnName); for(int r = 0; r < model->rowCount(); r++) { QModelIndex idx = model->index(r, 0); @@ -158,6 +174,97 @@ int ScriptConstructorView::columnType(const std::string & columnName) const return 1; // scale } +QString ScriptConstructorView::columnDescription(const QString & name) const +{ + if(_columnDescriptionsByName.contains(name)) + return _columnDescriptionsByName.value(name); + + ColumnsModel * cols = ColumnsModel::singleton(); + return cols ? cols->getColumnDescription(name) : QString(); +} + +QString ScriptConstructorView::columnTransformedPreview(const QString & name, int transformedTo) const +{ + ColumnsModel * cols = ColumnsModel::singleton(); + if(!cols) + return ""; + + int idx = _columnIndexByName.value(name, -1); + + // Very rare (cache stale): fall back to the ColumnsModel's own lookup. + if(idx < 0) + return cols->getColumnTransformedToolTip(name, transformedTo); + + ::columnType realType = static_cast<::columnType>(cols->provideInfoAt(varInfoType::VariableType, idx).toInt()); + ::columnType chosenType = static_cast<::columnType>(transformedTo); + + if(chosenType == realType) + return ""; + + varInfoType previewType; + + switch(chosenType) + { + default: previewType = varInfoType::PreviewScale; break; + case ::columnType::ordinal: previewType = varInfoType::PreviewOrdinal; break; + case ::columnType::nominal: previewType = varInfoType::PreviewNominal; break; + } + + return cols->provideInfoAt(previewType, idx).toString(); +} + +void ScriptConstructorView::rebuildColumnCache() +{ + JASPTIMER_SCOPE(ScriptConstructor rebuildColumnCache); + + _columnTypesByName.clear(); + _columnIndexByName.clear(); + _columnDescriptionsByName.clear(); + + QAbstractItemModel * model = _columnsModel ? _columnsModel : ColumnsModel::singleton(); + if(!model) + return; + + _nameRole = static_cast(model->roleNames().key("columnName")); + _typeRole = static_cast(model->roleNames().key("columnType")); + + // Descriptions can only be read in O(1) when the model is (the) ColumnsModel itself; + // otherwise columnDescription() falls back to the singleton on demand. + ColumnsModel * cols = qobject_cast(model); + + int rows = model->rowCount(); + for(int r = 0; r < rows; r++) + { + QModelIndex idx = model->index(r, 0); + QString name = model->data(idx, _nameRole).toString(); + if(name.isEmpty()) + continue; + + int t = model->data(idx, _typeRole).toInt(); + _columnTypesByName[name] = t > 0 ? t : 1; + _columnIndexByName[name] = r; + + if(cols) + _columnDescriptionsByName[name] = cols->provideInfoAt(varInfoType::ColumnDescription, r).toString().trimmed(); + } +} + +void ScriptConstructorView::schedulePaletteRebuild() +{ + if(!_chromeBuilt || _paletteRebuildScheduled) + return; + + _paletteRebuildScheduled = true; + QTimer::singleShot(0, this, [this]() + { + _paletteRebuildScheduled = false; + if(!_chromeBuilt) + return; + rebuildColumnCache(); + buildColumnPalette(); + }); +} + void ScriptConstructorView::setFilterErrorMsg(const QString & msg) { if(msg == _filterErrorMsg) return; @@ -189,6 +296,7 @@ bool ScriptConstructorView::jsonChanged() const void ScriptConstructorView::initializeFromJSON(const QString & json) { + JASPTIMER_SCOPE(ScriptConstructor initializeFromJSON); _localUndoStack.clear(); std::string s = json.isEmpty() ? fq(_lastAppliedJson) : fq(json); _model.fromJson(s); @@ -289,6 +397,7 @@ QQmlComponent * ScriptConstructorView::textComponent() { if(!_textComp) { + JASPTIMER_SCOPE(ScriptConstructor compile textComponent); _textComp = new QQmlComponent(qmlEngine(this)); _textComp->setData("import QtQuick\nText { verticalAlignment: Text.AlignVCenter }", QUrl("ScriptConstructorText")); } @@ -299,6 +408,7 @@ QQmlComponent * ScriptConstructorView::imageComponent() { if(!_imageComp) { + JASPTIMER_SCOPE(ScriptConstructor compile imageComponent); _imageComp = new QQmlComponent(qmlEngine(this)); _imageComp->setData("import QtQuick\nImage { smooth: true; sourceSize.width: width * 2; sourceSize.height: height * 2; }", QUrl("ScriptConstructorImage")); } @@ -309,6 +419,7 @@ QQmlComponent * ScriptConstructorView::textInputComponent() { if(!_textInputComp) { + JASPTIMER_SCOPE(ScriptConstructor compile textInputComponent); _textInputComp = new QQmlComponent(qmlEngine(this)); _textInputComp->setData("import QtQuick\nTextInput { selectByMouse: true }", QUrl("ScriptConstructorTextInput")); } @@ -319,6 +430,7 @@ QQmlComponent * ScriptConstructorView::checkBoxComponent() { if(!_checkBoxComp) { + JASPTIMER_SCOPE(ScriptConstructor compile checkBoxComponent); _checkBoxComp = new QQmlComponent(qmlEngine(this)); _checkBoxComp->setData("import QtQuick\nimport QtQuick.Controls\nCheckBox {}", QUrl("ScriptConstructorCheckBox")); } @@ -329,6 +441,7 @@ QQmlComponent * ScriptConstructorView::rectangleComponent() { if(!_rectComp) { + JASPTIMER_SCOPE(ScriptConstructor compile rectangleComponent); _rectComp = new QQmlComponent(qmlEngine(this)); _rectComp->setData("import QtQuick\nRectangle {}", QUrl("ScriptConstructorRectangle")); } @@ -339,6 +452,7 @@ QQmlComponent * ScriptConstructorView::tooltipAreaComponent() { if(!_tooltipAreaComp) { + JASPTIMER_SCOPE(ScriptConstructor compile tooltipAreaComponent); _tooltipAreaComp = new QQmlComponent(qmlEngine(this)); _tooltipAreaComp->setData( "import QtQuick\n" @@ -349,7 +463,7 @@ QQmlComponent * ScriptConstructorView::tooltipAreaComponent() " acceptedButtons: Qt.NoButton\n" " hoverEnabled: true\n" " ToolTip.delay: 500\n" - " ToolTip.text: parent.toolTip\n" + " ToolTip.text: parent && parent.toolTip ? parent.toolTip : ''\n" " ToolTip.visible: ToolTip.text !== '' && containsMouse\n" " ToolTip.toolTip.background: Rectangle { color: jaspTheme.tooltipBackgroundColor; radius: jaspTheme.borderRadius }\n" "}\n", QUrl("ScriptConstructorToolTipArea")); @@ -362,6 +476,8 @@ QQuickItem * ScriptConstructorView::newLeaf(QQmlComponent * comp) if(!comp || comp->isError()) return nullptr; + JASPTIMER_SCOPE(ScriptConstructor newLeaf incubation); + QQmlIncubator incubator(QQmlIncubator::Synchronous); comp->create(incubator); @@ -377,6 +493,9 @@ QQuickItem * ScriptConstructorView::newLeaf(QQmlComponent * comp) void ScriptConstructorView::componentComplete() { + JASPTIMER_FINISH(ScriptConstructorView ctorToComponentComplete); + JASPTIMER_SCOPE(ScriptConstructorView componentComplete); + QQuickItem::componentComplete(); if(!_chromeBuilt) @@ -392,9 +511,11 @@ void ScriptConstructorView::componentComplete() { if(ColumnsModel * singleton = ColumnsModel::singleton()) { - connect(singleton, &QAbstractItemModel::modelReset, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); - connect(singleton, &QAbstractItemModel::rowsInserted, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); - connect(singleton, &QAbstractItemModel::rowsRemoved, this, [this](){ if(_chromeBuilt) buildColumnPalette(); }); + connect(singleton, &QAbstractItemModel::modelReset, this, [this](){ schedulePaletteRebuild(); }); + connect(singleton, &QAbstractItemModel::rowsInserted, this, [this](){ schedulePaletteRebuild(); }); + connect(singleton, &QAbstractItemModel::rowsRemoved, this, [this](){ schedulePaletteRebuild(); }); + connect(singleton, &QAbstractItemModel::dataChanged, this, [this](){ schedulePaletteRebuild(); }); + connect(singleton, &QAbstractItemModel::headerDataChanged, this, [this](){ schedulePaletteRebuild(); }); } buildColumnPalette(); } @@ -404,6 +525,7 @@ void ScriptConstructorView::componentComplete() void ScriptConstructorView::buildChrome() { + JASPTIMER_SCOPE(ScriptConstructor buildChrome); JaspTheme * theme = JaspTheme::currentTheme(); _background = newLeaf(rectangleComponent()); @@ -555,6 +677,8 @@ void ScriptConstructorView::rebuildFormulaItems() if(!_chromeBuilt || !_scriptColumn) return; + JASPTIMER_SCOPE(ScriptConstructor rebuildFormulaItems); + clearFormulaItems(); for(ScriptNode * formula : _model.formulas()) @@ -568,6 +692,7 @@ void ScriptConstructorView::rebuildFormulaItems() void ScriptConstructorView::layoutAll() { + JASPTIMER_SCOPE(ScriptConstructor layoutAll); qreal w = width(), h = height(); qreal barH = blockDim() * 1.75; @@ -697,6 +822,8 @@ void ScriptConstructorView::layoutScriptArea() { if(!_scriptColumn) return; + JASPTIMER_SCOPE(ScriptConstructor layoutScriptArea); + qreal y = spacing(); qreal x = spacing(); @@ -796,6 +923,8 @@ void ScriptConstructorView::buildOperatorBar() { if(!_operatorBar) return; + JASPTIMER_SCOPE(ScriptConstructor buildOperatorBar); + auto placeOperator = [this](qreal & x, const ScriptOperatorDef & def) { ScriptNode * proto = new ScriptNodeOperator(def.op, def.vertical); @@ -838,6 +967,8 @@ void ScriptConstructorView::buildFunctionPalette() { if(!_functionPalette) return; + JASPTIMER_SCOPE(ScriptConstructor buildFunctionPalette); + QQuickItem * content = _functionPalette->content(); _clearPaletteChildren(content); @@ -876,6 +1007,12 @@ void ScriptConstructorView::buildColumnPalette() { if(!_columnPalette) return; + JASPTIMER_SCOPE(ScriptConstructor buildColumnPalette); + + // (Re)build the O(1) column cache in a single pass before creating items, so each + // ScriptNodeItem::rebuild() below avoids its own O(N) scan of the columns model. + rebuildColumnCache(); + QQuickItem * content = _columnPalette->content(); // Clear any previously built column prototypes (rebuilt when the dataset changes). @@ -889,7 +1026,7 @@ void ScriptConstructorView::buildColumnPalette() qreal maxW = 0; qreal y = spacing(); int rows = model->rowCount(); - int nameRole = static_cast(model->roleNames().key("columnName")); + int nameRole = _nameRole >= 0 ? _nameRole : static_cast(model->roleNames().key("columnName")); for(int r = 0; r < rows; r++) { diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index fd84280498..f9826d234c 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "scriptconstructormodel.h" @@ -14,6 +15,7 @@ class ScriptPalette; class QQmlComponent; class QAbstractItemModel; class QKeyEvent; +class ColumnsModel; /// /// C++ replacement for the old QML FilterConstructor / ComputedColumnsConstructor. @@ -79,6 +81,10 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider // ScriptColumnTypeProvider: resolve a column's actual type from the columns model. int columnType(const std::string & columnName) const override; + // Cached (O(1)) column info used by ScriptNodeItem while building tooltips. + QString columnDescription(const QString & name) const; + QString columnTransformedPreview(const QString & name, int transformedTo) const; + // --- QML-callable API mirroring the old constructors --- Q_INVOKABLE bool checkAndApply(); Q_INVOKABLE void initializeFromJSON(const QString & json = QString()); @@ -159,6 +165,11 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void updateBackgroundDecoration(); + // One-pass column cache (name -> type/index/description); keeps columnType() and the + // palette/tooltip builds O(1) per column instead of O(N) scans per column. + void rebuildColumnCache(); + void schedulePaletteRebuild(); + ScriptConstructorModel _model; // Local undo for in-progress constructor editing (separate from the dataset's UndoStack). @@ -180,6 +191,14 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider qreal _columnPaletteContentWidth = 0, _functionPaletteContentWidth = 0; + // Column lookup caches, rebuilt in a single pass (see rebuildColumnCache()). + QHash _columnTypesByName, + _columnIndexByName; + QHash _columnDescriptionsByName; + int _nameRole = -1, + _typeRole = -1; + bool _paletteRebuildScheduled = false; + QPointer _textComp, _imageComp, _textInputComp, diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index efb01f00f5..cb786c0888 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -3,6 +3,7 @@ #include "jasptheme.h" #include "qutils.h" #include "data/columnsmodel.h" +#include "timers.h" #include #include #include @@ -567,6 +568,7 @@ bool ScriptNodeItem::shouldDrag(qreal x, qreal) const void ScriptNodeItem::rebuild() { + JASPTIMER_SCOPE(ScriptNodeItem rebuild); clearLeaves(); if(!_node) return; @@ -813,18 +815,15 @@ void ScriptNodeItem::rebuild() QStringList parts; parts << tr("Click icon to change column type"); - if(ColumnsModel * cols = ColumnsModel::singleton()) - { - const QString description = cols->getColumnDescription(tq(col->columnName())); - if(!description.isEmpty()) - parts << tr("Column description: ") + description; + const QString description = _view->columnDescription(tq(col->columnName())); + if(!description.isEmpty()) + parts << tr("Column description: ") + description; - if(effective != actual) - { - const QString preview = cols->getColumnTransformedToolTip(tq(col->columnName()), col->columnTypeUser()); - if(!preview.isEmpty()) - parts << preview; - } + if(effective != actual) + { + const QString preview = _view->columnTransformedPreview(tq(col->columnName()), col->columnTypeUser()); + if(!preview.isEmpty()) + parts << preview; } tip = parts.join("\n\n"); @@ -843,6 +842,7 @@ void ScriptNodeItem::layout() { if(!_node) return; + JASPTIMER_SCOPE(ScriptNodeItem layout); qreal block = _view->blockDim(); qreal spacing = _view->spacing(); qreal x = 0, maxH = block; From b19b9564b8ebd84a195dcae62c794e21a991bd56 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 27 Aug 2026 16:54:58 +0200 Subject: [PATCH 19/25] Defer ScriptConstructor chrome until visible, native tooltips, per-kind incubation timers - Replace the per-item QtQuick ToolTip overlay with QToolTip::showText on hover (removes one QML incubation per node item and the tooltip-area component altogether) - Add deferUntilVisible so the computed-column constructor only builds its chrome/palettes when the tab becomes effectively visible (itemChange + requestBuild + singleShot re-check); FilterWindow keeps building eagerly - Add per-leaf-kind incubation timers (incubate , createSync , compile Component) to pinpoint whether first-use compilation or instantiation dominates constructor load time - Add quicktest for deferred build --- .../JASP/Widgets/ComputeColumnWindow.qml | 6 + Desktop/qquick/scriptconstructorview.cpp | 157 ++++++++++++------ Desktop/qquick/scriptconstructorview.h | 25 ++- Desktop/qquick/scriptnodeitem.cpp | 56 ++++--- Desktop/qquick/scriptnodeitem.h | 10 +- Tests/qmlTests/tst_scriptconstructor.qml | 26 +++ 6 files changed, 197 insertions(+), 83 deletions(-) diff --git a/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml b/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml index c4f923adfc..87969eb89e 100644 --- a/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml +++ b/Desktop/components/JASP/Widgets/ComputeColumnWindow.qml @@ -15,6 +15,11 @@ FocusScope property int minimumHeightTextBoxes: 50 * preferencesModel.uiScale property real desiredMinimumHeight: computeColumnButtons.height + computeColumnErrorScroll.height + (isRCode ? computeColumnEditRectangle.desiredMinimumHeight : computedColumnConstructor.desiredMinimumHeight) + // The C++ constructor only builds its chrome/palettes when it is actually visible. + // StackLayout sets our visibility for tab switches; this forwards it so the deferred + // build runs the first time the computed column tab is shown. + onVisibleChanged: if(visible) computedColumnConstructor.requestBuild() + Connections { target: columnModel.column @@ -180,6 +185,7 @@ FocusScope anchors.fill: parent anchors.leftMargin: 1 visible: !isRCode + deferUntilVisible: true showGeneratedRCode: false KeyNavigation.tab: applyComputedColumnButton diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index cc3f932011..45d7a9f02b 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -16,8 +16,25 @@ #include #include #include +#include +#include #include +#ifdef PROFILE_JASP +namespace +{ +// JASPTIMER_SCOPE takes a compile-time name; this variant takes a runtime (std::string) name +// so leaf creation can be timed per component kind. +struct RuntimeTimerMeasure +{ + explicit RuntimeTimerMeasure(std::string name) : _name(std::move(name)) { _getTimer(_name)->resume(); } + ~RuntimeTimerMeasure() { try { _getTimerC(_name)->stop(); } catch(...) {} } + + std::string _name; +}; +} +#endif + ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) : QQuickItem(parent) { @@ -46,7 +63,7 @@ ScriptConstructorView::ScriptConstructorView(QQuickItem * parent) ScriptConstructorView::~ScriptConstructorView() { - for(auto & comp : {_textComp, _imageComp, _textInputComp, _checkBoxComp, _rectComp, _tooltipAreaComp}) + for(auto & comp : {_textComp, _imageComp, _textInputComp, _checkBoxComp, _rectComp}) delete comp.data(); } @@ -120,6 +137,13 @@ void ScriptConstructorView::setShowGeneratedRCode(bool v) layoutAll(); } +void ScriptConstructorView::setDeferUntilVisible(bool v) +{ + if(v == _deferUntilVisible) return; + _deferUntilVisible = v; + emit deferUntilVisibleChanged(); +} + void ScriptConstructorView::setColumnsModel(QAbstractItemModel * m) { if(m == _columnsModel) return; @@ -448,38 +472,28 @@ QQmlComponent * ScriptConstructorView::rectangleComponent() return _rectComp; } -QQmlComponent * ScriptConstructorView::tooltipAreaComponent() -{ - if(!_tooltipAreaComp) - { - JASPTIMER_SCOPE(ScriptConstructor compile tooltipAreaComponent); - _tooltipAreaComp = new QQmlComponent(qmlEngine(this)); - _tooltipAreaComp->setData( - "import QtQuick\n" - "import QtQuick.Controls\n" - "MouseArea {\n" - " anchors.fill: parent\n" - " z: 5\n" - " acceptedButtons: Qt.NoButton\n" - " hoverEnabled: true\n" - " ToolTip.delay: 500\n" - " ToolTip.text: parent && parent.toolTip ? parent.toolTip : ''\n" - " ToolTip.visible: ToolTip.text !== '' && containsMouse\n" - " ToolTip.toolTip.background: Rectangle { color: jaspTheme.tooltipBackgroundColor; radius: jaspTheme.borderRadius }\n" - "}\n", QUrl("ScriptConstructorToolTipArea")); - } - return _tooltipAreaComp; -} - -QQuickItem * ScriptConstructorView::newLeaf(QQmlComponent * comp) +QQuickItem * ScriptConstructorView::newLeaf(QQmlComponent * comp, const char * kind) { if(!comp || comp->isError()) return nullptr; - JASPTIMER_SCOPE(ScriptConstructor newLeaf incubation); + Q_UNUSED(kind); + +#ifdef PROFILE_JASP + RuntimeTimerMeasure incubateScope(std::string("ScriptConstructor incubate ") + kind); +#endif QQmlIncubator incubator(QQmlIncubator::Synchronous); + +#ifdef PROFILE_JASP + { + // Time only the raw synchronous create (compilation on first use happens here). + RuntimeTimerMeasure createScope(std::string("ScriptConstructor createSync ") + kind); + comp->create(incubator); + } +#else comp->create(incubator); +#endif if(incubator.isError()) return nullptr; @@ -497,12 +511,20 @@ void ScriptConstructorView::componentComplete() JASPTIMER_SCOPE(ScriptConstructorView componentComplete); QQuickItem::componentComplete(); - - if(!_chromeBuilt) - { - buildChrome(); - _chromeBuilt = true; - } + _componentComplete = true; + + // With deferUntilVisible the (expensive) chrome + palettes are only built once the + // view is effectively visible (e.g. the computed-column constructor in a hidden + // StackLayout tab). The singleShot re-check runs after the surrounding layout has + // applied its page visibility, so a tab that is current from the start still builds. + if(!_deferUntilVisible) + ensureChromeBuilt(); + else + QTimer::singleShot(0, this, [this]() + { + if(_componentComplete && !_chromeBuilt && isVisible()) + ensureChromeBuilt(); + }); // If no columns model was bound from QML (e.g. the property name shadows the // `columnsModel` context property), fall back to the ColumnsModel singleton and @@ -517,18 +539,41 @@ void ScriptConstructorView::componentComplete() connect(singleton, &QAbstractItemModel::dataChanged, this, [this](){ schedulePaletteRebuild(); }); connect(singleton, &QAbstractItemModel::headerDataChanged, this, [this](){ schedulePaletteRebuild(); }); } - buildColumnPalette(); + buildColumnPalette(); // No-op until the chrome exists (deferred case). } + rebuildFormulaItems(); // No-op until the chrome exists (deferred case). +} + +void ScriptConstructorView::ensureChromeBuilt() +{ + if(_chromeBuilt) + return; + + JASPTIMER_SCOPE(ScriptConstructor ensureChromeBuilt); + + buildChrome(); + _chromeBuilt = true; + rebuildFormulaItems(); } +void ScriptConstructorView::itemChange(ItemChange change, const ItemChangeData & value) +{ + QQuickItem::itemChange(change, value); + + // When the item becomes effectively visible (including ancestor-driven changes like + // a StackLayout switching to its tab) build the chrome if it hasn't been built yet. + if(change == ItemVisibleHasChanged && value.boolValue && _componentComplete && !_chromeBuilt) + ensureChromeBuilt(); +} + void ScriptConstructorView::buildChrome() { JASPTIMER_SCOPE(ScriptConstructor buildChrome); JaspTheme * theme = JaspTheme::currentTheme(); - _background = newLeaf(rectangleComponent()); + _background = newLeaf(rectangleComponent(), "rectangle"); if(_background) { _background->setParentItem(this); @@ -537,7 +582,7 @@ void ScriptConstructorView::buildChrome() } // Faint centred decoration distinguishing a filter from a computed-column constructor. - _backgroundDecoration = newLeaf(imageComponent()); + _backgroundDecoration = newLeaf(imageComponent(), "image"); if(_backgroundDecoration) { _backgroundDecoration->setParentItem(this); @@ -571,7 +616,7 @@ void ScriptConstructorView::buildChrome() _scriptColumn = new QQuickItem(_scriptArea); _scriptColumn->setParentItem(_scriptArea); - _trash = newLeaf(rectangleComponent()); + _trash = newLeaf(rectangleComponent(), "rectangle"); if(_trash) { _trash->setParentItem(_scriptArea); @@ -581,17 +626,14 @@ void ScriptConstructorView::buildChrome() _trash->setProperty("radius", 6.0); _trash->setZ(10); - // Double-click erases the entire slate (handled via eventFilter). + // Double-click erases the entire slate; hover shows a tooltip (handled via eventFilter). + _trashToolTip = tr("Dump unwanted snippets here; double-click to erase the entire slate"); _trash->setAcceptedMouseButtons(Qt::LeftButton); + _trash->setAcceptHoverEvents(true); _trash->installEventFilter(this); - // Hover tooltip (mirrors the old DropTrash.qml). - _trash->setProperty("toolTip", tr("Dump unwanted snippets here; double-click to erase the entire slate")); - if(QQuickItem * overlay = newLeaf(tooltipAreaComponent())) - overlay->setParentItem(_trash); - // Trash icon centred inside the drop zone. - QQuickItem * icon = newLeaf(imageComponent()); + QQuickItem * icon = newLeaf(imageComponent(), "image"); if(icon) { icon->setParentItem(_trash); @@ -606,7 +648,7 @@ void ScriptConstructorView::buildChrome() } } - _hint = newLeaf(textComponent()); + _hint = newLeaf(textComponent(), "text"); if(_hint) { _hint->setParentItem(this); @@ -620,7 +662,7 @@ void ScriptConstructorView::buildChrome() } // Generated R code display (computed-column mode, toggled via showGeneratedRCode). - _rCodeDisplay = newLeaf(textComponent()); + _rCodeDisplay = newLeaf(textComponent(), "text"); if(_rCodeDisplay) { _rCodeDisplay->setParentItem(this); @@ -867,11 +909,24 @@ void ScriptConstructorView::keyPressEvent(QKeyEvent * event) bool ScriptConstructorView::eventFilter(QObject * obj, QEvent * event) { - // Double-clicking the trash zone erases the whole slate (mirrors the old DropTrash.qml). - if(obj == _trash && event->type() == QEvent::MouseButtonDblClick) + // Double-clicking the trash zone erases the whole slate (mirrors the old DropTrash.qml); + // hovering it shows a tooltip (replaces the old per-item QtQuick ToolTip overlay). + if(obj == _trash) { - _model.clear(); - return true; + switch(event->type()) + { + case QEvent::MouseButtonDblClick: + _model.clear(); + return true; + case QEvent::HoverEnter: + QToolTip::showText(static_cast(event)->globalPosition().toPoint(), _trashToolTip, nullptr, QRect(), 15000); + return true; + case QEvent::HoverLeave: + QToolTip::hideText(); + return true; + default: + break; + } } return QQuickItem::eventFilter(obj, event); @@ -1107,6 +1162,8 @@ void ScriptConstructorView::startDragExisting(ScriptNodeItem * item, const QPoin { if(!item) return; + QToolTip::hideText(); + _draggedItem = item; _dragIsNew = false; _draggedNewNode = nullptr; @@ -1124,6 +1181,8 @@ void ScriptConstructorView::startDragNew(ScriptNode * newNode, const QPointF & s { if(!newNode) return; + QToolTip::hideText(); + ScriptNodeItem * item = makeNodeItem(newNode, this); item->setZ(100); item->setPosition(mapFromScene(scenePos)); diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index f9826d234c..146a0ccf0a 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -40,6 +40,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider Q_PROPERTY( qreal desiredMinimumHeight READ desiredMinimumHeight NOTIFY desiredMinimumHeightChanged ) Q_PROPERTY( bool canUndo READ canUndo NOTIFY canUndoChanged ) Q_PROPERTY( bool canRedo READ canRedo NOTIFY canRedoChanged ) + Q_PROPERTY( bool deferUntilVisible READ deferUntilVisible WRITE setDeferUntilVisible NOTIFY deferUntilVisibleChanged ) public: enum Mode { Filter = 0, ComputedColumn = 1, ComputedDataSet = 2 }; @@ -96,20 +97,27 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider bool canUndo() const { return _localUndoStack.canUndo(); } bool canRedo() const { return _localUndoStack.canRedo(); } + bool deferUntilVisible() const { return _deferUntilVisible; } + void setDeferUntilVisible(bool v); + + // Builds the chrome (idempotent). Called at completion when visible or deferred + // until the view first becomes visible (deferUntilVisible). + void ensureChromeBuilt(); + Q_INVOKABLE void requestBuild() { if(_componentComplete) ensureChromeBuilt(); } + // --- used by ScriptNodeItem / ScriptDropSpot --- QQmlComponent * textComponent(); QQmlComponent * imageComponent(); QQmlComponent * textInputComponent(); QQmlComponent * checkBoxComponent(); QQmlComponent * rectangleComponent(); - QQmlComponent * tooltipAreaComponent(); qreal blockDim() const; qreal fontPixelSize() const; qreal spacing() const; QQuickItem * scriptArea() const { return _scriptArea; } - QQuickItem * newLeaf(QQmlComponent * comp); + QQuickItem * newLeaf(QQmlComponent * comp, const char * kind); void nodeEdited(); void refresh() { rebuildFormulaItems(); } @@ -124,7 +132,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider ScriptDropSpot * dropSpotAt(const QPointF & scenePos, ScriptNodeItem * dragged = nullptr) const; void collectDropSpots(QList & out) const; -signals: + signals: void modeChanged(); void constructorJsonChanged(); void rCodeChanged(QString rScript); @@ -136,6 +144,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void desiredMinimumHeightChanged(); void canUndoChanged(); void canRedoChanged(); + void deferUntilVisibleChanged(); /// Emitted when the user applies a valid formula. The surrounding window persists it /// (FilterModel::applyConstructorJson or Column::setConstructorJson/setRCode). @@ -144,6 +153,7 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider protected: void componentComplete() override; void geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) override; + void itemChange(ItemChange change, const ItemChangeData & value) override; void keyPressEvent(QKeyEvent * event) override; bool eventFilter(QObject * obj, QEvent * event) override; @@ -203,11 +213,12 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider _imageComp, _textInputComp, _checkBoxComp, - _rectComp, - _tooltipAreaComp; + _rectComp; QAbstractItemModel * _columnsModel = nullptr; + QString _trashToolTip; + // Natural size of the background watermark image, cached on load (the Image's // sourceSize = 2x binding makes implicitWidth follow width afterwards). QSizeF _backgroundImageSize; @@ -222,7 +233,9 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider bool _somethingChanged = false, _lastCheckPassed = true, _showGeneratedRCode = false, - _chromeBuilt = false; + _chromeBuilt = false, + _deferUntilVisible = false, + _componentComplete = false; QString _lastAppliedJson; QString _filterErrorMsg; QString _hintText; diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index cb786c0888..2cea4d3a59 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -128,7 +129,7 @@ QQuickItem * ScriptDropSpot::ensurePlaceholder() if(_placeholder) return _placeholder; - _placeholder = _view->newLeaf(_view->textComponent()); + _placeholder = _view->newLeaf(_view->textComponent(), "text"); if(_placeholder) { _placeholder->setParentItem(this); @@ -151,7 +152,7 @@ QQuickItem * ScriptDropSpot::ensureMarker() if(_marker) return _marker; - _marker = _view->newLeaf(_view->rectangleComponent()); + _marker = _view->newLeaf(_view->rectangleComponent(), "rectangle"); if(_marker) { _marker->setParentItem(this); @@ -286,7 +287,7 @@ QQuickItem * ScriptDropSpot::ensureInput() if(_input) return _input; - _input = _view->newLeaf(_view->textInputComponent()); + _input = _view->newLeaf(_view->textInputComponent(), "textInput"); if(_input) { _input->setParentItem(this); @@ -398,17 +399,9 @@ ScriptNodeItem::ScriptNodeItem(ScriptConstructorView * view, ScriptNode * node, { setAcceptedMouseButtons(Qt::LeftButton | Qt::RightButton); - // Transparent tooltip overlay: a MouseArea that only reports hover (acceptedButtons: - // Qt.NoButton) so a QtQuick ToolTip can show on hover without breaking drag & drop. - if(view) - { - QQuickItem * overlay = view->newLeaf(view->tooltipAreaComponent()); - if(overlay) - { - overlay->setParentItem(this); - overlay->setZ(5); - } - } + // Hover shows a native tooltip via QToolTip (see hoverEnterEvent), replacing the old + // per-item QtQuick ToolTip overlay that required one QML incubation per node item. + setAcceptHoverEvents(true); } void ScriptNodeItem::setToolTip(const QString & toolTip) @@ -421,6 +414,7 @@ void ScriptNodeItem::setToolTip(const QString & toolTip) ScriptNodeItem::~ScriptNodeItem() { + QToolTip::hideText(); clearLeaves(); } @@ -449,7 +443,7 @@ void ScriptNodeItem::clearLeaves() QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) { - QQuickItem * item = _view->newLeaf(_view->textComponent()); + QQuickItem * item = _view->newLeaf(_view->textComponent(), "text"); if(!item) return nullptr; item->setParentItem(this); @@ -468,7 +462,7 @@ QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) { - QQuickItem * item = _view->newLeaf(_view->imageComponent()); + QQuickItem * item = _view->newLeaf(_view->imageComponent(), "image"); if(!item) return nullptr; item->setParentItem(this); @@ -489,7 +483,7 @@ QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) QQuickItem * ScriptNodeItem::makeParenText(const QString & text) { - QQuickItem * item = _view->newLeaf(_view->textComponent()); + QQuickItem * item = _view->newLeaf(_view->textComponent(), "text"); if(!item) return nullptr; item->setParentItem(this); @@ -508,7 +502,7 @@ QQuickItem * ScriptNodeItem::makeParenText(const QString & text) QQuickItem * ScriptNodeItem::makeComma() { // Argument separator text (", ") — rendered between function/row-function arguments. - QQuickItem * item = _view->newLeaf(_view->textComponent()); + QQuickItem * item = _view->newLeaf(_view->textComponent(), "text"); if(!item) return nullptr; item->setParentItem(this); @@ -590,7 +584,7 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::Number: { auto * lit = static_cast(_node); - QQuickItem * input = _view->newLeaf(_view->textInputComponent()); + QQuickItem * input = _view->newLeaf(_view->textInputComponent(), "textInput"); if(input) { input->setParentItem(this); @@ -607,7 +601,7 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::String: { auto * lit = static_cast(_node); - QQuickItem * input = _view->newLeaf(_view->textInputComponent()); + QQuickItem * input = _view->newLeaf(_view->textInputComponent(), "textInput"); if(input) { input->setParentItem(this); @@ -624,7 +618,7 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::Boolean: { auto * lit = static_cast(_node); - QQuickItem * box = _view->newLeaf(_view->checkBoxComponent()); + QQuickItem * box = _view->newLeaf(_view->checkBoxComponent(), "checkBox"); if(box) { box->setParentItem(this); @@ -664,7 +658,7 @@ void ScriptNodeItem::rebuild() if(op->isVertical() && _acceptsDrops) { // Fraction: the horizontal line is drawn in layout(); the ÷ image is only the bar prototype. - _fractionBar = _view->newLeaf(_view->rectangleComponent()); + _fractionBar = _view->newLeaf(_view->rectangleComponent(), "rectangle"); if(_fractionBar) { _fractionBar->setParentItem(this); @@ -697,7 +691,7 @@ void ScriptNodeItem::rebuild() if(isSqrt && _acceptsDrops) { // Radical: a √ head (drawn tall) with an overline layered above the argument in layout(). - QQuickItem * head = _view->newLeaf(_view->imageComponent()); + QQuickItem * head = _view->newLeaf(_view->imageComponent(), "image"); if(head) { head->setParentItem(this); @@ -708,7 +702,7 @@ void ScriptNodeItem::rebuild() addLeaf(head); } - _overline = _view->newLeaf(_view->rectangleComponent()); + _overline = _view->newLeaf(_view->rectangleComponent(), "rectangle"); if(_overline) { _overline->setParentItem(this); @@ -1147,6 +1141,20 @@ void ScriptNodeItem::mouseDoubleClickEvent(QMouseEvent * event) event->accept(); } +void ScriptNodeItem::hoverEnterEvent(QHoverEvent * event) +{ + if(!_toolTip.isEmpty()) + QToolTip::showText(event->globalPosition().toPoint(), _toolTip, nullptr, QRect(), 15000); + + event->accept(); +} + +void ScriptNodeItem::hoverLeaveEvent(QHoverEvent * event) +{ + QToolTip::hideText(); + event->accept(); +} + void ScriptNodeItem::onLiteralEditFinished() { QQuickItem * input = qobject_cast(sender()); diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index 88d354edfc..d2135128c4 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -92,10 +92,12 @@ class ScriptNodeItem : public QQuickItem void toolTipChanged(); protected: - void mousePressEvent(QMouseEvent * event) override; - void mouseMoveEvent(QMouseEvent * event) override; - void mouseReleaseEvent(QMouseEvent * event) override; - void mouseDoubleClickEvent(QMouseEvent * event) override; + void mousePressEvent(QMouseEvent * event) override; + void mouseMoveEvent(QMouseEvent * event) override; + void mouseReleaseEvent(QMouseEvent * event) override; + void mouseDoubleClickEvent(QMouseEvent * event) override; + void hoverEnterEvent(QHoverEvent * event) override; + void hoverLeaveEvent(QHoverEvent * event) override; private slots: void onLiteralEditFinished(); diff --git a/Tests/qmlTests/tst_scriptconstructor.qml b/Tests/qmlTests/tst_scriptconstructor.qml index f889cced1e..4b12c1c514 100644 --- a/Tests/qmlTests/tst_scriptconstructor.qml +++ b/Tests/qmlTests/tst_scriptconstructor.qml @@ -17,10 +17,36 @@ TestCase height: 600 } + ScriptConstructor + { + id: scHidden + mode: ScriptConstructor.Filter + width: 500 + height: 400 + visible: false + deferUntilVisible: true + } + // In the headless test there is no ColumnsModel, so column types resolve to the // scale fallback. The exact per-type R output is covered by the golden tests in // testall.cpp which use a real column-type provider. + function test_deferred_build_on_visible() + { + // With deferUntilVisible the chrome is not built while the view is hidden. + compare(scHidden.children.length, 0) + + // An explicit build request (as ComputeColumnWindow sends when it becomes + // visible) builds the chrome, idempotently. + scHidden.requestBuild() + verify(scHidden.children.length > 0) + + scHidden.requestBuild() + verify(scHidden.children.length > 0) + + compare(scHidden.rCode, "") + } + function test_load_json_generates_r() { sc.constructorJson = '{"formulas":[{"nodeType":"Operator","operator":">","leftArgument":{"nodeType":"Column","columnName":"TestInts","columnTypeUser":-1,"columnTypeDrop":-1},"rightArgument":{"nodeType":"Number","value":2}}]}' From 93c2c6010c4b2bbe1c9566cf60e3771a9b60231c Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 3 Sep 2026 16:47:11 +0200 Subject: [PATCH 20/25] ScriptConstructor: skip double init build, add fine-grained init timers, add headless MainWindow filterwindow test --- Desktop/qquick/scriptconstructorview.cpp | 24 +++- Desktop/qquick/scriptnodeitem.cpp | 157 +++++++++++++---------- Tests/testall.cpp | 79 ++++++++++++ Tests/testall.h | 8 ++ 4 files changed, 201 insertions(+), 67 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 45d7a9f02b..8cec63845e 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -539,10 +539,15 @@ void ScriptConstructorView::componentComplete() connect(singleton, &QAbstractItemModel::dataChanged, this, [this](){ schedulePaletteRebuild(); }); connect(singleton, &QAbstractItemModel::headerDataChanged, this, [this](){ schedulePaletteRebuild(); }); } - buildColumnPalette(); // No-op until the chrome exists (deferred case). + // Only build here if ensureChromeBuilt() hasn't already done so (the deferred + // case); otherwise buildChrome() already populated the column palette. + if(!_chromeBuilt) + buildColumnPalette(); // No-op until the chrome exists (deferred case). } - rebuildFormulaItems(); // No-op until the chrome exists (deferred case). + // ensureChromeBuilt() already ran this after building the chrome. + if(!_chromeBuilt) + rebuildFormulaItems(); // No-op until the chrome exists (deferred case). } void ScriptConstructorView::ensureChromeBuilt() @@ -704,6 +709,9 @@ void ScriptConstructorView::clearFormulaItems() void ScriptConstructorView::_clearPaletteChildren(QQuickItem * palette) { if(!palette) return; + + JASPTIMER_SCOPE(ScriptConstructor clearPaletteChildren); + for(QQuickItem * child : palette->childItems()) { // Palette prototype items own their ScriptNode prototype; free it too. @@ -960,6 +968,8 @@ QString ScriptConstructorView::defaultHintText() const void ScriptConstructorView::updateBackgroundDecoration() { + JASPTIMER_SCOPE(ScriptConstructor updateBackgroundDecoration); + if(!_backgroundDecoration) return; const QString file = _model.mode() == ScriptConstructorMode::Filter @@ -1016,6 +1026,10 @@ void ScriptConstructorView::buildOperatorBar() _operatorBarContent->setWidth(x); _operatorBarContent->setHeight(blockDim()); + +#ifdef PROFILE_JASP + Log::log() << "ScriptConstructor buildOperatorBar created " << _operatorBarContent->childItems().size() << " items" << std::endl; +#endif } void ScriptConstructorView::buildFunctionPalette() @@ -1055,6 +1069,9 @@ void ScriptConstructorView::buildFunctionPalette() _functionPaletteContentWidth = maxW + spacing() * 2; _functionPalette->setContentHeight(y); +#ifdef PROFILE_JASP + Log::log() << "ScriptConstructor buildFunctionPalette created " << content->childItems().size() << " items" << std::endl; +#endif if(_chromeBuilt) layoutAll(); } @@ -1103,6 +1120,9 @@ void ScriptConstructorView::buildColumnPalette() _columnPaletteContentWidth = maxW + spacing() * 2; _columnPalette->setContentHeight(y); +#ifdef PROFILE_JASP + Log::log() << "ScriptConstructor buildColumnPalette created " << content->childItems().size() << " items (rows: " << rows << ")" << std::endl; +#endif if(_chromeBuilt) layoutAll(); } diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 2cea4d3a59..3dc94c722e 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -397,6 +397,7 @@ ScriptNodeItem::ScriptNodeItem(ScriptConstructorView * view, ScriptNode * node, , _view(view) , _node(node) { + JASPTIMER_SCOPE(ScriptNodeItem ctor); setAcceptedMouseButtons(Qt::LeftButton | Qt::RightButton); // Hover shows a native tooltip via QToolTip (see hoverEnterEvent), replacing the old @@ -420,6 +421,7 @@ ScriptNodeItem::~ScriptNodeItem() void ScriptNodeItem::clearLeaves() { + JASPTIMER_SCOPE(ScriptNodeItem clearLeaves); for(QQuickItem * leaf : _leaves) if(leaf) leaf->deleteLater(); @@ -443,18 +445,22 @@ void ScriptNodeItem::clearLeaves() QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) { + JASPTIMER_SCOPE(ScriptNodeItem makeText total); QQuickItem * item = _view->newLeaf(_view->textComponent(), "text"); if(!item) return nullptr; item->setParentItem(this); item->setProperty("text", text); - JaspTheme * theme = JaspTheme::currentTheme(); - QFont f = theme->font(); - f.setPixelSize(static_cast(_view->fontPixelSize())); - f.setBold(bold); - item->setProperty("font", f); - item->setProperty("color", theme->textEnabled()); + { + JASPTIMER_SCOPE(ScriptNodeItem makeText setProps); + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + f.setBold(bold); + item->setProperty("font", f); + item->setProperty("color", theme->textEnabled()); + } addLeaf(item); return item; @@ -462,11 +468,17 @@ QQuickItem * ScriptNodeItem::makeText(const QString & text, bool bold) QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) { + JASPTIMER_SCOPE(ScriptNodeItem makeImage total); QQuickItem * item = _view->newLeaf(_view->imageComponent(), "image"); if(!item) return nullptr; item->setParentItem(this); - item->setProperty("source", JaspTheme::currentTheme()->iconPath() + "/" + iconFile); + { + // Suspect #1 for slow init: Image loads synchronously by default, so this + // property write can trigger a PNG load + decode on the GUI thread. + JASPTIMER_SCOPE(ScriptNodeItem makeImage setSource); + item->setProperty("source", JaspTheme::currentTheme()->iconPath() + "/" + iconFile); + } item->setProperty("fillMode", 1); // Image.PreserveAspectFit qreal dim = _view->blockDim(); @@ -483,18 +495,22 @@ QQuickItem * ScriptNodeItem::makeImage(const QString & iconFile) QQuickItem * ScriptNodeItem::makeParenText(const QString & text) { + JASPTIMER_SCOPE(ScriptNodeItem makeParenText total); QQuickItem * item = _view->newLeaf(_view->textComponent(), "text"); if(!item) return nullptr; item->setParentItem(this); item->setProperty("text", text); - JaspTheme * theme = JaspTheme::currentTheme(); - QFont f = theme->font(); - f.setPixelSize(static_cast(_view->fontPixelSize())); - item->setProperty("font", f); - item->setProperty("color", theme->textEnabled()); - item->setVisible(false); // visibility controlled in layout() + { + JASPTIMER_SCOPE(ScriptNodeItem makeParenText setProps); + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + item->setProperty("font", f); + item->setProperty("color", theme->textEnabled()); + item->setVisible(false); // visibility controlled in layout() + } return item; } @@ -508,11 +524,14 @@ QQuickItem * ScriptNodeItem::makeComma() item->setParentItem(this); item->setProperty("text", ", "); - JaspTheme * theme = JaspTheme::currentTheme(); - QFont f = theme->font(); - f.setPixelSize(static_cast(_view->fontPixelSize())); - item->setProperty("font", f); - item->setProperty("color", theme->textEnabled()); + { + JASPTIMER_SCOPE(ScriptNodeItem makeComma setProps); + JaspTheme * theme = JaspTheme::currentTheme(); + QFont f = theme->font(); + f.setPixelSize(static_cast(_view->fontPixelSize())); + item->setProperty("font", f); + item->setProperty("color", theme->textEnabled()); + } _argumentCommas.append(item); return item; @@ -520,6 +539,7 @@ QQuickItem * ScriptNodeItem::makeComma() ScriptDropSpot * ScriptNodeItem::makeDropSpot(const DropTarget & target, const QString & placeholder) { + JASPTIMER_SCOPE(ScriptNodeItem makeDropSpot); ScriptDropSpot * spot = new ScriptDropSpot(_view, this); spot->setTarget(target); spot->setAcceptsDrops(_acceptsDrops); @@ -630,6 +650,7 @@ void ScriptNodeItem::rebuild() } case ScriptNode::Type::Column: { + JASPTIMER_SCOPE(ScriptNodeItem rebuildColumn); auto * col = static_cast(_node); int actual = 1; @@ -647,6 +668,7 @@ void ScriptNodeItem::rebuild() case ScriptNode::Type::Operator: case ScriptNode::Type::OperatorVertical: { + JASPTIMER_SCOPE(ScriptNodeItem rebuildOperator); auto * op = static_cast(_node); const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op->op(), op->isVertical()); @@ -683,6 +705,7 @@ void ScriptNodeItem::rebuild() } case ScriptNode::Type::Function: { + JASPTIMER_SCOPE(ScriptNodeItem rebuildFunction); auto * func = static_cast(_node); const ScriptFunctionDef * funcDef = ScriptConstructorRegistry::instance().functionDef(func->functionName()); const bool hasImage = funcDef && !funcDef->image.empty(); @@ -742,6 +765,7 @@ void ScriptNodeItem::rebuild() } case ScriptNode::Type::RowFunction: { + JASPTIMER_SCOPE(ScriptNodeItem rebuildRowFunction); auto * rowFunc = static_cast(_node); const ScriptFunctionDef * rowDef = ScriptConstructorRegistry::instance().rowFunctionDef(rowFunc->functionName()); const bool hasImage = rowDef && !rowDef->image.empty(); @@ -773,61 +797,64 @@ void ScriptNodeItem::rebuild() } // Compute the hover tooltip for this element. - QString tip; - - switch(_node->type()) - { - case ScriptNode::Type::Operator: - case ScriptNode::Type::OperatorVertical: - { - const std::string & op = static_cast(_node)->op(); - if(const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op, static_cast(_node)->isVertical())) - tip = def->toolTipForMode(_view->model()->mode()); - break; - } - case ScriptNode::Type::Function: { - const std::string & fn = static_cast(_node)->functionName(); - if(const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(fn)) - tip = def->toolTipForMode(_view->model()->mode()); - break; - } - case ScriptNode::Type::RowFunction: - { - const std::string & fn = static_cast(_node)->functionName(); - if(const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().rowFunctionDef(fn)) - tip = def->toolTipForMode(_view->model()->mode()); - break; - } - case ScriptNode::Type::Column: - { - auto * col = static_cast(_node); + JASPTIMER_SCOPE(ScriptNodeItem tooltip); + QString tip; + + switch(_node->type()) + { + case ScriptNode::Type::Operator: + case ScriptNode::Type::OperatorVertical: + { + const std::string & op = static_cast(_node)->op(); + if(const ScriptOperatorDef * def = ScriptConstructorRegistry::instance().operatorDef(op, static_cast(_node)->isVertical())) + tip = def->toolTipForMode(_view->model()->mode()); + break; + } + case ScriptNode::Type::Function: + { + const std::string & fn = static_cast(_node)->functionName(); + if(const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().functionDef(fn)) + tip = def->toolTipForMode(_view->model()->mode()); + break; + } + case ScriptNode::Type::RowFunction: + { + const std::string & fn = static_cast(_node)->functionName(); + if(const ScriptFunctionDef * def = ScriptConstructorRegistry::instance().rowFunctionDef(fn)) + tip = def->toolTipForMode(_view->model()->mode()); + break; + } + case ScriptNode::Type::Column: + { + auto * col = static_cast(_node); - const int actual = _view->columnType(col->columnName()); - const int effective = col->effectiveColumnType(actual); + const int actual = _view->columnType(col->columnName()); + const int effective = col->effectiveColumnType(actual); - QStringList parts; - parts << tr("Click icon to change column type"); + QStringList parts; + parts << tr("Click icon to change column type"); - const QString description = _view->columnDescription(tq(col->columnName())); - if(!description.isEmpty()) - parts << tr("Column description: ") + description; + const QString description = _view->columnDescription(tq(col->columnName())); + if(!description.isEmpty()) + parts << tr("Column description: ") + description; - if(effective != actual) - { - const QString preview = _view->columnTransformedPreview(tq(col->columnName()), col->columnTypeUser()); - if(!preview.isEmpty()) - parts << preview; + if(effective != actual) + { + const QString preview = _view->columnTransformedPreview(tq(col->columnName()), col->columnTypeUser()); + if(!preview.isEmpty()) + parts << preview; + } + + tip = parts.join("\n\n"); + break; + } + default: + break; } - tip = parts.join("\n\n"); - break; + setToolTip(tip); } - default: - break; - } - - setToolTip(tip); layout(); } diff --git a/Tests/testall.cpp b/Tests/testall.cpp index a100af17f8..6d1e0a3987 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -24,10 +24,20 @@ #include "scriptnode.h" #include "scriptconstructorregistry.h" +#include "mainwindow.h" +#include "data/filtermodel.h" +#include "data/columnsmodel.h" +#include "qquick/scriptconstructorview.h" +#include "timers.h" +#include "log.h" + #include #include #include #include +#include +#include +#include #include #include #include "data/asyncloader.h" @@ -77,6 +87,75 @@ bool TestAll::_newPkgWithDataSet() return _pkg->dataSet() != nullptr; } +QQuickItem * TestAll::_findQuickItemByName(const QString & objectName) +{ + for(QWindow * window : QGuiApplication::topLevelWindows()) + if(QQuickWindow * quickWindow = qobject_cast(window)) + if(QQuickItem * item = quickWindow->findChild(objectName)) + return item; + return nullptr; +} + +void TestAll::testMainWindowShowsFilterWindow() +{ + // Makes MainWindow::checkForUpdates() bail out (mainwindow.cpp). + QCoreApplication::setApplicationName("JASPTest"); + + // The full QML UI contains WebEngine views (ChatWindow, results page), so WebEngine must be + // initialized before MainWindow creates its QQmlApplicationEngine (same order as main.cpp). + QtWebEngineQuick::initialize(); + + // MainWindow constructs the one-and-only DataSetPackage singleton, so no other package may + // exist at this point (the cleanup() of any previous test has deleted it). + QVERIFY(DataSetPackage::pkg() == nullptr); + + MainWindow * mainWindow = new MainWindow(nullptr); + + QSignalSpy qmlLoadedSpy(mainWindow, &MainWindow::qmlLoadedChanged); + QTRY_VERIFY(!qmlLoadedSpy.isEmpty()); // loadQML() runs from a QTimer::singleShot in the ctor + + // Load a dataset the way production does: into the package owned by MainWindow, mark it + // as the shown dataset (DataSetLoader::loadPackage does the same) and then notify the UI + // (newDataLoaded -> MainWindow::populateUIfromDataSet). + DataSet * dataSet = DataSetPackage::pkg()->createDataSet(); + QVERIFY(dataSet != nullptr); + // Exactly what DataSetLoader::loadPackage does (datasetloader.cpp): setShownDataSet may + // early-return (the empty dataset was already made shown by the EngineSync-ctor reset), + // so refresh() is needed to (re)emit shownDataSetChanged now that makeConnections() has + // wired up the models. + DataSetPackage::pkg()->workspace()->setShownDataSet(dataSet); + DataSetPackage::pkg()->workspace()->refresh(); + + // Pre-set the delimiter: with MainWindow connected, askCsvDelimiterSignal would otherwise + // open a CSV delimiter dialog and deadlock the synchronous import on the main thread. + DesktopCommunicator::singleton()->setKnownCsvDelimiter(','); + + CSVImporter importer; + importer.loadDataSet(fq(_testLibrary().absoluteFilePath("csv/debug.csv")), dataSet, [](int){}); + DataSetPackage::pkg()->newDataLoaded(); + + // Open the filter window the way the UI does (Loader in DataPanel.qml). + FilterModel * filterModel = mainWindow->findChild(); + QVERIFY(filterModel != nullptr); + filterModel->setFilterVisible(true); + + // FilterWindow (objectName "filterWindow") must appear and the ScriptConstructor inside it + // must have built its chrome now that it is visible. + QQuickItem * filterWindow = nullptr; + QTRY_VERIFY((filterWindow = _findQuickItemByName("filterWindow")) != nullptr); + + ScriptConstructorView * scriptConstructor = filterWindow->findChild(); + QVERIFY(scriptConstructor != nullptr); + QTRY_VERIFY(scriptConstructor->scriptArea() != nullptr); // non-null once the chrome is built + + // Leave the full timer table behind for profiling (needs JASP_TIMER_USED=ON). + JASPTIMER_PRINTALL(); + + // Deliberately do not delete mainWindow: tearing down the EngineSync / DatabaseInterface + // during process shutdown throws (sqlite session already gone), which would fail the test + // even though the assertions passed. The binary exits right after this slot anyway. +} + #define TO_STR2(x) #x #define TO_STR(x) TO_STR2(x) diff --git a/Tests/testall.h b/Tests/testall.h index 41f45219ee..3b365fc99b 100644 --- a/Tests/testall.h +++ b/Tests/testall.h @@ -5,6 +5,7 @@ class DataSetPackage; class Importer; class DataSet; class DataSetSyncer; +class QQuickItem; class TestAll: public QObject { @@ -83,9 +84,16 @@ private slots: void testScriptConstructorAllowedColumnTypes(); void testScriptConstructorRowFunctionFreeSlot(); + // Boots the real QML MainWindow headlessly, loads a dataset and shows the filter window + // (which instantiates the C++ ScriptConstructorView). Serves as a profiling harness for + // the ScriptConstructor initialization path (use with JASP_TIMER_USED=ON) and as a + // regression test that the full UI bootstrap + filter window opening works headlessly. + void testMainWindowShowsFilterWindow(); + private: DataSetPackage * _pkg = nullptr; Importer * _importer = nullptr; bool _newPkgWithDataSet(); bool _checkDoSyncFake(); + QQuickItem * _findQuickItemByName(const QString & objectName); }; From 0e507a63a16e8fc148cd3ae919139d29a7e25c97 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 3 Sep 2026 17:01:20 +0200 Subject: [PATCH 21/25] ScriptConstructor: load image leaves asynchronously so PNG decode no longer blocks init (componentComplete 3.8s -> 25ms in debug) --- Desktop/qquick/scriptconstructorview.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 8cec63845e..283f4c89c5 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -434,7 +434,10 @@ QQmlComponent * ScriptConstructorView::imageComponent() { JASPTIMER_SCOPE(ScriptConstructor compile imageComponent); _imageComp = new QQmlComponent(qmlEngine(this)); - _imageComp->setData("import QtQuick\nImage { smooth: true; sourceSize.width: width * 2; sourceSize.height: height * 2; }", QUrl("ScriptConstructorImage")); + // asynchronous: true so icon decoding happens on the loader thread instead of blocking + // the GUI thread; sizes are pinned via width/height + implicitWidth/Height by makeImage, + // so layout never depends on the load having finished. + _imageComp->setData("import QtQuick\nImage { smooth: true; asynchronous: true; sourceSize.width: width * 2; sourceSize.height: height * 2; }", QUrl("ScriptConstructorImage")); } return _imageComp; } From d30f31381dbbaa8584a20d2b4cd52009f83720d0 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 3 Sep 2026 17:22:27 +0200 Subject: [PATCH 22/25] ScriptConstructor trash: drop the visible border and apply the emptied filter on double-click (old DropTrash called checkAndApplyFilter; without it the FilterModel pushed the erased tree back on the next sync) --- Desktop/qquick/scriptconstructorview.cpp | 8 +++-- Tests/testall.cpp | 39 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 283f4c89c5..74189a5f57 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -629,9 +629,7 @@ void ScriptConstructorView::buildChrome() { _trash->setParentItem(_scriptArea); _trash->setProperty("color", QColor(0, 0, 0, 0)); - QQmlProperty(_trash, "border.color").write(theme ? theme->gray() : QColor("gray")); - QQmlProperty(_trash, "border.width").write(1); - _trash->setProperty("radius", 6.0); + // No border: the old DropTrash.qml was just the icon on a transparent hit-zone. _trash->setZ(10); // Double-click erases the entire slate; hover shows a tooltip (handled via eventFilter). @@ -927,7 +925,11 @@ bool ScriptConstructorView::eventFilter(QObject * obj, QEvent * event) switch(event->type()) { case QEvent::MouseButtonDblClick: + // Mirrors the old DropTrash: erase the slate AND apply the (now empty) filter, + // otherwise the surrounding FilterModel keeps the old constructorJson and pushes + // the erased formula tree straight back into the view on the next filter sync. _model.clear(); + checkAndApply(); return true; case QEvent::HoverEnter: QToolTip::showText(static_cast(event)->globalPosition().toPoint(), _trashToolTip, nullptr, QRect(), 15000); diff --git a/Tests/testall.cpp b/Tests/testall.cpp index 6d1e0a3987..e21d01f796 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -148,6 +149,44 @@ void TestAll::testMainWindowShowsFilterWindow() QVERIFY(scriptConstructor != nullptr); QTRY_VERIFY(scriptConstructor->scriptArea() != nullptr); // non-null once the chrome is built + // --- Trash can regression: double-click must erase the entire script area --- + // Put a formula in the script area (a column node at the root) and refresh the view. + ScriptNodeColumn * columnNode = new ScriptNodeColumn("contNormal"); + scriptConstructor->model()->insertNode(columnNode, DropTarget::root()); + scriptConstructor->refresh(); + QCOMPARE(scriptConstructor->model()->formulaCount(), 1); + + // The trash rectangle is the only script-area child with z == 10. + QQuickItem * trash = nullptr; + for(QQuickItem * child : scriptConstructor->scriptArea()->childItems()) + if(child->z() == 10) + trash = child; + QVERIFY2(trash != nullptr, "Trash rectangle not found in script area"); + + QQuickWindow * quickWindow = trash->window(); + QVERIFY(quickWindow != nullptr); + QPointF centre = trash->mapToScene(QPointF(trash->width() / 2, trash->height() / 2)); + + // Simulate a full double-click sequence directly on the trash item (the offscreen + // harness window is too small for the trash to be within the scene bounds). + auto sendMouse = [&](QEvent::Type type) + { + QMouseEvent me(type, centre, quickWindow->mapToGlobal(centre), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QCoreApplication::sendEvent(trash, &me); + }; + + // Clearing must also reach the surrounding FilterModel (old DropTrash called + // checkAndApplyFilter), so watch for the applyRequested signal. + QSignalSpy applySpy(scriptConstructor, &ScriptConstructorView::applyRequested); + + sendMouse(QEvent::MouseButtonPress); + sendMouse(QEvent::MouseButtonRelease); + sendMouse(QEvent::MouseButtonDblClick); + sendMouse(QEvent::MouseButtonRelease); + + QCOMPARE(scriptConstructor->model()->formulaCount(), 0); + QVERIFY(!applySpy.isEmpty()); // the emptied filter must have been applied + // Leave the full timer table behind for profiling (needs JASP_TIMER_USED=ON). JASPTIMER_PRINTALL(); From cf43a57fee67ee4938e47b830a168c62f885c329 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 3 Sep 2026 19:09:13 +0200 Subject: [PATCH 23/25] ScriptConstructor trash: proper ScriptTrashItem with hand cursor + double-click diagnostics; harness gets loader width + trash delivery assertions - Replace the QML-Rectangle-leaf + eventFilter trash with a ScriptTrashItem QQuickItem subclass (same pattern as ScriptDropSpot/ScriptNodeItem), with a PointingHandCursor, tooltip handling and debug event counters. - Double-click still clears the model and applies the emptied filter. - Harness: give the offscreen SplitView's Loader a proper size (0-width ancestors exclude subtrees from hit-testing while their content stays visible), walk the topmost-item chain to prove ScriptTrashItem is the only accepting item at its position, and assert press/dblclick delivery + erase + applyRequested via deterministic counters. Full apply chain is kept out of the harness: it rebuilds the DataSetView and corrupts the QML GC offscreen. --- Desktop/qquick/scriptconstructorview.cpp | 74 ++++---------- Desktop/qquick/scriptconstructorview.h | 3 - Desktop/qquick/scriptnodeitem.cpp | 72 ++++++++++++++ Desktop/qquick/scriptnodeitem.h | 29 ++++++ Tests/testall.cpp | 117 +++++++++++++++++++---- 5 files changed, 218 insertions(+), 77 deletions(-) diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index 74189a5f57..fdae09492e 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -624,34 +624,25 @@ void ScriptConstructorView::buildChrome() _scriptColumn = new QQuickItem(_scriptArea); _scriptColumn->setParentItem(_scriptArea); - _trash = newLeaf(rectangleComponent(), "rectangle"); - if(_trash) + ScriptTrashItem * trash = new ScriptTrashItem(this); + trash->setToolTipText(tr("Dump unwanted snippets here; double-click to erase the entire slate")); + trash->setParentItem(_scriptArea); + trash->setZ(10); + _trash = trash; + + // Trash icon centred inside the drop zone. + QQuickItem * icon = newLeaf(imageComponent(), "image"); + if(icon) { - _trash->setParentItem(_scriptArea); - _trash->setProperty("color", QColor(0, 0, 0, 0)); - // No border: the old DropTrash.qml was just the icon on a transparent hit-zone. - _trash->setZ(10); - - // Double-click erases the entire slate; hover shows a tooltip (handled via eventFilter). - _trashToolTip = tr("Dump unwanted snippets here; double-click to erase the entire slate"); - _trash->setAcceptedMouseButtons(Qt::LeftButton); - _trash->setAcceptHoverEvents(true); - _trash->installEventFilter(this); - - // Trash icon centred inside the drop zone. - QQuickItem * icon = newLeaf(imageComponent(), "image"); - if(icon) - { - icon->setParentItem(_trash); - icon->setProperty("source", (theme ? theme->iconPath() : QString()) + "/trashcan.png"); - icon->setProperty("fillMode", 1); // Image.PreserveAspectFit - icon->setAcceptedMouseButtons(Qt::NoButton); - qreal dim = blockDim() * 1.6; - icon->setWidth(dim); - icon->setHeight(dim); - icon->setX((blockDim() * 3 - dim) / 2); - icon->setY((blockDim() * 3 - dim) / 2); - } + icon->setParentItem(_trash); + icon->setProperty("source", (theme ? theme->iconPath() : QString()) + "/trashcan.png"); + icon->setProperty("fillMode", 1); // Image.PreserveAspectFit + icon->setAcceptedMouseButtons(Qt::NoButton); + qreal dim = blockDim() * 1.6; + icon->setWidth(dim); + icon->setHeight(dim); + icon->setX((blockDim() * 3 - dim) / 2); + icon->setY((blockDim() * 3 - dim) / 2); } _hint = newLeaf(textComponent(), "text"); @@ -916,35 +907,6 @@ void ScriptConstructorView::keyPressEvent(QKeyEvent * event) QQuickItem::keyPressEvent(event); } -bool ScriptConstructorView::eventFilter(QObject * obj, QEvent * event) -{ - // Double-clicking the trash zone erases the whole slate (mirrors the old DropTrash.qml); - // hovering it shows a tooltip (replaces the old per-item QtQuick ToolTip overlay). - if(obj == _trash) - { - switch(event->type()) - { - case QEvent::MouseButtonDblClick: - // Mirrors the old DropTrash: erase the slate AND apply the (now empty) filter, - // otherwise the surrounding FilterModel keeps the old constructorJson and pushes - // the erased formula tree straight back into the view on the next filter sync. - _model.clear(); - checkAndApply(); - return true; - case QEvent::HoverEnter: - QToolTip::showText(static_cast(event)->globalPosition().toPoint(), _trashToolTip, nullptr, QRect(), 15000); - return true; - case QEvent::HoverLeave: - QToolTip::hideText(); - return true; - default: - break; - } - } - - return QQuickItem::eventFilter(obj, event); -} - void ScriptConstructorView::refreshHint() { if(!_hint) return; diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index 146a0ccf0a..d49fe831e0 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -155,7 +155,6 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void geometryChange(const QRectF & newGeometry, const QRectF & oldGeometry) override; void itemChange(ItemChange change, const ItemChangeData & value) override; void keyPressEvent(QKeyEvent * event) override; - bool eventFilter(QObject * obj, QEvent * event) override; private: void buildChrome(); @@ -217,8 +216,6 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider QAbstractItemModel * _columnsModel = nullptr; - QString _trashToolTip; - // Natural size of the background watermark image, cached on load (the Image's // sourceSize = 2x binding makes implicitWidth follow width afterwards). QSizeF _backgroundImageSize; diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 3dc94c722e..58933f419b 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -4,6 +4,7 @@ #include "qutils.h" #include "data/columnsmodel.h" #include "timers.h" +#include "log.h" #include #include #include @@ -388,6 +389,77 @@ void ScriptDropSpot::parseAndCreateLiteral() _input->setProperty("text", _defaultText); } +// ===================================================================================== +// ScriptTrashItem +// ===================================================================================== + +ScriptTrashItem::ScriptTrashItem(ScriptConstructorView * view, QQuickItem * parent) + : QQuickItem(parent) + , _view(view) +{ + setAcceptedMouseButtons(Qt::LeftButton); + setAcceptHoverEvents(true); + setCursor(Qt::PointingHandCursor); // mirrors the old MouseArea cursorShape +} + +void ScriptTrashItem::mousePressEvent(QMouseEvent * event) +{ + // Accept the press so the window treats this item as the press target (and can + // synthesize the double click on the second press). + debugPressCount++; + Log::log() << "DIAG trash mousePress" << std::endl; + event->accept(); +} + +void ScriptTrashItem::mouseDoubleClickEvent(QMouseEvent * event) +{ + Log::log() << "DIAG trash mouseDoubleClick" << std::endl; + debugDoubleClickCount++; + // Mirrors the old DropTrash: erase the slate AND apply the (now empty) filter, otherwise + // the surrounding FilterModel keeps the old constructorJson and pushes the erased formula + // tree straight back into the view on the next filter sync. + if(event->button() == Qt::LeftButton && _view) + { + _view->model()->clear(); + _view->checkAndApply(); + } + event->accept(); +} + +void ScriptTrashItem::hoverEnterEvent(QHoverEvent * event) +{ + // Diagnostic for "visible but unclickable": mouse presses are only delivered when EVERY + // ancestor contains the cursor position, while hover is per-item. Log which ancestor + // (if any) excludes the point, so the culprit is visible in the log. +#ifdef PROFILE_JASP + { + const QPointF scenePos = mapToScene(boundingRect().center()); + QQuickItem * ancestor = parentItem(); + while(ancestor) + { + const QPointF local = ancestor->mapFromScene(scenePos); + const bool contained = ancestor->contains(local); + Log::log() << "DIAG trash hover ancestor " << ancestor->metaObject()->className() + << " name='" << ancestor->objectName() << "' size=" << ancestor->width() << "x" << ancestor->height() + << " contains=" << contained << (contained ? "" : " <== EXCLUDES THE POINT") + << std::endl; + ancestor = ancestor->parentItem(); + } + } +#endif + + if(!_toolTipText.isEmpty()) + QToolTip::showText(event->globalPosition().toPoint(), _toolTipText, nullptr, QRect(), 15000); + + event->accept(); +} + +void ScriptTrashItem::hoverLeaveEvent(QHoverEvent * event) +{ + QToolTip::hideText(); + event->accept(); +} + // ===================================================================================== // ScriptNodeItem // ===================================================================================== diff --git a/Desktop/qquick/scriptnodeitem.h b/Desktop/qquick/scriptnodeitem.h index d2135128c4..0baf13606b 100644 --- a/Desktop/qquick/scriptnodeitem.h +++ b/Desktop/qquick/scriptnodeitem.h @@ -55,6 +55,35 @@ private slots: bool _acceptsDrops = true; }; +/// +/// The trash zone at the bottom-right of the script area: a transparent hit-zone with the +/// trashcan icon on top. Hovering shows a tooltip; double-clicking erases the entire slate +/// and applies the emptied filter (mirrors the old DropTrash.qml). +class ScriptTrashItem : public QQuickItem +{ + Q_OBJECT + +public: + explicit ScriptTrashItem(ScriptConstructorView * view, QQuickItem * parent = nullptr); + + void setToolTipText(const QString & text) { _toolTipText = text; } + + // Test/debug counters: incremented on delivery of the corresponding event, so tests (and + // the "visible but unclickable" diagnostics) can verify delivery deterministically. + int debugPressCount = 0; + int debugDoubleClickCount = 0; + +protected: + void mousePressEvent(QMouseEvent * event) override; + void mouseDoubleClickEvent(QMouseEvent * event) override; + void hoverEnterEvent(QHoverEvent * event) override; + void hoverLeaveEvent(QHoverEvent * event) override; + +private: + ScriptConstructorView * _view = nullptr; + QString _toolTipText; +}; + /// /// Visual representation of a single ScriptNode. Creates incubated QML leaves (Text, Image, /// TextInput, CheckBox) for its content plus ScriptDropSpot children for its slots, and lays diff --git a/Tests/testall.cpp b/Tests/testall.cpp index e21d01f796..1b6e0b0b34 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -28,6 +28,7 @@ #include "data/filtermodel.h" #include "data/columnsmodel.h" #include "qquick/scriptconstructorview.h" +#include "qquick/scriptnodeitem.h" #include "timers.h" #include "log.h" @@ -40,6 +41,7 @@ #include #include #include +#include #include #include "data/asyncloader.h" @@ -97,6 +99,41 @@ QQuickItem * TestAll::_findQuickItemByName(const QString & objectName) return nullptr; } +static void dumpTopmostItemAt(QQuickWindow * window, const QPointF & scenePos) +{ + // Walk the scene like Qt Quick's press delivery does: children top-first (z then paint + // order), printing every item that contains the point. The first item that is visible, + // enabled and accepts the left button is the one that will swallow the press. + Log::log() << "DIAG item chain at " << scenePos.x() << "," << scenePos.y() << " (top-first):" << std::endl; + bool foundReceiver = false; + + std::function walk = [&](QQuickItem * item) + { + QList children = item->childItems(); + std::stable_sort(children.begin(), children.end(), [](QQuickItem * a, QQuickItem * b){ return a->z() < b->z(); }); + for(int i = children.size() - 1; i >= 0; i--) + { + QQuickItem * child = children[i]; + const QPointF local = child->mapFromScene(scenePos); + if(!child->isVisible() || local.x() < 0 || local.y() < 0 || local.x() > child->width() || local.y() > child->height()) + continue; + + const bool accepts = child->acceptedMouseButtons().testFlag(Qt::LeftButton); + Log::log() << " " << child->metaObject()->className() << " name='" << child->objectName() + << "' z=" << child->z() << " enabled=" << child->isEnabled() + << " acceptsLeft=" << accepts + << " pos=" << child->x() << "," << child->y() << " size=" << child->width() << "x" << child->height() + << (accepts && child->isEnabled() && !foundReceiver ? " <== would take the press" : "") + << std::endl; + if(accepts && child->isEnabled() && !foundReceiver) + foundReceiver = true; + walk(child); + } + }; + + walk(window->contentItem()); +} + void TestAll::testMainWindowShowsFilterWindow() { // Makes MainWindow::checkForUpdates() bail out (mainwindow.cpp). @@ -150,42 +187,86 @@ void TestAll::testMainWindowShowsFilterWindow() QTRY_VERIFY(scriptConstructor->scriptArea() != nullptr); // non-null once the chrome is built // --- Trash can regression: double-click must erase the entire script area --- - // Put a formula in the script area (a column node at the root) and refresh the view. - ScriptNodeColumn * columnNode = new ScriptNodeColumn("contNormal"); - scriptConstructor->model()->insertNode(columnNode, DropTarget::root()); - scriptConstructor->refresh(); - QCOMPARE(scriptConstructor->model()->formulaCount(), 1); - - // The trash rectangle is the only script-area child with z == 10. + // The trash item is the only script-area child with z == 10. QQuickItem * trash = nullptr; for(QQuickItem * child : scriptConstructor->scriptArea()->childItems()) if(child->z() == 10) trash = child; - QVERIFY2(trash != nullptr, "Trash rectangle not found in script area"); + QVERIFY2(trash != nullptr, "Trash item not found in script area"); QQuickWindow * quickWindow = trash->window(); QVERIFY(quickWindow != nullptr); - QPointF centre = trash->mapToScene(QPointF(trash->width() / 2, trash->height() / 2)); - // Simulate a full double-click sequence directly on the trash item (the offscreen - // harness window is too small for the trash to be within the scene bounds). - auto sendMouse = [&](QEvent::Type type) + // The offscreen SplitView never assigns a width to the Loader (it stays 0 wide), which + // excludes the whole filter window from mouse hit-testing even though its content is + // visible (children overflow unclipped ancestors). In the real app the SplitView/anchors + // give the loader a proper width; emulate that here. + if(QQuickItem * loader = filterWindow->parentItem()) { - QMouseEvent me(type, centre, quickWindow->mapToGlobal(centre), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); - QCoreApplication::sendEvent(trash, &me); + loader->setHeight(std::max(loader->height(), 600.0)); + loader->setWidth(std::max(loader->width(), 1200.0)); + } + + auto trashCentre = [&]() + { + return trash->mapToScene(QPointF(trash->width() / 2, trash->height() / 2)); }; + const QPointF centre = trashCentre(); + QVERIFY2(centre.x() > 0 && centre.y() > 0 && centre.x() < quickWindow->width() && centre.y() < quickWindow->height(), + qPrintable(QString("Trash not inside the window bounds: centre=%1,%2 constructor=%3x%4 at %5,%6 window=%7x%8 trashSize=%9x%10") + .arg(centre.x()).arg(centre.y()) + .arg(scriptConstructor->width()).arg(scriptConstructor->height()) + .arg(scriptConstructor->x()).arg(scriptConstructor->y()) + .arg(quickWindow->width()).arg(quickWindow->height()) + .arg(trash->width()).arg(trash->height()))); + + // The engine/loader threads push filter results back into the view asynchronously; a push + // whose json differs from the current model resets it (fromJson emits reset), wiping + // unapplied formulas at any event-loop spin. QTest's mouse helpers spin internally, and + // the resulting QML GC churn reliably crashes this offscreen harness (not seen in the + // real app). So: + // - break the QML applyRequested -> FilterModel connection, so the trash's checkAndApply + // cannot trigger the full apply chain (re-running the filter rebuilds the entire + // DataSetView) inside the harness; and + // - verify the trash via its deterministic debug event counters, delivering the mouse + // sequence directly to the item (no event-loop spin -> no interleaving). + // NOTE: real window hit-testing delivery to the trash was verified separately (the + // topmost-item walk shows ScriptTrashItem is the only accepting item at its position, and + // QTest-delivered presses arrived at it) — it is only the spinning+GC combination that + // cannot run in this harness. + QObject::disconnect(scriptConstructor, &ScriptConstructorView::applyRequested, scriptConstructor, nullptr); - // Clearing must also reach the surrounding FilterModel (old DropTrash called - // checkAndApplyFilter), so watch for the applyRequested signal. QSignalSpy applySpy(scriptConstructor, &ScriptConstructorView::applyRequested); + // Boolean-valid formula (Filter mode requires it) so the trash's checkAndApply succeeds. + ScriptNodeOperator * equals = new ScriptNodeOperator("==", false); + equals->setLeft(new ScriptNodeColumn("contNormal")); + ScriptNodeLiteral * one = new ScriptNodeLiteral(ScriptNode::Type::Number); + one->setNumberValue(1); + equals->setRight(one); + scriptConstructor->model()->insertNode(equals, DropTarget::root()); + scriptConstructor->refresh(); + QCOMPARE(scriptConstructor->model()->formulaCount(), 1); + + ScriptTrashItem * trashItem = qobject_cast(trash); + QVERIFY2(trashItem != nullptr, "Trash is not a ScriptTrashItem"); + const int pressesBefore = trashItem->debugPressCount; + const int dblClicksBefore = trashItem->debugDoubleClickCount; + + auto sendMouse = [&](QEvent::Type type) + { + QMouseEvent me(type, trashCentre(), quickWindow->mapToGlobal(trashCentre()), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QCoreApplication::sendEvent(trash, &me); + }; sendMouse(QEvent::MouseButtonPress); sendMouse(QEvent::MouseButtonRelease); sendMouse(QEvent::MouseButtonDblClick); sendMouse(QEvent::MouseButtonRelease); - QCOMPARE(scriptConstructor->model()->formulaCount(), 0); - QVERIFY(!applySpy.isEmpty()); // the emptied filter must have been applied + QCOMPARE(trashItem->debugPressCount, pressesBefore + 1); + QCOMPARE(trashItem->debugDoubleClickCount, dblClicksBefore + 1); + QCOMPARE(scriptConstructor->model()->formulaCount(), 0); // slate erased + QVERIFY(!applySpy.isEmpty()); // emptied filter was applied // Leave the full timer table behind for profiling (needs JASP_TIMER_USED=ON). JASPTIMER_PRINTALL(); From bca66fbf87eda08300340f1f4c682b0814d234cb Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 3 Sep 2026 19:21:38 +0200 Subject: [PATCH 24/25] Remove the temporary ScriptConstructor trash diagnostics (kept the debug event counters used by the harness) --- Desktop/qquick/scriptnodeitem.cpp | 23 -------------------- Tests/testall.cpp | 36 ------------------------------- 2 files changed, 59 deletions(-) diff --git a/Desktop/qquick/scriptnodeitem.cpp b/Desktop/qquick/scriptnodeitem.cpp index 58933f419b..0d67ef1ee7 100644 --- a/Desktop/qquick/scriptnodeitem.cpp +++ b/Desktop/qquick/scriptnodeitem.cpp @@ -4,7 +4,6 @@ #include "qutils.h" #include "data/columnsmodel.h" #include "timers.h" -#include "log.h" #include #include #include @@ -407,13 +406,11 @@ void ScriptTrashItem::mousePressEvent(QMouseEvent * event) // Accept the press so the window treats this item as the press target (and can // synthesize the double click on the second press). debugPressCount++; - Log::log() << "DIAG trash mousePress" << std::endl; event->accept(); } void ScriptTrashItem::mouseDoubleClickEvent(QMouseEvent * event) { - Log::log() << "DIAG trash mouseDoubleClick" << std::endl; debugDoubleClickCount++; // Mirrors the old DropTrash: erase the slate AND apply the (now empty) filter, otherwise // the surrounding FilterModel keeps the old constructorJson and pushes the erased formula @@ -428,26 +425,6 @@ void ScriptTrashItem::mouseDoubleClickEvent(QMouseEvent * event) void ScriptTrashItem::hoverEnterEvent(QHoverEvent * event) { - // Diagnostic for "visible but unclickable": mouse presses are only delivered when EVERY - // ancestor contains the cursor position, while hover is per-item. Log which ancestor - // (if any) excludes the point, so the culprit is visible in the log. -#ifdef PROFILE_JASP - { - const QPointF scenePos = mapToScene(boundingRect().center()); - QQuickItem * ancestor = parentItem(); - while(ancestor) - { - const QPointF local = ancestor->mapFromScene(scenePos); - const bool contained = ancestor->contains(local); - Log::log() << "DIAG trash hover ancestor " << ancestor->metaObject()->className() - << " name='" << ancestor->objectName() << "' size=" << ancestor->width() << "x" << ancestor->height() - << " contains=" << contained << (contained ? "" : " <== EXCLUDES THE POINT") - << std::endl; - ancestor = ancestor->parentItem(); - } - } -#endif - if(!_toolTipText.isEmpty()) QToolTip::showText(event->globalPosition().toPoint(), _toolTipText, nullptr, QRect(), 15000); diff --git a/Tests/testall.cpp b/Tests/testall.cpp index 1b6e0b0b34..34d9389026 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -30,7 +30,6 @@ #include "qquick/scriptconstructorview.h" #include "qquick/scriptnodeitem.h" #include "timers.h" -#include "log.h" #include #include @@ -99,41 +98,6 @@ QQuickItem * TestAll::_findQuickItemByName(const QString & objectName) return nullptr; } -static void dumpTopmostItemAt(QQuickWindow * window, const QPointF & scenePos) -{ - // Walk the scene like Qt Quick's press delivery does: children top-first (z then paint - // order), printing every item that contains the point. The first item that is visible, - // enabled and accepts the left button is the one that will swallow the press. - Log::log() << "DIAG item chain at " << scenePos.x() << "," << scenePos.y() << " (top-first):" << std::endl; - bool foundReceiver = false; - - std::function walk = [&](QQuickItem * item) - { - QList children = item->childItems(); - std::stable_sort(children.begin(), children.end(), [](QQuickItem * a, QQuickItem * b){ return a->z() < b->z(); }); - for(int i = children.size() - 1; i >= 0; i--) - { - QQuickItem * child = children[i]; - const QPointF local = child->mapFromScene(scenePos); - if(!child->isVisible() || local.x() < 0 || local.y() < 0 || local.x() > child->width() || local.y() > child->height()) - continue; - - const bool accepts = child->acceptedMouseButtons().testFlag(Qt::LeftButton); - Log::log() << " " << child->metaObject()->className() << " name='" << child->objectName() - << "' z=" << child->z() << " enabled=" << child->isEnabled() - << " acceptsLeft=" << accepts - << " pos=" << child->x() << "," << child->y() << " size=" << child->width() << "x" << child->height() - << (accepts && child->isEnabled() && !foundReceiver ? " <== would take the press" : "") - << std::endl; - if(accepts && child->isEnabled() && !foundReceiver) - foundReceiver = true; - walk(child); - } - }; - - walk(window->contentItem()); -} - void TestAll::testMainWindowShowsFilterWindow() { // Makes MainWindow::checkForUpdates() bail out (mainwindow.cpp). From b0efd20d90adb6761bcd4981248fedc4896649d9 Mon Sep 17 00:00:00 2001 From: Virtuoos Automatisch Date: Thu, 3 Sep 2026 19:57:26 +0200 Subject: [PATCH 25/25] ScriptConstructor: best-spot drop resolution - leftmost-empty fill and hover preview - Model: findReasonableInsertionSpot scans all root formulas (top-to-bottom) and returns the leftmost empty slot that accepts the dropped node's keys (leftMostEmptyDropSpotRec replaces the old last-formula rightmost logic). Gobble-left stays the fallback when no slot accepts, so dropping an operator onto a formula it cannot fit still absorbs it. - View: bestDropSpotFor() resolves the drop target: precise hit on an accepting spot wins, else the leftmost accepting empty slot of the formula under the cursor, else the topmost-then-leftmost accepting empty slot of the whole constructor. dragMove uses it so the green highlight previews the actual destination; endDrag applies it to new nodes and moves. - New model test testScriptConstructorLeftMostEmpty covers left/right filling, function-argument order (skipping non-accepting slots), topmost-formula priority, row functions and the preserved gobble behavior. --- CommonData/scriptconstructormodel.cpp | 61 ++++++++++----- Desktop/qquick/scriptconstructorview.cpp | 94 ++++++++++++++++++++++-- Desktop/qquick/scriptconstructorview.h | 7 ++ Tests/testall.cpp | 87 ++++++++++++++++++++++ Tests/testall.h | 4 + 5 files changed, 225 insertions(+), 28 deletions(-) diff --git a/CommonData/scriptconstructormodel.cpp b/CommonData/scriptconstructormodel.cpp index 7ee2fe9c27..a2e6ed7bd1 100644 --- a/CommonData/scriptconstructormodel.cpp +++ b/CommonData/scriptconstructormodel.cpp @@ -316,29 +316,49 @@ static DropTarget makeSlotTarget(ScriptNode * parent, DropTarget::Kind kind, int return t; } -static DropTarget rightMostEmptyDropSpotRec(ScriptNode * node) +static DropTarget leftMostEmptyDropSpotRec(ScriptNode * node, const stringvec & dragKeys) { + // In-order leftmost empty slot that accepts the dragged node's keys: for operators the + // left subtree/slot before the right one, for functions and row functions the arguments + // in ascending index (descending into filled arguments to find nested empty slots further + // left). Non-accepting empty slots are skipped, search continues to their right. if(!node) return DropTarget::none(); if(auto * op = dynamic_cast(node)) { + if(op->leftChild()) + { + DropTarget sub = leftMostEmptyDropSpotRec(op->leftChild(), dragKeys); + if(sub.isValid()) + return sub; + } + else if(ScriptConstructorModel::keysOverlap(dragKeys, op->dropKeysLeft())) + return makeSlotTarget(op, DropTarget::Kind::OperatorLeft, 0, op->dropKeysLeft()); + if(op->rightChild()) - return rightMostEmptyDropSpotRec(op->rightChild()); - return makeSlotTarget(op, DropTarget::Kind::OperatorRight, 1, op->dropKeysRight()); + { + DropTarget sub = leftMostEmptyDropSpotRec(op->rightChild(), dragKeys); + if(sub.isValid()) + return sub; + } + else if(ScriptConstructorModel::keysOverlap(dragKeys, op->dropKeysRight())) + return makeSlotTarget(op, DropTarget::Kind::OperatorRight, 1, op->dropKeysRight()); + + return DropTarget::none(); } if(auto * func = dynamic_cast(node)) { - for(int i = func->childCount() - 1; i >= 0; i--) + for(int i = 0; i < func->childCount(); i++) { const auto & arg = func->arguments()[i]; if(arg.value) { - DropTarget sub = rightMostEmptyDropSpotRec(arg.value); + DropTarget sub = leftMostEmptyDropSpotRec(arg.value, dragKeys); if(sub.isValid()) return sub; } - else + else if(ScriptConstructorModel::keysOverlap(dragKeys, arg.dropKeys)) return makeSlotTarget(func, DropTarget::Kind::FunctionArg, i, arg.dropKeys); } return DropTarget::none(); @@ -346,15 +366,15 @@ static DropTarget rightMostEmptyDropSpotRec(ScriptNode * node) if(auto * rowFunc = dynamic_cast(node)) { - for(int i = rowFunc->childCount() - 1; i >= 0; i--) + for(int i = 0; i < rowFunc->childCount(); i++) { if(rowFunc->childAt(i)) { - DropTarget sub = rightMostEmptyDropSpotRec(rowFunc->childAt(i)); + DropTarget sub = leftMostEmptyDropSpotRec(rowFunc->childAt(i), dragKeys); if(sub.isValid()) return sub; } - else + else if(ScriptConstructorModel::keysOverlap(dragKeys, {"number"})) return makeSlotTarget(rowFunc, DropTarget::Kind::RowFunctionArg, i, {"number"}); } return DropTarget::none(); @@ -403,20 +423,21 @@ static DropTarget rightMostFilledDropSpotRec(ScriptNode * node) DropTarget ScriptConstructorModel::findReasonableInsertionSpot(ScriptNode * node) const { - if(_formulas.empty()) - return DropTarget::none(); - - ScriptNode * last = _formulas.back(); - if(last == node) + // "Best spot" for a node dropped without an explicit target: scan the root formulas in + // order (they are laid out top-to-bottom) and take the first formula whose leftmost empty + // slot accepts the node — consecutive drops fill the constructor left-to-right, + // top-to-bottom. Returning nothing lets the caller fall back to gobble-left absorption. + for(ScriptNode * formula : _formulas) { - if(_formulas.size() == 1) - return DropTarget::none(); - last = _formulas[_formulas.size() - 2]; - if(last == node) - return DropTarget::none(); + if(formula == node) + continue; + + DropTarget spot = leftMostEmptyDropSpotRec(formula, node->dragKeys()); + if(spot.isValid()) + return spot; } - return rightMostEmptyDropSpotRec(last); + return DropTarget::none(); } std::vector ScriptConstructorModel::allowedColumnTypes(ScriptNode * node) const diff --git a/Desktop/qquick/scriptconstructorview.cpp b/Desktop/qquick/scriptconstructorview.cpp index fdae09492e..3aaba5915f 100644 --- a/Desktop/qquick/scriptconstructorview.cpp +++ b/Desktop/qquick/scriptconstructorview.cpp @@ -1239,6 +1239,84 @@ ScriptDropSpot * ScriptConstructorView::dropSpotAt(const QPointF & scenePos, Scr return bestSpot; } +ScriptDropSpot * ScriptConstructorView::bestDropSpotFor(ScriptNode * node, const QPointF & scenePos, ScriptNodeItem * dragged) const +{ + if(!node) return nullptr; + + // 1) A precise hit on a spot that accepts the node always wins (dropSpotAt already skips + // filled spots and anything inside the dragged subtree). + if(ScriptDropSpot * hit = dropSpotAt(scenePos, dragged)) + if(hit->target().accepts(node)) + return hit; + + // Candidate spots: empty (or holding the dragged item itself), accepting the node's keys, + // and not inside the dragged subtree. + QList spots; + const_cast(this)->collectDropSpots(spots); + + QList> candidates; // scene top-left of the spot + for(ScriptDropSpot * spot : spots) + { + if(!spot || (spot->filledItem() && spot->filledItem() != dragged)) + continue; + + if(dragged) + { + bool insideDragged = false; + for(QQuickItem * p = spot->parentItem(); p; p = p->parentItem()) + { + if(p == dragged) + { + insideDragged = true; + break; + } + } + if(insideDragged) continue; + } + + if(!spot->target().accepts(node)) + continue; + + candidates.append({ spot->mapToScene(QPointF(0, 0)), spot }); + } + + if(candidates.isEmpty()) + return nullptr; + + // Sort topmost, then leftmost ("fill the constructor left-to-right, top-to-bottom"). + std::sort(candidates.begin(), candidates.end(), [](const auto & a, const auto & b) + { + if(!qFuzzyCompare(a.first.y(), b.first.y())) + return a.first.y() < b.first.y(); + return a.first.x() < b.first.x(); + }); + + // 2) Dropped on a formula: use its leftmost accepting empty spot. + ScriptNodeItem * formulaUnderCursor = nullptr; + for(ScriptNodeItem * root : _rootItems) + { + if(!root) continue; + QPointF topLeft = root->mapToScene(QPointF(0, 0)); + if(QRectF(topLeft, QSizeF(root->width(), root->height())).contains(scenePos)) + { + formulaUnderCursor = root; + break; + } + } + + if(formulaUnderCursor) + { + for(const auto & candidate : candidates) + for(QQuickItem * p = candidate.second->parentItem(); p; p = p->parentItem()) + if(p == formulaUnderCursor) + return candidate.second; + return nullptr; + } + + // 3) Dropped on empty space: the topmost, then leftmost accepting empty spot anywhere. + return candidates.first().second; +} + void ScriptConstructorView::clearHover() { if(_hoveredSpot) @@ -1254,7 +1332,9 @@ void ScriptConstructorView::dragMove(const QPointF & scenePos) _draggedItem->setPosition(mapFromScene(scenePos) - _dragOffset); - ScriptDropSpot * spot = dropSpotAt(scenePos, _draggedItem); + // Preview the resolved "best spot" (precise hit, else the spot the drop would take), so + // the green highlight shows the actual destination while dragging. + ScriptDropSpot * spot = bestDropSpotFor(_draggedItem->node(), scenePos, _draggedItem); if(spot != _hoveredSpot) { @@ -1274,7 +1354,7 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) if(!_draggedItem) return; ScriptNode * node = _draggedItem->node(); - ScriptDropSpot * spot = dropSpotAt(scenePos, _draggedItem); + ScriptDropSpot * spot = bestDropSpotFor(node, scenePos, _draggedItem); clearHover(); @@ -1288,7 +1368,7 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) else _model.removeNode(node); } - else if(spot && spot->target().accepts(node)) + else if(spot) { DropTarget target = spot->target(); @@ -1299,13 +1379,11 @@ void ScriptConstructorView::endDrag(const QPointF & scenePos) } else { - // No specific spot under the cursor. + // No spot the node fits in: new nodes resolve a reasonable insertion point (topmost + // formula's leftmost slot) and, for operators with a free left slot, absorb + // ("gobble") an existing formula; existing nodes are re-rooted. if(_dragIsNew) - { - // A brand-new node resolves a reasonable insertion point and, for operators - // with a free left slot, absorbs ("gobbles") the preceding formula. _model.insertNode(node, DropTarget::none()); - } else _model.moveNode(node, DropTarget::root()); } diff --git a/Desktop/qquick/scriptconstructorview.h b/Desktop/qquick/scriptconstructorview.h index d49fe831e0..7a1a188d99 100644 --- a/Desktop/qquick/scriptconstructorview.h +++ b/Desktop/qquick/scriptconstructorview.h @@ -130,6 +130,13 @@ class ScriptConstructorView : public QQuickItem, public ScriptColumnTypeProvider void dragMove(const QPointF & scenePos); void endDrag(const QPointF & scenePos); ScriptDropSpot * dropSpotAt(const QPointF & scenePos, ScriptNodeItem * dragged = nullptr) const; + + /// Resolves the "best" drop spot for a node at a scene position: a precise hit on an + /// accepting spot wins; otherwise the leftmost accepting empty slot of the formula under + /// the cursor; otherwise the topmost-then-leftmost accepting empty slot of the whole + /// constructor; otherwise nullptr (caller falls back to root insertion / gobble-left). + ScriptDropSpot * bestDropSpotFor(ScriptNode * node, const QPointF & scenePos, ScriptNodeItem * dragged) const; + void collectDropSpots(QList & out) const; signals: diff --git a/Tests/testall.cpp b/Tests/testall.cpp index 34d9389026..879145d5ae 100644 --- a/Tests/testall.cpp +++ b/Tests/testall.cpp @@ -1939,6 +1939,93 @@ void TestAll::testScriptConstructorGobble() QCOMPARE(model.toR(), std::string("(contNormal.scale > null)\n")); } +void TestAll::testScriptConstructorLeftMostEmpty() +{ + // The dataset is not used directly, but opening it creates the session database that + // cleanup() closes (all model-level tests follow this pattern). + QVERIFY(_newPkgWithDataSet()); + + FixedColumnTypeProvider provider; + provider.types["contNormal"] = 1; // scale + + ScriptConstructorModel model; + model.setColumnTypeProvider(&provider); + model.setMode(ScriptConstructorMode::Filter); + + // --- Operator with two empty slots: consecutive no-target drops fill left, then right --- + ScriptNode * plus = new ScriptNodeOperator("+", false); + model.insertNode(plus, DropTarget::none()); + QCOMPARE(model.formulaCount(), 1); + + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + { + auto * op = dynamic_cast(model.formulaAt(0)); + QVERIFY(op != nullptr); + auto * left = dynamic_cast(op->leftChild()); + QVERIFY(left != nullptr); + QCOMPARE(left->columnName(), std::string("contNormal")); + QVERIFY(op->rightChild() == nullptr); + } + + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + QCOMPARE(model.toR(), std::string("(contNormal.scale + contNormal.scale)\n")); + + // --- Function arguments fill in ascending order, skipping non-accepting slots --- + // ifelse's first argument wants booleans, so a number column must land in the second one. + model.fromJson(formulas({funcNode("ifelse", { + funcArg("test", {"boolean"}, Json::nullValue), + funcArg("then", {"boolean","string","number"}, Json::nullValue), + funcArg("else", {"boolean","string","number"}, Json::nullValue)})})); + QCOMPARE(model.formulaCount(), 1); + + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + { + auto * func = dynamic_cast(model.formulaAt(0)); + QVERIFY(func != nullptr); + QVERIFY(func->arguments()[0].value == nullptr); // test (booleans only): skipped + QVERIFY(func->arguments()[1].value != nullptr); // then: filled + QVERIFY(func->arguments()[2].value == nullptr); + } + + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + QCOMPARE(model.toR(), std::string("ifelse(NULL, contNormal.scale, contNormal.scale)\n")); + + // --- Topmost formula wins: the first formula with an accepting empty slot gets the drop --- + model.fromJson(formulas({ + opNode("+", colNode("contNormal"), Json::nullValue), + opNode("+", colNode("contNormal"), Json::nullValue)})); + QCOMPARE(model.formulaCount(), 2); + + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + { + auto * first = dynamic_cast(model.formulaAt(0)); + auto * second = dynamic_cast(model.formulaAt(1)); + QVERIFY(first != nullptr); + QVERIFY(second != nullptr); + QVERIFY(first->rightChild() != nullptr); // topmost formula was filled + QVERIFY(second->rightChild() == nullptr); + } + + // --- Row functions fill their slots left to right --- + model.fromJson(formulas({})); + auto * rowMean = new ScriptNodeRowFunction("rowMean"); + rowMean->addChild(nullptr); // the palette clone starts with one empty slot + model.insertNode(rowMean, DropTarget::none()); + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + model.insertNode(new ScriptNodeColumn("contNormal"), DropTarget::none()); + QCOMPARE(model.toR(), std::string("rowMeanNaRm(contNormal.scale, contNormal.scale)\n")); + + // --- Gobble is still preferred when no empty slot anywhere accepts the node --- + model.fromJson(formulas({colNode("contNormal")})); + ScriptNode * op = new ScriptNodeOperator(">", false); + model.insertNode(op, DropTarget::none()); + QCOMPARE(model.formulaCount(), 1); + auto * rootOp = dynamic_cast(model.formulaAt(0)); + QVERIFY(rootOp != nullptr); + QVERIFY(dynamic_cast(rootOp->leftChild()) != nullptr); // absorbed + QVERIFY(rootOp->rightChild() == nullptr); +} + void TestAll::testScriptConstructorAllowedColumnTypes() { QVERIFY(_newPkgWithDataSet()); diff --git a/Tests/testall.h b/Tests/testall.h index 3b365fc99b..a567dea1cd 100644 --- a/Tests/testall.h +++ b/Tests/testall.h @@ -84,6 +84,10 @@ private slots: void testScriptConstructorAllowedColumnTypes(); void testScriptConstructorRowFunctionFreeSlot(); + // "Best spot" drop resolution: a drop without an explicit target fills the leftmost + // empty accepting slot, working left-to-right / top-to-bottom through the formulas. + void testScriptConstructorLeftMostEmpty(); + // Boots the real QML MainWindow headlessly, loads a dataset and shows the filter window // (which instantiates the C++ ScriptConstructorView). Serves as a profiling harness for // the ScriptConstructor initialization path (use with JASP_TIMER_USED=ON) and as a