/*************************************************************************** * Copyright (c) 2007 Werner Mayer * * * * This file is part of the FreeCAD CAx development system. * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Library General Public * * License as published by the Free Software Foundation; either * * version 2 of the License, or (at your option) any later version. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU Library General Public License for more details. * * * * You should have received a copy of the GNU Library General Public * * License along with this library; see the file COPYING.LIB. If not, * * write to the Free Software Foundation, Inc., 59 Temple Place, * * Suite 330, Boston, MA 02111-1307, USA * * * ***************************************************************************/ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "CallTips.h" Q_DECLARE_METATYPE(Gui::CallTip) //< allows use of QVariant namespace Gui { /** * template class Temporary. * Allows variable changes limited to a scope. */ template class Temporary { public: Temporary(TYPE& var, const TYPE tmpVal) : _var(var) , _saveVal(var) { var = tmpVal; } ~Temporary() { _var = _saveVal; } private: TYPE& _var; TYPE _saveVal; }; } /* namespace Gui */ using namespace Gui; CallTipsList::CallTipsList(QPlainTextEdit* parent) : QListWidget(parent) , textEdit(parent) , cursorPos(0) , validObject(true) , doCallCompletion(false) { // make the user assume that the widget is active QPalette pal = parent->palette(); pal.setColor( QPalette::Inactive, QPalette::Highlight, pal.color(QPalette::Active, QPalette::Highlight) ); pal.setColor( QPalette::Inactive, QPalette::HighlightedText, pal.color(QPalette::Active, QPalette::HighlightedText) ); parent->setPalette(pal); connect(this, &QListWidget::itemActivated, this, &CallTipsList::callTipItemActivated); hideKeys.append(Qt::Key_Space); hideKeys.append(Qt::Key_Exclam); hideKeys.append(Qt::Key_QuoteDbl); hideKeys.append(Qt::Key_NumberSign); hideKeys.append(Qt::Key_Dollar); hideKeys.append(Qt::Key_Percent); hideKeys.append(Qt::Key_Ampersand); hideKeys.append(Qt::Key_Apostrophe); hideKeys.append(Qt::Key_Asterisk); hideKeys.append(Qt::Key_Plus); hideKeys.append(Qt::Key_Comma); hideKeys.append(Qt::Key_Minus); hideKeys.append(Qt::Key_Period); hideKeys.append(Qt::Key_Slash); hideKeys.append(Qt::Key_Colon); hideKeys.append(Qt::Key_Semicolon); hideKeys.append(Qt::Key_Less); hideKeys.append(Qt::Key_Equal); hideKeys.append(Qt::Key_Greater); hideKeys.append(Qt::Key_Question); hideKeys.append(Qt::Key_At); hideKeys.append(Qt::Key_Backslash); compKeys.append(Qt::Key_ParenLeft); compKeys.append(Qt::Key_ParenRight); compKeys.append(Qt::Key_BracketLeft); compKeys.append(Qt::Key_BracketRight); compKeys.append(Qt::Key_BraceLeft); compKeys.append(Qt::Key_BraceRight); } CallTipsList::~CallTipsList() = default; void CallTipsList::keyboardSearch(const QString& wordPrefix) { // first search for the item that matches perfectly for (int i = 0; i < count(); ++i) { QString text = item(i)->text(); if (text.startsWith(wordPrefix)) { setCurrentRow(i); return; } } // now do a case insensitive comparison for (int i = 0; i < count(); ++i) { QString text = item(i)->text(); if (text.startsWith(wordPrefix, Qt::CaseInsensitive)) { setCurrentRow(i); return; } } if (currentItem()) { currentItem()->setSelected(false); } } void CallTipsList::validateCursor() { QTextCursor cursor = textEdit->textCursor(); int currentPos = cursor.position(); if (currentPos < this->cursorPos) { hide(); } else { cursor.setPosition(this->cursorPos); cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); QString word = cursor.selectedText(); if (!word.isEmpty()) { // the following text might be an operator, brackets, ... const QChar underscore = QLatin1Char('_'); const QChar ch = word.at(0); if (!ch.isLetterOrNumber() && ch != underscore) { word.clear(); } } if (currentPos > this->cursorPos + word.length()) { hide(); } else if (!word.isEmpty()) { // If the word is empty we should not allow one to do a search, // otherwise we may select the next item which is not okay in this // context. This might happen if e.g. Shift is pressed. keyboardSearch(word); } } } QString CallTipsList::extractContext(const QString& line) const { int len = line.size(); int index = len - 1; for (int i = 0; i < len; i++) { int pos = len - 1 - i; const char ch = line.at(pos).toLatin1(); if ((ch >= 48 && ch <= 57) || // Numbers (ch >= 65 && ch <= 90) || // Uppercase letters (ch >= 97 && ch <= 122) || // Lowercase letters (ch == '.') || (ch == '_') || // dot or underscore (ch == ' ') || (ch == '\t')) { // whitespace (between dot and text) index = pos; } else { break; } } return line.mid(index); } QMap CallTipsList::extractTips(const QString& context) const { Base::PyGILStateLocker lock; QMap tips; if (context.isEmpty()) { return tips; } try { PyErr_Clear(); Py::Module module("__main__"); if (module.ptr() == nullptr) { return tips; } Py::Dict dict = module.getDict(); // this is used to filter out input of the form "1." QStringList items = context.split(QLatin1Char('.')); QString modname = items.front(); items.pop_front(); if (!dict.hasKey(std::string(modname.toLatin1()))) { return tips; // unknown object } // Don't use hasattr & getattr because if a property is bound to a method this will be // executed twice. PyObject* code = Py_CompileString( static_cast(context.toLatin1()), "", Py_eval_input ); if (!code) { PyErr_Clear(); return tips; } PyObject* eval = nullptr; if (PyCode_Check(code)) { eval = PyEval_EvalCode(code, dict.ptr(), dict.ptr()); } Py_DECREF(code); if (!eval) { PyErr_Clear(); return tips; } Py::Object obj(eval, true); // Checks whether the type is a subclass of PyObjectBase because to get the doc string // of a member we must get it by its type instead of its instance otherwise we get the // wrong string, namely that of the type of the member. // Note: 3rd party libraries may use their own type object classes so that we cannot // reliably use Py::Type. To be on the safe side we should use Py::Object to assign // the used type object to. // Py::Object type = obj.type(); Py::Object type(PyObject_Type(obj.ptr()), true); Py::Object inst = obj; // the object instance PyObject* typeobj = Base::getTypeAsObject(&Base::PyObjectBase::Type); PyObject* typedoc = Base::getTypeAsObject(&App::DocumentObjectPy::Type); PyObject* basetype = Base::getTypeAsObject(&PyBaseObject_Type); if (PyObject_IsSubclass(type.ptr(), typedoc) == 1) { // From the template Python object we don't query its type object because there we keep // a list of additional methods that we won't see otherwise. But to get the correct doc // strings we query the type's dict in the class itself. // To see if we have a template Python object we check for the existence of // '__fc_template__' See also: FeaturePythonPyT if (!obj.hasAttr("__fc_template__")) { obj = type; } } else if (PyObject_IsSubclass(type.ptr(), typeobj) == 1) { obj = type; } else if (PyObject_IsInstance(obj.ptr(), basetype) == 1) { // New style class which can be a module, type, list, tuple, int, float, ... // Make sure it's not a type object PyObject* typetype = Base::getTypeAsObject(&PyType_Type); if (PyObject_IsInstance(obj.ptr(), typetype) != 1) { // For wrapped objects with PySide2 use the object, not its type // as otherwise attributes added at runtime won't be listed (e.g. MainWindowPy) QString typestr(QLatin1String(Py_TYPE(obj.ptr())->tp_name)); // this should be now a user-defined Python class // http://stackoverflow.com/questions/12233103/in-python-at-runtime-determine-if-an-object-is-a-class-old-and-new-type-instan if (!typestr.startsWith(QLatin1String("PySide")) && Py_TYPE(obj.ptr())->tp_flags & Py_TPFLAGS_HEAPTYPE) { obj = type; } } } // If we have an instance of PyObjectBase then determine whether it's valid or not if (PyObject_IsInstance(inst.ptr(), typeobj) == 1) { auto baseobj = static_cast(inst.ptr()); validObject = baseobj->isValid(); } else { // PyObject_IsInstance might set an exception PyErr_Clear(); } Py::List list(obj.dir()); // If we derive from PropertyContainerPy we can search for the properties in the // C++ twin class. PyObject* proptypeobj = Base::getTypeAsObject(&App::PropertyContainerPy::Type); if (PyObject_IsSubclass(type.ptr(), proptypeobj) == 1) { // These are the attributes of the instance itself which are NOT accessible by // its type object extractTipsFromProperties(inst, tips); } // If we derive from App::DocumentPy we have direct access to the objects by their internal // names. So, we add these names to the list, too. PyObject* appdoctypeobj = Base::getTypeAsObject(&App::DocumentPy::Type); if (PyObject_IsSubclass(type.ptr(), appdoctypeobj) == 1) { auto docpy = static_cast(inst.ptr()); auto document = docpy->getDocumentPtr(); // Make sure that the C++ object is alive if (document) { std::vector objects = document->getObjects(); Py::List list; for (const auto& object : objects) { list.append(Py::String(object->getNameInDocument())); } extractTipsFromObject(inst, list, tips); } } // If we derive from Gui::DocumentPy we have direct access to the objects by their internal // names. So, we add these names to the list, too. PyObject* guidoctypeobj = Base::getTypeAsObject(&Gui::DocumentPy::Type); if (PyObject_IsSubclass(type.ptr(), guidoctypeobj) == 1) { auto docpy = static_cast(inst.ptr()); if (docpy->getDocumentPtr()) { App::Document* document = docpy->getDocumentPtr()->getDocument(); // Make sure that the C++ object is alive if (document) { std::vector objects = document->getObjects(); Py::List list; for (const auto& object : objects) { list.append(Py::String(object->getNameInDocument())); } extractTipsFromObject(inst, list, tips); } } } // These are the attributes from the type object extractTipsFromObject(obj, list, tips); } catch (Py::Exception& e) { // Just clear the Python exception e.clear(); } return tips; } bool shibokenMayCrash(void) { // Shiboken 6.4.8 to 6.7.3 crash if we try to read their object // attributes without a current stack frame. // FreeCAD issue: https://github.com/FreeCAD/FreeCAD/issues/14101 // Qt issue: https://bugreports.qt.io/browse/PYSIDE-2796 Py::Module shiboken("shiboken6"); Py::Tuple version(shiboken.getAttr("__version_info__")); int major = Py::Long(version.getItem(0)); int minor = Py::Long(version.getItem(1)); int patch = Py::Long(version.getItem(2)); bool brokenVersion = (major == 6 && minor >= 4 && minor < 8); bool fixedVersion = (major == 6 && minor == 7 && patch > 2); return brokenVersion && !fixedVersion; } Py::Object CallTipsList::getAttrWorkaround(Py::Object& obj, Py::String& name) const { QString typestr(QLatin1String(Py_TYPE(obj.ptr())->tp_name)); bool hasWorkingGetAttr = !(typestr == QLatin1String("Shiboken.ObjectType") && shibokenMayCrash()); if (hasWorkingGetAttr) { return obj.getAttr(name.as_string()); } Py::Dict globals; Py::Dict locals; locals.setItem("obj", obj); locals.setItem("attr", name); Py::Object bouncer(Py_CompileString("getattr(obj, attr)", "", Py_eval_input)); Py::Object attr(PyEval_EvalCode(bouncer.ptr(), globals.ptr(), locals.ptr())); return attr; } void CallTipsList::extractTipsFromObject(Py::Object& obj, Py::List& list, QMap& tips) const { for (Py::List::iterator it = list.begin(); it != list.end(); ++it) { try { Py::String attrname(*it); std::string name(attrname.as_string()); Py::Object attr = getAttrWorkaround(obj, attrname); if (!attr.ptr()) { Base::Console().log("Python attribute '%s' returns null!\n", name.c_str()); continue; } CallTip tip; QString str = QString::fromLatin1(name.c_str()); tip.name = str; if (attr.isCallable()) { PyObject* basetype = Base::getTypeAsObject(&PyBaseObject_Type); if (PyObject_IsSubclass(attr.ptr(), basetype) == 1) { tip.type = CallTip::Class; } else { PyErr_Clear(); // PyObject_IsSubclass might set an exception tip.type = CallTip::Method; } } else if (PyModule_Check(attr.ptr())) { tip.type = CallTip::Module; } else { tip.type = CallTip::Member; } if (str == QLatin1String("__doc__") && attr.isString()) { Py::Object help = attr; if (help.isString()) { Py::String doc(help); QString longdoc = QString::fromUtf8(doc.as_string().c_str()); int pos = longdoc.indexOf(QLatin1Char('\n')); pos = qMin(pos, 70); if (pos < 0) { pos = qMin(longdoc.length(), 70); } tip.description = stripWhiteSpace(longdoc); tip.parameter = longdoc.left(pos); } } else if (attr.hasAttr("__doc__")) { Py::Object help = attr.getAttr("__doc__"); if (help.isString()) { Py::String doc(help); QString longdoc = QString::fromUtf8(doc.as_string().c_str()); int pos = longdoc.indexOf(QLatin1Char('\n')); pos = qMin(pos, 70); if (pos < 0) { pos = qMin(longdoc.length(), 70); } tip.description = stripWhiteSpace(longdoc); tip.parameter = longdoc.left(pos); } } // Do not override existing items QMap::iterator pos = tips.find(str); if (pos == tips.end()) { tips[str] = tip; } } catch (Py::Exception& e) { // Just clear the Python exception e.clear(); } } } void CallTipsList::extractTipsFromProperties(Py::Object& obj, QMap& tips) const { auto cont = static_cast(obj.ptr()); App::PropertyContainer* container = cont->getPropertyContainerPtr(); // Make sure that the C++ object is alive if (!container) { return; } std::map Map; container->getPropertyMap(Map); for (const auto& It : Map) { CallTip tip; QString str = QString::fromLatin1(It.first.c_str()); tip.name = str; tip.type = CallTip::Property; QString longdoc = QString::fromUtf8(container->getPropertyDocumentation(It.second)); // a point, mesh or shape property if (It.second->isDerivedFrom(Base::Type::fromName("App::PropertyComplexGeoData"))) { Py::Object data(It.second->getPyObject(), true); if (data.hasAttr("__doc__")) { Py::Object help = data.getAttr("__doc__"); if (help.isString()) { Py::String doc(help); longdoc = QString::fromUtf8(doc.as_string().c_str()); } } } if (!longdoc.isEmpty()) { int pos = longdoc.indexOf(QLatin1Char('\n')); pos = qMin(pos, 70); if (pos < 0) { pos = qMin(longdoc.length(), 70); } tip.description = stripWhiteSpace(longdoc); tip.parameter = longdoc.left(pos); } tips[str] = tip; } } void CallTipsList::showTips(const QString& line) { // search only once static QPixmap type_module_icon = BitmapFactory().pixmap("ClassBrowser/type_module.svg"); static QPixmap type_class_icon = BitmapFactory().pixmap("ClassBrowser/type_class.svg"); static QPixmap method_icon = BitmapFactory().pixmap("ClassBrowser/method.svg"); static QPixmap member_icon = BitmapFactory().pixmap("ClassBrowser/member.svg"); static QPixmap property_icon = BitmapFactory().pixmap("ClassBrowser/property.svg"); // object is in error state static QPixmap forbidden_icon( Gui::BitmapFactory().pixmapFromSvg("forbidden", property_icon.size() / 4) ); static QPixmap forbidden_type_module_icon = BitmapFactory().merge(type_module_icon, forbidden_icon, BitmapFactoryInst::BottomLeft); static QPixmap forbidden_type_class_icon = BitmapFactory().merge(type_class_icon, forbidden_icon, BitmapFactoryInst::BottomLeft); static QPixmap forbidden_method_icon = BitmapFactory().merge(method_icon, forbidden_icon, BitmapFactoryInst::BottomLeft); static QPixmap forbidden_member_icon = BitmapFactory().merge(member_icon, forbidden_icon, BitmapFactoryInst::BottomLeft); static QPixmap forbidden_property_icon = BitmapFactory().merge(property_icon, forbidden_icon, BitmapFactoryInst::BottomLeft); this->validObject = true; QString context = extractContext(line); context = context.simplified(); QMap tips = extractTips(context); clear(); for (QMap::Iterator it = tips.begin(); it != tips.end(); ++it) { addItem(it.key()); QListWidgetItem* item = this->item(this->count() - 1); item->setData(Qt::ToolTipRole, QVariant(it.value().description)); item->setData(Qt::UserRole, QVariant::fromValue(it.value())); //< store full CallTip data switch (it.value().type) { case CallTip::Module: { item->setIcon((this->validObject ? type_module_icon : forbidden_type_module_icon)); } break; case CallTip::Class: { item->setIcon((this->validObject ? type_class_icon : forbidden_type_class_icon)); } break; case CallTip::Method: { item->setIcon((this->validObject ? method_icon : forbidden_method_icon)); } break; case CallTip::Member: { item->setIcon((this->validObject ? member_icon : forbidden_member_icon)); } break; case CallTip::Property: { item->setIcon((this->validObject ? property_icon : forbidden_property_icon)); } break; default: break; } } if (count() == 0) { return; // nothing found } // get the minimum width and height of the box int h = 0; int w = 0; for (int i = 0; i < count(); ++i) { QRect r = visualItemRect(item(i)); w = qMax(w, r.width()); h += r.height(); } // Add an offset w += 2 * frameWidth(); h += 2 * frameWidth(); // get the start position of the word prefix QTextCursor cursor = textEdit->textCursor(); this->cursorPos = cursor.position(); QRect rect = textEdit->cursorRect(cursor); int posX = rect.x(); int posY = rect.y(); int boxH = h; // Decide whether to show downstairs or upstairs if (posY > textEdit->viewport()->height() / 2) { h = qMin(qMin(h, posY), 250); if (h < boxH) { w += textEdit->style()->pixelMetric(QStyle::PM_ScrollBarExtent); } setGeometry(posX, posY - h, w, h); } else { h = qMin(qMin(h, textEdit->viewport()->height() - fontMetrics().height() - posY), 250); if (h < boxH) { w += textEdit->style()->pixelMetric(QStyle::PM_ScrollBarExtent); } setGeometry(posX, posY + fontMetrics().height(), w, h); } setCurrentRow(0); show(); } void CallTipsList::showEvent(QShowEvent* e) { QListWidget::showEvent(e); // install this object to filter timer events for the tooltip label qApp->installEventFilter(this); } void CallTipsList::hideEvent(QHideEvent* e) { QListWidget::hideEvent(e); qApp->removeEventFilter(this); } /** * Get all incoming events of the text edit and redirect some of them, like key up and * down, mouse press events, ... to the widget itself. */ bool CallTipsList::eventFilter(QObject* watched, QEvent* event) { // This is a trick to avoid to hide the tooltip window after the defined time span // of 10 seconds. We just filter out all timer events to keep the label visible. if (watched->inherits("QLabel")) { auto label = qobject_cast(watched); // Ignore the timer events to prevent from being closed if (label->windowFlags() & Qt::ToolTip && event->type() == QEvent::Timer) { return true; } } if (isVisible() && watched == textEdit->viewport()) { if (event->type() == QEvent::MouseButtonPress) { hide(); } } else if (isVisible() && watched == textEdit) { if (event->type() == QEvent::KeyPress) { auto ke = static_cast(event); if (ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down) { keyPressEvent(ke); return true; } else if (ke->key() == Qt::Key_PageUp || ke->key() == Qt::Key_PageDown) { keyPressEvent(ke); return true; } else if (ke->key() == Qt::Key_Escape) { hide(); return true; } else if ((ke->key() == Qt::Key_Minus) && (ke->modifiers() & Qt::ShiftModifier)) { // do nothing here, but this check is needed to ignore underscore // which in Qt 4.8 gives Key_Minus instead of Key_Underscore } else if (this->hideKeys.indexOf(ke->key()) > -1) { Q_EMIT itemActivated(currentItem()); return false; } else if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { Q_EMIT itemActivated(currentItem()); return true; } else if (ke->key() == Qt::Key_Tab) { // enable call completion for activating items Temporary tmp(this->doCallCompletion, true); //< previous state restored on // scope exit Q_EMIT itemActivated(currentItem()); return true; } else if (this->compKeys.indexOf(ke->key()) > -1) { Q_EMIT itemActivated(currentItem()); return false; } else if (ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Meta || ke->key() == Qt::Key_Alt || ke->key() == Qt::Key_AltGr) { // filter these meta keys to avoid to call keyboardSearch() return true; } } else if (event->type() == QEvent::KeyRelease) { auto ke = static_cast(event); if (ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down || ke->key() == Qt::Key_PageUp || ke->key() == Qt::Key_PageDown) { QList items = selectedItems(); if (!items.isEmpty()) { QPoint p(width(), 0); QString text = items.front()->toolTip(); if (!text.isEmpty()) { QToolTip::showText(mapToGlobal(p), text); } else { QToolTip::showText(p, QString()); } } return true; } } else if (event->type() == QEvent::FocusOut) { if (!hasFocus()) { hide(); } } } return QListWidget::eventFilter(watched, event); } void CallTipsList::callTipItemActivated(QListWidgetItem* item) { hide(); if (!item->isSelected()) { return; } QString text = item->text(); QTextCursor cursor = textEdit->textCursor(); cursor.setPosition(this->cursorPos); cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); QString sel = cursor.selectedText(); if (!sel.isEmpty()) { // in case the cursor moved too far on the right side const QChar underscore = QLatin1Char('_'); const QChar ch = sel.at(sel.size() - 1); if (!ch.isLetterOrNumber() && ch != underscore) { cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor); } } cursor.insertText(text); // get CallTip from item's UserRole-data auto callTip = item->data(Qt::UserRole).value(); // if call completion enabled and we've something callable (method or class constructor) ... if (this->doCallCompletion && (callTip.type == CallTip::Method || callTip.type == CallTip::Class)) { cursor.insertText(QLatin1String("()")); //< just append parenthesis to identifier even inserted. /** * Try to find out if call needs arguments. * For this we search the description for appropriate hints ... */ QRegularExpression argumentMatcher( QRegularExpression::escape(callTip.name) + QLatin1String(R"(\s*\(\s*\w+.*\))") ); argumentMatcher.setPatternOptions( QRegularExpression::InvertedGreedinessOption ); //< set regex non-greedy! if (argumentMatcher.match(callTip.description).hasMatch()) { // if arguments are needed, we just move the cursor one left, to between the parentheses. cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1); textEdit->setTextCursor(cursor); } } textEdit->ensureCursorVisible(); QRect rect = textEdit->cursorRect(cursor); int posX = rect.x(); int posY = rect.y(); QPoint p(posX, posY); p = textEdit->mapToGlobal(p); QToolTip::showText(p, callTip.parameter); } QString CallTipsList::stripWhiteSpace(const QString& str) const { QString stripped = str; QStringList lines = str.split(QLatin1String("\n")); int minspace = std::numeric_limits::max(); int line = 0; for (QStringList::iterator it = lines.begin(); it != lines.end(); ++it, ++line) { if (it->size() > 0 && line > 0) { int space = 0; for (int i = 0; i < it->size(); i++) { if ((*it)[i] == QLatin1Char('\t')) { space++; } else { break; } } if (it->size() > space) { minspace = std::min(minspace, space); } } } // remove all leading tabs from each line if (minspace > 0 && minspace < std::numeric_limits::max()) { int line = 0; QStringList strippedlines; for (QStringList::iterator it = lines.begin(); it != lines.end(); ++it, ++line) { if (line == 0 && !it->isEmpty()) { strippedlines << *it; } else if (it->size() > 0 && line > 0) { strippedlines << it->mid(minspace); } } stripped = strippedlines.join(QLatin1String("\n")); } return stripped; } #include "moc_CallTips.cpp"