From 43c17b41cb60a1194b6a3ae94e70352178afb549 Mon Sep 17 00:00:00 2001 From: saschbe Date: Wed, 7 Oct 2026 17:44:28 +0200 Subject: [PATCH 1/2] Use field styling for plain clipboard paste in attached user texts --- .../dynamicelementtextitem.cpp | 38 ++++++- .../qetgraphicsitem/dynamicelementtextitem.h | 1 + tests/qttest/CMakeLists.txt | 8 ++ tests/qttest/attached_plain_paste_probe.cpp | 103 ++++++++++++++++++ .../qttest/run_attached_plain_paste_probe.py | 60 ++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/qttest/attached_plain_paste_probe.cpp create mode 100644 tests/qttest/run_attached_plain_paste_probe.py diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index 51e3756c6..554b634f7 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -35,6 +35,42 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include + +void DynamicElementTextItem::keyPressEvent(QKeyEvent *event) +{ + if (!event->matches(QKeySequence::Paste) || m_text_from != UserText || + !(textInteractionFlags() & Qt::TextEditable)) { + DiagramTextItem::keyPressEvent(event); + return; + } + if (diagram() && diagram()->isReadOnly()) { + event->accept(); + return; + } + const QMimeData *mime = QApplication::clipboard()->mimeData(); + if (mime && (mime->hasText() || mime->hasHtml())) { + const QString text = mime->hasText() ? mime->text() : + QTextDocumentFragment::fromHtml(mime->html()).toPlainText(); + prepareAlignment(); + QTextCursor cursor = textCursor(); + cursor.beginEditBlock(); + // An empty character format inherits the field's default font, + // rather than the clipboard's font or the preceding character's. + cursor.insertText(text, QTextCharFormat()); + cursor.setCharFormat(QTextCharFormat()); + cursor.endEditBlock(); + setTextCursor(cursor); + finishAlignment(); + } + event->accept(); +} /** @brief DynamicElementTextItem::DynamicElementTextItem @@ -1875,4 +1911,4 @@ void DynamicElementTextItem::setRotationPointCenter(bool center) bool DynamicElementTextItem::rotationPointCenter() const { return m_rotation_point_center; -} \ No newline at end of file +} diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.h b/sources/qetgraphicsitem/dynamicelementtextitem.h index 2e1ee9ee6..f8eae5e0b 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.h +++ b/sources/qetgraphicsitem/dynamicelementtextitem.h @@ -128,6 +128,7 @@ class DynamicElementTextItem : public DiagramTextItem void refreshResizeHandlesVisibility(); protected: + void keyPressEvent(QKeyEvent *event) override; void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 1a9ad8487..3eee63723 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -73,6 +73,14 @@ add_test(NAME tst_darkimagelayers COMMAND tst_darkimagelayers) target_include_directories(tst_darkimagelayers PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_darkimagelayers PRIVATE Qt::Test Qt::Widgets) find_package(Python3 QUIET COMPONENTS Interpreter) +if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") + add_test(NAME tst_attachedplainpaste + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_attached_plain_paste_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/attached_plain_paste_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_attachedplainpaste PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) +endif() if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") add_test(NAME tst_darkimageintegration COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_image_dark_probe.py diff --git a/tests/qttest/attached_plain_paste_probe.cpp b/tests/qttest/attached_plain_paste_probe.cpp new file mode 100644 index 000000000..1b627fee1 --- /dev/null +++ b/tests/qttest/attached_plain_paste_probe.cpp @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Real item/key-event regression, linked against the application's objects. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../sources/diagram.h" +#include "../../sources/qetproject.h" +#include "../../sources/qetresult.h" +#include "../../sources/qetmessagebox.h" +#include "../../sources/qetgraphicsitem/dynamicelementtextitem.h" +#include "../../sources/qetgraphicsitem/independenttextitem.h" + +static void check(bool value, const char *message) { if (!value) throw std::runtime_error(message); } +static void key(Diagram *scene, QGraphicsItem *item, int code, Qt::KeyboardModifiers modifiers) { + QKeyEvent event(QEvent::KeyPress,code,modifiers); scene->sendEvent(item,&event); +} +static void clipboard(const QString &html, const QString &plain = QString(), bool withPlain = false) { + auto *mime=new QMimeData; mime->setHtml(html); if(withPlain) mime->setText(plain); QApplication::clipboard()->setMimeData(mime); +} +static void select(QGraphicsTextItem *item,int start,int end) { + auto cursor=item->textCursor(); cursor.setPosition(start); cursor.setPosition(end,QTextCursor::KeepAnchor); item->setTextCursor(cursor); +} +static void fieldStyle(QGraphicsTextItem *item,int start,int length,const QFont &field) { + item->document()->documentLayout(); item->boundingRect(); + for(int i=start;itoPlainText().at(i)=='\n') continue; + QTextCursor cursor(item->document()); cursor.setPosition(i); cursor.setPosition(i+1,QTextCursor::KeepAnchor); + const auto format=cursor.charFormat(); const QFont resolved=format.font().resolve(item->document()->defaultFont()); + check(resolved.family()==field.family()&&qAbs(resolved.pointSizeF()-field.pointSizeF())<0.001,"inserted character resolves to field font/size"); + check(!format.hasProperty(QTextFormat::ForegroundBrush)&&!format.isAnchor(),"clipboard colour/link discarded"); + const auto block=cursor.block(); const auto runs=block.layout()->glyphRuns(i-block.position(),1); + check(!runs.isEmpty(),"inserted character laid out"); + for(const auto &run:runs) check(qAbs(run.rawFont().pixelSize()-QRawFont::fromFont(field).pixelSize())<0.01,"actual rendered font size follows field"); + } +} +int main(int argc,char **argv) { + QApplication app(argc,argv); std::freopen(argv[2],"w",stdout); + QTemporaryDir settings; QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat,QSettings::UserScope,settings.path()); QCoreApplication::setOrganizationName("QETPlainPasteRegression"); + QETProject::setBackupEnabled(false); QET::QetMessageBox::setNonInteractive(true); + QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf"); + try { + const QString output=QString::fromLocal8Bit(argv[3]); + QETProject project(QString::fromLocal8Bit(argv[1])); check(project.state()==QETProject::Ok,"fixture opens"); + auto *scene=project.diagrams().first(); auto *text=scene->elements().first()->dynamicTextItems().first(); + text->setTextFrom(DynamicElementTextItem::UserText); + const QFont field("Liberation Sans",11); text->setFont(field); + QGraphicsView view(scene); view.resize(400,300); view.show(); view.activateWindow(); QApplication::setActiveWindow(&view); view.setFocus(); QApplication::processEvents(); + auto edit=[&] { text->setTextInteractionFlags(Qt::TextEditorInteraction); text->setFocus(); scene->setFocus(); QApplication::processEvents(); check(text->hasFocus(),"real editing focus"); }; + auto paste=[&] { key(scene,text,Qt::Key_V,Qt::ControlModifier); }; + const QString rich="

new

line

"; + text->setText("old"); edit(); select(text,0,3); clipboard(rich); paste(); + check(text->toPlainText()=="new\nline","HTML-only clipboard converts to plain with newlines"); fieldStyle(text,0,8,field); + key(scene,text,Qt::Key_Z,Qt::ControlModifier); check(text->toPlainText()=="old","native undo is one paste step"); + key(scene,text,Qt::Key_Y,Qt::ControlModifier); check(text->toPlainText()=="new\nline","native redo"); fieldStyle(text,0,8,field); + scene->clearFocus(); QApplication::processEvents(); check(text->text()=="new\nline","editing session updates stored user text"); + scene->undoStack().undo(); check(text->toPlainText()=="old","project undo"); scene->undoStack().redo(); check(text->toPlainText()=="new\nline","project redo"); + std::puts("PASS: HTML-only paste, newlines, field font/size, native and project undo/redo"); + text->setText("AB"); text->setHtml("

AB

"); + edit(); select(text,2,2); clipboard("ignored","tail",true); paste(); + check(text->toPlainText()=="ABtail","clipboard plain representation takes precedence"); fieldStyle(text,2,4,field); + QTextCursor prior(text->document()); prior.setPosition(0); prior.setPosition(1,QTextCursor::KeepAnchor); check(prior.charFormat().fontWeight()>=QFont::Bold,"existing preceding format untouched"); + select(text,1,4); clipboard("Z"); paste(); check(text->toPlainText()=="AZil","selection replaced in formatted document"); fieldStyle(text,1,1,field); + key(scene,text,Qt::Key_Z,Qt::ControlModifier); check(text->toPlainText()=="ABtail","selection paste undo"); + key(scene,text,Qt::Key_Y,Qt::ControlModifier); check(text->toPlainText()=="AZil","selection paste redo"); + scene->clearFocus(); QApplication::processEvents(); + std::puts("PASS: paste after formatted text, plain/HTML precedence, selection replacement and undo/redo"); + text->setText("before"); edit(); select(text,0,6); clipboard("ignored","one\ntwo\nthree",true); paste(); fieldStyle(text,0,13,field); scene->clearFocus(); QApplication::processEvents(); + project.setFilePath(output+"/plain.qet"); check(project.write().isOk(),"save project"); + QDomDocument xml; QFile file(project.filePath()); check(file.open(QIODevice::ReadOnly),"read saved project"); check(bool(xml.setContent(file.readAll())),"parse saved project"); + bool found=false; const auto nodes=xml.elementsByTagName("dynamic_elmt_text"); + for(int i=0;iuuid().toString()) { found=true; check(node.firstChildElement("html").isNull(),"no new HTML persistence"); check(node.firstChildElement("text").text()=="one\ntwo\nthree","existing plain XML text"); } } + check(found,"attached text saved"); QETProject reopened(project.filePath()); check(reopened.state()==QETProject::Ok,"reopen project"); + DynamicElementTextItem *loaded=nullptr; for(auto *element:reopened.diagrams().first()->elements()) for(auto *item:element->dynamicTextItems()) if(item->uuid()==text->uuid()) loaded=item; + check(loaded&&loaded->toPlainText()=="one\ntwo\nthree"&&loaded->font()==field,"plain text and field font survive reopen"); fieldStyle(loaded,0,13,field); + std::puts("PASS: multiline clipboard, unchanged XML representation and save/reopen"); + QKeyEvent origin(QEvent::KeyPress,Qt::Key_V,Qt::ControlModifier|Qt::ShiftModifier); check(!origin.matches(QKeySequence::Paste),"Ctrl+Shift+V excluded from standard Paste"); + edit(); const QString before=text->toPlainText(); clipboard("do not paste"); scene->sendEvent(text,&origin); check(text->toPlainText()==before,"Ctrl+Shift+V not intercepted"); scene->clearFocus(); + auto *free=new IndependentTextItem; scene->addItem(free); free->setPlainText("free"); free->setTextInteractionFlags(Qt::TextEditorInteraction); free->setFocus(); scene->setFocus(); QApplication::processEvents(); select(free,0,4); + clipboard("

bold italic large

"); key(scene,free,Qt::Key_V,Qt::ControlModifier); + check(free->document()->find("bold").charFormat().fontWeight()>=QFont::Bold&&free->document()->find("italic").charFormat().fontItalic()&&free->document()->find("large").charFormat().fontPointSize()==20,"free-text rich paste unchanged"); + scene->clearFocus(); QApplication::processEvents(); project.setFilePath(output+"/free.qet"); check(project.write().isOk(),"save free text"); + QETProject freeReopened(project.filePath()); check(freeReopened.state()==QETProject::Ok,"reopen free text"); bool freeFound=false; + for(auto *item:freeReopened.diagrams().first()->items()) if(auto *loadedFree=dynamic_cast(item)) if(loadedFree->uuid()==free->uuid()) { freeFound=true; check(loadedFree->document()->find("bold").charFormat().fontWeight()>=QFont::Bold&&loadedFree->document()->find("italic").charFormat().fontItalic()&&loadedFree->document()->find("large").charFormat().fontPointSize()==20,"free-text formatting survives reopen"); } + check(freeFound,"free text saved"); std::puts("PASS: Ctrl+Shift+V excluded; free-text rich paste and reopen unchanged"); + } catch(const std::exception &error) { std::printf("FAIL: %s\n",error.what()); return 1; } + return 0; +} diff --git a/tests/qttest/run_attached_plain_paste_probe.py b/tests/qttest/run_attached_plain_paste_probe.py new file mode 100644 index 000000000..5da9bb600 --- /dev/null +++ b/tests/qttest/run_attached_plain_paste_probe.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Link the attached plain-paste integration probe using an existing Ninja app build. + +Reuses app objects without duplicating the app or introducing a core-library +refactor. No application source or existing executable is replaced. The +generated manifest, probe binary, reports and export fixtures stay in build/. +""" +import argparse +import pathlib +import re +import shutil +import subprocess +import sys + +parser = argparse.ArgumentParser() +parser.add_argument('--build', required=True) +parser.add_argument('--binary', required=True) +parser.add_argument('--source', required=True) +parser.add_argument('--fixture', required=True) +args = parser.parse_args() +build = pathlib.Path(args.build).resolve() +binary = pathlib.Path(args.binary).resolve() +source = pathlib.Path(args.source).resolve() +fixture = pathlib.Path(args.fixture).resolve() +manifest = (build / 'build.ninja').read_text(encoding='utf-8') +match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE) +if not match: + sys.exit('Cannot find the application main object in the Ninja build.') +main_object = match.group(1) +probe_object = main_object.replace('main.cpp.', 'attached_plain_paste_probe.cpp.') +probe_binary = binary.with_name('attached_plain_paste_probe' + binary.suffix) +def escape(path): + return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ') +main_source = source.parents[2] / 'sources' / 'main.cpp' +manifest = manifest.replace(main_object, probe_object) +manifest = manifest.replace(escape(main_source), escape(source)) +if binary.suffix: + manifest = manifest.replace(binary.name, probe_binary.name) +else: + old_target = escape(binary.name if binary.parent == build else binary) + new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary) + manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':') + old_file = binary.name if binary.parent == build else binary.as_posix() + new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix() + manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$', + ' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE) +probe_manifest = build / 'attached-plain-paste-probe.ninja' +probe_manifest.write_text(manifest, encoding='utf-8') +ninja = shutil.which('ninja') +if not ninja: + sys.exit('Ninja is required for the integration probe.') +target = probe_binary.name if probe_binary.parent == build else str(probe_binary) +subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True) +output = build / 'attached-plain-paste' +output.mkdir(exist_ok=True) +report = output / 'results.txt' +result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90) +if report.exists(): + print(report.read_text(encoding='utf-8', errors='replace')) +sys.exit(result.returncode) From ca6b79ab799267ed107197839b54c210d11cfb53 Mon Sep 17 00:00:00 2001 From: saschbe Date: Wed, 7 Oct 2026 20:09:32 +0200 Subject: [PATCH 2/2] Use Qt's platform redo shortcut in plain-paste regression --- tests/qttest/attached_plain_paste_probe.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/qttest/attached_plain_paste_probe.cpp b/tests/qttest/attached_plain_paste_probe.cpp index 1b627fee1..90e961969 100644 --- a/tests/qttest/attached_plain_paste_probe.cpp +++ b/tests/qttest/attached_plain_paste_probe.cpp @@ -29,6 +29,15 @@ static void check(bool value, const char *message) { if (!value) throw std::runt static void key(Diagram *scene, QGraphicsItem *item, int code, Qt::KeyboardModifiers modifiers) { QKeyEvent event(QEvent::KeyPress,code,modifiers); scene->sendEvent(item,&event); } +static void redo(Diagram *scene, QGraphicsItem *item) { + // Qt uses Ctrl+Y on Windows and Ctrl+Shift+Z on Linux. + const QKeySequence shortcut(QKeySequence::Redo); + check(shortcut.count()==1,"platform redo shortcut is a single key combination"); + const auto combination=shortcut[0]; + QKeyEvent event(QEvent::KeyPress,combination.key(),combination.keyboardModifiers()); + check(event.matches(QKeySequence::Redo),"generated key matches platform Redo"); + scene->sendEvent(item,&event); +} static void clipboard(const QString &html, const QString &plain = QString(), bool withPlain = false) { auto *mime=new QMimeData; mime->setHtml(html); if(withPlain) mime->setText(plain); QApplication::clipboard()->setMimeData(mime); } @@ -67,7 +76,7 @@ int main(int argc,char **argv) { text->setText("old"); edit(); select(text,0,3); clipboard(rich); paste(); check(text->toPlainText()=="new\nline","HTML-only clipboard converts to plain with newlines"); fieldStyle(text,0,8,field); key(scene,text,Qt::Key_Z,Qt::ControlModifier); check(text->toPlainText()=="old","native undo is one paste step"); - key(scene,text,Qt::Key_Y,Qt::ControlModifier); check(text->toPlainText()=="new\nline","native redo"); fieldStyle(text,0,8,field); + redo(scene,text); check(text->toPlainText()=="new\nline","native redo"); fieldStyle(text,0,8,field); scene->clearFocus(); QApplication::processEvents(); check(text->text()=="new\nline","editing session updates stored user text"); scene->undoStack().undo(); check(text->toPlainText()=="old","project undo"); scene->undoStack().redo(); check(text->toPlainText()=="new\nline","project redo"); std::puts("PASS: HTML-only paste, newlines, field font/size, native and project undo/redo"); @@ -77,7 +86,7 @@ int main(int argc,char **argv) { QTextCursor prior(text->document()); prior.setPosition(0); prior.setPosition(1,QTextCursor::KeepAnchor); check(prior.charFormat().fontWeight()>=QFont::Bold,"existing preceding format untouched"); select(text,1,4); clipboard("Z"); paste(); check(text->toPlainText()=="AZil","selection replaced in formatted document"); fieldStyle(text,1,1,field); key(scene,text,Qt::Key_Z,Qt::ControlModifier); check(text->toPlainText()=="ABtail","selection paste undo"); - key(scene,text,Qt::Key_Y,Qt::ControlModifier); check(text->toPlainText()=="AZil","selection paste redo"); + redo(scene,text); check(text->toPlainText()=="AZil","selection paste redo"); scene->clearFocus(); QApplication::processEvents(); std::puts("PASS: paste after formatted text, plain/HTML precedence, selection replacement and undo/redo"); text->setText("before"); edit(); select(text,0,6); clipboard("ignored","one\ntwo\nthree",true); paste(); fieldStyle(text,0,13,field); scene->clearFocus(); QApplication::processEvents();