diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index d54b872ba..dfa30c109 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -301,10 +301,16 @@ set(QET_SRC_FILES ${QET_DIR}/sources/shortcutmanager.h ${QET_DIR}/sources/shortcutbarsettings.cpp ${QET_DIR}/sources/shortcutbarsettings.h + ${QET_DIR}/sources/diagramtoolbarsettings.cpp + ${QET_DIR}/sources/diagramtoolbarsettings.h + ${QET_DIR}/sources/toolbarsettings.cpp + ${QET_DIR}/sources/toolbarsettings.h ${QET_DIR}/sources/diagramcontexttoolbar.cpp ${QET_DIR}/sources/diagramcontexttoolbar.h ${QET_DIR}/sources/diagramgestureoverlay.cpp ${QET_DIR}/sources/diagramgestureoverlay.h + ${QET_DIR}/sources/gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.h ${QET_DIR}/sources/commandsearchpopup.cpp ${QET_DIR}/sources/commandsearchpopup.h ${QET_DIR}/sources/titleblockcell.cpp @@ -856,6 +862,14 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h + ${QET_DIR}/sources/ui/customizedialog.cpp + ${QET_DIR}/sources/ui/customizedialog.h + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.h + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.h ${QET_DIR}/sources/undocommand/addelementtextcommand.cpp ${QET_DIR}/sources/undocommand/addelementtextcommand.h diff --git a/sources/bordertitleblock.cpp b/sources/bordertitleblock.cpp index f63877eb4..6ab3e0171 100644 --- a/sources/bordertitleblock.cpp +++ b/sources/bordertitleblock.cpp @@ -708,13 +708,26 @@ void BorderTitleBlock::drawDxf( // render the titleblock, using the TitleBlockTemplate object if (display_titleblock_) { Createdxf::layer = DxfExport::Layer::TitleBlock; - //qp -> translate(titleblock_rect_.topLeft()); - QRectF rect = titleBlockRect(); + //Laid out horizontally, then turned like the QPainter in + //draw(): a quarter turn counter-clockwise around its top + //left, which for a title block at the right is the bottom + //right of the diagram (issue #1339). + QRectF rect = titleBlockRectForQPainter(); + QTransform dxf_transform; + if (m_edge != Qt::BottomEdge) + { + const QPointF pivot(rect.left() * Createdxf::xScale, + Createdxf::sheetHeight + - rect.top() * Createdxf::yScale); + dxf_transform.translate(pivot.x(), pivot.y()); + dxf_transform.rotate(90); + dxf_transform.translate(-pivot.x(), -pivot.y()); + } m_titleblock_template_renderer -> renderDxf(rect, rect.width(), file_path, - color); - //qp -> translate(-titleblock_rect_.topLeft()); + color, + dxf_transform); } // Transform back to QET scale diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index 6dc295196..fb2d94869 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -331,7 +331,8 @@ int exportImages(QETProject &project, const QString &format, /// One DXF file per diagram, written by the same code as the export dialog /// (DxfExport) with the dialog's default options -- the export settings of /// the preferences -- so both give the same file. -int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals) +int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals, + bool dxfBlocks, bool dxfAttributes) { const QList diagrams = project.diagrams(); if (diagrams.isEmpty()) { @@ -344,6 +345,10 @@ int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals) properties.format = QStringLiteral("DXF"); if (showTerminals) properties.draw_terminals = true; + if (dxfBlocks || dxfAttributes) + properties.dxf_blocks = true; + if (dxfAttributes) + properties.dxf_attributes = true; int index = 0; bool has_images = false; @@ -982,6 +987,8 @@ int run(const QStringList &args) // collected below. QStringList filtered = args; const bool showTerminals = filtered.removeAll("--show-terminals") > 0; + const bool dxfBlocks = filtered.removeAll("--dxf-blocks") > 0; + const bool dxfAttributes = filtered.removeAll("--dxf-attributes") > 0; // --no-slaves and --no-junctions leave rows out of --export-bom. const bool includeSlaves = filtered.removeAll("--no-slaves") == 0; const bool includeJunctions = filtered.removeAll("--no-junctions") == 0; @@ -1038,7 +1045,7 @@ int run(const QStringList &args) if (format == "pdf") return exportPdf(project, output, showTerminals); if (format == "dxf") - return exportDxf(project, output, showTerminals); + return exportDxf(project, output, showTerminals, dxfBlocks, dxfAttributes); if (format == "cables" || format == "wires") return exportCsv(project, format, output); if (format == "bom") diff --git a/sources/cli_export.h b/sources/cli_export.h index e9323a035..efb3ad59e 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -45,7 +45,7 @@ namespace CLIExport { qelectrotech --export-pdf [--show-terminals] qelectrotech --export-png [--show-terminals] qelectrotech --export-svg [--show-terminals] - qelectrotech --export-dxf [--show-terminals] + qelectrotech --export-dxf [--show-terminals] [--dxf-blocks] [--dxf-attributes] qelectrotech --export-cables qelectrotech --export-wires qelectrotech --export-bom [--no-slaves] [--no-junctions] @@ -66,6 +66,11 @@ namespace CLIExport { does; off by default, matching the GUI export dialog's default. For DXF, draws the terminal markers. Has no effect on the list export modes. + --dxf-blocks: write each symbol as a DXF block, placed with one + INSERT per use, so a CAD program selects it as one object. + --dxf-attributes: as --dxf-blocks, and a symbol's texts (its label, + function...) are attributes of its INSERT. LibreCAD does not + show attributes. cables: wiring list (one row per conductor) as CSV. wires: list of distinct wire numbers as CSV. bom: bill of materials (one row per element) as CSV. diff --git a/sources/createdxf.cpp b/sources/createdxf.cpp index 5135274ee..36c42fbf3 100644 --- a/sources/createdxf.cpp +++ b/sources/createdxf.cpp @@ -22,6 +22,9 @@ #include #include "dxfexport.h" +#include +#include + const double Createdxf::sheetWidth = 4000; const double Createdxf::sheetHeight = 2700; @@ -65,7 +68,16 @@ Createdxf::~Createdxf() /* Header section of every DXF file. @param layers : the layers the entities will be written on, declared in the LAYER table beside layer "0", which every DXF has. */ -void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers) +/** + @brief Createdxf::dxfBegin + Write the header, the tables with @a layers, and the BLOCKS section, + then open the ENTITIES section. @a writeBlocks, if given, is called + inside the BLOCKS section to write the block definitions + (dxfBlockBegin(), the entities, dxfBlockEnd()): a block has to be + defined before any entity, so before anything is drawn. +*/ +void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers, + const std::function &writeBlocks) { layer = QStringLiteral("0"); @@ -273,17 +285,203 @@ void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers) To_Dxf << "SECTION" << "\r\n"; To_Dxf << 2 << "\r\n"; To_Dxf << "BLOCKS" << "\r\n"; + file.close(); + } + if (writeBlocks) + writeBlocks(); + QFile entities(fileName); + if (entities.open(QFile::Append)) { + QTextStream To_Dxf(&entities); To_Dxf << 0 << "\r\n"; To_Dxf << "ENDSEC" << "\r\n"; To_Dxf << 0 << "\r\n"; To_Dxf << "SECTION" << "\r\n"; To_Dxf << 2 << "\r\n"; To_Dxf << "ENTITIES" << "\r\n"; - file.close(); + entities.close(); } } } +/** + @brief Createdxf::dxfBlockBegin + Start the definition of block @a name, with its base point at @a x, + @a y. Only inside dxfBegin()'s writeBlocks; every entity written until + dxfBlockEnd() belongs to the block. + @param hasAttributes : the block has attribute definitions +*/ +void Createdxf::dxfBlockBegin(const QString &fileName, + const QString &name, + double x, + double y, + bool hasAttributes) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "BLOCK" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << "0" << "\r\n"; + To_Dxf << 2 << "\r\n"; + To_Dxf << name << "\r\n"; + To_Dxf << 70 << "\r\n"; + To_Dxf << (hasAttributes ? 2 : 0) << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + file.close(); +} + +/** + @brief Createdxf::dxfBlockEnd + End the block started by dxfBlockBegin(). +*/ +void Createdxf::dxfBlockEnd(const QString &fileName) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "ENDBLK" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << "0" << "\r\n"; + file.close(); +} + +/** + @brief Createdxf::drawInsert + Place block @a name at @a x, @a y, turned counter-clockwise by + @a rotation degrees, on the current layer, followed by its + @a attributes. +*/ +void Createdxf::drawInsert(const QString &fileName, + const QString &name, + double x, + double y, + double rotation, + const QList &attributes) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "INSERT" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << layer << "\r\n"; + if (!attributes.isEmpty()) { + To_Dxf << 66 << "\r\n"; + To_Dxf << 1 << "\r\n"; + } + To_Dxf << 2 << "\r\n"; + To_Dxf << name << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + To_Dxf << 50 << "\r\n"; + To_Dxf << rotation << "\r\n"; + for (const Attribute &attribute : attributes) { + To_Dxf << 0 << "\r\n"; + To_Dxf << "ATTRIB" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << (attribute.layer.isEmpty() ? layer : attribute.layer) << "\r\n"; + To_Dxf << 62 << "\r\n"; + To_Dxf << entityColour(attribute.colour) << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << attribute.x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << attribute.y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + To_Dxf << 40 << "\r\n"; + To_Dxf << attribute.height << "\r\n"; + To_Dxf << 1 << "\r\n"; + To_Dxf << singleLine(attribute.text) << "\r\n"; + To_Dxf << 2 << "\r\n"; + To_Dxf << attribute.tag << "\r\n"; + To_Dxf << 70 << "\r\n"; + To_Dxf << (attribute.invisible ? 1 : 0) << "\r\n"; + To_Dxf << 50 << "\r\n"; + To_Dxf << attribute.rotation << "\r\n"; + To_Dxf << 41 << "\r\n"; + To_Dxf << attribute.xScaleW << "\r\n"; + } + if (!attributes.isEmpty()) { + To_Dxf << 0 << "\r\n"; + To_Dxf << "SEQEND" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << layer << "\r\n"; + } + file.close(); +} + +/** + @brief Createdxf::drawAttdef + Define an attribute of the block being written (between + dxfBlockBegin() and dxfBlockEnd()): its tag, where it sits and how it + looks; @a attribute's text is the default value. +*/ +void Createdxf::drawAttdef(const QString &fileName, const Attribute &attribute) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "ATTDEF" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << (attribute.layer.isEmpty() ? layer : attribute.layer) << "\r\n"; + To_Dxf << 62 << "\r\n"; + To_Dxf << entityColour(attribute.colour) << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << attribute.x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << attribute.y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + To_Dxf << 40 << "\r\n"; + To_Dxf << attribute.height << "\r\n"; + To_Dxf << 1 << "\r\n"; + To_Dxf << singleLine(attribute.text) << "\r\n"; + To_Dxf << 3 << "\r\n"; + To_Dxf << attribute.tag << "\r\n"; + To_Dxf << 2 << "\r\n"; + To_Dxf << attribute.tag << "\r\n"; + To_Dxf << 70 << "\r\n"; + To_Dxf << (attribute.invisible ? 1 : 0) << "\r\n"; + To_Dxf << 50 << "\r\n"; + To_Dxf << attribute.rotation << "\r\n"; + To_Dxf << 41 << "\r\n"; + To_Dxf << attribute.xScaleW << "\r\n"; + file.close(); +} + +/** + @brief Createdxf::blockName + @return @a name as a block or attribute name an R10 reader accepts: + upper case, only A-Z, 0-9, _, - and $, at most 31 characters +*/ +QString Createdxf::blockName(const QString &name) +{ + QString result; + for (const QChar c : name.toUpper()) { + const ushort u = c.unicode(); + const bool ok = (u >= 'A' && u <= 'Z') || (u >= '0' && u <= '9') + || u == '_' || u == '-' || u == '$'; + result += ok ? c : QChar('_'); + } + return result.left(31); +} + /** @brief Createdxf::dxfEnd End Section of every DXF File @@ -504,6 +702,24 @@ int Createdxf::dxfColor(QPen pen) { return Createdxf::dxfColor(pen.color()); } +/** + @brief Createdxf::drawArcEllipse + Draw an arc of an ellipse, or a whole ellipse, in DXF units. + @param x, y : top left of the ellipse's bounding rect (y is the top, the + DXF Y axis going up) + @param w, h : width and height of that rect + @param startAngle, spanAngle : in degrees, as QPainter::drawArc() takes + them: counter-clockwise from 3 o'clock + @param hotspot_x, hotspot_y, rotation_angle : the whole shape is turned + clockwise by rotation_angle degrees around the hotspot, as a rotated + symbol is + A circle, or an arc of one, is written as one exact CIRCLE or ARC. R10, + the version this file declares, has no ELLIPSE entity, so a true + ellipse is written as a polyline through points of the curve, one every + 5 degrees. It used to be four ARCs fitted through a point between the + curve and its chord, which drew every circle as a flattened diamond + (issue #1339). +*/ void Createdxf::drawArcEllipse( const QString &file_path, qreal x, @@ -517,138 +733,46 @@ void Createdxf::drawArcEllipse( qreal rotation_angle, const int &colorcode) { - // vector of parts of arc (stored as a pair of startAngle and spanAngle) for each quadrant. - QVector< QPair > arc_parts_vector; + if (qFuzzyIsNull(spanAngle) || w <= 0 || h <= 0) + return; - if (spanAngle > 0) { - qreal start = startAngle; - qreal span; - int i; - for ( i = startAngle; i < startAngle+spanAngle; i++ ) { - int absolute_theta = (i > 0) ? i : -i; - if (absolute_theta == 0 || absolute_theta == 90 || - absolute_theta == 180 || absolute_theta == 270 || - absolute_theta == 360) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); - start = i; - } - } - if (start != i) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); - } - } else { - qreal start = startAngle; - qreal span; - int i; - for ( i = startAngle; i > startAngle+spanAngle; i-- ) { - int absolute_theta = (i > 0) ? i : -i; - if (absolute_theta == 0 || absolute_theta == 90 || - absolute_theta == 180 || absolute_theta == 270 || - absolute_theta == 360) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); - start = i; - } - } - if (start != i) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); + const qreal a = w/2; + const qreal b = h/2; + const QPointF center = DxfExport::rotation_transformed( + x + a, y - b, hotspot_x, hotspot_y, rotation_angle); + const bool full_turn = qAbs(spanAngle) >= 360; + + if (qAbs(a - b) <= 1e-6 * qMax(a, b)) + { + if (full_turn) { + drawCircle(file_path, a, center.x(), center.y(), colorcode); + return; } + //A DXF ARC always runs counter-clockwise from 50 to 51. + qreal start = startAngle - rotation_angle; + if (spanAngle < 0) + start += spanAngle; + start = std::fmod(start, 360.0); + if (start < 0) + start += 360; + drawArc(file_path, center.x(), center.y(), a, + start, start + qAbs(spanAngle), colorcode); + return; } - for (int i = 0; i < arc_parts_vector.size(); i++) { - - QPair arc = arc_parts_vector[i]; - if (arc.second == 0) - continue; - qreal arc_startAngle = arc.first * 3.142/180; - qreal arc_spanAngle = arc.second * 3.142/180; - - qreal a = w/2; - qreal b = h/2; - - qreal x1 = x + w/2 + a*cos(arc_startAngle); - qreal y1 = y - h/2 + b*sin(arc_startAngle); - qreal x2 = x + w/2 + a*cos(arc_startAngle + arc_spanAngle); - qreal y2 = y - h/2 + b*sin(arc_startAngle + arc_spanAngle); - - - qreal mid_ellipse_x = x + w/2 + a*cos(arc_startAngle + arc_spanAngle/2); - qreal mid_ellipse_y = y - h/2 + b*sin(arc_startAngle + arc_spanAngle/2); - qreal mid_line_x = (x1+x2)/2; - qreal mid_line_y = (y1+y2)/2; - - qreal x3 = (mid_ellipse_x + mid_line_x)/2; - qreal y3 = (mid_ellipse_y + mid_line_y)/2; - - // find circumcenter of points (x1,y1), (x3,y3) and (x2,y2) - qreal a1 = 2*x2 - 2*x1; - qreal b1 = 2*y2 - 2*y1; - qreal c1 = x1*x1 + y1*y1 - x2*x2 - y2*y2; - - qreal a2 = 2*x3 - 2*x1; - qreal b2 = 2*y3 - 2*y1; - qreal c2 = x1*x1 + y1*y1 - x3*x3 - y3*y3; - - qreal center_x = (b1*c2 - b2*c1) / (a1*b2 - a2*b1); - qreal center_y = (a1*c2 - a2*c1) / (b1*a2 - b2*a1); - - qreal radius = sqrt( (x1-center_x)*(x1-center_x) + (y1-center_y)*(y1-center_y) ); - - if ( x1 > center_x && y1 > center_y ) - arc_startAngle = asin( (y1 - center_y) / radius ); - else if ( x1 > center_x && y1 < center_y ) - arc_startAngle = 3.142*2 - asin( (center_y - y1) / radius ); - else if ( x1 < center_x && y1 < center_y ) - arc_startAngle = 3.142 + asin( (center_y - y1) / radius ); - else - arc_startAngle = 3.142 - asin( (y1 - center_y) / radius ); - - qreal arc_endAngle; - - if ( x2 > center_x && y2 > center_y ) - arc_endAngle = asin( (y2 - center_y) / radius ); - else if ( x2 > center_x && y2 < center_y ) - arc_endAngle = 3.142*2 - asin( (center_y - y2) / radius ); - else if ( x2 < center_x && y2 < center_y ) - arc_endAngle = 3.142 + asin( (center_y - y2) / radius ); - else - arc_endAngle = 3.142 - asin( (y2 - center_y) / radius ); - - if (arc_endAngle < arc_startAngle) { - qreal temp = arc_startAngle; - arc_startAngle = arc_endAngle; - arc_endAngle = temp; - } - - QPointF transformed_point = DxfExport::rotation_transformed( - center_x, - center_y, - hotspot_x, - hotspot_y, - rotation_angle); - center_x = transformed_point.x(); - center_y = transformed_point.y(); - arc_endAngle *= 180/3.142; - arc_startAngle *= 180/3.142; - arc_endAngle -= rotation_angle; - arc_startAngle -= rotation_angle; - - drawArc( - file_path, - center_x, - center_y, - radius, - arc_startAngle, - arc_endAngle, - colorcode); + const qreal span = full_turn ? 360 : spanAngle; + const int steps = qMax(2, qCeil(qAbs(span) / 5)); + QPolygonF poly; + poly.reserve(steps + 1); + for (int i = 0 ; i <= steps ; ++i) + { + const qreal theta = qDegreesToRadians(startAngle + span * i / steps); + poly << DxfExport::rotation_transformed( + x + a + a*std::cos(theta), + y - b + b*std::sin(theta), + hotspot_x, hotspot_y, rotation_angle); } + drawPolyline(file_path, poly, colorcode, true); } @@ -809,6 +933,31 @@ void Createdxf::drawTextAligned( double xAlign, double xScaleW, int colour) +{ + drawTextAligned(fileName, text, x, y, height, rotation, oblique, + hAlign, vAlign, xAlign, y, xScaleW, colour); +} + +/** + @brief Createdxf::drawTextAligned + As above, with the second alignment point given in full (@a xAlign, + @a yAlign) instead of on the same horizontal line as the insertion + point, which is what a rotated aligned text needs. +*/ +void Createdxf::drawTextAligned( + const QString& fileName, + const QString& text, + double x, + double y, + double height, + double rotation, + double oblique, + int hAlign, + int vAlign, + double xAlign, + double yAlign, + double xScaleW, + int colour) { if (!fileName.isEmpty()) { QFile file(fileName); @@ -869,7 +1018,7 @@ void Createdxf::drawTextAligned( To_Dxf << 11 << "\r\n"; // XYZ To_Dxf << xAlign << "\r\n"; // X in UCS (User Coordinate System)coordinates To_Dxf << 21 << "\r\n"; - To_Dxf << y << "\r\n"; // Y in UCS (User Coordinate System)coordinates + To_Dxf << yAlign << "\r\n"; // Y in UCS (User Coordinate System)coordinates To_Dxf << 31 << "\r\n"; To_Dxf << 0.0 << "\r\n"; // Z in UCS (User Coordinate System)coordinates } diff --git a/sources/createdxf.h b/sources/createdxf.h index 5fb042e82..47d3cd24c 100644 --- a/sources/createdxf.h +++ b/sources/createdxf.h @@ -22,6 +22,8 @@ #include #include +#include + /** @brief The Createdxf class This class exports the project to DXF Format @@ -31,8 +33,38 @@ class Createdxf public: Createdxf(); ~Createdxf(); - static void dxfBegin (const QString&, const QStringList &layers = QStringList()); + static void dxfBegin (const QString&, const QStringList &layers = QStringList(), + const std::function &writeBlocks = {}); static void dxfEnd(const QString&); + static void dxfBlockBegin(const QString &fileName, + const QString &name, + double x, + double y, + bool hasAttributes = false); + static void dxfBlockEnd(const QString &fileName); + /// One attribute of a placed block, in DXF units + struct Attribute + { + QString tag; + QString text; + double x = 0; + double y = 0; + double height = 0; + double rotation = 0; + double xScaleW = 1; + int colour = 0; + bool invisible = false; + QString layer; ///< empty: the INSERT's layer + }; + static void drawInsert(const QString &fileName, + const QString &name, + double x, + double y, + double rotation, + const QList &attributes = {}); + static void drawAttdef(const QString &fileName, + const Attribute &attribute); + static QString blockName(const QString &name); // you can add more functions to create more drawings. static void drawCircle( const QString&, @@ -141,6 +173,20 @@ class Createdxf double xAlign, double xScale, int colour); + static void drawTextAligned( + const QString& fileName, + const QString& text, + double x, + double y, + double height, + double rotation, + double oblique, + int hAlign, + int vAlign, + double xAlign, + double yAlign, + double xScale, + int colour); static void drawPolyline( const QString &filepath, diff --git a/sources/darkimagerendering.h b/sources/darkimagerendering.h new file mode 100644 index 000000000..ad7c25206 --- /dev/null +++ b/sources/darkimagerendering.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef DARK_IMAGE_RENDERING_H +#define DARK_IMAGE_RENDERING_H + +#include +#include +#include + +namespace DarkImageRendering { +// View-local raster composition boundary. Qt still paints the scene in +// its native order, with its own transforms, clipping and opacity. +// A different painter (printing, export, another view or an item cache) +// cannot consume this display-only context. +struct Context { + QPainter *painter; + bool invertScene; + std::function flush; +}; +inline thread_local Context *current = nullptr; +class Scope { + Context *previous; +public: + explicit Scope(Context *context) : previous(current) { current = context; } + ~Scope() { current = previous; } + Scope(const Scope &) = delete; + Scope &operator=(const Scope &) = delete; +}; +inline void paintPixmap(QPainter *painter, const QPixmap &pixmap, bool adapt) +{ + Context *context = current; + const bool boundary = context && context->painter == painter && adapt != context->invertScene; + if (boundary) context->flush(context->invertScene, QRect()); + painter->drawPixmap(pixmap.rect(), pixmap); + if (boundary) { + // Only the raster was drawn since the preceding flush. Avoid a + // whole-viewport copy for a small logo; include filtering/AA edges. + const QRect area = painter->worldTransform().mapRect(QRectF(pixmap.rect())).toAlignedRect().adjusted(-2,-2,2,2); + context->flush(adapt, area); + } +} +} +#endif diff --git a/sources/diagramgestureoverlay.cpp b/sources/diagramgestureoverlay.cpp index 03d2606d1..c1bedd8d7 100644 --- a/sources/diagramgestureoverlay.cpp +++ b/sources/diagramgestureoverlay.cpp @@ -58,14 +58,16 @@ bool DiagramGestureOverlay::isEnabled() /** @brief DiagramGestureOverlay::showAt - Show the ring centred on @a center with @a actions, at most eight, - clockwise from the top. + Show the ring centred on @a center with @a sectors directions (4 or 8) + and @a actions, one per direction clockwise from the top. */ void DiagramGestureOverlay::showAt(const QPoint ¢er, - const QList &actions) + const QList &actions, + int sectors) { m_center = center; - m_actions = actions.mid(0, sectors); + m_sectors = sectors == 4 ? 4 : 8; + m_actions = actions.mid(0, m_sectors); m_active = -1; move(center - QPoint(overlay_width / 2, outer_radius + 1)); show(); @@ -88,7 +90,7 @@ int DiagramGestureOverlay::sectorAt(const QPoint &viewport_pos) const if (angle < 0) { angle += 360; } - return int(qRound(angle / (360.0 / sectors))) % sectors; + return int(qRound(angle / (360.0 / m_sectors))) % m_sectors; } /** @@ -102,7 +104,7 @@ QAction *DiagramGestureOverlay::actionAt(const QPoint &viewport_pos) const return nullptr; } QAction *action = m_actions.at(sector); - return action->isEnabled() ? action : nullptr; + return action && action->isEnabled() ? action : nullptr; } void DiagramGestureOverlay::setPointer(const QPoint &viewport_pos) @@ -120,7 +122,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const QPointF c(overlay_width / 2, outer_radius + 1); - const qreal step = 360.0 / sectors; + const qreal step = 360.0 / m_sectors; QColor base = palette().color(QPalette::Window); base.setAlpha(225); @@ -130,7 +132,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) //Highlight the sector the mouse points at, if it holds a command if (m_active >= 0 && m_active < m_actions.count() - && m_actions.at(m_active)->isEnabled()) + && m_actions.at(m_active) && m_actions.at(m_active)->isEnabled()) { QPainterPath wedge; wedge.moveTo(c); @@ -151,6 +153,9 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) for (int i = 0 ; i < m_actions.count() ; ++i) { QAction *action = m_actions.at(i); + if (!action) { + continue; + } const qreal a = qDegreesToRadians(i * step); const QPointF p(c.x() + icon_radius * qSin(a), c.y() - icon_radius * qCos(a)); const QRect r(int(p.x()) - icon_size / 2, int(p.y()) - icon_size / 2, @@ -167,7 +172,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) } //Name of the highlighted command, under the ring - if (m_active >= 0 && m_active < m_actions.count()) + if (m_active >= 0 && m_active < m_actions.count() && m_actions.at(m_active)) { const QString name = m_actions.at(m_active)->text().remove(QLatin1Char('&')); const QRect label(0, 2 * outer_radius + 4, width(), 20); diff --git a/sources/diagramgestureoverlay.h b/sources/diagramgestureoverlay.h index 5fca8dfc2..a2bbb4077 100644 --- a/sources/diagramgestureoverlay.h +++ b/sources/diagramgestureoverlay.h @@ -29,8 +29,9 @@ class QAction; mouse's direction highlighted. Releasing runs it, releasing near the centre cancels -- SolidWorks' mouse gestures. - Commands are placed clockwise from the top. The ring only draws; the - view tracks the mouse and asks sectorAt() on release. + Commands are placed clockwise from the top, one per direction (4 or 8). + A null entry is an empty direction. The ring only draws; the view + tracks the mouse and asks sectorAt() on release. */ class DiagramGestureOverlay : public QWidget { @@ -39,14 +40,15 @@ class DiagramGestureOverlay : public QWidget public: explicit DiagramGestureOverlay(QWidget *viewport); - void showAt(const QPoint ¢er, const QList &actions); + void showAt(const QPoint ¢er, const QList &actions, + int sectors = 8); void setPointer(const QPoint &viewport_pos); int sectorAt(const QPoint &viewport_pos) const; QAction *actionAt(const QPoint &viewport_pos) const; - static bool isEnabled(); + int sectors() const { return m_sectors; } - static const int sectors = 8; + static bool isEnabled(); protected: void paintEvent(QPaintEvent *event) override; @@ -55,6 +57,7 @@ class DiagramGestureOverlay : public QWidget QPoint m_center; QList m_actions; int m_active = -1; + int m_sectors = 8; }; #endif // DIAGRAMGESTUREOVERLAY_H diff --git a/sources/diagramtoolbarsettings.cpp b/sources/diagramtoolbarsettings.cpp new file mode 100644 index 000000000..df95b01ff --- /dev/null +++ b/sources/diagramtoolbarsettings.cpp @@ -0,0 +1,352 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "diagramtoolbarsettings.h" + +#include "shortcutmanager.h" + +#include +#include +#include +#include + +namespace { + const QString CONTENTS = QStringLiteral("diagrameditor/toolbars/"); + const QString CUSTOM_NAMES = QStringLiteral("diagrameditor/custom_toolbars/names"); + const QString CUSTOM_TITLES = QStringLiteral("diagrameditor/custom_toolbars/titles"); + //Set on a window by markWindow() + const char *const MARKED = "qetDiagramToolbars"; +} + +/** + @return every toolbar, built-in first, then the user's own in the order + they were added +*/ +QList DiagramToolbarSettings::toolbars() +{ + QList list; + for (const QString &name : builtInNames()) { + list << Toolbar{name, builtInTitle(name), false}; + } + return list << customToolbars(); +} + +/** + @return the object names of the toolbars the diagram editor always has. + The scripts toolbar is not one: it is filled from the scripts folder. +*/ +QStringList DiagramToolbarSettings::builtInNames() +{ + return {QStringLiteral("toolbar"), + QStringLiteral("display"), + QStringLiteral("diagram"), + QStringLiteral("adding"), + QStringLiteral("diagram_depth_toolbar")}; +} + +QString DiagramToolbarSettings::builtInTitle(const QString &name) +{ + if (name == QLatin1String("toolbar")) + return QCoreApplication::translate("QETDiagramEditor", "Outils"); + if (name == QLatin1String("display")) + return QCoreApplication::translate("QETDiagramEditor", "Affichage"); + if (name == QLatin1String("diagram")) + return QCoreApplication::translate("QETDiagramEditor", "Schéma"); + if (name == QLatin1String("adding")) + return QCoreApplication::translate("QETDiagramEditor", "Ajouter"); + if (name == QLatin1String("diagram_depth_toolbar")) + return QCoreApplication::translate("QETDiagramEditor", "Profondeur", "toolbar title"); + return QString(); +} + +/** + @return the toolbars the user added, in the order they were added +*/ +QList DiagramToolbarSettings::customToolbars() +{ + QSettings settings; + const QStringList names = settings.value(CUSTOM_NAMES).toStringList(); + const QStringList titles = settings.value(CUSTOM_TITLES).toStringList(); + QList list; + for (int i = 0 ; i < names.size() ; ++i) { + list << Toolbar{names.at(i), titles.value(i, names.at(i)), true}; + } + return list; +} + +/** + @brief DiagramToolbarSettings::setCustomToolbars + Store the user's own toolbars. The contents of one that is no longer + in @a toolbars are removed with it. +*/ +void DiagramToolbarSettings::setCustomToolbars(const QList &toolbars) +{ + QStringList names, titles; + for (const Toolbar &toolbar : toolbars) { + names << toolbar.name; + titles << toolbar.title; + } + for (const Toolbar &old : customToolbars()) { + if (!names.contains(old.name)) { + removeIds(old.name); + } + } + QSettings settings; + if (names.isEmpty()) { + settings.remove(CUSTOM_NAMES); + settings.remove(CUSTOM_TITLES); + } else { + settings.setValue(CUSTOM_NAMES, names); + settings.setValue(CUSTOM_TITLES, titles); + } +} + +/** + @return an object name for a new toolbar that none of @a existing has + and none had before (stored positions stay with the toolbar they + belonged to) +*/ +QString DiagramToolbarSettings::newCustomName(const QList &existing) +{ + QStringList taken; + for (const Toolbar &toolbar : existing) taken << toolbar.name; + for (const Toolbar &toolbar : customToolbars()) taken << toolbar.name; + for (int i = 1 ; ; ++i) { + const QString name = QStringLiteral("custom_%1").arg(i); + if (!taken.contains(name)) return name; + } +} + +/** + @return the ids toolbar @a name holds: the user's list if they saved + one, the defaults otherwise. A saved empty list stays empty. +*/ +QStringList DiagramToolbarSettings::ids(const QString &name) +{ + QSettings settings; + const QString key = CONTENTS + name; + if (!settings.contains(key)) { + return defaultIds(name); + } + return settings.value(key).toStringList(); +} + +/** + @return what toolbar @a name holds for a new user: the toolbars as + they were built before they could be changed. A user's own toolbar + starts empty. +*/ +QStringList DiagramToolbarSettings::defaultIds(const QString &name) +{ + const QString sep = separatorId(); + if (name == QLatin1String("toolbar")) { + return {QStringLiteral("diagrameditor.new_file"), + QStringLiteral("diagrameditor.open_file"), + QStringLiteral("diagrameditor.save_file"), + QStringLiteral("diagrameditor.save_file_as"), + QStringLiteral("diagrameditor.close_file"), + QStringLiteral("diagrameditor.print"), + QStringLiteral("diagrameditor.export_to_pdf"), + sep, + QStringLiteral("diagrameditor.undo"), + QStringLiteral("diagrameditor.redo"), + sep, + QStringLiteral("diagrameditor.cut"), + QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.paste"), + QStringLiteral("diagrameditor.duplicate"), + sep, + QStringLiteral("diagrameditor.delete_selection"), + QStringLiteral("diagrameditor.rotate_selection")}; + } + if (name == QLatin1String("display")) { + return {QStringLiteral("diagrameditor.mode_selection"), + QStringLiteral("diagrameditor.mode_visualise"), + sep, + QStringLiteral("widget:handler_size"), + sep, + QStringLiteral("diagrameditor.draw_grid"), + QStringLiteral("widget:text_grid"), + QStringLiteral("diagrameditor.draw_guides"), + QStringLiteral("widget:background_color"), + sep, + QStringLiteral("diagrameditor.zoom_content"), + QStringLiteral("diagrameditor.zoom_fit"), + QStringLiteral("diagrameditor.zoom_reset")}; + } + if (name == QLatin1String("diagram")) { + return {QStringLiteral("diagrameditor.edit_diagram_properties"), + QStringLiteral("diagrameditor.conductor_reset"), + QStringLiteral("diagrameditor.auto_conductor"), + QStringLiteral("diagrameditor.auto_break_conductor"), + QStringLiteral("widget:conductor_color")}; + } + if (name == QLatin1String("adding")) { + //add_pdf is skipped when built without QtPdf, as before + return {QStringLiteral("diagrameditor.add_text"), + QStringLiteral("diagrameditor.add_image"), + QStringLiteral("diagrameditor.add_pdf"), + QStringLiteral("diagrameditor.add_line"), + QStringLiteral("diagrameditor.add_rectangle"), + QStringLiteral("diagrameditor.add_ellipse"), + QStringLiteral("diagrameditor.add_arc"), + QStringLiteral("diagrameditor.add_polyline"), + QStringLiteral("diagrameditor.add_path"), + QStringLiteral("diagrameditor.add_fillet"), + QStringLiteral("diagrameditor.add_terminal_strip")}; + } + if (name == QLatin1String("diagram_depth_toolbar")) { + return {QStringLiteral("depth.forward"), + QStringLiteral("depth.raise"), + QStringLiteral("depth.lower"), + QStringLiteral("depth.backward")}; + } + return {}; +} + +/** + @brief DiagramToolbarSettings::setIds + Save @a ids for toolbar @a name. Saving the defaults removes the key, so + a later change of defaults still reaches this user. +*/ +void DiagramToolbarSettings::setIds(const QString &name, const QStringList &ids) +{ + if (ids == defaultIds(name)) { + removeIds(name); + } else { + QSettings().setValue(CONTENTS + name, ids); + } +} + +void DiagramToolbarSettings::removeIds(const QString &name) +{ + QSettings().remove(CONTENTS + name); +} + +QString DiagramToolbarSettings::separatorId() +{ + return QStringLiteral("separator"); +} + +/** + @return the toolbar buttons that are widgets, not commands. Each can be + on one toolbar at a time. +*/ +QStringList DiagramToolbarSettings::widgetIds() +{ + return {QStringLiteral("widget:handler_size"), + QStringLiteral("widget:text_grid"), + QStringLiteral("widget:background_color"), + QStringLiteral("widget:conductor_color")}; +} + +bool DiagramToolbarSettings::isWidget(const QString &id) +{ + return id.startsWith(QLatin1String("widget:")); +} + +QString DiagramToolbarSettings::widgetTitle(const QString &id) +{ + if (id == QLatin1String("widget:handler_size")) + return QCoreApplication::translate("DiagramToolbarSettings", "Taille des poignées"); + if (id == QLatin1String("widget:text_grid")) + return QCoreApplication::translate("DiagramToolbarSettings", "Grille des textes"); + if (id == QLatin1String("widget:background_color")) + return QCoreApplication::translate("DiagramToolbarSettings", "Couleur de fond du folio"); + if (id == QLatin1String("widget:conductor_color")) + return QCoreApplication::translate("DiagramToolbarSettings", "Couleur des conducteurs"); + return id; +} + +/** + @return every command that can go on a toolbar: the diagram editor's, + its stored scripts', and the depth commands it shares with the element + editor +*/ +QStringList DiagramToolbarSettings::availableCommandIds() +{ + QStringList ids; + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) + { + if (info.id.startsWith(QLatin1String("diagrameditor.")) + || info.id.startsWith(QLatin1String("depth."))) { + ids << info.id; + } + } + return ids; +} + +/** + @brief DiagramToolbarSettings::fill + Empty @a toolbar and put the commands of @a ids on it, in order. + @a resolve gives the window's action for an id, a widget id included; + an id it has no action for is skipped. A separator never starts or + ends the toolbar and never follows another: those are left out, as + Qt would otherwise draw them. +*/ +void DiagramToolbarSettings::fill(QToolBar *toolbar, const QStringList &ids, + const std::function &resolve) +{ + //Separators belong to the toolbar that made them; commands do not + const QList old_actions = toolbar->actions(); + toolbar->clear(); + for (QAction *action : old_actions) { + if (action->isSeparator() && action->parent() == toolbar) delete action; + } + bool pending_separator = false; + for (const QString &id : ids) + { + if (id == separatorId()) { + pending_separator = !toolbar->actions().isEmpty(); + continue; + } + QAction *action = resolve(id); + if (!action) { + continue; + } + if (pending_separator) { + toolbar->addSeparator(); + pending_separator = false; + } + toolbar->addAction(action); + } +} + +/** + @brief DiagramToolbarSettings::markWindow + Mark @a window as one whose toolbars applyToAll() rebuilds. It must + have a rebuildToolBars() slot or invokable. +*/ +void DiagramToolbarSettings::markWindow(QObject *window) +{ + window->setProperty(MARKED, true); +} + +/** + @brief DiagramToolbarSettings::applyToAll + Rebuild the toolbars of every open window marked by markWindow(), + after the settings change. +*/ +void DiagramToolbarSettings::applyToAll() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->property(MARKED).toBool()) { + QMetaObject::invokeMethod(widget, "rebuildToolBars"); + } + } +} diff --git a/sources/diagramtoolbarsettings.h b/sources/diagramtoolbarsettings.h new file mode 100644 index 000000000..d240821c9 --- /dev/null +++ b/sources/diagramtoolbarsettings.h @@ -0,0 +1,79 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef DIAGRAMTOOLBARSETTINGS_H +#define DIAGRAMTOOLBARSETTINGS_H + +#include +#include +#include + +#include + +class QAction; +class QToolBar; + +/** + @brief What each of the diagram editor's toolbars holds. + + A toolbar is a list of ids, in order: ShortcutManager command ids, the + separator id, and the ids of the few toolbar buttons that are widgets + rather than commands (the colour buttons, the text grid, the handle + size). Stored in QSettings, one key per toolbar, by the toolbar's + object name; a toolbar the user never changed uses the defaults below, + which are the toolbars as they were before they could be changed. + + The user can also add toolbars of their own. They start empty. + + An id no command carries any more is skipped, so a list saved by + another version still opens. +*/ +class DiagramToolbarSettings +{ + public: + struct Toolbar { + QString name; ///< object name, used for storage and by QMainWindow::saveState() + QString title; ///< shown to the user + bool custom = false; + }; + + static QList toolbars(); + static QStringList builtInNames(); + static QString builtInTitle(const QString &name); + static QList customToolbars(); + static void setCustomToolbars(const QList &toolbars); + static QString newCustomName(const QList &existing); + + static QStringList ids(const QString &name); + static QStringList defaultIds(const QString &name); + static void setIds(const QString &name, const QStringList &ids); + static void removeIds(const QString &name); + + static QString separatorId(); + static QStringList widgetIds(); + static bool isWidget(const QString &id); + static QString widgetTitle(const QString &id); + static QStringList availableCommandIds(); + + static void fill(QToolBar *toolbar, const QStringList &ids, + const std::function &resolve); + + static void markWindow(QObject *window); + static void applyToAll(); +}; + +#endif // DIAGRAMTOOLBARSETTINGS_H diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 0e67bf5ac..88120f9a3 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -42,6 +42,7 @@ #include "diagram.h" #include "diagramcontexttoolbar.h" #include "diagramgestureoverlay.h" +#include "gesturesettings.h" #include "shortcutbarsettings.h" #include "shortcutmanager.h" #include "ElementsCollection/xmlelementcollection.h" @@ -794,7 +795,8 @@ void DiagramView::mouseMoveEvent(QMouseEvent *e) m_diagram->clearEventInterface(); m_swallow_native_menu = true; } - m_gesture_overlay->showAt(m_gesture_origin, selectionCommands()); + m_gesture_overlay->showAt(m_gesture_origin, gestureCommands(), + GestureSettings::directions()); } if (m_gesture_overlay->isVisible()) { m_gesture_overlay->setPointer(pos); @@ -981,6 +983,28 @@ QList DiagramView::selectionCommands() const return actions; } +/** + @brief DiagramView::gestureCommands + @return the gesture ring's commands for the current selection, as this + window's actions. A list the user picked keeps its directions: a + command this window lacks leaves its direction empty (nullptr). + Otherwise the ring shows the shortcut bar's commands, in order. +*/ +QList DiagramView::gestureCommands() const +{ + QETDiagramEditor *qde = diagramEditor(); + const auto context = ShortcutBarSettings::contextFor(m_diagram->selectedItems()); + if (!qde || !GestureSettings::isCustom(context)) { + return selectionCommands(); + } + QList actions; + for (const QString &id : GestureSettings::ids(context)) { + actions << (id.isEmpty() ? nullptr + : ShortcutManager::instance().action(id, qde)); + } + return actions; +} + /** @brief DiagramView::showContextToolbar After a click that leaves something selected, show the shortcut bar's diff --git a/sources/diagramview.h b/sources/diagramview.h index ebf3fd1f8..e7a99827d 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -154,6 +154,7 @@ class DiagramView : public PaletteGraphicsView void placeCellRulers(); void showContextToolbar(const QPoint &viewport_pos); QList selectionCommands() const; + QList gestureCommands() const; /// Lowest and highest allowed value of the view transform scale (m11). /// Prevents wheel-zoom from driving the transform to overflow, which diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index 86ec87780..f0351966b 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -35,6 +35,7 @@ #include "qetgraphicsitem/independenttextitem.h" #include "qetgraphicsitem/qetshapeitem.h" #include "qetgraphicsitem/terminal.h" +#include "qetinformation.h" #include "textlines.h" #include @@ -43,6 +44,230 @@ #include #include +namespace { + /** + Draw the drawing of @a elmt's definition: its static texts, lines, + rectangles, circles, polygons and arcs, and its terminals when + @a draw_terminals, as if it were placed at @a elem_pos_x, + @a elem_pos_y and turned @a rotation_angle degrees, with @a elmt's + mirrors and its texts kept readable (Element::symbolTextsTransform()) + when @a as_placed. Used for every symbol drawn in full, and once per + block for a block's content. + */ + void drawSymbol(const QString &file_path, Element *elmt, + qreal elem_pos_x, qreal elem_pos_y, + double rotation_angle, bool draw_terminals, + bool as_placed = false) + { + using namespace DxfExport; + //The mirrors of an element are about its own axes, before it is + //rotated, see Element::setMirror() + const bool mirror_h = as_placed && elmt -> hasHorizontalMirror(); + const bool mirror_v = as_placed && elmt -> hasVerticalMirror(); + const QTransform texts_transform = as_placed + ? elmt -> symbolTextsTransform() + : QTransform(); + const qreal text_turn = as_placed && elmt -> hasUprightSymbolTexts() + ? 0 + : rotation_angle; + const qreal mirror_x = mirror_h ? -1 : 1; + const qreal mirror_y = mirror_v ? -1 : 1; + ElementPictureFactory::primitives primitives = ElementPictureFactory::instance()->getPrimitives(elmt->location()); + + Createdxf::layer = Layer::SymbolTexts; + for(QGraphicsSimpleTextItem *text : primitives.m_texts) + { + qreal fontSize = text->font().pointSizeF(); + if (fontSize < 0) + fontSize = text->font().pixelSize(); + + QPointF text_pos = text->pos(); + //A text the element mirrors, or turns in a project that keeps + //symbol texts horizontal, keeps reading as in the symbol: + //only its box moves, as ElementPictureFactory draws it on the + //folio. It is then drawn as in a symbol neither mirrored nor + //turned, moved to where the element puts the box's centre. + if (!texts_transform.isIdentity()) + { + QTransform box_transform; + box_transform.translate(text->pos().x(), text->pos().y()); + box_transform.rotate(text->rotation()); + const QFontMetricsF metrics(text->font()); + const QRectF box(0, -metrics.ascent(), + text->boundingRect().width(), + text->boundingRect().height()); + const QPointF centre = box_transform.mapRect(box).center(); + text_pos += texts_transform.map(centre) - centre; + } + + qreal x = elem_pos_x + text_pos.x(); + qreal y = elem_pos_y + text_pos.y(); + + qreal angle = text -> rotation() + text_turn; + qreal angler = angle * M_PI/180; + int xdir = -sin(angler); + int ydir = -cos(angler); + + QPointF transformed_point = DxfExport::rotation_transformed(x, y, elem_pos_x, elem_pos_y, -text_turn); + x = transformed_point.x() - ydir * fontSize * 0.5; + y = transformed_point.y() - xdir * fontSize * 0.5; + QStringList lines = text->text().split('\n'); + qreal offset = fontSize * 1.6; + for (QString line : lines) + { + if (line.size() > 0 && line != "_" ) { + Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360 - angle, 0, 0.72); + } + x += offset * xdir; + y -= offset * ydir; + } + } + + Createdxf::layer = Layer::Symbols; + for (QLineF line : primitives.m_lines) + { + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); + QLineF l = t.map(line); + Createdxf::drawLine(file_path, l, 0); + } + + for (QRectF rect : primitives.m_rectangles) + { + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); + QRectF r = t.mapRect(rect); + Createdxf::drawRectangle(file_path,r,0); + } + + for (QRectF circle_rect : primitives.m_circles) + { + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); + QPointF c = t.map(QPointF(circle_rect.center().x(),circle_rect.center().y())); + Createdxf::drawCircle(file_path,c,circle_rect.width()/2,0); + } + + for (QVector polygon : primitives.m_polygons) + { + if (polygon.size() == 0) + continue; + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); + QPolygonF poly = t.map(polygon); + if(poly.isClosed()) + Createdxf::drawPolygon(file_path,poly,0); + else + Createdxf::drawPolyline(file_path,poly,0); + } + + // Draw arcs and ellipses + for (QVector arc : primitives.m_arcs) + { + if (arc.size() == 0) + continue; + qreal x = (elem_pos_x + arc.at(0)); + qreal y = (elem_pos_y + arc.at(1)); + qreal w = arc.at(2); + qreal h = arc.at(3); + qreal startAngle = arc.at(4); + qreal spanAngle = arc .at(5); + //In a mirror the arc starts where it ended: an angle a + //becomes 180 - a in a horizontal one, -a in a vertical one + if (mirror_h) + { + x = elem_pos_x - arc.at(0) - w; + startAngle = 180 - startAngle - spanAngle; + } + if (mirror_v) + { + y = elem_pos_y - arc.at(1) - h; + startAngle = -startAngle - spanAngle; + } + QRectF r(x,y,w,h); + QPointF hotspot(elem_pos_x,elem_pos_y); + Createdxf::drawArcEllipse(file_path, r, startAngle, spanAngle, hotspot, rotation_angle, 0); + } + if (draw_terminals) { + Createdxf::layer = Layer::Terminals; + // Draw terminals + QList list_terminals = elmt->terminals(); + QColor col("red"); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); + foreach(Terminal *tp, list_terminals) { + QPointF c = t.map(QPointF(tp->dock_elmt_.x(),tp->dock_elmt_.y())); + Createdxf::drawCircle(file_path,c,3.0,Createdxf::dxfColor(col)); + } + } + } + + /// One line of a text as the export writes it: where it starts, in + /// scene coordinates, its size, and its angle (degrees, clockwise) + struct TextLine + { + QString text; + QPointF pos; + qreal font_size; + qreal angle; + }; + + /// The lines of @a dti that are written, one under the other as drawn + QList textLines(DiagramTextItem *dti) + { + qreal fontSize = dti -> font().pointSizeF(); + if (fontSize < 0) + fontSize = dti -> font().pixelSize(); + + qreal angle = dti -> rotation(); + + QGraphicsItem *parent = dti->parentItem(); + while (parent) { + angle += parent->rotation(); + parent = parent->parentItem(); + } + + qreal angler = angle * M_PI/180; + int xdir = -sin(angler); + int ydir = -cos(angler); + qreal x = (dti->scenePos().x()) + + xdir * fontSize * 1.8 + - ydir * fontSize; + qreal y = dti->scenePos().y() + - ydir * fontSize * 1.8 + - xdir * fontSize * 0.9; + //As drawn: a text with a width is wrapped + QStringList lines = TextLines::layoutLines(dti -> document()); + qreal offset = fontSize * 1.6; + QList result; + foreach (QString line, lines) { + if (line.size() > 0 && line != "_" ) + result << TextLine{line, QPointF(x, y), fontSize, angle}; + x += offset * xdir; + y -= offset * ydir; + } + return result; + } + + /// @a tag, or @a tag with _2, _3... if @a used has it, at most 31 + /// characters + QString uniqueTag(const QString &tag, const QSet &used) + { + QString unique = tag; + for (int i = 2 ; used.contains(unique) ; ++i) { + const QString suffix = QStringLiteral("_%1").arg(i); + unique = tag.left(31 - suffix.size()) + suffix; + } + return unique; + } + + /// Nothing would be drawn for @a elmt (its definition is missing) + bool drawsNothing(Element *elmt, bool draw_terminals) + { + const ElementPictureFactory::primitives p = + ElementPictureFactory::instance()->getPrimitives(elmt->location()); + return p.m_lines.isEmpty() && p.m_rectangles.isEmpty() + && p.m_circles.isEmpty() && p.m_polygons.isEmpty() + && p.m_arcs.isEmpty() && p.m_texts.isEmpty() + && (!draw_terminals || elmt->terminals().isEmpty()); + } +} + /** @brief DxfExport::folioSize @return the size of @a diagram as exported with @a properties: its border @@ -81,21 +306,13 @@ void DxfExport::write(Diagram *diagram, int width, int height, width -= 2*Diagram::margin; height -= 2*Diagram::margin; - Createdxf::xScale = Createdxf::sheetWidth / double(width); - Createdxf::yScale = Createdxf::sheetHeight / double(height); - - Createdxf::dxfBegin(file_path, Layer::all()); - - //Each kind of content on its own layer (discussion #1071). The - //border's title block switches to Layer::TitleBlock itself, see - //BorderTitleBlock::drawDxf(). - Createdxf::layer = Layer::Border; - //Add project elements (lines, rectangles, circles, texts) to dxf file - if (properties.draw_border) { - QRectF rect(Diagram::margin,Diagram::margin,width,height); - Createdxf::drawRectangle(file_path,rect,0); - } - diagram -> border_and_titleblock.drawDxf(file_path, 0); + //One scale for both axes, the largest that fits the sheet: two + //scales stretched the drawing and turned every circle into an oval + //(issue #1339). + const double scale = qMin(Createdxf::sheetWidth / double(width), + Createdxf::sheetHeight / double(height)); + Createdxf::xScale = scale; + Createdxf::yScale = scale; // Build the lists of elements. QList list_elements; @@ -165,6 +382,186 @@ void DxfExport::write(Diagram *diagram, int width, int height, } } + //Symbols as blocks: one block per symbol definition the folio + //uses, named after its file and numbered in the order first met, + //so a repeat export is identical. A block has to be written before + //any entity, so they are all collected first. + QHash block_names; // location -> block name + QList block_models; // one placed symbol per block + if (properties.dxf_blocks) { + QSet used; + for (Element *elmt : std::as_const(list_elements)) { + const QString key = elmt -> location().toString(); + if (!elmt -> symbolTextsTransform().isIdentity() + || block_names.contains(key) + || drawsNothing(elmt, properties.draw_terminals)) + continue; + QString file_name = elmt -> location().fileName(); + if (file_name.endsWith(QLatin1String(".elmt"))) + file_name.chop(5); + const QString base = Createdxf::blockName( + QStringLiteral("QET_") + file_name); + QString name = base; + for (int i = 2 ; used.contains(name) ; ++i) { + const QString suffix = QStringLiteral("_%1").arg(i); + name = base.left(31 - suffix.size()) + suffix; + } + used << name; + block_names.insert(key, name); + block_models << elmt; + } + } + //A mirrored symbol, or a turned one whose texts stay horizontal, + //is drawn in full: an INSERT would mirror or turn its texts too, + //which QElectroTech keeps readable + const auto blockOf = [&block_names](Element *elmt) { + return !elmt -> symbolTextsTransform().isIdentity() + ? QString() + : block_names.value(elmt -> location().toString()); + }; + + //With dxf_attributes, a symbol's own texts (its label, its + //function...) become attributes of its INSERT, written where the + //text would be, so a CAD program moves them with the symbol and + //lists them. Off by default: LibreCAD does not show attributes, + //so the labels would vanish there. Tagged + //with the information they show (LABEL, FUNCTION...), or TEXT1, + //TEXT2... for a typed text; a second line gets _2. The block + //defines each tag once, where the first symbol has it. + QHash> attributes; + QHash> attribute_definitions; // block -> ATTDEFs + QSet attribute_texts; + { + QHash> used_tags; + QHash typed_texts; + QHash> defined_tags; + for (DiagramTextItem *dti : std::as_const(list_texts)) { + if (!properties.dxf_attributes) + break; + auto *deti = qgraphicsitem_cast(dti); + Element *elmt = deti ? deti -> parentElement() : nullptr; + const QString block = elmt ? blockOf(elmt) : QString(); + if (block.isEmpty()) + continue; + + const QString base = + deti -> textFrom() == DynamicElementTextItem::ElementInfo + && !deti -> infoName().isEmpty() + ? Createdxf::blockName(deti -> infoName()) + : QStringLiteral("TEXT%1").arg(++typed_texts[elmt]); + //From the folio into the block: undo the INSERT + const QPointF insert(elmt -> pos().x() * Createdxf::xScale, + Createdxf::sheetHeight - elmt -> pos().y() * Createdxf::yScale); + const double turn = std::fmod(360 - elmt -> orientation() * 90, 360); + const QTransform to_block = QTransform() + .translate(0, Createdxf::sheetHeight) + .rotate(-turn) + .translate(-insert.x(), -insert.y()); + + const QList lines = textLines(dti); + for (int i = 0 ; i < lines.size() ; ++i) { + const TextLine &line = lines.at(i); + const QString tag = uniqueTag( + i == 0 ? base : base + QStringLiteral("_%1").arg(i + 1), + used_tags.value(elmt)); + used_tags[elmt] << tag; + + Createdxf::Attribute attribute; + attribute.tag = tag; + attribute.text = line.text; + attribute.x = line.pos.x() * Createdxf::xScale; + attribute.y = Createdxf::sheetHeight - line.pos.y() * Createdxf::yScale; + attribute.height = line.font_size * Createdxf::yScale; + attribute.rotation = 360 - line.angle; + attribute.xScaleW = 0.72; + attribute.colour = Createdxf::dxfColor(dti -> color()); + attribute.layer = Layer::SymbolTexts; + attributes[elmt] << attribute; + + if (!defined_tags[block].contains(tag)) { + defined_tags[block] << tag; + Createdxf::Attribute definition = attribute; + const QPointF in_block = to_block.map(QPointF(attribute.x, attribute.y)); + definition.x = in_block.x(); + definition.y = in_block.y(); + definition.rotation = std::fmod(attribute.rotation - turn + 720, 360); + definition.text.clear(); + attribute_definitions[block] << definition; + } + } + attribute_texts << dti; + } + + //The rest of what is known about a symbol (manufacturer, + //reference, supplier, quantity...) as hidden attributes, so a + //CAD program can list the parts from the drawing. A field + //already written as a visible attribute (dxf_attributes) is not + //repeated; the label formula is how the label is made, not + //part data. + for (Element *elmt : std::as_const(list_elements)) { + const QString block = blockOf(elmt); + if (block.isEmpty()) + continue; + const DiagramContext information = elmt -> elementInformations(); + const QPointF insert(elmt -> pos().x() * Createdxf::xScale, + Createdxf::sheetHeight - elmt -> pos().y() * Createdxf::yScale); + for (const QString &key : QETInformation::elementInfoKeys()) { + if (key == QETInformation::ELMT_FORMULA) + continue; + const QString value = information.value(key).toString(); + const QString tag = Createdxf::blockName(key); + if (value.isEmpty() || used_tags.value(elmt).contains(tag)) + continue; + used_tags[elmt] << tag; + + Createdxf::Attribute attribute; + attribute.tag = tag; + attribute.text = value; + attribute.x = insert.x(); + attribute.y = insert.y(); + attribute.height = 9 * Createdxf::yScale; + attribute.invisible = true; + attribute.layer = Layer::SymbolTexts; + attributes[elmt] << attribute; + + if (!defined_tags[block].contains(tag)) { + defined_tags[block] << tag; + Createdxf::Attribute definition = attribute; + definition.x = 0; + definition.y = Createdxf::sheetHeight; + definition.text.clear(); + attribute_definitions[block] << definition; + } + } + } + } + + //A block is drawn as if its symbol sat unturned at the folio's + //origin, which is DXF (0, sheetHeight); that is its base point. + Createdxf::dxfBegin(file_path, Layer::all(), [&]() { + for (Element *model : std::as_const(block_models)) { + const QString block = block_names.value(model -> location().toString()); + const QList definitions = attribute_definitions.value(block); + Createdxf::dxfBlockBegin(file_path, block, 0, Createdxf::sheetHeight, + !definitions.isEmpty()); + drawSymbol(file_path, model, 0, 0, 0, properties.draw_terminals); + for (const Createdxf::Attribute &definition : definitions) + Createdxf::drawAttdef(file_path, definition); + Createdxf::dxfBlockEnd(file_path); + } + }); + + //Each kind of content on its own layer (discussion #1071). The + //border's title block switches to Layer::TitleBlock itself, see + //BorderTitleBlock::drawDxf(). + Createdxf::layer = Layer::Border; + //Add project elements (lines, rectangles, circles, texts) to dxf file + if (properties.draw_border) { + QRectF rect(Diagram::margin,Diagram::margin,width,height); + Createdxf::drawRectangle(file_path,rect,0); + } + diagram -> border_and_titleblock.drawDxf(file_path, 0); + // Draw shapes Createdxf::layer = Layer::Shapes; foreach (QetShapeItem *qsi, list_shapes) qsi->toDXF(file_path, qsi->pen()); @@ -175,142 +572,25 @@ void DxfExport::write(Diagram *diagram, int width, int height, gti->toDXF(file_path); } - //Draw elements + //Draw elements: each one in full, or as an INSERT of its block foreach(Element *elmt, list_elements) { - double rotation_angle = elmt -> orientation() * 90; - //The mirrors of an element are about its own axes, before it is - //rotated, see Element::setMirror() - const qreal mirror_x = elmt -> hasHorizontalMirror() ? -1 : 1; - const qreal mirror_y = elmt -> hasVerticalMirror() ? -1 : 1; + const double rotation_angle = elmt -> orientation() * 90; + const qreal elem_pos_x = elmt -> pos().x(); + const qreal elem_pos_y = elmt -> pos().y(); - qreal elem_pos_x = elmt -> pos().x(); - qreal elem_pos_y = elmt -> pos().y();// - (diagram -> margin / 2); - - ElementPictureFactory::primitives primitives = ElementPictureFactory::instance()->getPrimitives(elmt->location()); - - Createdxf::layer = Layer::SymbolTexts; - for(QGraphicsSimpleTextItem *text : primitives.m_texts) - { - qreal fontSize = text->font().pointSizeF(); - if (fontSize < 0) - fontSize = text->font().pixelSize(); - - QPointF text_pos = text->pos(); - //A text the element mirrors, or turns in a project that keeps - //symbol texts horizontal, keeps reading as in the symbol: - //only its box moves, as ElementPictureFactory draws it on the - //folio. It is then drawn as in a symbol neither mirrored nor - //turned, moved to where the element puts the box's centre. - const QTransform texts_transform = elmt -> symbolTextsTransform(); - const qreal text_turn = elmt -> hasUprightSymbolTexts() ? 0 : rotation_angle; - if (!texts_transform.isIdentity()) - { - QTransform box_transform; - box_transform.translate(text->pos().x(), text->pos().y()); - box_transform.rotate(text->rotation()); - const QFontMetricsF metrics(text->font()); - const QRectF box(0, -metrics.ascent(), - text->boundingRect().width(), - text->boundingRect().height()); - const QPointF centre = box_transform.mapRect(box).center(); - text_pos += texts_transform.map(centre) - centre; - } - - qreal x = elem_pos_x + text_pos.x(); - qreal y = elem_pos_y + text_pos.y(); - - qreal angle = text -> rotation() + text_turn; - qreal angler = angle * M_PI/180; - int xdir = -sin(angler); - int ydir = -cos(angler); - - QPointF transformed_point = rotation_transformed(x, y, elem_pos_x, elem_pos_y, -text_turn); - x = transformed_point.x() - ydir * fontSize * 0.5; - y = transformed_point.y() - xdir * fontSize * 0.5; - QStringList lines = text->text().split('\n'); - qreal offset = fontSize * 1.6; - for (QString line : lines) - { - if (line.size() > 0 && line != "_" ) { - Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360 - angle, 0, 0.72); - } - x += offset * xdir; - y -= offset * ydir; - } - } - - Createdxf::layer = Layer::Symbols; - for (QLineF line : primitives.m_lines) - { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); - QLineF l = t.map(line); - Createdxf::drawLine(file_path, l, 0); - } - - for (QRectF rect : primitives.m_rectangles) - { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); - QRectF r = t.mapRect(rect); - Createdxf::drawRectangle(file_path,r,0); - } - - for (QRectF circle_rect : primitives.m_circles) - { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); - QPointF c = t.map(QPointF(circle_rect.center().x(),circle_rect.center().y())); - Createdxf::drawCircle(file_path,c,circle_rect.width()/2,0); - } - - for (QVector polygon : primitives.m_polygons) - { - if (polygon.size() == 0) - continue; - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); - QPolygonF poly = t.map(polygon); - if(poly.isClosed()) - Createdxf::drawPolygon(file_path,poly,0); - else - Createdxf::drawPolyline(file_path,poly,0); - } - - // Draw arcs and ellipses - for (QVector arc : primitives.m_arcs) - { - if (arc.size() == 0) - continue; - qreal x = (elem_pos_x + arc.at(0)); - qreal y = (elem_pos_y + arc.at(1)); - qreal w = arc.at(2); - qreal h = arc.at(3); - qreal startAngle = arc.at(4); - qreal spanAngle = arc .at(5); - //In a mirror the arc starts where it ended: an angle a - //becomes 180 - a in a horizontal one, -a in a vertical one - if (elmt -> hasHorizontalMirror()) - { - x = elem_pos_x - arc.at(0) - w; - startAngle = 180 - startAngle - spanAngle; - } - if (elmt -> hasVerticalMirror()) - { - y = elem_pos_y - arc.at(1) - h; - startAngle = -startAngle - spanAngle; - } - QRectF r(x,y,w,h); - QPointF hotspot(elem_pos_x,elem_pos_y); - Createdxf::drawArcEllipse(file_path, r, startAngle, spanAngle, hotspot, rotation_angle, 0); - } - if (properties.draw_terminals) { - Createdxf::layer = Layer::Terminals; - // Draw terminals - QList list_terminals = elmt->terminals(); - QColor col("red"); - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); - foreach(Terminal *tp, list_terminals) { - QPointF c = t.map(QPointF(tp->dock_elmt_.x(),tp->dock_elmt_.y())); - Createdxf::drawCircle(file_path,c,3.0,Createdxf::dxfColor(col)); - } + const QString block = blockOf(elmt); + if (block.isEmpty()) { + drawSymbol(file_path, elmt, elem_pos_x, elem_pos_y, + rotation_angle, properties.draw_terminals, true); + } else { + Createdxf::layer = Layer::Symbols; + //QElectroTech turns a symbol clockwise, DXF counter-clockwise + Createdxf::drawInsert(file_path, block, + elem_pos_x * Createdxf::xScale, + Createdxf::sheetHeight - elem_pos_y * Createdxf::yScale, + std::fmod(360 - rotation_angle, 360), + attributes.value(elmt)); } } @@ -373,39 +653,14 @@ void DxfExport::write(Diagram *diagram, int width, int height, //Draw text items foreach(DiagramTextItem *dti, list_texts) { + //Already written as an attribute of its symbol's INSERT + if (attribute_texts.contains(dti)) + continue; //A free text, or a symbol's own text (its label and the like) Createdxf::layer = qgraphicsitem_cast(dti) ? Layer::Texts : Layer::SymbolTexts; - qreal fontSize = dti -> font().pointSizeF(); - if (fontSize < 0) - fontSize = dti -> font().pixelSize(); - - qreal angle = dti -> rotation(); - - QGraphicsItem *parent = dti->parentItem(); - while (parent) { - angle += parent->rotation(); - parent = parent->parentItem(); - } - - qreal angler = angle * M_PI/180; - int xdir = -sin(angler); - int ydir = -cos(angler); - qreal x = (dti->scenePos().x()) - + xdir * fontSize * 1.8 - - ydir * fontSize; - qreal y = dti->scenePos().y() - - ydir * fontSize * 1.8 - - xdir * fontSize * 0.9; - //As drawn: a text with a width is wrapped - QStringList lines = TextLines::layoutLines(dti -> document()); - qreal offset = fontSize * 1.6; - foreach (QString line, lines) { - if (line.size() > 0 && line != "_" ) - Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360-angle, Createdxf::dxfColor(dti->color()), 0.72 ); - x += offset * xdir; - y -= offset * ydir; - } + for (const TextLine &line : textLines(dti)) + Createdxf::drawText(file_path, line.text, line.pos, line.font_size, 360-line.angle, Createdxf::dxfColor(dti->color()), 0.72 ); } //Draw the slave cross-reference labels diff --git a/sources/editor/elementscene.cpp b/sources/editor/elementscene.cpp index a9b8d85d2..2d5d035a7 100644 --- a/sources/editor/elementscene.cpp +++ b/sources/editor/elementscene.cpp @@ -23,6 +23,7 @@ #include "../NameList/ui/namelistwidget.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" +#include "../QetGraphicsItemModeler/textresizehandles.h" #include "editorcommands.h" #include "elementcontent.h" #include "elementprimitivedecorator.h" @@ -1498,9 +1499,49 @@ void ElementScene::managePrimitivesGroups() m_decorator -> setPos(0, 0); m_decorator -> setItems(selected_items); } + manageTextResizeHandles(selected_items); m_decorator_lock -> unlock(); } +/** + @brief ElementScene::manageTextResizeHandles + Show the corner handles that change the width of a text field when it + is the only selected item. + Called again when the undo stack changes, including from the push of a + resize itself: the handles of the same text are then kept, not deleted + while they are still emitting. + @param selected_items +*/ +void ElementScene::manageTextResizeHandles(const QList &selected_items) +{ + QGraphicsTextItem *text = nullptr; + if (selected_items.size() == 1 && + selected_items.first()->type() == PartDynamicTextField::Type) + { + text = static_cast(selected_items.first()); + } + if (m_text_resize_handles && m_text_resize_handles->parentItem() == text) { + m_text_resize_handles->updateHandlesPos(); + return; + } + + if (m_text_resize_handles) { + m_text_resize_handles->hide(); + m_text_resize_handles->deleteLater(); + m_text_resize_handles.clear(); + } + + if (text) + { + m_text_resize_handles = new TextResizeHandles(text); + connect(m_text_resize_handles, &TextResizeHandles::resizeFinished, this, + [this, text](qreal old_width, qreal new_width, QPointF old_pos, QPointF new_pos) + { + m_undo_stack.push(new TextResizeCommand(text, old_width, new_width, old_pos, new_pos)); + }); + } +} + /** @brief ElementScene::stackAction Push the provided \a command on the undo stack. diff --git a/sources/editor/elementscene.h b/sources/editor/elementscene.h index ee275703f..5d3beb427 100644 --- a/sources/editor/elementscene.h +++ b/sources/editor/elementscene.h @@ -33,6 +33,7 @@ class QETElementEditor; class ESEventInterface; class QKeyEvent; class CustomElementGraphicPart; +class TextResizeHandles; /** @brief The ElementScene class This class is the canvas allowing the visual edition of an electrical element. @@ -90,6 +91,7 @@ class ElementScene : public QGraphicsScene m_y_grid; QPointer m_single_selected_item; + QPointer m_text_resize_handles; bool m_hotspot_visible = true; bool m_background_frame_visible = false; @@ -180,6 +182,7 @@ class ElementScene : public QGraphicsScene void slot_editAuthorInformations(); void slot_editProperties(); void managePrimitivesGroups(); + void manageTextResizeHandles(const QList &selected_items); void stackAction(ElementEditionCommand *); signals: diff --git a/sources/editor/ui/qetelementeditor.cpp b/sources/editor/ui/qetelementeditor.cpp index 7469a2a26..af39d81bb 100644 --- a/sources/editor/ui/qetelementeditor.cpp +++ b/sources/editor/ui/qetelementeditor.cpp @@ -54,6 +54,7 @@ #include "../../dxf/dxftoelmt.h" #include "../../qet_elementscaler/qet_elementscaler.h" #include "../UndoCommand/openelmtcommand.h" +#include "../../toolbarsettings.h" #include #include @@ -89,6 +90,7 @@ QETElementEditor::QETElementEditor(QWidget *parent) : readSettings(); // restoreGeometry before show() show(); readSettingsState(); // restoreState() must be called after show() in Qt6 + ToolbarSettings::applyTo(this); } /** diff --git a/sources/exportproperties.cpp b/sources/exportproperties.cpp index 7e85a4b2d..cd53bc806 100644 --- a/sources/exportproperties.cpp +++ b/sources/exportproperties.cpp @@ -37,6 +37,8 @@ ExportProperties::ExportProperties() : draw_terminal_names(false), draw_bg_transparent(false), draw_colored_conductors(true), + dxf_blocks(false), + dxf_attributes(false), exported_area(QET::BorderArea) { } @@ -77,6 +79,10 @@ void ExportProperties::toSettings(QSettings &settings, draw_bg_transparent); settings.setValue(prefix % "drawcoloredconductors", draw_colored_conductors); + settings.setValue(prefix % "dxfblocks", + dxf_blocks); + settings.setValue(prefix % "dxfattributes", + dxf_attributes); settings.setValue(prefix % "area", QET::diagramAreaToString(exported_area)); } @@ -115,6 +121,10 @@ void ExportProperties::fromSettings(QSettings &settings, draw_colored_conductors = settings.value( prefix % "drawcoloredconductors", true ).toBool(); + dxf_blocks = settings.value(prefix % "dxfblocks", + false).toBool(); + dxf_attributes = settings.value(prefix % "dxfattributes", + false).toBool(); exported_area = QET::diagramAreaFromString( settings.value( diff --git a/sources/exportproperties.h b/sources/exportproperties.h index af34f6868..c8feca461 100644 --- a/sources/exportproperties.h +++ b/sources/exportproperties.h @@ -50,6 +50,8 @@ class ExportProperties { bool draw_terminal_names; ///< Whether to render terminal names/labels bool draw_bg_transparent; ///< Whether to use transparency for SVG-Export bool draw_colored_conductors; ///< Whether to render conductors colors + bool dxf_blocks; ///< DXF: each symbol as a block, placed with an INSERT + bool dxf_attributes; ///< DXF, with dxf_blocks: a symbol's texts as attributes of its INSERT QET::DiagramArea exported_area; ///< Area of diagrams to be rendered }; #endif diff --git a/sources/exportpropertieswidget.cpp b/sources/exportpropertieswidget.cpp index 8eccc0805..0ce0dac46 100644 --- a/sources/exportpropertieswidget.cpp +++ b/sources/exportpropertieswidget.cpp @@ -66,6 +66,8 @@ ExportProperties ExportPropertiesWidget::exportProperties() const export_properties.draw_terminal_names = draw_terminal_names -> isChecked(); export_properties.draw_bg_transparent = draw_bg_transparent -> isChecked(); export_properties.draw_colored_conductors = draw_colored_conductors -> isChecked(); + export_properties.dxf_blocks = dxf_blocks -> isChecked(); + export_properties.dxf_attributes = dxf_attributes -> isChecked(); export_properties.exported_area = export_border -> isChecked() ? QET::BorderArea : QET::ElementsArea; return(export_properties); @@ -89,6 +91,9 @@ void ExportPropertiesWidget::setExportProperties(const ExportProperties &export_ draw_terminal_names -> setChecked(export_properties.draw_terminal_names); draw_bg_transparent -> setChecked(export_properties.draw_bg_transparent); draw_colored_conductors -> setChecked(export_properties.draw_colored_conductors); + dxf_blocks -> setChecked(export_properties.dxf_blocks); + dxf_attributes -> setChecked(export_properties.dxf_attributes); + dxf_attributes -> setEnabled(export_properties.dxf_blocks); if (export_properties.exported_area == QET::BorderArea) { export_border -> setChecked(true); @@ -219,6 +224,17 @@ void ExportPropertiesWidget::build() // use transparent background for SVG-Export draw_bg_transparent = new QCheckBox(tr("SVG: fond transparent"), groupbox_options); optionshlayout -> addWidget(draw_bg_transparent, 4, 0); + + // each symbol as a DXF block (issue #1339) + dxf_blocks = new QCheckBox(tr("DXF : symboles en blocs"), groupbox_options); + dxf_blocks -> setToolTip(tr("Chaque symbole devient un bloc DXF, sélectionnable d'un clic dans un logiciel de CAO")); + optionshlayout -> addWidget(dxf_blocks, 4, 1); + + // with blocks, a symbol's texts as attributes of its block + dxf_attributes = new QCheckBox(tr("DXF : textes des symboles en attributs"), groupbox_options); + dxf_attributes -> setToolTip(tr("Les textes d'un symbole (repère, fonction...) deviennent des attributs de son bloc. LibreCAD n'affiche pas les attributs.")); + optionshlayout -> addWidget(dxf_attributes, 5, 1); + connect(dxf_blocks, &QCheckBox::toggled, dxf_attributes, &QCheckBox::setEnabled); vboxLayout -> addWidget(groupbox_options); @@ -234,6 +250,8 @@ void ExportPropertiesWidget::build() setTabOrder(draw_titleblock, draw_terminals); setTabOrder(draw_terminals, draw_colored_conductors); setTabOrder(draw_colored_conductors, draw_bg_transparent); + setTabOrder(draw_bg_transparent, dxf_blocks); + setTabOrder(dxf_blocks, dxf_attributes); // connexion du bouton permettant le choix du repertoire connect(button_browse, &QPushButton::released, this, &ExportPropertiesWidget::slot_chooseADirectory); @@ -249,6 +267,8 @@ void ExportPropertiesWidget::build() connect(draw_terminal_names, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_bg_transparent, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_colored_conductors, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_blocks, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_attributes, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); #else connect(draw_grid, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_border, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); @@ -257,5 +277,7 @@ void ExportPropertiesWidget::build() connect(draw_terminal_names, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_bg_transparent, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_colored_conductors, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_blocks, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_attributes, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); #endif } diff --git a/sources/exportpropertieswidget.h b/sources/exportpropertieswidget.h index ae0c97acd..5b96e4273 100644 --- a/sources/exportpropertieswidget.h +++ b/sources/exportpropertieswidget.h @@ -65,6 +65,8 @@ class ExportPropertiesWidget : public QWidget { QCheckBox *draw_terminal_names; QCheckBox *draw_bg_transparent; QCheckBox *draw_colored_conductors; + QCheckBox *dxf_blocks; + QCheckBox *dxf_attributes; QRadioButton *export_border; QRadioButton *export_elements; QButtonGroup *exported_content_choices; diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index f0a44ae36..3c6ef11b5 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -164,7 +164,8 @@ void ElementPictureFactory::dropCache(const ElementsLocation &location) } } m_pixmap_H.remove(uuid); - m_primitives_H.remove(uuid); + //The text items belong to the cache; nothing else holds them. + qDeleteAll(m_primitives_H.take(uuid).m_texts); } /** @@ -304,6 +305,9 @@ bool ElementPictureFactory::build(const ElementsLocation &location, QPainter painter; QPicture pic; primitives primitives_; + //The low-zoom pass fills a second set of primitives that is only + //drawn, never kept: its text items are deleted at the end. + primitives low_primitives; if (picture) { painter.begin(picture); } @@ -377,8 +381,7 @@ bool ElementPictureFactory::build(const ElementsLocation &location, // complex font/text operations. } else { parseElement(qde, painter, primitives_); - primitives fake_prim; - parseElement(qde, low_painter, fake_prim); + parseElement(qde, low_painter, low_primitives); } } } @@ -388,6 +391,7 @@ bool ElementPictureFactory::build(const ElementsLocation &location, painter.end(); low_painter.end(); m_build_texts_undo = QTransform(); + qDeleteAll(low_primitives.m_texts); const auto uuid_ = cacheKey(location); if (!texts_transform.isIdentity()) { diff --git a/sources/gesturesettings.cpp b/sources/gesturesettings.cpp new file mode 100644 index 000000000..298c4a3c5 --- /dev/null +++ b/sources/gesturesettings.cpp @@ -0,0 +1,125 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "gesturesettings.h" + +#include + +namespace { +const QString directions_key = QStringLiteral("diagrameditor/gestures/directions"); + +QString settingsKey(ShortcutBarSettings::Context context) +{ + switch (context) + { + case ShortcutBarSettings::Canvas: + return QStringLiteral("diagrameditor/gestures/canvas"); + case ShortcutBarSettings::Selection: + return QStringLiteral("diagrameditor/gestures/selection"); + case ShortcutBarSettings::Conductor: + return QStringLiteral("diagrameditor/gestures/conductor"); + } + return QString(); +} +} + +/** + @return how many directions the ring has: 8 (the default) or 4 +*/ +int GestureSettings::directions() +{ + return QSettings().value(directions_key, 8).toInt() == 4 ? 4 : 8; +} + +/** + @brief GestureSettings::setDirections + Save 4 or 8 directions. 8 is the default and removes the key. +*/ +void GestureSettings::setDirections(int directions) +{ + QSettings settings; + if (directions == 4) { + settings.setValue(directions_key, 4); + } else { + settings.remove(directions_key); + } +} + +/** + @return true if the user picked the commands for @a context, false + when the ring follows the shortcut bar +*/ +bool GestureSettings::isCustom(ShortcutBarSettings::Context context) +{ + return QSettings().contains(settingsKey(context)); +} + +/** + @return the ids on the ring for @a context: the user's list if they + picked one, the defaults otherwise +*/ +QStringList GestureSettings::ids(ShortcutBarSettings::Context context) +{ + QSettings settings; + const QString key = settingsKey(context); + if (!settings.contains(key)) { + return defaultIds(context); + } + return settings.value(key).toStringList(); +} + +/** + @return the shortcut bar's commands for @a context, without its pinned + elements: what the ring has shown since it was added +*/ +QStringList GestureSettings::defaultIds(ShortcutBarSettings::Context context) +{ + QStringList ids; + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (!ShortcutBarSettings::isElement(id)) { + ids << id; + } + } + return ids; +} + +/** + @brief GestureSettings::setIds + Save @a ids for @a context; trailing empty directions are dropped. + Saving the defaults removes the key, so the ring follows the shortcut + bar again. +*/ +void GestureSettings::setIds(ShortcutBarSettings::Context context, QStringList ids) +{ + while (!ids.isEmpty() && ids.constLast().isEmpty()) { + ids.removeLast(); + } + if (ids == defaultIds(context)) { + reset(context); + } else { + QSettings().setValue(settingsKey(context), ids); + } +} + +/** + @brief GestureSettings::reset + Make the ring for @a context follow the shortcut bar again. +*/ +void GestureSettings::reset(ShortcutBarSettings::Context context) +{ + QSettings().remove(settingsKey(context)); +} diff --git a/sources/gesturesettings.h b/sources/gesturesettings.h new file mode 100644 index 000000000..2089e877f --- /dev/null +++ b/sources/gesturesettings.h @@ -0,0 +1,47 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef GESTURESETTINGS_H +#define GESTURESETTINGS_H + +#include "shortcutbarsettings.h" + +#include + +/** + @brief The commands on the mouse gesture ring (right-drag on a folio). + + Until the user picks them, the ring shows the shortcut bar's commands + for the selection, as it always has. A picked list is stored in + QSettings, one key per context, and is positional: entry i is the + command in direction i, clockwise from the top, and an empty entry is + an empty direction. Pinned elements are never on the ring. +*/ +class GestureSettings +{ + public: + static int directions(); + static void setDirections(int directions); + + static bool isCustom(ShortcutBarSettings::Context context); + static QStringList ids(ShortcutBarSettings::Context context); + static QStringList defaultIds(ShortcutBarSettings::Context context); + static void setIds(ShortcutBarSettings::Context context, QStringList ids); + static void reset(ShortcutBarSettings::Context context); +}; + +#endif // GESTURESETTINGS_H diff --git a/sources/main.cpp b/sources/main.cpp index 9058b7cc8..09aab1f21 100644 --- a/sources/main.cpp +++ b/sources/main.cpp @@ -42,6 +42,13 @@ #include #include +#include +#include +#ifdef Q_OS_WIN +#include +#include +#endif + #ifdef Q_OS_MACOS #include @@ -120,6 +127,43 @@ static QStringList headlessArguments(const QStringList &args) return kept; } +/** + The headless runs below (the exports, --info, --resave and --run) return + before the log-file handler is installed further down, so they keep + Qt's default handler. On Linux and macOS that one writes to stderr. On + Windows it writes to the debugger when the program has no console, and + a GUI program started by another program never has one: what the run + reports -- a wire that could not be reconnected, which way the project + database was filled -- never reached the caller. Write it to stderr + ourselves, in the form the default handler uses on the other systems, + so a caller reads the same lines on every system. +*/ +static void headlessMessageHandler(QtMsgType type, + const QMessageLogContext &context, + const QString &msg) +{ + const QByteArray line = qFormatLogMessage(type, context, msg).toUtf8(); + fprintf(stderr, "%s\n", line.constData()); + fflush(stderr); + if (type == QtFatalMsg) { + abort(); + } +} + +static void installHeadlessMessageHandler() +{ +#ifdef Q_OS_WIN + // stdout and stderr are in text mode on Windows and turn "\n" into + // "\r\n": a caller would read lines ending in '\r' where the other + // systems give none. Binary mode gives both streams the same line + // ending on every system, for the JSON of --info, the messages of the + // exports and what a script logs. + _setmode(_fileno(stdout), _O_BINARY); + _setmode(_fileno(stderr), _O_BINARY); +#endif + qInstallMessageHandler(headlessMessageHandler); +} + int main(int argc, char **argv) { // before creating Application: @@ -190,6 +234,7 @@ int main(int argc, char **argv) for (int i = 0; i < argc; ++i) raw_args << QString::fromLocal8Bit(argv[i]); if (CLIExport::isExportRequest(raw_args)) { + installHeadlessMessageHandler(); QApplication export_app(argc, argv); // No crash-recovery backups in one-shot CLI mode: the backup write // runs on a background thread referencing the project and races the @@ -207,6 +252,7 @@ int main(int argc, char **argv) // #162). Same reasoning as the export branch above for running // before SingleApplication and answering message boxes headlessly. if (QetScripting::isRunRequest(raw_args)) { + installHeadlessMessageHandler(); QApplication script_app(argc, argv); QETProject::setBackupEnabled(false); QET::QetMessageBox::setNonInteractive(true); diff --git a/sources/palettegraphicsview.cpp b/sources/palettegraphicsview.cpp index ab9b322db..77c7ddeda 100644 --- a/sources/palettegraphicsview.cpp +++ b/sources/palettegraphicsview.cpp @@ -18,6 +18,7 @@ #include "palettegraphicsview.h" #include "qetpalette.h" +#include "darkimagerendering.h" #include #include @@ -99,12 +100,13 @@ bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event) */ void PaletteGraphicsView::paintEvent(QPaintEvent *event) { - if (invertsLightness() && !customBackgroundColor()) + if (invertsLightness()) { paintInverted(event); return; } m_buffer = QImage(); + m_composite = QImage(); QGraphicsView::paintEvent(event); } @@ -130,7 +132,7 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) const QSize size(qCeil(viewport()->width() * ratio), qCeil(viewport()->height() * ratio)); if (m_buffer.size() != size || m_buffer.devicePixelRatio() != ratio) { - m_buffer = QImage(size, QImage::Format_RGB32); + m_buffer = QImage(size, QImage::Format_ARGB32_Premultiplied); m_buffer.setDevicePixelRatio(ratio); } @@ -141,9 +143,13 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) m_buffer_painter.setClipRect(exposed); m_buffer_painter.setRenderHints(renderHints()); m_buffer_painter.setWorldTransform(viewportTransform()); + m_exposed = exposed; + m_layered = false; + DarkImageRendering::Context context{&m_buffer_painter, !customBackgroundColor(), [this](bool invert, const QRect &bounds) { flushLayer(invert, bounds); }}; + DarkImageRendering::Scope rasterScope(&context); m_inverting = true; - paintingInverted(true); + paintingInverted(!customBackgroundColor()); const OptimizationFlags flags = optimizationFlags(); setOptimizationFlag(QGraphicsView::IndirectPainting, true); QGraphicsView::paintEvent(event); @@ -151,10 +157,44 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) paintingInverted(false); m_inverting = false; + if (m_layered) flushLayer(!customBackgroundColor()); m_buffer_painter.end(); blitInverted(exposed); } +void PaletteGraphicsView::flushLayer(bool invert, const QRect &bounds) +{ + const QRect area = bounds.isNull() ? m_exposed : bounds.intersected(m_exposed); + if (area.isEmpty()) return; + const qreal ratio = m_buffer.devicePixelRatio(); + const QRect pixels = QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect(); + QImage layer = m_buffer.copy(pixels); + layer.setDevicePixelRatio(ratio); + if (invert) { + const QPalette palette = QApplication::palette(); + QET::Palette::invertLightnessLayer(layer, palette.color(QPalette::Base), palette.color(QPalette::Text)); + } + if (!m_layered) { + if (m_composite.size() != m_buffer.size() || m_composite.devicePixelRatio() != ratio) { + m_composite = QImage(m_buffer.size(), QImage::Format_ARGB32_Premultiplied); + m_composite.setDevicePixelRatio(ratio); + } + m_composite.fill(Qt::transparent); + m_layered = true; + } + QPainter composite(&m_composite); + composite.drawImage(QPointF(pixels.topLeft()) / ratio, layer); + // Keep Qt's active painter and its saved state stack intact. Replacing + // or restarting it here would lose ancestor clipping and opacity. + m_buffer_painter.save(); + m_buffer_painter.resetTransform(); + m_buffer_painter.setClipping(false); + m_buffer_painter.setOpacity(1.0); + m_buffer_painter.setCompositionMode(QPainter::CompositionMode_Source); + m_buffer_painter.fillRect(QRectF(QPointF(pixels.topLeft()) / ratio, QSizeF(pixels.size()) / ratio), Qt::transparent); + m_buffer_painter.restore(); +} + /** @brief PaletteGraphicsView::drawBackground Into the off-screen image while painting inverted, else as @@ -199,15 +239,16 @@ void PaletteGraphicsView::drawForeground(QPainter *painter, const QRectF &rect) void PaletteGraphicsView::blitInverted(const QRect &area) { const qreal ratio = m_buffer.devicePixelRatio(); - QImage part = m_buffer.copy(QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect()); + const QRect pixels = QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect(); + QImage part = (m_layered ? m_composite : m_buffer).copy(pixels); part.setDevicePixelRatio(ratio); // The application palette, for the reason given in invertsLightness(). const QPalette application_palette = QApplication::palette(); - QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), + if (!m_layered && !customBackgroundColor()) QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), application_palette.color(QPalette::Text)); QPainter painter(viewport()); - painter.drawImage(area.topLeft(), part); + painter.drawImage(QPointF(pixels.topLeft()) / ratio, part); drawRubberBand(painter); } diff --git a/sources/palettegraphicsview.h b/sources/palettegraphicsview.h index 96d2ace36..36818e51b 100644 --- a/sources/palettegraphicsview.h +++ b/sources/palettegraphicsview.h @@ -90,11 +90,15 @@ class PaletteGraphicsView : public QGraphicsView void paintInverted(QPaintEvent *event); void blitInverted(const QRect &area); void drawRubberBand(QPainter &painter); + void flushLayer(bool invert, const QRect &bounds = QRect()); /// The off-screen image the hooks paint into while m_inverting: /// the viewport's size, in its coordinates. Kept between paints, /// dropped when the view paints on a light palette again. QImage m_buffer; + QImage m_composite; + QRect m_exposed; + bool m_layered = false; QPainter m_buffer_painter; /// True while paintEvent() paints for an inverted display. bool m_inverting = false; diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 67ba6ab23..62390bab9 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -41,7 +41,12 @@ #include "ui/aboutqetdialog.h" #include "ui/configpage/generalconfigurationpage.h" #include "ui/configpage/shortcutsconfigpage.h" +#include "ui/configpage/gesturesconfigpage.h" #include "ui/configpage/shortcutbarconfigpage.h" +#include "ui/configpage/toolbarsconfigpage.h" +#include "ui/configpage/toolbarcommandsconfigpage.h" +#include "ui/customizedialog.h" +#include #include "machine_info.h" #include "TerminalStrip/ui/terminalstripeditorwindow.h" #include "qetversion.h" @@ -2225,6 +2230,9 @@ void QETApp::configureQET() cd.addPage(new PrintConfigPage()); cd.addPage(new ShortcutsConfigPage()); cd.addPage(new ShortcutBarConfigPage()); + cd.addPage(new GesturesConfigPage()); + cd.addPage(new ToolbarsConfigPage()); + cd.addPage(new ToolbarCommandsConfigPage()); #ifdef QET_SPACEMOUSE_SUPPORT cd.addPage(new SpaceMouseConfigPage()); #endif @@ -2252,6 +2260,36 @@ void QETApp::configureQET() #endif } +/** + @brief QETApp::customizeQET + Open the Customise window: the toolbar, shortcut bar, keyboard and + gesture pages of the configuration dialog, as tabs of one window. + @param tab : the tab to show first +*/ +void QETApp::customizeQET(int tab) +{ + QWidget *parent_widget = qApp->activeWindow(); + + CustomizeDialog dialog; + //Same reason as the configuration dialog (#527) + dialog.setWindowModality(Qt::ApplicationModal); + dialog.addPage(new ToolbarsConfigPage()); + dialog.addPage(new ToolbarCommandsConfigPage()); + dialog.addPage(new ShortcutBarConfigPage()); + dialog.addPage(new ShortcutsConfigPage()); + dialog.addPage(new GesturesConfigPage()); + if (auto *tabs = dialog.findChild(QStringLiteral("customizeTabs"))) { + tabs->setCurrentIndex(tab); + } + QET::trackDialogGeometry(&dialog); + + if (parent_widget) { + dialog.setParent(parent_widget, dialog.windowFlags()); + } + dialog.exec(); + dialog.setParent(nullptr, dialog.windowFlags()); +} + /** @brief QETApp::exportConfiguration Save the settings of QElectroTech to a file the user chooses, to keep diff --git a/sources/qetapp.h b/sources/qetapp.h index cd58aa671..234db5fa5 100644 --- a/sources/qetapp.h +++ b/sources/qetapp.h @@ -294,6 +294,7 @@ class QETApp : public QObject void openTitleBlockTemplate(const QString &); void openTitleBlockTemplateFiles(const QStringList &); void configureQET(); + void customizeQET(int tab = 0); /// Save the settings to a file (discussion #610) void exportConfiguration(); /// Replace the settings with a saved file, then close QElectroTech diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 7a40892c9..2dee574ba 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -27,9 +27,11 @@ #endif #include #include +#include #include "ElementsCollection/elementscollectionwidget.h" #include "ElementsCollection/elementpickerpopup.h" #include "shortcutbarsettings.h" +#include "diagramtoolbarsettings.h" #include "qetgraphicsitem/conductor.h" #include "itemgroups.h" #include "commandsearchpopup.h" @@ -86,6 +88,7 @@ #include "wiringlistexport.h" #include "ui/wiringlistdialog.h" #include "ui/terminalnumberingdialog.h" +#include "toolbarsettings.h" #include #include #include @@ -187,6 +190,7 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) : readSettings(); // restoreGeometry before show() show(); readSettingsState(); // restoreState() must be called after show() in Qt6 + ToolbarSettings::applyTo(this); #ifdef QET_HAS_SCRIPTING setUpLiveIndicator(); setUpMacroRecorder(); @@ -1324,50 +1328,27 @@ void QETDiagramEditor::setUpToolBar() diagram_tool_bar = new QToolBar(tr("Schéma"), this); diagram_tool_bar -> setObjectName("diagram"); - main_tool_bar -> addActions(m_file_actions_group.actions()); - main_tool_bar -> addAction(m_print); - main_tool_bar -> addAction(m_export_to_pdf); - main_tool_bar -> addSeparator(); - main_tool_bar -> addAction(undo); - main_tool_bar -> addAction(redo); - main_tool_bar -> addSeparator(); - main_tool_bar -> addAction(m_cut); - main_tool_bar -> addAction(m_copy); - main_tool_bar -> addAction(m_paste); - main_tool_bar -> addAction(m_duplicate); - main_tool_bar -> addSeparator(); - main_tool_bar -> addAction(m_delete_selection); - main_tool_bar -> addAction(m_rotate_selection); - - // Modes selection / visualisation et zoom - view_tool_bar -> addAction(m_mode_selection); - view_tool_bar -> addAction(m_mode_visualise); - view_tool_bar -> addSeparator(); - view_tool_bar -> addWidget(new DiagramEditorHandlerSizeWidget(this)); - view_tool_bar -> addSeparator(); - view_tool_bar -> addAction(m_draw_grid); - view_tool_bar -> addWidget(m_text_grid_button); - view_tool_bar -> addAction(m_draw_guides); - view_tool_bar -> addWidget(m_background_color_button); - view_tool_bar -> addSeparator(); - view_tool_bar -> addActions(m_zoom_action_toolBar); - - diagram_tool_bar -> addAction (m_edit_diagram_properties); - diagram_tool_bar -> addAction (m_conductor_reset); - diagram_tool_bar -> addAction (m_auto_conductor); - diagram_tool_bar -> addAction (m_auto_break_conductor); - //Sits with the conductor actions it works alongside: it colours - //the selected conductors and sets the colour of the next one drawn. - m_conductor_color_button = new ConductorColorToolButton(this, this); - diagram_tool_bar -> addWidget (m_conductor_color_button); - m_add_item_tool_bar = new QToolBar(tr("Ajouter"), this); m_add_item_tool_bar->setObjectName("adding"); - m_add_item_tool_bar->addActions(m_add_item_actions_group.actions()); m_depth_tool_bar = new QToolBar(tr("Profondeur", "toolbar title")); m_depth_tool_bar->setObjectName("diagram_depth_toolbar"); - m_depth_tool_bar->addActions(m_depth_action_group->actions()); + + //The toolbar buttons that are widgets, not commands. Each is held by + //an action of this window, so it survives being taken off a toolbar. + auto add_widget = [this](const QString &id, QWidget *widget) { + auto *action = new QWidgetAction(this); + action->setDefaultWidget(widget); + action->setText(DiagramToolbarSettings::widgetTitle(id)); + m_toolbar_widgets.insert(id, action); + }; + add_widget(QStringLiteral("widget:handler_size"), new DiagramEditorHandlerSizeWidget(this)); + add_widget(QStringLiteral("widget:text_grid"), m_text_grid_button); + add_widget(QStringLiteral("widget:background_color"), m_background_color_button); + //Sits with the conductor actions it works alongside: it colours + //the selected conductors and sets the colour of the next one drawn. + m_conductor_color_button = new ConductorColorToolButton(this, this); + add_widget(QStringLiteral("widget:conductor_color"), m_conductor_color_button); addToolBar(Qt::TopToolBarArea, main_tool_bar); addToolBar(Qt::TopToolBarArea, view_tool_bar); @@ -1375,6 +1356,9 @@ void QETDiagramEditor::setUpToolBar() addToolBar(Qt::TopToolBarArea, m_add_item_tool_bar); addToolBar(Qt::TopToolBarArea, m_depth_tool_bar); + DiagramToolbarSettings::markWindow(this); + rebuildToolBars(); + m_scripts_tool_bar = new QToolBar(tr("Scripts", "toolbar title"), this); m_scripts_tool_bar->setObjectName("scripts"); addToolBar(Qt::TopToolBarArea, m_scripts_tool_bar); @@ -1384,6 +1368,59 @@ void QETDiagramEditor::setUpToolBar() #endif } +/** + @brief QETDiagramEditor::rebuildToolBars + Fill every toolbar from DiagramToolbarSettings, adding the user's own + toolbars that this window does not have yet and removing those that + were deleted. Called when the window is built, when the settings + change, and when the stored scripts change (a script can be on any + toolbar). +*/ +void QETDiagramEditor::rebuildToolBars() +{ + const QList custom = DiagramToolbarSettings::customToolbars(); + QStringList custom_names; + for (const DiagramToolbarSettings::Toolbar &toolbar : custom) { + custom_names << toolbar.name; + } + for (QToolBar *toolbar : m_custom_tool_bars) { + if (!custom_names.contains(toolbar->objectName())) { + removeToolBar(toolbar); + toolbar->deleteLater(); + } + } + m_custom_tool_bars.erase(std::remove_if(m_custom_tool_bars.begin(), m_custom_tool_bars.end(), + [&custom_names](QToolBar *toolbar) { return !custom_names.contains(toolbar->objectName()); }), + m_custom_tool_bars.end()); + + for (const DiagramToolbarSettings::Toolbar &toolbar : custom) + { + auto existing = std::find_if(m_custom_tool_bars.cbegin(), m_custom_tool_bars.cend(), + [&toolbar](QToolBar *t) { return t->objectName() == toolbar.name; }); + if (existing != m_custom_tool_bars.cend()) { + (*existing)->setWindowTitle(toolbar.title); + continue; + } + auto *new_tool_bar = new QToolBar(toolbar.title, this); + new_tool_bar->setObjectName(toolbar.name); + new_tool_bar->setMovable(!ToolbarSettings::locked()); + addToolBar(Qt::TopToolBarArea, new_tool_bar); + m_custom_tool_bars << new_tool_bar; + } + + auto resolve = [this](const QString &id) -> QAction * { + if (DiagramToolbarSettings::isWidget(id)) { + return m_toolbar_widgets.value(id); + } + return ShortcutManager::instance().action(id, this); + }; + const QList built_in {main_tool_bar, view_tool_bar, diagram_tool_bar, + m_add_item_tool_bar, m_depth_tool_bar}; + for (QToolBar *toolbar : built_in + m_custom_tool_bars) { + DiagramToolbarSettings::fill(toolbar, DiagramToolbarSettings::ids(toolbar->objectName()), resolve); + } +} + /** @brief QETDiagramEditor::setUpMenu */ @@ -1438,12 +1475,9 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_paste_origin); menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); + menu_edition -> addSeparator(); menu_edition -> addAction(m_insert_last_element); menu_edition -> addAction(m_show_element_picker); - menu_edition -> addAction(m_show_shortcut_bar); - menu_edition -> addAction(m_repeat_last_command); - menu_edition -> addAction(m_command_search); - menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a //toolbar button has no key, so text fields, images and every drawing @@ -1453,9 +1487,24 @@ void QETDiagramEditor::setUpMenu() m_add_item_menu -> setIcon(QET::Icons::Add); m_add_item_menu -> addActions(m_add_item_actions_group.actions()); menu_edition -> addSeparator(); - menu_edition -> addActions(m_select_actions_group.actions()); + //The menu had grown to over forty entries and no longer fitted on a + //laptop screen (issue #1336). Whole families now sit one level down, + //as Ajouter and Aligner already did; every action and its shortcut + //is unchanged. + QMenu *select_menu = menu_edition -> addMenu(QET::Icons::EditSelectAll, tr("Sélection")); + select_menu -> addActions(m_select_actions_group.actions()); menu_edition -> addSeparator(); - menu_edition -> addActions(m_selection_actions_group.actions()); + menu_edition -> addAction(m_delete_selection); + menu_edition -> addAction(m_rotate_selection); + menu_edition -> addAction(m_rotate_group_selection); + menu_edition -> addAction(m_rotate_texts); + menu_edition -> addAction(m_find_element); + menu_edition -> addAction(m_edit_selection); + QMenu *group_menu = menu_edition -> addMenu(QET::Icons::textGroup, tr("Grouper")); + group_menu -> addAction(m_group_selection); + group_menu -> addAction(m_ungroup_selection); + group_menu -> addSeparator(); + group_menu -> addAction(m_group_selected_texts); m_align_menu = menu_edition -> addMenu(tr("Aligner")); //Snap to grid, then the horizontal three, then the vertical three, //in the order setUpActions() adds them @@ -1468,18 +1517,20 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_conductor_reset); menu_edition -> addSeparator(); menu_edition -> addAction(m_edit_diagram_properties); - menu_edition -> addActions(m_row_column_actions_group.actions()); - //Not added to a menu here: it exists so the folio's context menu can - //hold the row and column actions one level down (see + //Shared with the folio's context menu (see //DiagramView::contextMenuActions()). - m_row_column_menu = new QMenu(tr("Lignes et colonnes"), this); + m_row_column_menu = menu_edition -> addMenu(tr("Lignes et colonnes")); m_row_column_menu -> setIcon(QET::Icons::EditTableInsertColumnRight); m_row_column_menu -> addActions(m_row_column_actions_group.actions()); - menu_edition -> addSeparator(); - menu_edition -> addActions(m_depth_action_group->actions()); + QMenu *depth_menu = menu_edition -> addMenu(QET::Icons::BringForward, tr("Profondeur")); + depth_menu -> addActions(m_depth_action_group->actions()); menu_edition -> addSeparator(); menu_edition -> addAction(m_find); menu_edition -> addAction(m_jump_to_element); + menu_edition -> addAction(m_command_search); + menu_edition -> addSeparator(); + menu_edition -> addAction(m_show_shortcut_bar); + menu_edition -> addAction(m_repeat_last_command); // menu Projet menu_project -> addAction(m_project_edit_properties); @@ -4047,6 +4098,7 @@ void QETDiagramEditor::rebuildScriptActions() if (has_scripts != m_had_scripts) m_scripts_tool_bar->setVisible(has_scripts); m_had_scripts = has_scripts; + rebuildToolBars(); updateScriptActions(); } diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 431e27d9b..ee6292a52 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -140,6 +140,7 @@ class QETDiagramEditor : public QETMainWindow void rememberPlacedElement(const ElementsLocation &location); void showElementPicker(); void showShortcutBar(); + void rebuildToolBars(); bool repeatLastCommand(); void generateTerminalBlock(); void setWindowedMode(); @@ -330,6 +331,10 @@ class QETDiagramEditor : public QETMainWindow *m_add_item_tool_bar = nullptr, *m_depth_tool_bar = nullptr, *m_scripts_tool_bar = nullptr; ///< One button per stored script + /// Toolbars the user added (DiagramToolbarSettings) + QList m_custom_tool_bars; + /// The toolbar buttons that are widgets, by DiagramToolbarSettings id + QHash m_toolbar_widgets; #ifdef QET_HAS_SCRIPTING QMenu *m_scripts_menu = nullptr; QList m_script_actions; ///< One per stored script, rebuilt when the folder changes diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 00f8b030b..c8ef93ce7 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "diagramimageitem.h" +#include "../darkimagerendering.h" #include "../shownkinds.h" #include "../PropertiesEditor/propertieseditordialog.h" @@ -97,7 +98,7 @@ DiagramImageItem::~DiagramImageItem() @param widget the QWidget where we draw the pixmap */ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { - painter -> drawPixmap(pixmap_.rect(),pixmap_); + DarkImageRendering::paintPixmap(painter, pixmap_, m_adapt_to_dark_theme); Q_UNUSED(option); Q_UNUSED(widget); @@ -155,6 +156,14 @@ void DiagramImageItem::setLabel(const QString &label) emit labelChanged(); } +void DiagramImageItem::setAdaptToDarkTheme(bool adapt) +{ + if (m_adapt_to_dark_theme == adapt) return; + m_adapt_to_dark_theme = adapt; + update(); + emit adaptToDarkThemeChanged(); +} + /** @brief DiagramImageItem::editProperty Open the appropriate dialog to edit this image @@ -1479,6 +1488,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) if (e.tagName() != "image") { return (false); } + setAdaptToDarkTheme(e.attribute("adapt_to_dark_theme", "false") == "true"); const QUuid uuid(e.attribute(QStringLiteral("uuid"))); if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid); @@ -1623,6 +1633,7 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const { QDomElement result = document.createElement("image"); result.setAttribute("uuid", m_uuid.toString()); + if (m_adapt_to_dark_theme) result.setAttribute("adapt_to_dark_theme", "true"); //write some attribute result.setAttribute("x", QString::number(pos().x())); result.setAttribute("y", QString::number(pos().y())); diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index b3263f9b3..1298563a4 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -53,6 +53,7 @@ class DiagramImageItem : public QetGraphicsItem { // colours -- as one value, so that every edit of it (crop, colour // key, mirror, replace) is undone together with the displayed pixmap. Q_PROPERTY(QVariant imageSource READ imageSourceVariant WRITE setImageSourceVariant) + Q_PROPERTY(bool adaptToDarkTheme READ adaptToDarkTheme WRITE setAdaptToDarkTheme NOTIFY adaptToDarkThemeChanged) // A second, deliberately non-compensating property on the SAME // underlying value -- setPivot() (above) intentionally adjusts // pos() to keep the image visually in place, which is exactly @@ -149,12 +150,15 @@ class DiagramImageItem : public QetGraphicsItem { void setPivotRaw(const QPointF &pivot); QString label() const { return m_label; } void setLabel(const QString &label); + bool adaptToDarkTheme() const { return m_adapt_to_dark_theme; } + void setAdaptToDarkTheme(bool adapt); signals: void pixmapChanged(); void transformChanged(); void uuidChanged(); void labelChanged(); + void adaptToDarkThemeChanged(); protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; @@ -258,6 +262,7 @@ class DiagramImageItem : public QetGraphicsItem { // Empty by default, and then neither saved nor painted, so a picture // without one costs exactly what it did before. QString m_label; + bool m_adapt_to_dark_theme = false; QFont m_label_font; QSizeF m_label_size; // in scene units, measured once in setLabel() QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale() diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index cc44c6b0c..08fdfafb8 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -22,6 +22,8 @@ #include "../qetapp.h" #include "../richtext/richtexteditor_p.h" #include "../textanchor.h" +#include "../utils/qetutils.h" +#include "../QetGraphicsItemModeler/textresizehandles.h" /** @brief DiagramTextItem::DiagramTextItem @@ -335,6 +337,7 @@ void DiagramTextItem::focusInEvent(QFocusEvent *event) m_previous_html_text = toHtml(); m_previous_text = toPlainText(); + refreshTextResizeHandles(); } /** @@ -357,6 +360,89 @@ void DiagramTextItem::focusOutEvent(QFocusEvent *event) setFlag(QGraphicsItem::ItemIsMovable, true); setFlag(QGraphicsTextItem::ItemIsFocusable, false); + refreshTextResizeHandles(); +} + +/** + @brief DiagramTextItem::itemChange + Show or hide the resize handles with the selection and the scene. + @param change + @param value + @return +*/ +QVariant DiagramTextItem::itemChange(GraphicsItemChange change, const QVariant &value) +{ + //Also when the text comes back to a scene: Qt keeps it selected + //across removeItem()/addItem() (undoing a delete) and sends no + //selection change, and its handles were removed with the scene. + if (change == QGraphicsItem::ItemSelectedHasChanged + || change == QGraphicsItem::ItemSceneHasChanged) + refreshTextResizeHandles(); + + return QGraphicsTextItem::itemChange(change, value); +} + +/** + @brief DiagramTextItem::textResizeHandlesWanted + @return true when the corner handles to change the width of this text + should be shown. A text without a "textWidth" property never has them. +*/ +bool DiagramTextItem::textResizeHandlesWanted() const +{ + return false; +} + +/** + @brief DiagramTextItem::isEditing + @return true while the text itself is being typed in +*/ +bool DiagramTextItem::isEditing() const +{ + return textInteractionFlags() & Qt::TextEditable; +} + +/** + @brief DiagramTextItem::refreshTextResizeHandles + Create or remove the corner handles to change the width of this text, + according to textResizeHandlesWanted(). + Called from itemChange() and when the edition starts or ends, not from + paint(): moving items from paint() left fragments behind + (qelectrotech#1002). +*/ +void DiagramTextItem::refreshTextResizeHandles() +{ + const bool wanted = scene() && textResizeHandlesWanted(); + if (wanted && !m_resize_handles) + { + m_resize_handles = new TextResizeHandles(this, QETUtils::graphicsHandlerSize(this)); + connect(m_resize_handles, &TextResizeHandles::resizeFinished, + this, &DiagramTextItem::pushResizeCommand); + } + else if (!wanted && m_resize_handles) { + removeTextResizeHandles(); + } +} + +/** + @brief DiagramTextItem::removeTextResizeHandles +*/ +void DiagramTextItem::removeTextResizeHandles() +{ + delete m_resize_handles; + m_resize_handles = nullptr; +} + +/** + @brief DiagramTextItem::pushResizeCommand + Make the width change done with the resize handles undoable. The change + is already applied, live during the drag. +*/ +void DiagramTextItem::pushResizeCommand(qreal old_width, qreal new_width, + QPointF old_pos, QPointF new_pos) +{ + if (Diagram *diagram_ = diagram()) + diagram_->undoStack().push(new TextResizeCommand(this, old_width, new_width, + old_pos, new_pos)); } /** diff --git a/sources/qetgraphicsitem/diagramtextitem.h b/sources/qetgraphicsitem/diagramtextitem.h index 323c0db56..3a5d5ca4d 100644 --- a/sources/qetgraphicsitem/diagramtextitem.h +++ b/sources/qetgraphicsitem/diagramtextitem.h @@ -24,6 +24,7 @@ class Diagram; class QDomElement; class QDomDocument; +class TextResizeHandles; /** This class represents a selectable, movable and editable text field on a @@ -91,11 +92,15 @@ class DiagramTextItem : public QGraphicsTextItem bool isHtml() const; protected: + void refreshTextResizeHandles(); + TextResizeHandles *textResizeHandles() const {return m_resize_handles;} + void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; void focusInEvent(QFocusEvent *) override; void focusOutEvent(QFocusEvent *) override; + QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override; void mousePressEvent (QGraphicsSceneMouseEvent *event) override; @@ -110,7 +115,10 @@ class DiagramTextItem : public QGraphicsTextItem void prepareAlignment(); void finishAlignment(); + virtual bool textResizeHandlesWanted() const; virtual bool hasUserTextWidth() const {return false;} + bool isEditing() const; + void removeTextResizeHandles(); protected: @@ -128,7 +136,11 @@ class DiagramTextItem : public QGraphicsTextItem QPointF m_mouse_to_origin_movement; private: + void pushResizeCommand(qreal old_width, qreal new_width, + QPointF old_pos, QPointF new_pos); + QRectF m_alignment_rect; + TextResizeHandles *m_resize_handles = nullptr; Qt::Alignment m_alignment = (Qt::AlignTop | Qt::AlignLeft); }; #endif diff --git a/sources/qetgraphicsitem/independenttextitem.cpp b/sources/qetgraphicsitem/independenttextitem.cpp index 8e5d03ef4..833b8dd48 100644 --- a/sources/qetgraphicsitem/independenttextitem.cpp +++ b/sources/qetgraphicsitem/independenttextitem.cpp @@ -148,6 +148,16 @@ void IndependentTextItem::setTextWidth(qreal width) emit textWidthChanged(width); } +/** + @brief IndependentTextItem::textResizeHandlesWanted + @return true when this text is selected and not being typed in: the + corner handles then change its width. +*/ +bool IndependentTextItem::textResizeHandlesWanted() const +{ + return isSelected() && !isEditing(); +} + void IndependentTextItem::focusOutEvent(QFocusEvent *event) { DiagramTextItem::focusOutEvent(event); diff --git a/sources/qetgraphicsitem/independenttextitem.h b/sources/qetgraphicsitem/independenttextitem.h index 571c9eeea..bc2083e6a 100644 --- a/sources/qetgraphicsitem/independenttextitem.h +++ b/sources/qetgraphicsitem/independenttextitem.h @@ -63,6 +63,7 @@ class IndependentTextItem : public DiagramTextItem protected: void focusOutEvent(QFocusEvent *event) override; + bool textResizeHandlesWanted() const override; bool hasUserTextWidth() const override {return m_text_width > 0;} private: diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index 3ee58586a..0ff5def85 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -93,6 +93,11 @@ void QETMainWindow::initCommonActions() } }); + customize_action_ = new QAction(QET::Icons::ConfigureToolbars, tr("&Personnaliser..."), this); + ShortcutManager::instance().registerAction(customize_action_, "mainwindow.customize", tr("Général"), QKeySequence()); + customize_action_ -> setStatusTip(tr("Barres d'outils, barre de raccourcis, clavier et gestes de la souris, dans une seule fenêtre", "status bar tip")); + connect(customize_action_, &QAction::triggered, qet_app, [qet_app]() { qet_app->customizeQET(); }); + export_config_action_ = new QAction(QET::Icons::DocumentExport, tr("Enregistrer la configuration sous..."), this); export_config_action_ -> setStatusTip(tr("Enregistre les réglages de QElectroTech dans un fichier", "status bar tip")); connect(export_config_action_, &QAction::triggered, qet_app, &QETApp::exportConfiguration); @@ -190,6 +195,7 @@ void QETMainWindow::initCommonMenus() settings_menu_ = new QMenu(tr("&Configuration", "window menu"), this); settings_menu_ -> addAction(fullscreen_action_); settings_menu_ -> addAction(configure_action_); + settings_menu_ -> addAction(customize_action_); settings_menu_ -> addSeparator(); settings_menu_ -> addAction(export_config_action_); settings_menu_ -> addAction(import_config_action_); @@ -274,6 +280,22 @@ void QETMainWindow::updateFullScreenAction() ShortcutManager::instance().registerAction(fullscreen_action_, "mainwindow.fullscreen", tr("Général"), Qt::CTRL | Qt::SHIFT | Qt::Key_F); } +/** + @brief QETMainWindow::createPopupMenu + The menu shown on a right-click on a toolbar or a dock title, and the + Configuration > Afficher submenu: Qt's list of toolbars and docks, + then Personnaliser..., as in most applications with toolbars. +*/ +QMenu *QETMainWindow::createPopupMenu() +{ + QMenu *menu = QMainWindow::createPopupMenu(); + if (menu) { + menu -> addSeparator(); + menu -> addAction(customize_action_); + } + return menu; +} + /** Check whether a sub menu dedicated to docks and toolbars can be inserted on top of the settings menu. diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index fe76fb7e0..38aac40e3 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -43,6 +43,7 @@ class QETMainWindow : public QMainWindow { protected: void activateMenuBar(); bool event(QEvent *) override; + QMenu *createPopupMenu() override; void dragEnterEvent(QDragEnterEvent *e) override; void dropEvent(QDropEvent *e) override; virtual void firstActivation(QEvent *); @@ -56,6 +57,7 @@ class QETMainWindow : public QMainWindow { // attributes protected: QAction *configure_action_; ///< Launch the QElectroTech configuration dialog + QAction *customize_action_; ///< Launch the Customise window (toolbars, keys, gestures) QAction *export_config_action_; ///< Save the settings to a file QAction *import_config_action_; ///< Replace the settings with a saved file QAction *fullscreen_action_; ///< Toggle full screen diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index e8910a297..c8e661533 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -83,8 +83,14 @@ bool QET::Palette::isDark(const QPalette &palette) void QET::Palette::invertLightness(QImage &image, const QColor &sheet, const QColor &ink) { - if (image.format() != QImage::Format_RGB32) - image.convertTo(QImage::Format_RGB32); + if (image.format() != QImage::Format_RGB32) image.convertTo(QImage::Format_RGB32); + invertLightnessLayer(image, sheet, ink); +} + +void QET::Palette::invertLightnessLayer(QImage &image, const QColor &sheet, const QColor &ink) +{ + if (image.format() != QImage::Format_RGB32 && image.format() != QImage::Format_ARGB32) + image.convertTo(QImage::Format_ARGB32); // One table per channel maps the inverted value (0 = was white, // 255 = was black) onto the sheet..ink span. @@ -106,6 +112,7 @@ void QET::Palette::invertLightness(QImage &image, const QColor &sheet, quint32 *line = reinterpret_cast(image.scanLine(y)); for (int x = 0, width = image.width(); x < width; ++x) { const quint32 pixel = line[x]; + if ((pixel & 0xff000000u) == 0) continue; const int red = (pixel >> 16) & 0xff; const int green = (pixel >> 8) & 0xff; const int blue = pixel & 0xff; @@ -114,7 +121,7 @@ void QET::Palette::invertLightness(QImage &image, const QColor &sheet, if (blue > highest) highest = blue; if (blue < lowest) lowest = blue; const int offset = 255 - highest - lowest; - line[x] = 0xff000000u + line[x] = (pixel & 0xff000000u) | (quint32(red_of[red + offset]) << 16) | (quint32(green_of[green + offset]) << 8) | quint32(blue_of[blue + offset]); diff --git a/sources/qetpalette.h b/sources/qetpalette.h index e1a5cabcd..a60190c0b 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -66,6 +66,9 @@ namespace QET { */ void invertLightness(QImage &image, const QColor &sheet = Qt::black, const QColor &ink = Qt::white); + /// The same colour mapping for a transparent composition layer. + /// Retains alpha; premultiplied input is converted to straight ARGB32. + void invertLightnessLayer(QImage &image, const QColor &sheet, const QColor &ink); /** The color of the grid dots on a sheet of color \a sheet: black, diff --git a/sources/titleblock/qettemplateeditor.cpp b/sources/titleblock/qettemplateeditor.cpp index e3a44ecbb..04e57827b 100644 --- a/sources/titleblock/qettemplateeditor.cpp +++ b/sources/titleblock/qettemplateeditor.cpp @@ -27,6 +27,7 @@ #include "templatelocationsaver.h" #include "templatelogomanager.h" #include "templateview.h" +#include "../toolbarsettings.h" /** @param parent parent QWidget of this window @@ -46,6 +47,7 @@ QETTitleBlockTemplateEditor::QETTitleBlockTemplateEditor(QWidget *parent) : initActions(); initMenus(); initToolbars(); + ToolbarSettings::applyTo(this); readSettings(); } diff --git a/sources/titleblocktemplate.cpp b/sources/titleblocktemplate.cpp index 69214702e..f3b488592 100644 --- a/sources/titleblocktemplate.cpp +++ b/sources/titleblocktemplate.cpp @@ -1646,12 +1646,16 @@ void TitleBlockTemplate::render(QPainter &painter, Width of the titleblock to render @param file_path : @param color : + @param dxf_transform : + Applied to every DXF point after the title block is laid out, as the + QPainter is rotated for a title block at the right of the folio */ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, const DiagramContext &diagram_context, int titleblock_width, QString &file_path, - int color) const + int color, + const QTransform &dxf_transform) const { QList widths = columnsWidth(titleblock_width); @@ -1663,12 +1667,13 @@ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, *Createdxf::yScale; double recWidth = title_block_rect.width() * Createdxf::xScale; double recHeight = title_block_rect.height() * Createdxf::yScale; - Createdxf::drawRectangle(file_path, - xCoord, - yCoord, - recWidth, - recHeight, - color); + Createdxf::drawPolyline(file_path, + dxf_transform.map(QPolygonF(QRectF(xCoord, + yCoord, + recWidth, + recHeight))), + color, + true); // run through each individual cell for (int j = 0 ; j < rows_heights_.count() ; ++ j) { @@ -1702,7 +1707,10 @@ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, y = yCoord + recHeight - h - y*Createdxf::yScale; w *= Createdxf::xScale; - Createdxf::drawRectangle(file_path, x, y, w, h, color); + Createdxf::drawPolyline(file_path, + dxf_transform.map(QPolygonF(QRectF(x, y, w, h))), + color, + true); if (cells_[i][j] -> type() == TitleBlockCell::TextCell) { QString final_text = @@ -1715,7 +1723,8 @@ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, y, w, h, - color); + color, + dxf_transform); } } } @@ -1967,6 +1976,7 @@ void TitleBlockTemplate::renderTextCell(QPainter &painter, @param w @param h @param color + @param dxf_transform : see renderDxf() */ void TitleBlockTemplate::renderTextCellDxf( QString &file_path, @@ -1976,7 +1986,8 @@ void TitleBlockTemplate::renderTextCellDxf( qreal y, qreal w, qreal h, - int color) const + int color, + const QTransform &dxf_transform) const { if (text.isEmpty()) return; QFont text_font = TitleBlockTemplate::fontForCell(cell); @@ -2038,6 +2049,8 @@ void TitleBlockTemplate::renderTextCellDxf( const QStringList lines = text.split(QRegularExpression(QStringLiteral("\r\n|\r|\n"))); const qreal line_spacing = textHeight * Createdxf::yScale * 1.6; const int last = lines.size() - 1; + const qreal rotation = qRadiansToDegrees( + qAtan2(dxf_transform.m12(), dxf_transform.m11())); for (int i = 0 ; i < lines.size() ; ++i) { const QString &line = lines.at(i); @@ -2064,17 +2077,21 @@ void TitleBlockTemplate::renderTextCellDxf( } // x offset value below currently set heuristically based on appearance... + const QPointF insert = dxf_transform.map( + QPointF(x - 2*Createdxf::xScale, y1 + offset)); + const QPointF align = dxf_transform.map(QPointF(x2, y1 + offset)); Createdxf::drawTextAligned( file_path, line, - x - 2*Createdxf::xScale, - y1 + offset, + insert.x(), + insert.y(), textHeight*Createdxf::yScale, - 0, + rotation, 0, hAlign, vAlign, - x2, + align.x(), + align.y(), ratio, color); } diff --git a/sources/titleblocktemplate.h b/sources/titleblocktemplate.h index 8bc1821d6..55f918352 100644 --- a/sources/titleblocktemplate.h +++ b/sources/titleblocktemplate.h @@ -176,7 +176,8 @@ class TitleBlockTemplate : public QObject { const DiagramContext &, int, QString &, - int) const; + int, + const QTransform & = QTransform()) const; void renderCell(QPainter &, const TitleBlockCell &, const DiagramContext &, @@ -245,7 +246,8 @@ class TitleBlockTemplate : public QObject { qreal, qreal, qreal, - int) const; + int, + const QTransform &) const; // attributes private: diff --git a/sources/titleblocktemplaterenderer.cpp b/sources/titleblocktemplaterenderer.cpp index d41508829..93233db3f 100644 --- a/sources/titleblocktemplaterenderer.cpp +++ b/sources/titleblocktemplaterenderer.cpp @@ -91,15 +91,18 @@ void TitleBlockTemplateRenderer::render(QPainter *provided_painter, @param titleblock_width @param file_path @param color + @param dxf_transform : see TitleBlockTemplate::renderDxf() */ void TitleBlockTemplateRenderer::renderDxf(QRectF &title_block_rect, int titleblock_width, QString &file_path, - int color) { + int color, + const QTransform &dxf_transform) { if (!m_titleblock_template) return; m_titleblock_template -> renderDxf(title_block_rect, m_context, titleblock_width, - file_path, color); + file_path, color, + dxf_transform); } diff --git a/sources/titleblocktemplaterenderer.h b/sources/titleblocktemplaterenderer.h index b332d4cf4..4602bc640 100644 --- a/sources/titleblocktemplaterenderer.h +++ b/sources/titleblocktemplaterenderer.h @@ -18,6 +18,7 @@ #ifndef TITLEBLOCK_TEMPLATE_RENDERER_H #define TITLEBLOCK_TEMPLATE_RENDERER_H #include +#include #include "diagramcontext.h" class TitleBlockTemplate; @@ -40,7 +41,8 @@ class TitleBlockTemplateRenderer : public QObject int height() const; void render(QPainter *, int); - void renderDxf(QRectF &, int, QString &, int); + void renderDxf(QRectF &, int, QString &, int, + const QTransform & = QTransform()); private: const TitleBlockTemplate *m_titleblock_template; diff --git a/sources/toolbarsettings.cpp b/sources/toolbarsettings.cpp new file mode 100644 index 000000000..bd0379e35 --- /dev/null +++ b/sources/toolbarsettings.cpp @@ -0,0 +1,109 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "toolbarsettings.h" + +#include +#include +#include +#include + +namespace { + const QString ICON_SIZE = QStringLiteral("toolbars/icon_size"); + const QString BUTTON_STYLE = QStringLiteral("toolbars/button_style"); + const QString LOCKED = QStringLiteral("toolbars/locked"); + //Set on a window by applyTo() + const char *const APPLIED = "qetToolbarSettings"; +} + +int ToolbarSettings::iconSize() +{ + return QSettings().value(ICON_SIZE, 0).toInt(); +} + +Qt::ToolButtonStyle ToolbarSettings::buttonStyle() +{ + const int style = QSettings().value(BUTTON_STYLE, int(Qt::ToolButtonIconOnly)).toInt(); + switch (style) { + case Qt::ToolButtonTextBesideIcon: + case Qt::ToolButtonTextUnderIcon: + return Qt::ToolButtonStyle(style); + default: + return Qt::ToolButtonIconOnly; + } +} + +bool ToolbarSettings::locked() +{ + return QSettings().value(LOCKED, false).toBool(); +} + +/** + @brief ToolbarSettings::save + Store the three values; a default value removes its key. +*/ +void ToolbarSettings::save(int icon_size, Qt::ToolButtonStyle style, bool locked) +{ + QSettings settings; + if (icon_size > 0) settings.setValue(ICON_SIZE, icon_size); + else settings.remove(ICON_SIZE); + if (style != Qt::ToolButtonIconOnly) settings.setValue(BUTTON_STYLE, int(style)); + else settings.remove(BUTTON_STYLE); + if (locked) settings.setValue(LOCKED, true); + else settings.remove(LOCKED); +} + +/** + @brief ToolbarSettings::applyTo + Apply the settings to \a window and its toolbars. The window passes + its icon size and button style on to every toolbar that has none of + its own; an invalid size gives the style's default back. The window + is marked, so applyToAll() updates it again later. +*/ +void ToolbarSettings::applyTo(QMainWindow *window) +{ + if (!window) { + return; + } + window->setProperty(APPLIED, true); + const int size = iconSize(); + window->setIconSize(size > 0 ? QSize(size, size) : QSize()); + window->setToolButtonStyle(buttonStyle()); + const bool movable = !locked(); + for (QToolBar *toolbar : window->findChildren()) { + //Not a toolbar inside a panel: only the window's own + if (window->toolBarArea(toolbar) != Qt::NoToolBarArea) { + toolbar->setMovable(movable); + } + } +} + +/** + @brief ToolbarSettings::applyToAll + Apply the settings again to every open window that took them with + applyTo(), after they change. Other windows are left alone: they + would lose the settings the next time they open. +*/ +void ToolbarSettings::applyToAll() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + auto *window = qobject_cast(widget); + if (window && window->property(APPLIED).toBool()) { + applyTo(window); + } + } +} diff --git a/sources/toolbarsettings.h b/sources/toolbarsettings.h new file mode 100644 index 000000000..7e7dd1e1a --- /dev/null +++ b/sources/toolbarsettings.h @@ -0,0 +1,45 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef TOOLBARSETTINGS_H +#define TOOLBARSETTINGS_H + +#include +#include + +class QMainWindow; + +/** + @brief How every window's toolbars look: icon size, text with the + icons, and whether they can be moved. Stored in QSettings; a value + the user never changed keeps Qt's default, so nothing changes for + anyone who does not set one. +*/ +class ToolbarSettings +{ + public: + /// Icon size in pixels, or 0 for the style's default size + static int iconSize(); + static Qt::ToolButtonStyle buttonStyle(); + static bool locked(); + static void save(int icon_size, Qt::ToolButtonStyle style, bool locked); + + static void applyTo(QMainWindow *window); + static void applyToAll(); +}; + +#endif // TOOLBARSETTINGS_H diff --git a/sources/ui/borderpropertieswidget.cpp b/sources/ui/borderpropertieswidget.cpp index f59ddc9e6..944503404 100644 --- a/sources/ui/borderpropertieswidget.cpp +++ b/sources/ui/borderpropertieswidget.cpp @@ -18,8 +18,89 @@ #include "borderpropertieswidget.h" #include "../diagram.h" +#include "../qetapp.h" +#include "../titleblocktemplate.h" #include "ui_borderpropertieswidget.h" +#include +#include + +namespace { + /// ISO 216 A sizes in mm, landscape + struct PaperSize { const char *name; int width_mm; int height_mm; }; + const PaperSize paper_sizes[] = { + {"A0", 1189, 841}, + {"A1", 841, 594}, + {"A2", 594, 420}, + {"A3", 420, 297}, + {"A4", 297, 210}, + {"A5", 210, 148} + }; + + /// The PDF export draws one scene unit as one pixel at 96 dpi + int mmToPx(int mm) { return qRound(mm * 96.0 / 25.4); } + + /// The PDF export rounds a page within 3 pt of a paper size to that + /// size, and 4 px left over already rounds to 3.0 pt and misses + const int exact_fit = 3; + + /// Smallest column width or row height a paper size may give + const int min_cell = 10; + + /// Fewest columns or rows a folio may have: BorderTitleBlock raises + /// any lower count to 3 (MIN_COLUMN_COUNT, MIN_ROW_COUNT) + const int min_count = 3; + + /** + @brief fitCells + Split @p available pixels into cells close to @p current pixels. + First choice: cells within 15 %, then 30 %, of the current size + that leave at most exact_fit pixels, the closest to the current + size. Failing that, cells within 15 % that leave the fewest pixels. + @return the number of cells and their size + */ + QPair fitCells(int available, int current, int min_size, int max_count) + { + current = qMax(current, min_size); + auto size_for = [&](int count, double spread) { + const int size = available / count; + return (size < min_size || qAbs(size - current) > current * spread) + ? 0 : size; + }; + + for (double spread : {0.15, 0.30}) + { + QPair best(0, 0); + for (int count = min_count ; count <= max_count ; ++count) + { + const int size = size_for(count, spread); + if (size && available - count * size <= exact_fit && + (!best.first || qAbs(size - current) < qAbs(best.second - current))) + best = qMakePair(count, size); + } + if (best.first) return best; + } + + QPair best(0, 0); + std::tuple best_score(available, available); + for (int count = min_count ; count <= max_count ; ++count) + { + const int size = size_for(count, 0.15); + const std::tuple score(available - count * size, + qAbs(size - current)); + if (size && score < best_score) { + best_score = score; + best = qMakePair(count, size); + } + } + if (best.first) return best; + + //No size near the current one when the cell count is capped + const int count = qBound(min_count, qRound(double(available) / current), max_count); + return qMakePair(count, available / count); + } +} + /** @brief BorderPropertiesWidget::BorderPropertiesWidget default constructor @@ -31,6 +112,30 @@ BorderPropertiesWidget::BorderPropertiesWidget(const BorderProperties &bp, QWidg ui(new Ui::BorderPropertiesWidget) { ui->setupUi(this); + + ui->m_paper_cb->addItem(tr("Personnalisé")); + for (const PaperSize &paper : paper_sizes) + ui->m_paper_cb->addItem(QString::fromLatin1(paper.name)); + ui->m_orientation_cb->addItem(tr("Paysage")); + ui->m_orientation_cb->addItem(tr("Portrait")); + + if (TitleBlockTemplate *tbt = QETApp::defaultTitleBlockTemplate()) + m_titleblock_height = tbt->height(); + + //activated() is only emitted for a choice the user makes + auto paper_picked = [this]() { + m_paper_picked = ui->m_paper_cb->currentIndex() > 0; + applyPaperSize(); + }; + connect(ui->m_paper_cb, qOverload(&QComboBox::activated), + this, paper_picked); + connect(ui->m_orientation_cb, qOverload(&QComboBox::activated), + this, paper_picked); + for (QSpinBox *sb : {ui->m_colums_count_sp, ui->m_columns_width_sp, + ui->m_rows_count_sp, ui->m_rows_height_sp}) + connect(sb, qOverload(&QSpinBox::valueChanged), + this, &BorderPropertiesWidget::showMatchingPaperSize); + setProperties(bp); } @@ -57,6 +162,7 @@ void BorderPropertiesWidget::setProperties(const BorderProperties &bp) ui -> m_rows_count_sp ->setValue (m_properties.rows_count); ui -> m_rows_height_sp ->setValue (m_properties.rows_height); ui -> m_display_rows_cb ->setChecked (m_properties.display_rows); + showMatchingPaperSize(); } /** @@ -83,3 +189,126 @@ void BorderPropertiesWidget::setReadOnly(const bool &ro) { ui->border_gb->setDisabled(ro); } + +/** + @brief BorderPropertiesWidget::setTitleBlockSize + Tell the widget the title block the folio uses: its height counts in + the size of the page. If the user picked a paper size in this widget, + the columns and rows are fitted again so the folio keeps that size; + otherwise they are left alone. + @param height : height of the title block template + @param edge : edge of the folio the title block sits on +*/ +void BorderPropertiesWidget::setTitleBlockSize(int height, Qt::Edge edge) +{ + m_titleblock_height = height; + m_titleblock_edge = edge; + if (m_paper_picked) + applyPaperSize(); + else + showMatchingPaperSize(); +} + +/** + @brief BorderPropertiesWidget::applyPaperSize + Set the columns and rows so the folio, title block included, has the + size of the chosen paper. The column width and row height stay close + to the current ones. +*/ +void BorderPropertiesWidget::applyPaperSize() +{ + const int index = ui->m_paper_cb->currentIndex() - 1; + ui->m_orientation_cb->setEnabled(index >= 0); + if (index < 0) return; + + int columns, column_width, rows, row_height; + if (!fitPaperSize(index, ui->m_orientation_cb->currentIndex() == 0, + columns, column_width, rows, row_height)) + { + //The title block leaves no room on this paper + showMatchingPaperSize(); + return; + } + + m_updating = true; + ui->m_colums_count_sp->setValue(columns); + ui->m_columns_width_sp->setValue(column_width); + ui->m_rows_count_sp->setValue(rows); + ui->m_rows_height_sp->setValue(row_height); + m_updating = false; +} + +/** + @brief BorderPropertiesWidget::fitPaperSize + Compute the columns and rows that give the folio, title block + included, the size of a paper, with cells close to the current ones. + @param index : index of the paper in paper_sizes + @param landscape : true for landscape, false for portrait + @return false if the paper is too small for the title block +*/ +bool BorderPropertiesWidget::fitPaperSize(int index, bool landscape, + int &columns, int &column_width, + int &rows, int &row_height) const +{ + const PaperSize &paper = paper_sizes[index]; + const int page_width = mmToPx(landscape ? paper.width_mm : paper.height_mm); + const int page_height = mmToPx(landscape ? paper.height_mm : paper.width_mm); + + //The page is one pixel larger than the border, for its line + int width = page_width - 1 - qRound(m_properties.rows_header_width); + int height = page_height - 1 - qRound(m_properties.columns_header_height); + if (m_titleblock_edge == Qt::BottomEdge) + height -= m_titleblock_height; + else + width -= m_titleblock_height; + if (width < min_cell * min_count || height < min_cell * min_count) + return false; + + const QPair c = fitCells(width, + ui->m_columns_width_sp->value(), + min_cell, + ui->m_colums_count_sp->maximum()); + const QPair r = fitCells(height, + ui->m_rows_height_sp->value(), + min_cell, + ui->m_rows_count_sp->maximum()); + columns = c.first; + column_width = c.second; + rows = r.first; + row_height = r.second; + return true; +} + +/** + @brief BorderPropertiesWidget::showMatchingPaperSize + Show the paper size the folio has, or "Custom" if it has none: the + folio has a paper size when choosing it would change nothing. +*/ +void BorderPropertiesWidget::showMatchingPaperSize() +{ + if (m_updating) return; + m_paper_picked = false; + + int found = 0; + int orientation = ui->m_orientation_cb->currentIndex(); + for (int i = 0 ; i < int(std::size(paper_sizes)) && !found ; ++i) + { + for (int o = 0 ; o < 2 ; ++o) + { + int columns, column_width, rows, row_height; + if (fitPaperSize(i, o == 0, columns, column_width, rows, row_height) && + columns == ui->m_colums_count_sp->value() && + column_width == ui->m_columns_width_sp->value() && + rows == ui->m_rows_count_sp->value() && + row_height == ui->m_rows_height_sp->value()) + { + found = i + 1; + orientation = o; + break; + } + } + } + ui->m_paper_cb->setCurrentIndex(found); + ui->m_orientation_cb->setCurrentIndex(orientation); + ui->m_orientation_cb->setEnabled(found > 0); +} diff --git a/sources/ui/borderpropertieswidget.h b/sources/ui/borderpropertieswidget.h index 01669c761..1042e23c4 100644 --- a/sources/ui/borderpropertieswidget.h +++ b/sources/ui/borderpropertieswidget.h @@ -41,12 +41,26 @@ class BorderPropertiesWidget : public QWidget void setProperties(const BorderProperties &bp); const BorderProperties &properties(); void setReadOnly (const bool &ro); - - private slots: + void setTitleBlockSize(int height, Qt::Edge edge); private: + void applyPaperSize(); + void showMatchingPaperSize(); + bool fitPaperSize(int index, bool landscape, + int &columns, int &column_width, + int &rows, int &row_height) const; + Ui::BorderPropertiesWidget *ui; BorderProperties m_properties; + /// height of the title block the folio is drawn with, and + /// the edge it sits on: it counts in the size of the page + int m_titleblock_height = 0; + Qt::Edge m_titleblock_edge = Qt::BottomEdge; + /// true while the widget itself changes the spin boxes + bool m_updating = false; + /// true once the user picked a paper size, until the columns or + /// rows are edited by hand + bool m_paper_picked = false; }; #endif // BORDERPROPERTIESWIDGET_H diff --git a/sources/ui/borderpropertieswidget.ui b/sources/ui/borderpropertieswidget.ui index 7fcee9bfd..63021ab12 100644 --- a/sources/ui/borderpropertieswidget.ui +++ b/sources/ui/borderpropertieswidget.ui @@ -80,7 +80,24 @@ + + + + Format : + + + + + + Règle les colonnes et les lignes pour que le folio, cartouche compris, ait la taille de ce format de papier, à 96 points par pouce comme l'export PDF. + + + + + + + Qt::Vertical @@ -105,6 +122,8 @@ m_rows_height_sp m_display_columns_cb m_display_rows_cb + m_paper_cb + m_orientation_cb diff --git a/sources/ui/compositetexteditdialog.cpp b/sources/ui/compositetexteditdialog.cpp index ac4e9c89b..ab98e55cd 100644 --- a/sources/ui/compositetexteditdialog.cpp +++ b/sources/ui/compositetexteditdialog.cpp @@ -78,9 +78,9 @@ void CompositeTextEditDialog::setUpComboBox(bool is_report) } } -void CompositeTextEditDialog::on_m_info_cb_activated(const QString &arg1) +void CompositeTextEditDialog::on_m_info_cb_activated(int index) { - Q_UNUSED(arg1) + Q_UNUSED(index) ui->m_plain_text_edit->insertPlainText(ui->m_info_cb->currentData().toString()); } diff --git a/sources/ui/compositetexteditdialog.h b/sources/ui/compositetexteditdialog.h index 31d0e0d65..a3de0e3f8 100644 --- a/sources/ui/compositetexteditdialog.h +++ b/sources/ui/compositetexteditdialog.h @@ -28,7 +28,7 @@ class CompositeTextEditDialog : public QDialog QString plainText() const; private slots: - void on_m_info_cb_activated(const QString &arg1); + void on_m_info_cb_activated(int index); protected: void focusInEvent(QFocusEvent *event) override; diff --git a/sources/ui/configpage/configpages.cpp b/sources/ui/configpage/configpages.cpp index b9ac8562c..fb3ffc687 100644 --- a/sources/ui/configpage/configpages.cpp +++ b/sources/ui/configpage/configpages.cpp @@ -139,6 +139,10 @@ NewDiagramPage::NewDiagramPage(QETProject *project, } connect(ipw, &TitleBlockPropertiesWidget::openAutoNumFolioEditor, this, &NewDiagramPage::changeToAutoFolioTab); + bpw -> setTitleBlockSize(ipw -> currentTitleBlockHeight(), + ipw -> currentTitleBlockEdge()); + connect(ipw, &TitleBlockPropertiesWidget::titleBlockSizeChanged, + bpw, &BorderPropertiesWidget::setTitleBlockSize); // main tab widget QTabWidget *tab_widget = new QTabWidget(this); diff --git a/sources/ui/configpage/gesturesconfigpage.cpp b/sources/ui/configpage/gesturesconfigpage.cpp new file mode 100644 index 000000000..a14d80ef3 --- /dev/null +++ b/sources/ui/configpage/gesturesconfigpage.cpp @@ -0,0 +1,475 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "gesturesconfigpage.h" + +#include "../../gesturesettings.h" +#include "../../qeticons.h" +#include "../../shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const int ring_radius = 100; +const int ring_inner = 26; +const int slot_radius = 68; +const int slot_icon = 24; + /// What QListWidget puts on a drag +const QString list_mime = QStringLiteral("application/x-qabstractitemmodeldatalist"); + +/** + @return the command id carried by a drag from the command list, or an + empty string +*/ +QString droppedId(const QMimeData *mime) +{ + if (!mime || !mime->hasFormat(list_mime)) { + return QString(); + } + QByteArray data = mime->data(list_mime); + QDataStream stream(&data, QIODevice::ReadOnly); + int row, column; + QMap roles; + stream >> row >> column >> roles; + return roles.value(Qt::UserRole).toString(); +} +} + +GestureRingEditor::GestureRingEditor(QWidget *parent) : + QWidget(parent) +{ + setAcceptDrops(true); + setFocusPolicy(Qt::StrongFocus); + setMinimumSize(sizeHint()); +} + +QSize GestureRingEditor::sizeHint() const +{ + return QSize(2 * ring_radius + 8, 2 * ring_radius + 8); +} + +void GestureRingEditor::setIds(const QStringList &ids) +{ + m_ids = ids; + update(); +} + +void GestureRingEditor::setDirections(int directions) +{ + m_directions = directions == 4 ? 4 : 8; + setCurrentSlot(qMin(m_current, m_directions - 1)); + update(); +} + +void GestureRingEditor::setDescriptions(const QHash &descriptions) +{ + m_descriptions = descriptions; + update(); +} + +void GestureRingEditor::setCurrentSlot(int slot) +{ + if (slot < 0 || slot >= m_directions) { + return; + } + const bool changed = slot != m_current; + m_current = slot; + update(); + if (changed) { + emit currentSlotChanged(slot); + } +} + +/** + @return the slot in the direction of @a pos from the centre, 0 at the + top then clockwise, or -1 at the centre or outside the ring +*/ +int GestureRingEditor::slotAt(const QPoint &pos) const +{ + const QPointF d = QPointF(pos) - QPointF(rect().center()); + const qreal distance = qHypot(d.x(), d.y()); + if (distance < ring_inner || distance > ring_radius) { + return -1; + } + qreal angle = qRadiansToDegrees(qAtan2(d.x(), -d.y())); + if (angle < 0) { + angle += 360; + } + return int(qRound(angle / (360.0 / m_directions))) % m_directions; +} + +/** + @brief GestureRingEditor::place + Put @a id in the current slot. A command already elsewhere on the ring + moves here, so a command has one direction. +*/ +void GestureRingEditor::place(const QString &id) +{ + if (id.isEmpty()) { + return; + } + const int old = m_ids.indexOf(id); + if (old >= 0) { + m_ids[old].clear(); + } + while (m_ids.count() <= m_current) { + m_ids << QString(); + } + m_ids[m_current] = id; + update(); + emit edited(); +} + +void GestureRingEditor::clearCurrent() +{ + if (m_current < m_ids.count() && !m_ids.at(m_current).isEmpty()) { + m_ids[m_current].clear(); + update(); + emit edited(); + } +} + +QString GestureRingEditor::idAt(int slot) const +{ + return slot < m_ids.count() ? m_ids.at(slot) : QString(); +} + +QPointF GestureRingEditor::slotCenter(int slot) const +{ + const qreal a = qDegreesToRadians(slot * 360.0 / m_directions); + const QPointF c = QRectF(rect()).center(); + return QPointF(c.x() + slot_radius * qSin(a), c.y() - slot_radius * qCos(a)); +} + +void GestureRingEditor::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event) + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QPointF c = QRectF(rect()).center(); + const qreal step = 360.0 / m_directions; + const QRectF outer(c.x() - ring_radius, c.y() - ring_radius, + 2 * ring_radius, 2 * ring_radius); + + QColor line = palette().color(QPalette::Text); + line.setAlpha(110); + painter.setPen(line); + painter.setBrush(palette().color(QPalette::Base)); + painter.drawEllipse(outer); + + //The chosen slot + QPainterPath wedge; + wedge.moveTo(c); + wedge.arcTo(outer, 90 - m_current * step - step / 2, step); + wedge.closeSubpath(); + painter.setPen(Qt::NoPen); + QColor highlight = palette().color(QPalette::Highlight); + if (!hasFocus()) { + highlight.setAlpha(140); + } + painter.setBrush(highlight); + painter.drawPath(wedge); + + //Lines between the slots + painter.setPen(line); + for (int i = 0 ; i < m_directions ; ++i) { + const qreal a = qDegreesToRadians(i * step + step / 2); + painter.drawLine(QPointF(c.x() + ring_inner * qSin(a), c.y() - ring_inner * qCos(a)), + QPointF(c.x() + ring_radius * qSin(a), c.y() - ring_radius * qCos(a))); + } + painter.setBrush(palette().color(QPalette::Window)); + painter.drawEllipse(c, ring_inner, ring_inner); + + for (int i = 0 ; i < m_directions ; ++i) + { + const QString id = idAt(i); + if (id.isEmpty()) { + continue; + } + const QPointF p = slotCenter(i); + const QRect r(int(p.x()) - slot_icon / 2, int(p.y()) - slot_icon / 2, + slot_icon, slot_icon); + QAction *action = ShortcutManager::instance().action(id, nullptr); + if (action && !action->icon().isNull()) { + action->icon().paint(&painter, r); + } else { + painter.setPen(palette().color(i == m_current ? QPalette::HighlightedText + : QPalette::Text)); + painter.drawText(r.adjusted(-12, 0, 12, 0), Qt::AlignCenter, + m_descriptions.value(id, id).left(3)); + } + } +} + +void GestureRingEditor::mousePressEvent(QMouseEvent *event) +{ + setCurrentSlot(slotAt(event->position().toPoint())); + event->accept(); +} + +void GestureRingEditor::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) { + clearCurrent(); + } else if (event->key() == Qt::Key_Right || event->key() == Qt::Key_Down) { + setCurrentSlot((m_current + 1) % m_directions); + } else if (event->key() == Qt::Key_Left || event->key() == Qt::Key_Up) { + setCurrentSlot((m_current + m_directions - 1) % m_directions); + } else { + QWidget::keyPressEvent(event); + } +} + +void GestureRingEditor::dragEnterEvent(QDragEnterEvent *event) +{ + if (!droppedId(event->mimeData()).isEmpty()) { + event->acceptProposedAction(); + } +} + +void GestureRingEditor::dragMoveEvent(QDragMoveEvent *event) +{ + const int slot = slotAt(event->position().toPoint()); + if (slot < 0) { + event->ignore(); + return; + } + setCurrentSlot(slot); + event->acceptProposedAction(); +} + +void GestureRingEditor::dropEvent(QDropEvent *event) +{ + const int slot = slotAt(event->position().toPoint()); + const QString id = droppedId(event->mimeData()); + if (slot < 0 || id.isEmpty()) { + event->ignore(); + return; + } + setCurrentSlot(slot); + place(id); + event->acceptProposedAction(); +} + +/** + @brief GesturesConfigPage::GesturesConfigPage + @param parent +*/ +GesturesConfigPage::GesturesConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + const QStringList available = ShortcutBarSettings::availableIds(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) { + if (available.contains(info.id)) { + m_descriptions.insert(info.id, info.description); + } + } + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_pending.insert(c, GestureSettings::ids(c)); + m_custom.insert(c, GestureSettings::isCustom(c)); + } + + auto *explanation = new QLabel( + tr("Faites glisser avec le bouton droit sur un folio : un anneau " + "de commandes s'ouvre, relâchez vers celle à lancer. Tant que " + "vous ne changez pas l'anneau d'un contexte, il montre les " + "commandes de la barre de raccourcis. Les gestes s'activent " + "ou se désactivent dans la page Général."), this); + explanation->setWordWrap(true); + + m_directions = new QComboBox(this); + m_directions->setObjectName(QStringLiteral("directionsCombo")); + m_directions->addItem(tr("8 directions"), 8); + m_directions->addItem(tr("4 directions"), 4); + m_directions->setCurrentIndex(m_directions->findData(GestureSettings::directions())); + + m_context = new QComboBox(this); + m_context->setObjectName(QStringLiteral("contextCombo")); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_context->addItem(ShortcutBarSettings::title(c), c); + } + + m_available = new QListWidget(this); + m_available->setObjectName(QStringLiteral("availableList")); + m_available->setSortingEnabled(true); + m_available->setDragEnabled(true); + m_available->setDragDropMode(QAbstractItemView::DragOnly); + for (auto it = m_descriptions.cbegin(); it != m_descriptions.cend(); ++it) { + QIcon icon; + if (QAction *action = ShortcutManager::instance().action(it.key(), nullptr)) { + icon = action->icon(); + } + auto *item = new QListWidgetItem(icon, it.value(), m_available); + item->setData(Qt::UserRole, it.key()); + } + + m_ring = new GestureRingEditor(this); + m_ring->setObjectName(QStringLiteral("ringEditor")); + m_ring->setDescriptions(m_descriptions); + m_ring->setDirections(GestureSettings::directions()); + + m_slot_name = new QLabel(this); + m_slot_name->setAlignment(Qt::AlignCenter); + m_slot_name->setWordWrap(true); + m_state = new QLabel(this); + m_state->setWordWrap(true); + + auto *place = new QPushButton(tr("Placer →"), this); + place->setObjectName(QStringLiteral("placeButton")); + auto *clear = new QPushButton(tr("Vider la direction"), this); + auto *follow = new QPushButton(tr("Comme la barre de raccourcis"), this); + follow->setObjectName(QStringLiteral("followBarButton")); + + auto *ring_column = new QVBoxLayout(); + ring_column->addWidget(m_ring, 0, Qt::AlignHCenter); + ring_column->addWidget(m_slot_name); + ring_column->addWidget(clear); + ring_column->addStretch(); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Commandes disponibles"), this), 0, 0); + grid->addWidget(new QLabel(tr("Anneau, la direction choisie en surbrillance"), this), 0, 2); + grid->addWidget(m_available, 1, 0); + grid->addWidget(place, 1, 1, Qt::AlignVCenter); + grid->addLayout(ring_column, 1, 2); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + auto *top_row = new QHBoxLayout(); + top_row->addWidget(new QLabel(tr("Directions :"), this)); + top_row->addWidget(m_directions); + top_row->addSpacing(12); + top_row->addWidget(new QLabel(tr("Contexte :"), this)); + top_row->addWidget(m_context, 1); + + auto *state_row = new QHBoxLayout(); + state_row->addWidget(m_state, 1); + state_row->addWidget(follow); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(explanation); + layout->addLayout(top_row); + layout->addLayout(state_row); + layout->addLayout(grid); + + connect(m_directions, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + m_ring->setDirections(m_directions->currentData().toInt()); + updateState(); + }); + connect(m_context, qOverload(&QComboBox::currentIndexChanged), + this, &GesturesConfigPage::showContext); + connect(m_ring, &GestureRingEditor::edited, this, [this]() { + m_pending.insert(m_shown, m_ring->ids()); + m_custom.insert(m_shown, true); + updateState(); + }); + connect(m_ring, &GestureRingEditor::currentSlotChanged, + this, &GesturesConfigPage::updateState); + connect(place, &QPushButton::clicked, this, &GesturesConfigPage::placeSelected); + connect(m_available, &QListWidget::itemDoubleClicked, + this, &GesturesConfigPage::placeSelected); + connect(clear, &QPushButton::clicked, m_ring, &GestureRingEditor::clearCurrent); + connect(follow, &QPushButton::clicked, this, &GesturesConfigPage::followBar); + + showContext(); +} + +/** + @brief GesturesConfigPage::applyConf + Save the directions and every context's ring. +*/ +void GesturesConfigPage::applyConf() +{ + GestureSettings::setDirections(m_directions->currentData().toInt()); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + if (m_custom.value(c)) { + GestureSettings::setIds(c, m_pending.value(c)); + } else { + GestureSettings::reset(c); + } + } +} + +QString GesturesConfigPage::title() const +{ + return tr("Gestes de la souris", "configuration page title"); +} + +QIcon GesturesConfigPage::icon() const +{ + return QET::Icons::ConfigureShortcuts; +} + +void GesturesConfigPage::showContext() +{ + m_shown = static_cast( + m_context->currentData().toInt()); + m_ring->setIds(m_pending.value(m_shown)); + updateState(); +} + +void GesturesConfigPage::placeSelected() +{ + if (QListWidgetItem *item = m_available->currentItem()) { + m_ring->place(item->data(Qt::UserRole).toString()); + } +} + +/** + @brief GesturesConfigPage::followBar + Make the shown context's ring follow the shortcut bar again. +*/ +void GesturesConfigPage::followBar() +{ + m_custom.insert(m_shown, false); + m_pending.insert(m_shown, GestureSettings::defaultIds(m_shown)); + m_ring->setIds(m_pending.value(m_shown)); + updateState(); +} + +/** + @brief GesturesConfigPage::updateState + Name the chosen direction's command, and say whether the shown ring is + the user's or the shortcut bar's. +*/ +void GesturesConfigPage::updateState() +{ + const int slot = m_ring->currentSlot(); + const QStringList ids = m_ring->ids(); + const QString id = slot < ids.count() ? ids.at(slot) : QString(); + m_slot_name->setText(id.isEmpty() ? tr("(direction vide)") + : m_descriptions.value(id, id)); + m_state->setText(m_custom.value(m_shown) + ? tr("Anneau personnalisé pour ce contexte.") + : tr("Cet anneau suit la barre de raccourcis.")); +} diff --git a/sources/ui/configpage/gesturesconfigpage.h b/sources/ui/configpage/gesturesconfigpage.h new file mode 100644 index 000000000..7659fca0c --- /dev/null +++ b/sources/ui/configpage/gesturesconfigpage.h @@ -0,0 +1,112 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef GESTURESCONFIGPAGE_H +#define GESTURESCONFIGPAGE_H + +#include "configpage.h" +#include "../../shortcutbarsettings.h" + +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; + +/** + @brief The gesture ring as an editor: one slot per direction, clockwise + from the top. Click a slot to choose it; drop a command from the + command list on a slot to put it there; Delete empties the chosen slot. +*/ +class GestureRingEditor : public QWidget +{ + Q_OBJECT + + public: + explicit GestureRingEditor(QWidget *parent = nullptr); + + void setIds(const QStringList &ids); + QStringList ids() const { return m_ids; } + void setDirections(int directions); + void setDescriptions(const QHash &descriptions); + int currentSlot() const { return m_current; } + void setCurrentSlot(int slot); + int slotAt(const QPoint &pos) const; + void place(const QString &id); + void clearCurrent(); + + QSize sizeHint() const override; + + signals: + void edited(); + void currentSlotChanged(int slot); + + protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dropEvent(QDropEvent *event) override; + + private: + QString idAt(int slot) const; + QPointF slotCenter(int slot) const; + + QStringList m_ids; + QHash m_descriptions; + int m_directions = 8; + int m_current = 0; +}; + +/** + @brief The GesturesConfigPage class + Choose 4 or 8 directions for the mouse gesture ring, and which command + sits in each direction for each selection context. A context the user + does not edit keeps following the shortcut bar. Saved by applyConf(). +*/ +class GesturesConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit GesturesConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + void showContext(); + void placeSelected(); + void followBar(); + void updateState(); + + QComboBox *m_directions; + QComboBox *m_context; + QListWidget *m_available; + GestureRingEditor *m_ring; + QLabel *m_slot_name; + QLabel *m_state; + ShortcutBarSettings::Context m_shown = ShortcutBarSettings::Canvas; + QHash m_pending; + QHash m_custom; + QHash m_descriptions; +}; + +#endif // GESTURESCONFIGPAGE_H diff --git a/sources/ui/configpage/toolbarcommandsconfigpage.cpp b/sources/ui/configpage/toolbarcommandsconfigpage.cpp new file mode 100644 index 000000000..30bb36e3c --- /dev/null +++ b/sources/ui/configpage/toolbarcommandsconfigpage.cpp @@ -0,0 +1,423 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "toolbarcommandsconfigpage.h" + +#include "../../qeticons.h" +#include "../../shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ToolbarCommandsConfigPage::ToolbarCommandsConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + const QStringList available = DiagramToolbarSettings::availableCommandIds(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) { + if (available.contains(info.id)) { + m_descriptions.insert(info.id, info.description); + } + } + m_custom = DiagramToolbarSettings::customToolbars(); + for (const DiagramToolbarSettings::Toolbar &toolbar : DiagramToolbarSettings::toolbars()) { + m_pending.insert(toolbar.name, DiagramToolbarSettings::ids(toolbar.name)); + } + + auto *explanation = new QLabel( + tr("Choisissez les commandes de chaque barre d'outils de l'éditeur " + "de schémas. Glissez une commande de la liste de gauche vers la " + "barre, ou double-cliquez dessus ; Suppr la retire."), this); + explanation->setWordWrap(true); + + m_toolbar = new QComboBox(this); + m_toolbar->setObjectName(QStringLiteral("toolbarCombo")); + auto *new_toolbar = new QPushButton(tr("Nouvelle barre…"), this); + new_toolbar->setObjectName(QStringLiteral("newToolbarButton")); + m_rename = new QPushButton(tr("Renommer…"), this); + m_delete = new QPushButton(tr("Supprimer la barre"), this); + m_delete->setObjectName(QStringLiteral("deleteToolbarButton")); + auto *reset = new QPushButton(tr("Valeurs par défaut"), this); + reset->setObjectName(QStringLiteral("resetButton")); + + m_filter = new QLineEdit(this); + m_filter->setPlaceholderText(tr("Rechercher une commande")); + m_filter->setClearButtonEnabled(true); + + m_available = new QListWidget(this); + m_available->setObjectName(QStringLiteral("availableList")); + m_available->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_available->setSortingEnabled(true); + m_available->setDragDropMode(QAbstractItemView::DragOnly); + m_available->setDefaultDropAction(Qt::MoveAction); + m_chosen = new QListWidget(this); + m_chosen->setObjectName(QStringLiteral("chosenList")); + m_chosen->setSelectionMode(QAbstractItemView::ExtendedSelection); + //Takes commands dragged from the list on the left, and reorders + m_chosen->setDragDropMode(QAbstractItemView::DragDrop); + m_chosen->setDefaultDropAction(Qt::MoveAction); + + auto *add = new QPushButton(tr("Ajouter →"), this); + auto *separator = new QPushButton(tr("Séparateur"), this); + separator->setObjectName(QStringLiteral("separatorButton")); + auto *remove = new QPushButton(tr("← Retirer"), this); + auto *up = new QPushButton(tr("Monter"), this); + auto *down = new QPushButton(tr("Descendre"), this); + + auto *buttons = new QVBoxLayout(); + buttons->addStretch(); + buttons->addWidget(add); + buttons->addWidget(separator); + buttons->addWidget(remove); + buttons->addSpacing(12); + buttons->addWidget(up); + buttons->addWidget(down); + buttons->addStretch(); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Commandes disponibles"), this), 0, 0); + grid->addWidget(new QLabel(tr("Dans la barre, dans l'ordre"), this), 0, 2); + grid->addWidget(m_filter, 1, 0); + grid->addWidget(m_available, 2, 0); + grid->addLayout(buttons, 2, 1); + grid->addWidget(m_chosen, 1, 2, 2, 1); + + auto *toolbar_row = new QHBoxLayout(); + toolbar_row->addWidget(new QLabel(tr("Barre d'outils :"), this)); + toolbar_row->addWidget(m_toolbar, 1); + toolbar_row->addWidget(new_toolbar); + toolbar_row->addWidget(m_rename); + toolbar_row->addWidget(m_delete); + toolbar_row->addWidget(reset); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(explanation); + layout->addLayout(toolbar_row); + layout->addLayout(grid); + + connect(m_toolbar, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + storeToolbar(); + showToolbar(); + }); + connect(m_filter, &QLineEdit::textChanged, this, &ToolbarCommandsConfigPage::fillAvailable); + connect(add, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::addSelected); + connect(separator, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::addSeparator); + connect(remove, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::removeSelected); + connect(up, &QPushButton::clicked, this, [this]() { moveSelected(-1); }); + connect(down, &QPushButton::clicked, this, [this]() { moveSelected(1); }); + connect(reset, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::resetToolbar); + connect(new_toolbar, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::newToolbar); + connect(m_rename, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::renameToolbar); + connect(m_delete, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::deleteToolbar); + connect(m_available, &QListWidget::itemDoubleClicked, this, &ToolbarCommandsConfigPage::addSelected); + connect(m_chosen, &QListWidget::itemDoubleClicked, this, &ToolbarCommandsConfigPage::removeSelected); + //A command dropped from the left is no longer available. Later, + //not now: a dropped row is inserted before its data is set. + connect(m_chosen->model(), &QAbstractItemModel::rowsInserted, this, [this]() { + if (m_chosen->property("filling").toBool()) return; + QTimer::singleShot(0, this, [this]() { + storeToolbar(); + fillAvailable(); + }); + }); + + auto *delete_key = new QAction(m_chosen); + delete_key->setShortcut(QKeySequence::Delete); + delete_key->setShortcutContext(Qt::WidgetShortcut); + m_chosen->addAction(delete_key); + connect(delete_key, &QAction::triggered, this, &ToolbarCommandsConfigPage::removeSelected); + + fillToolbarCombo(DiagramToolbarSettings::builtInNames().constFirst()); +} + +/** + @brief ToolbarCommandsConfigPage::applyConf + Save the user's own toolbars and every toolbar's contents, then + rebuild the toolbars of the open diagram editors. +*/ +void ToolbarCommandsConfigPage::applyConf() +{ + storeToolbar(); + DiagramToolbarSettings::setCustomToolbars(m_custom); + for (const DiagramToolbarSettings::Toolbar &toolbar : DiagramToolbarSettings::toolbars()) { + DiagramToolbarSettings::setIds(toolbar.name, m_pending.value(toolbar.name)); + } + DiagramToolbarSettings::applyToAll(); +} + +QString ToolbarCommandsConfigPage::title() const +{ + return tr("Contenu des barres", "configuration page title: what each toolbar holds"); +} + +QIcon ToolbarCommandsConfigPage::icon() const +{ + return QET::Icons::ConfigureToolbars; +} + +/** + @brief ToolbarCommandsConfigPage::fillToolbarCombo + List the built-in toolbars and the user's own, and show @a current. +*/ +void ToolbarCommandsConfigPage::fillToolbarCombo(const QString ¤t) +{ + const QSignalBlocker blocker(m_toolbar); + m_toolbar->clear(); + for (const QString &name : DiagramToolbarSettings::builtInNames()) { + m_toolbar->addItem(DiagramToolbarSettings::builtInTitle(name), name); + } + for (const DiagramToolbarSettings::Toolbar &toolbar : std::as_const(m_custom)) { + m_toolbar->addItem(toolbar.title, toolbar.name); + } + const int index = m_toolbar->findData(current); + m_toolbar->setCurrentIndex(index < 0 ? 0 : index); + showToolbar(); +} + +/** + @brief ToolbarCommandsConfigPage::showToolbar + Fill both lists for the toolbar chosen in the combo box. +*/ +void ToolbarCommandsConfigPage::showToolbar() +{ + m_shown = m_toolbar->currentData().toString(); + const bool custom = !DiagramToolbarSettings::builtInNames().contains(m_shown); + m_rename->setEnabled(custom); + m_delete->setEnabled(custom); + + m_chosen->setProperty("filling", true); + m_chosen->clear(); + for (const QString &id : m_pending.value(m_shown)) { + m_chosen->addItem(makeItem(id)); + } + m_chosen->setProperty("filling", false); + fillAvailable(); +} + +/** + @brief ToolbarCommandsConfigPage::storeToolbar + Keep the shown toolbar's list, in the order on screen. +*/ +void ToolbarCommandsConfigPage::storeToolbar() +{ + if (m_shown.isEmpty()) { + return; + } + QStringList ids; + for (int i = 0 ; i < m_chosen->count() ; ++i) { + ids << m_chosen->item(i)->data(Qt::UserRole).toString(); + } + m_pending.insert(m_shown, ids); +} + +/** + @brief ToolbarCommandsConfigPage::fillAvailable + List what can still go on the shown toolbar and matches the search: a + command not already on it, and a widget button on no toolbar at all + (a widget can only be in one place). +*/ +void ToolbarCommandsConfigPage::fillAvailable() +{ + QStringList on_shown; + for (int i = 0 ; i < m_chosen->count() ; ++i) { + on_shown << m_chosen->item(i)->data(Qt::UserRole).toString(); + } + QStringList on_any = on_shown; + for (auto it = m_pending.cbegin() ; it != m_pending.cend() ; ++it) { + if (it.key() != m_shown) on_any << it.value(); + } + + QStringList ids; + for (auto it = m_descriptions.cbegin() ; it != m_descriptions.cend() ; ++it) { + if (!on_shown.contains(it.key())) ids << it.key(); + } + for (const QString &id : DiagramToolbarSettings::widgetIds()) { + if (!on_any.contains(id)) ids << id; + } + + m_available->clear(); + const QString filter = m_filter->text().trimmed(); + for (const QString &id : std::as_const(ids)) { + QListWidgetItem *item = makeItem(id); + if (filter.isEmpty() || item->text().contains(filter, Qt::CaseInsensitive)) { + m_available->addItem(item); + } else { + delete item; + } + } +} + +void ToolbarCommandsConfigPage::addSelected() +{ + const QList selected = m_available->selectedItems(); + if (selected.isEmpty()) { + return; + } + m_chosen->setProperty("filling", true); + for (QListWidgetItem *item : selected) { + m_chosen->addItem(m_available->takeItem(m_available->row(item))); + } + m_chosen->setProperty("filling", false); + storeToolbar(); + fillAvailable(); +} + +/** + @brief ToolbarCommandsConfigPage::addSeparator + Insert a separator after the selected row, or at the end. +*/ +void ToolbarCommandsConfigPage::addSeparator() +{ + const int row = m_chosen->currentRow(); + m_chosen->setProperty("filling", true); + m_chosen->insertItem(row < 0 ? m_chosen->count() : row + 1, + makeItem(DiagramToolbarSettings::separatorId())); + m_chosen->setProperty("filling", false); + storeToolbar(); +} + +void ToolbarCommandsConfigPage::removeSelected() +{ + for (QListWidgetItem *item : m_chosen->selectedItems()) { + delete m_chosen->takeItem(m_chosen->row(item)); + } + storeToolbar(); + fillAvailable(); +} + +/** + @brief ToolbarCommandsConfigPage::moveSelected + Move the selected command @a step rows, keeping it selected. +*/ +void ToolbarCommandsConfigPage::moveSelected(int step) +{ + const int row = m_chosen->currentRow(); + const int target = row + step; + if (row < 0 || target < 0 || target >= m_chosen->count()) { + return; + } + m_chosen->setProperty("filling", true); + QListWidgetItem *item = m_chosen->takeItem(row); + m_chosen->insertItem(target, item); + m_chosen->setProperty("filling", false); + m_chosen->setCurrentRow(target); + storeToolbar(); +} + +/** + @brief ToolbarCommandsConfigPage::resetToolbar + Give the shown toolbar its defaults back; the user's own toolbar is + emptied. +*/ +void ToolbarCommandsConfigPage::resetToolbar() +{ + m_pending.insert(m_shown, DiagramToolbarSettings::defaultIds(m_shown)); + //A widget the defaults put here leaves the toolbar it was moved to + for (const QString &id : DiagramToolbarSettings::defaultIds(m_shown)) { + if (!DiagramToolbarSettings::isWidget(id)) continue; + for (auto it = m_pending.begin() ; it != m_pending.end() ; ++it) { + if (it.key() != m_shown) it.value().removeAll(id); + } + } + showToolbar(); +} + +void ToolbarCommandsConfigPage::newToolbar() +{ + bool ok = false; + const QString title = QInputDialog::getText(this, tr("Nouvelle barre d'outils"), + tr("Nom de la barre d'outils :"), + QLineEdit::Normal, QString(), &ok).trimmed(); + if (!ok || title.isEmpty()) { + return; + } + storeToolbar(); + DiagramToolbarSettings::Toolbar toolbar; + toolbar.name = DiagramToolbarSettings::newCustomName(m_custom); + toolbar.title = title; + toolbar.custom = true; + m_custom << toolbar; + m_pending.insert(toolbar.name, QStringList()); + fillToolbarCombo(toolbar.name); +} + +void ToolbarCommandsConfigPage::renameToolbar() +{ + for (DiagramToolbarSettings::Toolbar &toolbar : m_custom) { + if (toolbar.name != m_shown) continue; + bool ok = false; + const QString title = QInputDialog::getText(this, tr("Renommer la barre d'outils"), + tr("Nom de la barre d'outils :"), + QLineEdit::Normal, toolbar.title, &ok).trimmed(); + if (ok && !title.isEmpty()) { + storeToolbar(); + toolbar.title = title; + fillToolbarCombo(m_shown); + } + return; + } +} + +void ToolbarCommandsConfigPage::deleteToolbar() +{ + const QString name = m_shown; + for (int i = 0 ; i < m_custom.size() ; ++i) { + if (m_custom.at(i).name == name) { + m_custom.removeAt(i); + m_pending.remove(name); + m_shown.clear(); + fillToolbarCombo(DiagramToolbarSettings::builtInNames().constFirst()); + return; + } + } +} + +/** + @brief ToolbarCommandsConfigPage::makeItem + @return a list item for @a id with the command's text and icon. An id + no live action carries (a command from a build without it, or a script + since removed) is still listed, by its id, so saving does not silently + drop it. +*/ +QListWidgetItem *ToolbarCommandsConfigPage::makeItem(const QString &id) const +{ + QString text = m_descriptions.value(id, id); + QString tip; + QIcon icon; + if (id == DiagramToolbarSettings::separatorId()) { + text = tr("─── Séparateur ───"); + } else if (DiagramToolbarSettings::isWidget(id)) { + text = DiagramToolbarSettings::widgetTitle(id); + } else if (QAction *action = ShortcutManager::instance().action(id, nullptr)) { + icon = action->icon(); + tip = action->statusTip(); + } + auto *item = new QListWidgetItem(icon, text); + item->setData(Qt::UserRole, id); + //Tells apart commands with the same name ("Ajouter une ligne") + item->setToolTip(tip); + return item; +} diff --git a/sources/ui/configpage/toolbarcommandsconfigpage.h b/sources/ui/configpage/toolbarcommandsconfigpage.h new file mode 100644 index 000000000..286978cc4 --- /dev/null +++ b/sources/ui/configpage/toolbarcommandsconfigpage.h @@ -0,0 +1,78 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef TOOLBARCOMMANDSCONFIGPAGE_H +#define TOOLBARCOMMANDSCONFIGPAGE_H + +#include "configpage.h" +#include "../../diagramtoolbarsettings.h" + +#include + +class QComboBox; +class QLineEdit; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +/** + @brief The ToolbarCommandsConfigPage class + Choose what each of the diagram editor's toolbars holds, and add, + rename or delete toolbars of one's own. Commands are dragged or added + from the list on the left. Changes are kept per toolbar while the + dialog is open, then saved and applied to the open windows by + applyConf(). +*/ +class ToolbarCommandsConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit ToolbarCommandsConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + void fillToolbarCombo(const QString ¤t); + void showToolbar(); + void storeToolbar(); + void fillAvailable(); + void addSelected(); + void addSeparator(); + void removeSelected(); + void moveSelected(int step); + void resetToolbar(); + void newToolbar(); + void renameToolbar(); + void deleteToolbar(); + QListWidgetItem *makeItem(const QString &id) const; + + QComboBox *m_toolbar; + QLineEdit *m_filter; + QListWidget *m_available; + QListWidget *m_chosen; + QPushButton *m_rename; + QPushButton *m_delete; + QString m_shown; + QList m_custom; + QHash m_pending; + QHash m_descriptions; +}; + +#endif // TOOLBARCOMMANDSCONFIGPAGE_H diff --git a/sources/ui/configpage/toolbarsconfigpage.cpp b/sources/ui/configpage/toolbarsconfigpage.cpp new file mode 100644 index 000000000..bd1abd000 --- /dev/null +++ b/sources/ui/configpage/toolbarsconfigpage.cpp @@ -0,0 +1,88 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "toolbarsconfigpage.h" + +#include "../../qeticons.h" +#include "../../toolbarsettings.h" + +#include +#include +#include +#include +#include +#include + +ToolbarsConfigPage::ToolbarsConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + auto *vlayout = new QVBoxLayout(this); + vlayout->addWidget(new QLabel(title(), this)); + auto *line = new QFrame(this); + line->setFrameShape(QFrame::HLine); + vlayout->addWidget(line); + + m_icon_size = new QComboBox(this); + m_icon_size->setObjectName(QStringLiteral("iconSizeCombo")); + m_icon_size->addItem(tr("Par défaut"), 0); + m_icon_size->addItem(tr("Petites (16 px)"), 16); + m_icon_size->addItem(tr("Moyennes (24 px)"), 24); + m_icon_size->addItem(tr("Grandes (32 px)"), 32); + m_icon_size->addItem(tr("Très grandes (48 px)"), 48); + const int size_index = m_icon_size->findData(ToolbarSettings::iconSize()); + m_icon_size->setCurrentIndex(size_index < 0 ? 0 : size_index); + + m_button_style = new QComboBox(this); + m_button_style->setObjectName(QStringLiteral("buttonStyleCombo")); + m_button_style->addItem(tr("Icône seule"), int(Qt::ToolButtonIconOnly)); + m_button_style->addItem(tr("Texte à côté de l'icône"), int(Qt::ToolButtonTextBesideIcon)); + m_button_style->addItem(tr("Texte sous l'icône"), int(Qt::ToolButtonTextUnderIcon)); + m_button_style->setCurrentIndex(m_button_style->findData(int(ToolbarSettings::buttonStyle()))); + + m_locked = new QCheckBox(tr("Verrouiller les barres d'outils (elles ne peuvent plus être déplacées)"), this); + m_locked->setObjectName(QStringLiteral("lockedCheck")); + m_locked->setChecked(ToolbarSettings::locked()); + + auto *form = new QFormLayout(); + form->addRow(tr("Taille des icônes :"), m_icon_size); + form->addRow(tr("Boutons :"), m_button_style); + form->addRow(m_locked); + vlayout->addLayout(form); + + auto *hint = new QLabel(tr("Pour afficher ou masquer une barre d'outils, faites un clic droit sur une barre d'outils."), this); + hint->setWordWrap(true); + vlayout->addWidget(hint); + vlayout->addStretch(); +} + +void ToolbarsConfigPage::applyConf() +{ + ToolbarSettings::save(m_icon_size->currentData().toInt(), + Qt::ToolButtonStyle(m_button_style->currentData().toInt()), + m_locked->isChecked()); + ToolbarSettings::applyToAll(); +} + +QString ToolbarsConfigPage::title() const +{ + return tr("Barres d'outils", "configuration page title"); +} + +QIcon ToolbarsConfigPage::icon() const +{ + return QET::Icons::ConfigureToolbars; +} diff --git a/sources/ui/configpage/toolbarsconfigpage.h b/sources/ui/configpage/toolbarsconfigpage.h new file mode 100644 index 000000000..175c58369 --- /dev/null +++ b/sources/ui/configpage/toolbarsconfigpage.h @@ -0,0 +1,49 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef TOOLBARSCONFIGPAGE_H +#define TOOLBARSCONFIGPAGE_H + +#include "configpage.h" + +class QCheckBox; +class QComboBox; + +/** + @brief The ToolbarsConfigPage class + Toolbar icon size, text with the icons, and locking the toolbars in + place, for every window. Saved and applied to the open windows by + applyConf(). +*/ +class ToolbarsConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit ToolbarsConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + QComboBox *m_icon_size; + QComboBox *m_button_style; + QCheckBox *m_locked; +}; + +#endif // TOOLBARSCONFIGPAGE_H diff --git a/sources/ui/customizedialog.cpp b/sources/ui/customizedialog.cpp new file mode 100644 index 000000000..03a0bca97 --- /dev/null +++ b/sources/ui/customizedialog.cpp @@ -0,0 +1,77 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "customizedialog.h" + +#include "configpage/configpage.h" + +#include +#include +#include +#include + +CustomizeDialog::CustomizeDialog(QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Personnaliser", "window title")); + + m_tabs = new QTabWidget(this); + m_tabs->setObjectName(QStringLiteral("customizeTabs")); + m_tabs->setDocumentMode(true); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, [this]() { + applyConf(); + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(m_tabs); + layout->addWidget(buttons); + + resize(1000, 700); +} + +/** + @brief CustomizeDialog::addPage + Add @a page as a tab, named after its title. The dialog takes it. +*/ +void CustomizeDialog::addPage(ConfigPage *page) +{ + if (!page || m_pages.contains(page)) { + return; + } + m_pages << page; + m_tabs->addTab(page, page->icon(), page->title()); +} + +QList CustomizeDialog::pages() const +{ + return m_pages; +} + +/** + @brief CustomizeDialog::applyConf + Apply every page, in tab order. +*/ +void CustomizeDialog::applyConf() +{ + for (ConfigPage *page : std::as_const(m_pages)) { + page->applyConf(); + } +} diff --git a/sources/ui/customizedialog.h b/sources/ui/customizedialog.h new file mode 100644 index 000000000..a764c1530 --- /dev/null +++ b/sources/ui/customizedialog.h @@ -0,0 +1,51 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef CUSTOMIZEDIALOG_H +#define CUSTOMIZEDIALOG_H + +#include +#include + +class ConfigPage; +class QTabWidget; + +/** + @brief The CustomizeDialog class + Everything about how the user works in one window, one tab per + configuration page: toolbars, what they hold, the shortcut bar, the + keyboard and the mouse gestures, like SolidWorks' Tools > Customize. + The pages are the ones of the configuration dialog, which still shows + them too. OK applies every page, Cancel none. +*/ +class CustomizeDialog : public QDialog +{ + Q_OBJECT + + public: + explicit CustomizeDialog(QWidget *parent = nullptr); + + void addPage(ConfigPage *page); + QList pages() const; + void applyConf(); + + private: + QTabWidget *m_tabs; + QList m_pages; +}; + +#endif // CUSTOMIZEDIALOG_H diff --git a/sources/ui/diagrambgcolorbutton.cpp b/sources/ui/diagrambgcolorbutton.cpp index dce538faf..e16e81321 100644 --- a/sources/ui/diagrambgcolorbutton.cpp +++ b/sources/ui/diagrambgcolorbutton.cpp @@ -85,7 +85,7 @@ DiagramBgColorToolButton::DiagramBgColorToolButton(QETDiagramEditor *editor, QWi m_recent.removeLast(); } - setMenu(new QMenu(this)); + setMenu(new QMenu(tr("Couleur de fond du folio"), this)); rebuildMenu(); setSwatch(m_current); } diff --git a/sources/ui/diagrampropertiesdialog.cpp b/sources/ui/diagrampropertiesdialog.cpp index cc3046b9c..15386bbfe 100644 --- a/sources/ui/diagrampropertiesdialog.cpp +++ b/sources/ui/diagrampropertiesdialog.cpp @@ -65,6 +65,10 @@ DiagramPropertiesDialog::DiagramPropertiesDialog(Diagram *diagram, QWidget *pare titleblock_infos -> setReadOnly(diagram_is_read_only); connect(titleblock_infos, &TitleBlockPropertiesWidget::openAutoNumFolioEditor, this, &DiagramPropertiesDialog::editAutoFolioNum); + border_infos -> setTitleBlockSize(titleblock_infos -> currentTitleBlockHeight(), + titleblock_infos -> currentTitleBlockEdge()); + connect(titleblock_infos, &TitleBlockPropertiesWidget::titleBlockSizeChanged, + border_infos, &BorderPropertiesWidget::setTitleBlockSize); //titleblock_infos->setMinimumSize(590,480); //Minimum Size needed for correct display //Conductor widget diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp index 3b0931437..3ee2df604 100644 --- a/sources/ui/imagepropertieswidget.cpp +++ b/sources/ui/imagepropertieswidget.cpp @@ -22,6 +22,9 @@ #include "../qetgraphicsitem/diagramimageitem.h" #include "../qeticons.h" #include "../ui_imagepropertieswidget.h" +#include +#include +#include /** @brief ImagePropertiesWidget::ImagePropertiesWidget @@ -53,6 +56,19 @@ ImagePropertiesWidget::~ImagePropertiesWidget() delete ui; } +bool ImagePropertiesWidget::event(QEvent *event) +{ + // The generic properties dialog resets on its Cancel button, but its + // title-bar close and Escape reach reject() directly. Roll back the + // same preview in all rejection paths, without changing other editors. + if (event->type() == QEvent::ParentChange) { + if (auto *dialog = qobject_cast(parentWidget())) { + connect(dialog, &QDialog::rejected, this, &ImagePropertiesWidget::reset, Qt::UniqueConnection); + } + } + return PropertiesEditorWidget::event(event); +} + /** @brief ImagePropertiesWidget::setImageItem Set the image to edit properties @@ -67,12 +83,15 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image) { disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); disconnect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + disconnect(m_image, &DiagramImageItem::adaptToDarkThemeChanged, this, &ImagePropertiesWidget::updateUi); } m_image = image; connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); connect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + connect(m_image, &DiagramImageItem::adaptToDarkThemeChanged, this, &ImagePropertiesWidget::updateUi); m_movable = image->isMovable(); + m_adapt_to_dark_theme = image->adaptToDarkTheme(); m_scaleX = m_image->scaleFactorX(); m_scaleY = m_image->scaleFactorY(); m_rotation = m_image->rotationAngle(); @@ -107,6 +126,7 @@ void ImagePropertiesWidget::apply() m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); m_label = m_image->label(); + m_adapt_to_dark_theme = m_image->adaptToDarkTheme(); } /** @@ -124,6 +144,9 @@ void ImagePropertiesWidget::reset() m_image->setSkewY(m_skewY); m_image->setLabel(m_label); m_image->setMovable(m_movable); + m_preview_dark_theme = true; + m_image->setAdaptToDarkTheme(m_adapt_to_dark_theme); + m_preview_dark_theme = false; updateUi(); } @@ -146,6 +169,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_adapt_to_dark_theme_cb, &QCheckBox::clicked, this, &ImagePropertiesWidget::apply); } else { @@ -155,6 +179,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_adapt_to_dark_theme_cb, &QCheckBox::clicked, this, &ImagePropertiesWidget::apply); } return true; @@ -179,7 +204,7 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const const qreal newSkewX = ui->m_skew_x_sb->value(); const qreal newSkewY = ui->m_skew_y_sb->value(); - QPropertyUndoCommand *undo = nullptr; + QUndoCommand *undo = nullptr; auto chain = [&](const char *property, qreal oldValue, qreal newValue, const QString &text) { if (qFuzzyCompare(oldValue, newValue)) @@ -188,9 +213,10 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const new QPropertyUndoCommand(m_image, property, oldValue, newValue, undo); else { - undo = new QPropertyUndoCommand(m_image, property, oldValue, newValue); - undo->enableAnimation(); - undo->setText(text); + auto *change = new QPropertyUndoCommand(m_image, property, oldValue, newValue); + change->enableAnimation(); + change->setText(text); + undo = change; } }; @@ -213,6 +239,14 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const } } + const bool adapt = ui->m_adapt_to_dark_theme_cb->isChecked(); + if (adapt != m_adapt_to_dark_theme) { + if (undo) new QPropertyUndoCommand(m_image, "adaptToDarkTheme", m_adapt_to_dark_theme, adapt, undo); + else { + undo = new QUndoCommand(tr("Modifier l'adaptation d'une image au thème sombre")); + new QPropertyUndoCommand(m_image, "adaptToDarkTheme", m_adapt_to_dark_theme, adapt, undo); + } + } return undo; } @@ -233,6 +267,9 @@ void ImagePropertiesWidget::updateUi() // signal from a resize handle used directly on the canvas while // this dialog is open). if (!m_image || m_updating_ratio) return; + if (m_live_edit && !m_preview_dark_theme) m_adapt_to_dark_theme = m_image->adaptToDarkTheme(); + const QSignalBlocker darkThemeBlocker(ui->m_adapt_to_dark_theme_cb); + ui->m_adapt_to_dark_theme_cb->setChecked(m_image->adaptToDarkTheme()); m_updating_ratio = true; ui->m_width_sb->setValue(m_image->scaleFactorX() * 100.0); @@ -395,3 +432,11 @@ void ImagePropertiesWidget::on_m_lock_pos_cb_clicked() { m_image->setMovable(!ui->m_lock_pos_cb->isChecked()); } + +void ImagePropertiesWidget::on_m_adapt_to_dark_theme_cb_toggled(bool checked) +{ + if (!m_image) return; + m_preview_dark_theme = true; + m_image->setAdaptToDarkTheme(checked); + m_preview_dark_theme = false; +} diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h index c692d7892..57ddf1108 100644 --- a/sources/ui/imagepropertieswidget.h +++ b/sources/ui/imagepropertieswidget.h @@ -43,6 +43,8 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void reset() override; bool setLiveEdit(bool live_edit) override; QUndoCommand* associatedUndo() const override; + protected: + bool event(QEvent *event) override; private: void updateUi() override; @@ -56,11 +58,14 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void on_m_lock_ratio_tb_toggled(bool checked); void on_m_restore_ratio_pb_clicked(); void on_m_lock_pos_cb_clicked(); + void on_m_adapt_to_dark_theme_cb_toggled(bool checked); private: Ui::ImagePropertiesWidget *ui; DiagramImageItem *m_image; bool m_movable; + bool m_adapt_to_dark_theme = false; + bool m_preview_dark_theme = false; // All tracked independently -- the image may already be // non-uniformly scaled, rotated, and/or skewed via the resize/ // rotate/skew handles before this dialog is even opened, and diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui index 4c9954cd6..4d00b6f29 100644 --- a/sources/ui/imagepropertieswidget.ui +++ b/sources/ui/imagepropertieswidget.ui @@ -173,7 +173,17 @@ - + + + + Adapter l’image au thème sombre + + + Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés. + + + + Qt::Vertical diff --git a/sources/ui/inditextpropertieswidget.cpp b/sources/ui/inditextpropertieswidget.cpp index 0ebe9ba15..afb2e0157 100644 --- a/sources/ui/inditextpropertieswidget.cpp +++ b/sources/ui/inditextpropertieswidget.cpp @@ -27,6 +27,17 @@ #include #include +/** + @return width as shown in the width spin box: whole pixels, 0 for the + automatic width ("Auto"). The box starts at 0, so no number means Auto + and the first step up is 1 px; IndependentTextItem::setTextWidth() + treats 0 as -1, the automatic width, so the two never disagree. +*/ +static int spinBoxWidth(qreal width) +{ + return width > 0 ? qRound(width) : 0; +} + /** @brief IndiTextPropertiesWidget::IndiTextPropertiesWidget @param text : the text to edit @@ -84,6 +95,7 @@ void IndiTextPropertiesWidget::setText(IndependentTextItem *text) m_connect_list << connect(m_text.data(), &IndependentTextItem::rotationChanged, this, &IndiTextPropertiesWidget::updateUi); m_connect_list << connect(m_text.data(), &IndependentTextItem::fontChanged, this, &IndiTextPropertiesWidget::updateUi); m_connect_list << connect(m_text.data(), &IndependentTextItem::textEdited, this, &IndiTextPropertiesWidget::updateUi); + m_connect_list << connect(m_text.data(), &IndependentTextItem::textWidthChanged, this, &IndiTextPropertiesWidget::updateUi); updateUi(); } @@ -209,6 +221,10 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const undo = new QPropertyUndoCommand(m_text.data(), "font", m_text->font(), m_selected_font); undo->setText(tr("Modifier la police d'un champ texte")); } + if (ui->m_width_sb->value() != spinBoxWidth(m_text->textWidth())) { + undo = new QPropertyUndoCommand(m_text.data(), "textWidth", m_text->textWidth(), qreal(ui->m_width_sb->value())); + undo->setText(tr("Modifier la largeur d'un champ texte")); + } return undo; } @@ -218,9 +234,11 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const bool size_equal = true; bool angle_equal = true; bool font_equal = true; + bool width_equal = true; qreal rotation_ = m_text_list.first()->rotation(); int size_ = m_text_list.first()->font().pointSize(); QFont font_ = m_text_list.first()->font(); + int width_ = spinBoxWidth(m_text_list.first()->textWidth()); for (QPointer piti : m_text_list) { if (piti->rotation() != rotation_) { @@ -232,6 +250,9 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const if (piti->font() != font_) { font_equal = false; } + if (spinBoxWidth(piti->textWidth()) != width_) { + width_equal = false; + } } if ((angle_equal && (ui->m_angle_sb->value() != rotation_)) || @@ -279,6 +300,20 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const } } } + else if ((width_equal && (ui->m_width_sb->value() != width_)) || + (!width_equal && (ui->m_width_sb->value() != ui->m_width_sb->minimum()))) + { + for (QPointer piti : m_text_list) + { + if (piti) + { + if (!parent_undo) { + parent_undo = new QUndoCommand(tr("Modifier la largeur de plusieurs champs texte")); + } + new QPropertyUndoCommand(piti.data(), "textWidth", piti->textWidth(), qreal(ui->m_width_sb->value()), parent_undo); + } + } + } return parent_undo; } } @@ -307,6 +342,9 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const if (m_font_is_selected && m_selected_font != m_text->font()) { new QPropertyUndoCommand(m_text.data(), "font", m_text->font(), m_selected_font, undo); } + if (ui->m_width_sb->value() != spinBoxWidth(m_text->textWidth())) { + new QPropertyUndoCommand(m_text.data(), "textWidth", m_text->textWidth(), qreal(ui->m_width_sb->value()), undo); + } if (undo->childCount()) { return undo; @@ -337,6 +375,7 @@ void IndiTextPropertiesWidget::setUpEditConnection() m_edit_connection << connect(ui->m_line_edit, &QLineEdit::textEdited, this, &IndiTextPropertiesWidget::apply); } m_edit_connection << connect(ui->m_angle_sb, QOverload::of(&QDoubleSpinBox::valueChanged), this, &IndiTextPropertiesWidget::apply); + m_edit_connection << connect(ui->m_width_sb, QOverload::of(&QSpinBox::valueChanged), this, &IndiTextPropertiesWidget::apply); m_edit_connection << connect(ui->m_size_sb, QOverload::of(&QSpinBox::valueChanged), [this]() { this->m_selected_font.setPointSize(ui->m_size_sb->value()); @@ -372,6 +411,7 @@ void IndiTextPropertiesWidget::updateUi() ui->m_line_edit->setText(m_text->toPlainText()); ui->m_angle_sb->setValue(m_text->rotation()); ui->m_size_sb->setValue(m_text->font().pointSize()); + ui->m_width_sb->setValue(spinBoxWidth(m_text->textWidth())); ui->m_line_edit->setDisabled(m_text->isHtml() ? true : false); ui->m_size_sb->setDisabled(m_text->isHtml() ? true : false); @@ -385,9 +425,11 @@ void IndiTextPropertiesWidget::updateUi() bool size_equal = true; bool angle_equal = true; bool font_equal = true; + bool width_equal = true; qreal rotation_ = m_text_list.first()->rotation(); int size_ = m_text_list.first()->font().pointSize(); QFont font_ = m_text_list.first()->font(); + int width_ = spinBoxWidth(m_text_list.first()->textWidth()); for (QPointer piti : m_text_list) { @@ -400,8 +442,13 @@ void IndiTextPropertiesWidget::updateUi() if (piti->font() != font_) { font_equal = false; } + if (spinBoxWidth(piti->textWidth()) != width_) { + width_equal = false; + } } ui->m_angle_sb->setValue(angle_equal ? rotation_ : 0); + //Different widths show the minimum, as the other boxes do + ui->m_width_sb->setValue(width_equal ? width_ : ui->m_width_sb->minimum()); bool valid_ = true; for (QPointer piti : m_text_list) { diff --git a/sources/ui/inditextpropertieswidget.ui b/sources/ui/inditextpropertieswidget.ui index 6b370bf5f..1572b1a34 100644 --- a/sources/ui/inditextpropertieswidget.ui +++ b/sources/ui/inditextpropertieswidget.ui @@ -64,7 +64,7 @@ - + Qt::Vertical @@ -126,7 +126,39 @@ - + + + + Largeur : + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Largeur du texte, le texte passe à la ligne. Auto : pas de retour à la ligne automatique + + + Auto + + + px + + + 0 + + + 10000 + + + 0 + + + + Le contenu, la taille et la police du texte ne peuvent être modifié car formaté en html. @@ -140,7 +172,7 @@ Veuillez utiliser l'éditeur avancé pour cela. - + Cliquez ici pour annuler le formatage html @@ -171,6 +203,7 @@ Veuillez utiliser l'éditeur avancé pour cela. m_y_sb m_angle_sb m_size_sb + m_width_sb m_break_html_pb diff --git a/sources/ui/titleblockpropertieswidget.cpp b/sources/ui/titleblockpropertieswidget.cpp index 88bdd4842..7b83c9155 100644 --- a/sources/ui/titleblockpropertieswidget.cpp +++ b/sources/ui/titleblockpropertieswidget.cpp @@ -336,6 +336,30 @@ QString TitleBlockPropertiesWidget::currentTitleBlockTemplateName() const return QString(); } +/** + @brief TitleBlockPropertiesWidget::currentTitleBlockHeight + @return the height of the current title block template +*/ +int TitleBlockPropertiesWidget::currentTitleBlockHeight() const +{ + const TitleBlockTemplate *tbt = nullptr; + if (!currentTitleBlockTemplateName().isEmpty()) + tbt = templateForIndex(ui -> m_tbt_cb -> currentIndex()); + if (!tbt) + tbt = QETApp::defaultTitleBlockTemplate(); + return tbt ? tbt -> height() : 0; +} + +/** + @brief TitleBlockPropertiesWidget::currentTitleBlockEdge + @return the edge of the folio the title block is displayed at +*/ +Qt::Edge TitleBlockPropertiesWidget::currentTitleBlockEdge() const +{ + return ui -> m_display_at_cb -> currentIndex() == 0 ? Qt::BottomEdge + : Qt::RightEdge; +} + /** @brief TitleBlockPropertiesWidget::addCollection add a collection of title block available in the combo box @@ -388,6 +412,15 @@ void TitleBlockPropertiesWidget::initDialog( this, &TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate); + auto size_changed = [this]() { + emit titleBlockSizeChanged(currentTitleBlockHeight(), + currentTitleBlockEdge()); + }; + connect(ui->m_tbt_cb, qOverload(&QComboBox::currentIndexChanged), + this, size_changed); + connect(ui->m_display_at_cb, qOverload(&QComboBox::currentIndexChanged), + this, size_changed); + if (project!= nullptr){ keys_2 = project -> folioAutoNum().keys(); foreach (QString str, keys_2) { ui -> auto_page_cb -> addItem(str); } diff --git a/sources/ui/titleblockpropertieswidget.h b/sources/ui/titleblockpropertieswidget.h index 127f04c9c..4804f9837 100644 --- a/sources/ui/titleblockpropertieswidget.h +++ b/sources/ui/titleblockpropertieswidget.h @@ -70,6 +70,8 @@ class TitleBlockPropertiesWidget : public QWidget void setTitleBlockTemplatesVisible(const bool &visible); void setReadOnly (const bool &ro); + int currentTitleBlockHeight() const; + Qt::Edge currentTitleBlockEdge() const; static void addTemplateVariables( DiagramContext &context, @@ -96,6 +98,7 @@ class TitleBlockPropertiesWidget : public QWidget signals: void set_auto_page_num() const; void openAutoNumFolioEditor (QString); + void titleBlockSizeChanged(int height, Qt::Edge edge); private: Ui::TitleBlockPropertiesWidget *ui; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 78a782061..683b745f0 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -66,6 +66,22 @@ include(../../cmake/fetch_pugixml.cmake) enable_testing() +add_executable(tst_darkimagelayers tst_darkimagelayers.cpp + ${QET_DIR}/sources/palettegraphicsview.cpp ${QET_DIR}/sources/palettegraphicsview.h + ${QET_DIR}/sources/qetpalette.cpp) +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_darkimageintegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_image_dark_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/image_dark_integration_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_darkimageintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) +endif() + add_executable(${PROJECT_NAME} tst_mytest.cpp) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) @@ -264,6 +280,80 @@ set_tests_properties(tst_shortcutspage PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=o target_include_directories(tst_shortcutspage PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_shortcutspage PRIVATE Qt::Test Qt::Widgets Qt::Svg) +# ToolbarSettings and its configuration page: defaults change nothing, +# saved values reach every open window that took them, panels are left alone. +add_executable( + tst_toolbarsettings + tst_toolbarsettings.cpp + ${QET_DIR}/sources/toolbarsettings.cpp + ${QET_DIR}/sources/toolbarsettings.h + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_toolbarsettings COMMAND tst_toolbarsettings) +set_tests_properties(tst_toolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_toolbarsettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_toolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) + +# DiagramToolbarSettings and its configuration page: unchanged toolbars +# store nothing and build as before, unknown ids and stray separators are +# skipped, the user's own toolbars are kept, a widget is in one place only. +add_executable( + tst_diagramtoolbarsettings + tst_diagramtoolbarsettings.cpp + ${QET_DIR}/sources/diagramtoolbarsettings.cpp + ${QET_DIR}/sources/diagramtoolbarsettings.h + ${QET_DIR}/sources/shortcutmanager.cpp + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_diagramtoolbarsettings COMMAND tst_diagramtoolbarsettings) +set_tests_properties(tst_diagramtoolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_diagramtoolbarsettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_diagramtoolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) + +# GestureSettings, the gesture ring's 4 / 8 directions and empty slots, and +# its configuration page: the ring follows the shortcut bar until the user +# picks commands, then keeps each one in its direction. +add_executable( + tst_gesturesettings + tst_gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.h + ${QET_DIR}/sources/shortcutbarsettings.cpp + ${QET_DIR}/sources/diagramgestureoverlay.cpp + ${QET_DIR}/sources/diagramgestureoverlay.h + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/shortcutmanager.cpp + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_gesturesettings COMMAND tst_gesturesettings) +set_tests_properties(tst_gesturesettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_gesturesettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_gesturesettings PRIVATE Qt::Test Qt::Widgets Qt::Svg Qt::Xml pugixml::pugixml) + +# CustomizeDialog: the configuration pages as tabs of one window; OK +# applies every page once, Cancel none. +add_executable( + tst_customizedialog + tst_customizedialog.cpp + ${QET_DIR}/sources/ui/customizedialog.cpp + ${QET_DIR}/sources/ui/customizedialog.h + ${QET_DIR}/sources/ui/configpage/configpage.h) +add_test(NAME tst_customizedialog COMMAND tst_customizedialog) +set_tests_properties(tst_customizedialog PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_customizedialog PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_customizedialog PRIVATE Qt::Test Qt::Widgets) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. @@ -462,6 +552,28 @@ target_link_libraries(tst_dxfwrappedtext PRIVATE Qt::Test) target_compile_definitions(tst_dxfwrappedtext PRIVATE "QET_TEST_BINARY_PATH=\"$\"") +# --export-dxf on a folio with its title block at the right (issue #1339): +# a circle and an arc stay one CIRCLE and one ARC, the title block is turned. +add_executable( + tst_dxfcurves + tst_dxfcurves.cpp) +add_test(NAME tst_dxfcurves COMMAND tst_dxfcurves) +add_dependencies(tst_dxfcurves qelectrotech) +target_link_libraries(tst_dxfcurves PRIVATE Qt::Test) +target_compile_definitions(tst_dxfcurves PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + +# --export-dxf --dxf-blocks: a symbol placed twice is one BLOCK and two +# INSERTs (issue #1339). +add_executable( + tst_dxfblocks + tst_dxfblocks.cpp) +add_test(NAME tst_dxfblocks COMMAND tst_dxfblocks) +add_dependencies(tst_dxfblocks qelectrotech) +target_link_libraries(tst_dxfblocks PRIVATE Qt::Test) +target_compile_definitions(tst_dxfblocks PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # --export-pdf puts an A3 folio on an A3 page whichever way it is turned: # QPageSize matches standard sheets upright only, so a wide folio is matched # upright and turned. fixtures/pdf_page_a3.qet has one folio each way. diff --git a/tests/qttest/fixtures/dxf_blocks.qet b/tests/qttest/fixtures/dxf_blocks.qet new file mode 100644 index 000000000..957427f80 --- /dev/null +++ b/tests/qttest/fixtures/dxf_blocks.qet @@ -0,0 +1,79 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + dxf_blocks + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + K1 + Schneider + + + + K1 + label + + + + + + + + + K2 + + + + K2 + label + + + + + + + + + + Imported elements + + + + + + Circle and arc + + + + + + + + + + diff --git a/tests/qttest/fixtures/dxf_curves.qet b/tests/qttest/fixtures/dxf_curves.qet new file mode 100644 index 000000000..d6033b5d6 --- /dev/null +++ b/tests/qttest/fixtures/dxf_curves.qet @@ -0,0 +1,56 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + dxf_curves + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Imported elements + + + + + + Circle and arc + + + + + + + + + + diff --git a/tests/qttest/image_dark_integration_probe.cpp b/tests/qttest/image_dark_integration_probe.cpp new file mode 100644 index 000000000..23d86f61c --- /dev/null +++ b/tests/qttest/image_dark_integration_probe.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Linked by run_image_dark_probe.py against the application's own objects. +#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/qetpalette.h" +#include "../../sources/palettegraphicsview.h" +#include "../../sources/qetgraphicsitem/diagramimageitem.h" +#include "../../sources/ui/imagepropertieswidget.h" +#include "../../sources/PropertiesEditor/propertieseditordialog.h" + +static void check(bool value, const char *message) { if (!value) throw std::runtime_error(message); } +static QByteArray png(const QPixmap &pixmap) { + QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); pixmap.save(&buffer,"PNG"); return bytes; +} +static QByteArray read(const QString &path) { QFile file(path); check(file.open(QIODevice::ReadOnly),"read export"); return file.readAll(); } +static DiagramImageItem *findImage(Diagram *diagram) { + for (auto *item : diagram->items()) if (auto *image = dynamic_cast(item)) return image; + return nullptr; +} +int main(int argc, char **argv) +{ + QApplication app(argc,argv); + std::freopen(argv[2],"w",stdout); std::freopen(argv[2],"a",stderr); + QTemporaryDir settings; + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat,QSettings::UserScope,settings.path()); + QCoreApplication::setOrganizationName("QETImageDarkRegression"); + 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,"load fixture"); + auto *diagram=project.diagrams().first(); + QImage original(40,30,QImage::Format_ARGB32); original.fill(QColor(197,81,33)); + for(int y=0;y<30;++y) for(int x=10;x<20;++x) original.setPixelColor(x,y,QColor(197,81,33,128)); + for(int y=10;y<16;++y) for(int x=24;x<30;++x) original.setPixelColor(x,y,Qt::transparent); + QPixmap source=QPixmap::fromImage(original); + auto *image=new DiagramImageItem(source); diagram->addItem(image); + image->setPos(55,55); image->setLabel("Image test"); image->setRotationAngle(17); image->setScaleFactorX(1.3); image->setSkewX(8); + check(!image->adaptToDarkTheme(),"new image defaults to original colours"); + QDomDocument xml; auto legacy=image->toXml(xml); + check(!legacy.hasAttribute("adapt_to_dark_theme"),"default does not add an attribute to old files"); + const QRect crop(4,3,24,20); + legacy.firstChild().setNodeValue(QString::fromLatin1(png(source.copy(crop)).toBase64())); + auto base=xml.createElement("image_base"); base.appendChild(xml.createTextNode(QString::fromLatin1(png(source).toBase64()))); legacy.appendChild(base); + auto cropElement=xml.createElement("crop"); cropElement.setAttribute("x",4); cropElement.setAttribute("y",3); cropElement.setAttribute("w",24); cropElement.setAttribute("h",20); legacy.appendChild(cropElement); + image->setAdaptToDarkTheme(true); check(image->fromXml(legacy),"load cropped legacy image"); + check(!image->adaptToDarkTheme(),"missing attribute resets an existing image to original colours"); + check(image->pixmap().size()==crop.size(),"crop survives"); + const QByteArray pristine=png(image->pixmap()); const auto transform=image->transform(); const auto bounds=image->boundingRect(); + ImagePropertiesWidget widget(image); auto *box=widget.findChild("m_adapt_to_dark_theme_cb"); + check(box && !box->isChecked(),"unchecked properties default"); + check(box->text()==QString::fromUtf8("Adapter l’image au thème sombre"),"checkbox wording"); + check(box->toolTip()==QString::fromUtf8("Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés."),"tooltip wording"); + box->setChecked(true); check(image->adaptToDarkTheme(),"preview"); widget.reset(); check(!image->adaptToDarkTheme(),"reset cancels preview"); + diagram->undoStack().clear(); box->setChecked(true); widget.apply(); check(image->adaptToDarkTheme(),"apply"); + diagram->undoStack().undo(); check(!image->adaptToDarkTheme()&&!box->isChecked(),"undo updates checkbox"); + diagram->undoStack().redo(); check(image->adaptToDarkTheme()&&box->isChecked(),"redo updates checkbox"); + widget.setLiveEdit(true); box->click(); check(!image->adaptToDarkTheme(),"live edit"); + check(diagram->undoStack().count()==2,"deliberate toggles do not merge"); + diagram->undoStack().undo(); check(image->adaptToDarkTheme(),"live undo"); + diagram->undoStack().redo(); check(!image->adaptToDarkTheme(),"live redo"); + box->click(); check(image->adaptToDarkTheme()&&diagram->undoStack().count()==3,"edit after undo has current baseline"); + for (auto role : {QDialogButtonBox::Cancel,QDialogButtonBox::Reset,QDialogButtonBox::Apply}) { + image->setAdaptToDarkTheme(false); + auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor); + editor->findChild("m_adapt_to_dark_theme_cb")->setChecked(true); + dialog.findChild()->button(role)->click(); + check(image->adaptToDarkTheme()==(role==QDialogButtonBox::Apply),"actual dialog button semantics"); + } + check(png(image->pixmap())==pristine && image->transform()==transform && image->boundingRect()==bounds && image->label()=="Image test","choice cannot modify pixels, transform, crop or caption"); + image->setAdaptToDarkTheme(false); + { + auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor); + editor->findChild("m_adapt_to_dark_theme_cb")->setChecked(true); + dialog.reject(); check(!image->adaptToDarkTheme(),"Escape/title-bar rejection cancels preview"); + } + PaletteGraphicsView view(diagram); view.setSceneRect(0,0,180,180); view.setFrameShape(QFrame::NoFrame); + view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.setAlignment(Qt::AlignLeft|Qt::AlignTop); view.resize(180,180); view.setAttribute(Qt::WA_DontShowOnScreen); view.show(); + for (bool dark : {false,true}) for(bool adapt : {false,true}) { + const auto palette=dark?QET::Palette::fusionDark():QET::Palette::fusionLight(); QApplication::setPalette(palette); + image->setAdaptToDarkTheme(adapt); QApplication::processEvents(); const auto shown=view.viewport()->grab().toImage(); + shown.save(output+QString("/screen-%1-%2.png").arg(dark).arg(adapt)); + const auto point=view.mapFromScene(image->mapToScene(QPointF(2,2))); const auto ratio=shown.devicePixelRatio(); + QImage expected(1,1,QImage::Format_RGB32); expected.fill(QColor(197,81,33)); + if(dark&&adapt) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text)); + check(shown.pixelColor(qRound(point.x()*ratio),qRound(point.y()*ratio))==expected.pixelColor(0,0),"actual rotated/cropped image display colour"); + project.setFilePath(output+QString("/project-%1-%2.qet").arg(dark).arg(adapt)); check(project.write().isOk(),"project save"); + QETProject reopened(project.filePath()); check(reopened.state()==QETProject::Ok,"project reopen"); + auto *loaded=findImage(reopened.diagrams().first()); check(loaded && loaded->adaptToDarkTheme()==adapt,"per-image persisted choice"); + check(png(loaded->pixmap())==pristine && loaded->transform()==transform && loaded->label()=="Image test","reopen keeps original image/crop/transform/caption"); + } + QApplication::setPalette(QET::Palette::fusionDark()); QImage exports[2]; + for(int i=0;i<2;++i) { + image->setAdaptToDarkTheme(i); exports[i]=QImage(180,180,QImage::Format_RGB32); exports[i].fill(Qt::white); + {QPainter painter(&exports[i]); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + exports[i].save(output+QString("/export-%1.png").arg(i)); + QSvgGenerator svg; svg.setFileName(output+QString("/export-%1.svg").arg(i)); svg.setSize(QSize(180,180)); svg.setViewBox(QRect(0,0,180,180)); + {QPainter painter(&svg); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + QPrinter printer; printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(output+QString("/print-%1.pdf").arg(i)); printer.setResolution(72); + {QPainter painter(&printer); check(painter.isActive(),"PDF print painter"); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + } + check(exports[0]==exports[1],"raster export unchanged"); check(read(output+"/export-0.svg")==read(output+"/export-1.svg"),"SVG export unchanged"); + std::puts("PASS: image defaults, XML crop/alpha/transform/label, properties preview/apply/reset/cancel, undo/redo and live edit, four display modes, project save/reopen, raster/SVG export, PDF print generation"); + } catch(const std::exception &error) {std::fprintf(stderr,"FAIL: %s\n",error.what());return 1;} + return 0; +} diff --git a/tests/qttest/run_image_dark_probe.py b/tests/qttest/run_image_dark_probe.py new file mode 100644 index 000000000..cd5831d90 --- /dev/null +++ b/tests/qttest/run_image_dark_probe.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Link the real image/widget 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 +import zlib + +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.', 'image_dark_integration_probe.cpp.') +probe_binary = binary.with_name('image_dark_integration_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 / 'image-dark-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 / 'image-dark-integration' +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')) +if result.returncode == 0: + def print_streams(path): + streams = re.findall(rb'\nstream\r?\n(.*?)\nendstream', path.read_bytes(), re.DOTALL) + decoded = [] + for stream in streams: + try: + data = zlib.decompress(stream) + except zlib.error: + data = stream + # Qt assigns every PDF a fresh XMP DocumentID (and dates). + # Metadata is not printing content and must not be compared + # as if it were a graphics/image/font stream. + if b'. +*/ +#include "ui/customizedialog.h" +#include "ui/configpage/configpage.h" + +#include +#include +#include +#include +#include + +/// A configuration page that counts how often it was applied +class CountingPage : public ConfigPage +{ + public: + CountingPage(const QString &title) : ConfigPage(nullptr), m_title(title) {} + void applyConf() override { ++applied; } + QString title() const override { return m_title; } + QIcon icon() const override { return QIcon(); } + int applied = 0; + private: + QString m_title; +}; + +/** + CustomizeDialog: one tab per page in the order added, OK applies every + page once, Cancel applies none. +*/ +class tst_customizedialog : public QObject +{ + Q_OBJECT + +private slots: + void tabsInOrder() + { + CustomizeDialog dialog; + auto *a = new CountingPage(QStringLiteral("Barres d'outils")); + auto *b = new CountingPage(QStringLiteral("Clavier")); + dialog.addPage(a); + dialog.addPage(b); + dialog.addPage(a); // twice: ignored + dialog.addPage(nullptr); + auto *tabs = dialog.findChild(QStringLiteral("customizeTabs")); + QVERIFY(tabs); + QCOMPARE(tabs->count(), 2); + QCOMPARE(tabs->tabText(0), QStringLiteral("Barres d'outils")); + QCOMPARE(tabs->tabText(1), QStringLiteral("Clavier")); + QCOMPARE(tabs->widget(1), b); + } + + void okAppliesEveryPage() + { + CustomizeDialog dialog; + auto *a = new CountingPage(QStringLiteral("a")); + auto *b = new CountingPage(QStringLiteral("b")); + dialog.addPage(a); + dialog.addPage(b); + dialog.show(); + dialog.findChild()->button(QDialogButtonBox::Ok)->click(); + QCOMPARE(a->applied, 1); + QCOMPARE(b->applied, 1); + QCOMPARE(dialog.result(), int(QDialog::Accepted)); + } + + void cancelAppliesNothing() + { + CustomizeDialog dialog; + auto *a = new CountingPage(QStringLiteral("a")); + dialog.addPage(a); + dialog.show(); + dialog.findChild()->button(QDialogButtonBox::Cancel)->click(); + QCOMPARE(a->applied, 0); + QCOMPARE(dialog.result(), int(QDialog::Rejected)); + } +}; + +QTEST_MAIN(tst_customizedialog) +#include "tst_customizedialog.moc" diff --git a/tests/qttest/tst_darkimagelayers.cpp b/tests/qttest/tst_darkimagelayers.cpp new file mode 100644 index 000000000..ac6261dee --- /dev/null +++ b/tests/qttest/tst_darkimagelayers.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include "darkimagerendering.h" +#include "palettegraphicsview.h" +#include "qetpalette.h" + +class Raster : public QGraphicsItem { +public: + QPixmap pixmap; + bool adapt = false; + explicit Raster(QGraphicsItem *parent = nullptr) : QGraphicsItem(parent) { + QImage image(40, 30, QImage::Format_ARGB32); + image.fill(QColor(197, 81, 33)); + for (int y = 0; y < 30; ++y) + for (int x = 10; x < 20; ++x) image.setPixelColor(x, y, QColor(197,81,33,128)); + for (int y = 0; y < 30; ++y) + for (int x = 30; x < 40; ++x) image.setPixelColor(x, y, Qt::transparent); + pixmap = QPixmap::fromImage(image); + } + QRectF boundingRect() const override { return QRectF(0,0,40,40); } + void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override { + DarkImageRendering::paintPixmap(p, pixmap, adapt); + p->fillRect(QRectF(0,34,8,4), Qt::black); // caption/decorations remain adapted + } +}; + +class tst_darkimagelayers : public QObject { + Q_OBJECT + static QColor sample(const QImage &image, const PaletteGraphicsView &view, QPointF scene) { + const QPoint point = view.mapFromScene(scene); + const qreal dpr = image.devicePixelRatio(); + return image.pixelColor(qRound(point.x()*dpr), qRound(point.y()*dpr)); + } + static void setup(PaletteGraphicsView &view) { + view.setFrameShape(QFrame::NoFrame); + view.setAlignment(Qt::AlignLeft | Qt::AlignTop); + view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.resize(100,80); view.show(); + } +private slots: + void cleanup() { + QApplication::setPalette(QET::Palette::fusionLight()); + PaletteGraphicsView::setCustomBackgroundColor(false); + } + void modes_data() { + QTest::addColumn("dark"); QTest::addColumn("adapt"); QTest::addColumn("custom"); + QTest::newRow("light-original") << false << false << false; + QTest::newRow("light-adapt") << false << true << false; + QTest::newRow("dark-original") << true << false << false; + QTest::newRow("dark-adapt") << true << true << false; + QTest::newRow("light-custom-original") << false << false << true; + QTest::newRow("light-custom-adapt") << false << true << true; + QTest::newRow("dark-custom-original") << true << false << true; + QTest::newRow("dark-custom-adapt") << true << true << true; + } + void modes() { + QFETCH(bool,dark); QFETCH(bool,adapt); QFETCH(bool,custom); + PaletteGraphicsView::setCustomBackgroundColor(custom); + const QPalette palette = dark ? QET::Palette::fusionDark() : QET::Palette::fusionLight(); + QApplication::setPalette(palette); + const QColor background = custom ? QColor(80,90,100) : QColor(Qt::white); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(background); + auto *raster = new Raster; raster->adapt = adapt; raster->setPos(10,10); scene.addItem(raster); + scene.addRect(32,10,5,20,QPen(Qt::NoPen),QBrush(Qt::black))->setZValue(1); + PaletteGraphicsView view(&scene); setup(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + const QImage shown = view.viewport()->grab().toImage(); + QImage expected(100,80,QImage::Format_RGB32); + expected.fill(custom ? background : (dark && !adapt ? palette.color(QPalette::Base) : QColor(Qt::white))); + QPainter p(&expected); + if (dark && custom && adapt) { + QImage adapted = raster->pixmap.toImage(); + QET::Palette::invertLightnessLayer(adapted,palette.color(QPalette::Base),palette.color(QPalette::Text)); + p.drawImage(QPoint(10,10),adapted); + } else p.drawPixmap(QPoint(10,10),raster->pixmap); + p.fillRect(QRect(32,10,5,20), dark && !adapt && !custom ? palette.color(QPalette::Text) : QColor(Qt::black)); + p.end(); + if (dark && adapt && !custom) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text)); + for (const QPoint point : {QPoint(15,15),QPoint(25,15),QPoint(45,15),QPoint(34,15)}) + QCOMPARE(sample(shown,view,point), expected.pixelColor(point)); + QCOMPARE(sample(shown,view,QPoint(13,45)), dark && !custom ? palette.color(QPalette::Text) : QColor(Qt::black)); + } + void parentClippingRotationOpacityAndOverlap() { + const auto palette = QET::Palette::fusionDark(); QApplication::setPalette(palette); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white); + auto *parent = scene.addRect(0,0,25,40,QPen(Qt::NoPen),QBrush(Qt::NoBrush)); + parent->setFlag(QGraphicsItem::ItemClipsChildrenToShape); parent->setPos(35,15); parent->setRotation(12); + auto *first = new Raster(parent); first->setOpacity(0.5); + auto *second = new Raster; second->setPos(36,30); scene.addItem(second); second->setZValue(2); + QImage blue(40,30,QImage::Format_ARGB32); blue.fill(QColor(20,80,210,128)); second->pixmap=QPixmap::fromImage(blue); + PaletteGraphicsView view(&scene); setup(view); QVERIFY(QTest::qWaitForWindowExposed(&view)); + const auto shown = view.viewport()->grab().toImage(); + // Original pixels blend once over the dark sheet, then the upper + // original image blends once over those pixels. No inverse transform. + QImage reference(1,1,QImage::Format_RGB32); reference.fill(palette.color(QPalette::Base)); + QPainter p(&reference); p.setOpacity(0.5); p.fillRect(reference.rect(),QColor(197,81,33)); p.end(); + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,5))),reference.pixelColor(0,0)); + QPainter upper(&reference); upper.fillRect(reference.rect(),QColor(20,80,210,128)); upper.end(); + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,20))),reference.pixelColor(0,0)); + // Pixel in the child's transparent stripe / beyond the parent's clip. + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(28,5))),palette.color(QPalette::Base)); + first->setPos(3,0); scene.update(); QApplication::processEvents(); + QVERIFY(!view.viewport()->grab().isNull()); + } + void alphaMapping() { + QImage image(2,1,QImage::Format_ARGB32_Premultiplied); + image.setPixelColor(0,0,QColor(0,0,0,128)); image.setPixelColor(1,0,Qt::transparent); + QET::Palette::invertLightnessLayer(image,QColor(30,30,30),QColor(220,220,220)); + QCOMPARE(image.pixelColor(0,0),QColor(220,220,220,128)); + QCOMPARE(image.pixelColor(1,0).alpha(),0); + } + void exportUnaffected() { + QApplication::setPalette(QET::Palette::fusionDark()); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white); + auto *raster=new Raster; raster->setPos(10,10); scene.addItem(raster); + QImage results[2]; + for(int i=0;i<2;++i) { + raster->adapt=i; results[i]=QImage(100,80,QImage::Format_RGB32); results[i].fill(Qt::white); + QPainter p(&results[i]); scene.render(&p,QRectF(0,0,100,80),scene.sceneRect()); + } + QCOMPARE(results[0],results[1]); + QCOMPARE(results[0].pixelColor(15,15),QColor(197,81,33)); + } +}; +QTEST_MAIN(tst_darkimagelayers) +#include "tst_darkimagelayers.moc" diff --git a/tests/qttest/tst_diagramtoolbarsettings.cpp b/tests/qttest/tst_diagramtoolbarsettings.cpp new file mode 100644 index 000000000..9c77cc8d9 --- /dev/null +++ b/tests/qttest/tst_diagramtoolbarsettings.cpp @@ -0,0 +1,245 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "diagramtoolbarsettings.h" +#include "shortcutmanager.h" +#include "ui/configpage/toolbarcommandsconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + A top-level window standing in for the diagram editor: counts the + calls applyToAll() makes. +*/ +class RebuildCounter : public QWidget +{ + Q_OBJECT + public: + int rebuilds = 0; + public slots: + void rebuildToolBars() { ++rebuilds; } +}; + +/** + DiagramToolbarSettings and its configuration page: an unchanged + toolbar stores nothing and is built as before, unknown ids and stray + separators are skipped, the user's own toolbars are kept and removed + with their contents, and the page saves what it shows. +*/ +class tst_diagramtoolbarsettings : public QObject +{ + Q_OBJECT + + QObject m_owner; + QHash m_actions; + + /// The action for @a id, made on first use, as the editor's resolver + QAction *resolve(const QString &id) + { + if (id.startsWith(QLatin1String("missing."))) return nullptr; + if (!m_actions.contains(id)) { + auto *action = new QAction(id, &m_owner); + m_actions.insert(id, action); + } + return m_actions.value(id); + } + + static QStringList shape(QToolBar *toolbar) + { + QStringList list; + for (QAction *action : toolbar->actions()) { + list << (action->isSeparator() ? QStringLiteral("|") : action->text()); + } + return list; + } + + static void clearSettings() + { + QSettings settings; + settings.remove(QStringLiteral("diagrameditor/toolbars")); + settings.remove(QStringLiteral("diagrameditor/custom_toolbars")); + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + clearSettings(); + //A few of the diagram editor's commands, as it registers them + for (const QString &id : {QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.paste"), + QStringLiteral("diagrameditor.zoom_fit"), + QStringLiteral("depth.raise"), + QStringLiteral("elementeditor.copy")}) { + ShortcutManager::instance().registerAction( + new QAction(id, &m_owner), id, QStringLiteral("test"), QKeySequence()); + } + } + + void init() { clearSettings(); } + + void defaultsStoreNothing() + { + for (const QString &name : DiagramToolbarSettings::builtInNames()) { + QVERIFY(!DiagramToolbarSettings::defaultIds(name).isEmpty()); + QCOMPARE(DiagramToolbarSettings::ids(name), DiagramToolbarSettings::defaultIds(name)); + DiagramToolbarSettings::setIds(name, DiagramToolbarSettings::defaultIds(name)); + } + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/toolbars/toolbar"))); + + DiagramToolbarSettings::setIds(QStringLiteral("toolbar"), {QStringLiteral("diagrameditor.copy")}); + QCOMPARE(DiagramToolbarSettings::ids(QStringLiteral("toolbar")), + QStringList{QStringLiteral("diagrameditor.copy")}); + //Saving an empty toolbar keeps it empty, not back to the defaults + DiagramToolbarSettings::setIds(QStringLiteral("toolbar"), {}); + QVERIFY(DiagramToolbarSettings::ids(QStringLiteral("toolbar")).isEmpty()); + } + + /// The defaults are built exactly as the toolbars were hand-built + /// before: the main toolbar's three separators in place + void defaultsBuildAsBefore() + { + QToolBar toolbar; + DiagramToolbarSettings::fill(&toolbar, DiagramToolbarSettings::defaultIds(QStringLiteral("toolbar")), + [this](const QString &id) { return resolve(id); }); + QCOMPARE(toolbar.actions().size(), 18); + QCOMPARE(shape(&toolbar).mid(6, 2), + (QStringList{QStringLiteral("diagrameditor.export_to_pdf"), QStringLiteral("|")})); + QCOMPARE(shape(&toolbar).count(QStringLiteral("|")), 3); + } + + void fillSkipsUnknownAndStraySeparators() + { + const QString sep = DiagramToolbarSettings::separatorId(); + QToolBar toolbar; + toolbar.addAction(QStringLiteral("left over")); + DiagramToolbarSettings::fill(&toolbar, + {sep, QStringLiteral("a"), sep, sep, QStringLiteral("missing.b"), + QStringLiteral("c"), sep, QStringLiteral("missing.d"), sep}, + [this](const QString &id) { return resolve(id); }); + QCOMPARE(shape(&toolbar), (QStringList{QStringLiteral("a"), QStringLiteral("|"), QStringLiteral("c")})); + + DiagramToolbarSettings::fill(&toolbar, {}, [this](const QString &id) { return resolve(id); }); + QVERIFY(toolbar.actions().isEmpty()); + } + + void customToolbarsKeptAndRemoved() + { + using T = DiagramToolbarSettings::Toolbar; + const QString first = DiagramToolbarSettings::newCustomName({}); + QCOMPARE(first, QStringLiteral("custom_1")); + DiagramToolbarSettings::setCustomToolbars({T{first, QStringLiteral("Mine"), true}, + T{QStringLiteral("custom_2"), QStringLiteral("Other"), true}}); + DiagramToolbarSettings::setIds(first, {QStringLiteral("diagrameditor.copy")}); + QCOMPARE(DiagramToolbarSettings::customToolbars().size(), 2); + QCOMPARE(DiagramToolbarSettings::customToolbars().first().title, QStringLiteral("Mine")); + QCOMPARE(DiagramToolbarSettings::toolbars().size(), + DiagramToolbarSettings::builtInNames().size() + 2); + QCOMPARE(DiagramToolbarSettings::newCustomName({}), QStringLiteral("custom_3")); + //A new toolbar starts empty + QVERIFY(DiagramToolbarSettings::ids(QStringLiteral("custom_2")).isEmpty()); + + DiagramToolbarSettings::setCustomToolbars({T{QStringLiteral("custom_2"), QStringLiteral("Other"), true}}); + QCOMPARE(DiagramToolbarSettings::customToolbars().size(), 1); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/toolbars/") + first)); + + DiagramToolbarSettings::setCustomToolbars({}); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/custom_toolbars/names"))); + } + + void availableCommandsAreTheDiagramEditors() + { + const QStringList ids = DiagramToolbarSettings::availableCommandIds(); + QVERIFY(ids.contains(QStringLiteral("diagrameditor.copy"))); + QVERIFY(ids.contains(QStringLiteral("depth.raise"))); + QVERIFY(!ids.contains(QStringLiteral("elementeditor.copy"))); + } + + void pageSavesAndRebuilds() + { + RebuildCounter marked, unmarked; + DiagramToolbarSettings::markWindow(&marked); + + ToolbarCommandsConfigPage page; + auto *combo = page.findChild(QStringLiteral("toolbarCombo")); + auto *available = page.findChild(QStringLiteral("availableList")); + auto *chosen = page.findChild(QStringLiteral("chosenList")); + QVERIFY(combo && available && chosen); + QCOMPARE(combo->count(), DiagramToolbarSettings::builtInNames().size()); + QCOMPARE(chosen->count(), DiagramToolbarSettings::defaultIds(QStringLiteral("toolbar")).size()); + + //OK without a change stores nothing + page.applyConf(); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/toolbars/toolbar"))); + QCOMPARE(marked.rebuilds, 1); + QCOMPARE(unmarked.rebuilds, 0); + + //Remove everything but the first command, add a separator + while (chosen->count() > 1) delete chosen->takeItem(1); + chosen->setCurrentRow(0); + page.findChild(QStringLiteral("separatorButton"))->click(); + page.applyConf(); + QCOMPARE(DiagramToolbarSettings::ids(QStringLiteral("toolbar")), + (QStringList{QStringLiteral("diagrameditor.new_file"), DiagramToolbarSettings::separatorId()})); + QCOMPARE(marked.rebuilds, 2); + } + + /// A widget button can be in one place only: once moved to another + /// toolbar it is no longer offered, and resetting its own toolbar + /// takes it back + void widgetInOnePlace() + { + ToolbarCommandsConfigPage page; + auto *combo = page.findChild(QStringLiteral("toolbarCombo")); + auto *available = page.findChild(QStringLiteral("availableList")); + auto *chosen = page.findChild(QStringLiteral("chosenList")); + auto offered = [available](const QString &id) { + for (int i = 0 ; i < available->count() ; ++i) + if (available->item(i)->data(Qt::UserRole).toString() == id) return true; + return false; + }; + const QString colour = QStringLiteral("widget:conductor_color"); + + //On "diagram" by default, so not offered for "toolbar" + QVERIFY(!offered(colour)); + //Taken off "diagram" (switching toolbars keeps the change) + combo->setCurrentIndex(combo->findData(QStringLiteral("diagram"))); + for (int i = chosen->count() - 1 ; i >= 0 ; --i) { + if (chosen->item(i)->data(Qt::UserRole).toString() == colour) delete chosen->takeItem(i); + } + combo->setCurrentIndex(combo->findData(QStringLiteral("toolbar"))); + QVERIFY(offered(colour)); + + //Reset "diagram": the colour button goes back there + combo->setCurrentIndex(combo->findData(QStringLiteral("diagram"))); + page.findChild(QStringLiteral("resetButton"))->click(); + QCOMPARE(chosen->count(), DiagramToolbarSettings::defaultIds(QStringLiteral("diagram")).size()); + combo->setCurrentIndex(combo->findData(QStringLiteral("toolbar"))); + QVERIFY(!offered(colour)); + } +}; + +QTEST_MAIN(tst_diagramtoolbarsettings) +#include "tst_diagramtoolbarsettings.moc" diff --git a/tests/qttest/tst_dxfblocks.cpp b/tests/qttest/tst_dxfblocks.cpp new file mode 100644 index 000000000..308088c9c --- /dev/null +++ b/tests/qttest/tst_dxfblocks.cpp @@ -0,0 +1,147 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include +#include +#include + +/** + --export-dxf --dxf-blocks (issue #1339): a symbol placed twice is one + BLOCK and two INSERTs, the second turned a quarter, and each symbol's + information is a hidden attribute of it; with --dxf-attributes its + label is a visible one. Without the switches there is neither. +*/ +class tst_dxfblocks : public QObject +{ + Q_OBJECT + + /// The group code / value pairs of the exported folio + static QList> exportPairs(const QStringList &extra) + { + const QString project = QFINDTESTDATA("fixtures/dxf_blocks.qet"); + QTemporaryDir dir; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + QStringList{QStringLiteral("--export-dxf"), project, dir.path()} + extra); + if (!proc.waitForFinished(60000) || proc.exitCode() != 0) + return {}; + QFile file(dir.filePath(QStringLiteral("01_diagram.dxf"))); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + const QStringList values = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + QList> pairs; + for (int i = 0 ; i + 1 < values.size() ; i += 2) + pairs << qMakePair(values.at(i).trimmed(), values.at(i + 1).trimmed()); + return pairs; + } + + /// The values of code @a code in each entity of type @a type + static QStringList values(const QList> &pairs, + const QString &type, const QString &code) + { + QStringList found; + for (int i = 0 ; i < pairs.size() ; ++i) { + if (pairs.at(i).first != QLatin1String("0") || pairs.at(i).second != type) + continue; + for (int j = i + 1 ; j < pairs.size() && pairs.at(j).first != QLatin1String("0") ; ++j) + if (pairs.at(j).first == code) + found << pairs.at(j).second; + } + return found; + } + +private slots: + void blocks() + { + const auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); + + QCOMPARE(values(pairs, QStringLiteral("BLOCK"), QStringLiteral("2")), + QStringList{QStringLiteral("QET_CURVES")}); + QCOMPARE(values(pairs, QStringLiteral("INSERT"), QStringLiteral("2")), + (QStringList{QStringLiteral("QET_CURVES"), QStringLiteral("QET_CURVES")})); + QCOMPARE(values(pairs, QStringLiteral("INSERT"), QStringLiteral("50")), + (QStringList{QStringLiteral("0"), QStringLiteral("270")})); + //The circle is drawn once, inside the block + QCOMPARE(values(pairs, QStringLiteral("CIRCLE"), QStringLiteral("8")).size(), 1); + } + + void labelsAsAttributes() + { + const auto pairs = exportPairs({QStringLiteral("--dxf-attributes")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-attributes failed"); + + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")) + .count(QStringLiteral("LABEL")), 2); + const QStringList texts = values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("1")); + QVERIFY(texts.contains(QStringLiteral("K1"))); + QVERIFY(texts.contains(QStringLiteral("K2"))); + //Defined once in the block, and no longer a loose text + QCOMPARE(values(pairs, QStringLiteral("ATTDEF"), QStringLiteral("2")) + .count(QStringLiteral("LABEL")), 1); + QVERIFY(!values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); + } + + void labelsStayTextsWithBlocksOnly() + { + const auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); + //Only hidden attributes (70 = 1); the labels are texts + QVERIFY(!values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("70")).contains(QStringLiteral("0"))); + QVERIFY(values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); + } + + void dataAsHiddenAttributes() + { + //The label is a text, so it is part data too: K1's label and + //manufacturer, K2's label, all hidden (70 = 1) + auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")), + (QStringList{QStringLiteral("LABEL"), QStringLiteral("MANUFACTURER"), + QStringLiteral("LABEL")})); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("70")), + (QStringList{QStringLiteral("1"), QStringLiteral("1"), QStringLiteral("1")})); + QVERIFY(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("1")) + .contains(QStringLiteral("Schneider"))); + + //With the labels as attributes, the label is not repeated + pairs = exportPairs({QStringLiteral("--dxf-attributes")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-attributes failed"); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")), + (QStringList{QStringLiteral("LABEL"), QStringLiteral("MANUFACTURER"), + QStringLiteral("LABEL")})); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("70")), + (QStringList{QStringLiteral("0"), QStringLiteral("1"), QStringLiteral("0")})); + } + + void flatWithoutSwitch() + { + const auto pairs = exportPairs({}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf failed"); + QVERIFY(values(pairs, QStringLiteral("BLOCK"), QStringLiteral("2")).isEmpty()); + QVERIFY(values(pairs, QStringLiteral("INSERT"), QStringLiteral("2")).isEmpty()); + QCOMPARE(values(pairs, QStringLiteral("CIRCLE"), QStringLiteral("8")).size(), 2); + QVERIFY(values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); + } +}; + +QTEST_APPLESS_MAIN(tst_dxfblocks) +#include "tst_dxfblocks.moc" diff --git a/tests/qttest/tst_dxfcurves.cpp b/tests/qttest/tst_dxfcurves.cpp new file mode 100644 index 000000000..c5f558077 --- /dev/null +++ b/tests/qttest/tst_dxfcurves.cpp @@ -0,0 +1,95 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include +#include +#include + +/** + --export-dxf on a folio whose title block is at the right (issue #1339): + a symbol's circle is one CIRCLE and its arc one ARC with the symbol's own + angles, which needs the same scale on both axes; the title block is + turned a quarter, as it is on screen. +*/ +class tst_dxfcurves : public QObject +{ + Q_OBJECT + + /// The entities on @a layer, each as its group codes and values. + static QList> entities(const QStringList &values, + const QString &type, + const QString &layer) + { + QList> found; + for (int i = 0 ; i + 1 < values.size() ; i += 2) + { + if (values.at(i).trimmed() != QLatin1String("0") + || values.at(i + 1).trimmed() != type) + continue; + QMultiHash entity; + for (int j = i + 2 ; j + 1 < values.size() + && values.at(j).trimmed() != QLatin1String("0") ; j += 2) + entity.insert(values.at(j).trimmed().toInt(), values.at(j + 1).trimmed()); + if (entity.value(8) == layer) + found << entity; + } + return found; + } + +private slots: + void curvesAndTitleBlock() + { + const QString project = QFINDTESTDATA("fixtures/dxf_curves.qet"); + QVERIFY2(!project.isEmpty(), "fixture project not found"); + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--export-dxf"), project, dir.path()}); + QVERIFY2(proc.waitForFinished(60000), "--export-dxf timed out"); + QCOMPARE(proc.exitCode(), 0); + + QFile file(dir.filePath(QStringLiteral("01_diagram.dxf"))); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + const QStringList values = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + const QString symbols = QStringLiteral("QET_SYMBOLS"); + + //The 20 px circle: one CIRCLE, nothing approximated + const auto circles = entities(values, QStringLiteral("CIRCLE"), symbols); + QCOMPARE(circles.size(), 1); + QVERIFY(entities(values, QStringLiteral("POLYLINE"), symbols).isEmpty()); + + //The arc from 30 to 150 degrees, same radius as the circle + const auto arcs = entities(values, QStringLiteral("ARC"), symbols); + QCOMPARE(arcs.size(), 1); + QCOMPARE(arcs.first().value(50).toDouble(), 30.0); + QCOMPARE(arcs.first().value(51).toDouble(), 150.0); + QCOMPARE(arcs.first().value(40).toDouble(), circles.first().value(40).toDouble()); + + //Every title block text turned a quarter, as drawn on screen + const auto texts = entities(values, QStringLiteral("TEXT"), QStringLiteral("QET_TITLEBLOCK")); + QVERIFY(!texts.isEmpty()); + for (const auto &text : texts) + QCOMPARE(text.value(50).toDouble(), 90.0); + } +}; + +QTEST_APPLESS_MAIN(tst_dxfcurves) +#include "tst_dxfcurves.moc" diff --git a/tests/qttest/tst_gesturesettings.cpp b/tests/qttest/tst_gesturesettings.cpp new file mode 100644 index 000000000..b06930ab5 --- /dev/null +++ b/tests/qttest/tst_gesturesettings.cpp @@ -0,0 +1,214 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "diagramgestureoverlay.h" +#include "gesturesettings.h" +#include "shortcutmanager.h" +#include "ui/configpage/gesturesconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + The mouse gesture ring's own command list: until the user picks one it + follows the shortcut bar as before; a picked list keeps its directions; + 4 or 8 directions; and the configuration page that edits it. +*/ +class tst_gesturesettings : public QObject +{ + Q_OBJECT + + QMainWindow m_window; + + QAction *addCommand(const QString &id, const QString &text) + { + auto *action = new QAction(text, &m_window); + ShortcutManager::instance().registerAction( + action, id, QStringLiteral("Éditeur de schémas"), QKeySequence()); + return action; + } + + QListWidgetItem *listItem(QListWidget *list, const QString &id) + { + for (int i = 0 ; i < list->count() ; ++i) { + if (list->item(i)->data(Qt::UserRole).toString() == id) { + return list->item(i); + } + } + return nullptr; + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("diagrameditor")); + addCommand(QStringLiteral("diagrameditor.copy"), QStringLiteral("Copier")); + addCommand(QStringLiteral("diagrameditor.cut"), QStringLiteral("Couper")); + addCommand(QStringLiteral("diagrameditor.paste"), QStringLiteral("Coller")); + } + + void cleanup() + { + QSettings().remove(QStringLiteral("diagrameditor")); + } + + void defaultsFollowTheBar() + { + QCOMPARE(GestureSettings::directions(), 8); + for (const auto c : ShortcutBarSettings::contexts()) { + QVERIFY(!GestureSettings::isCustom(c)); + QCOMPARE(GestureSettings::ids(c), ShortcutBarSettings::ids(c)); + } + //A changed bar still reaches the ring, without its elements + ShortcutBarSettings::setIds(ShortcutBarSettings::Selection, + {QStringLiteral("diagrameditor.copy"), + QStringLiteral("common://10_electric/relay.elmt"), + QStringLiteral("diagrameditor.cut")}); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Selection), + QStringList({QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.cut")})); + } + + void pickedListKeepsDirections() + { + GestureSettings::setIds(ShortcutBarSettings::Canvas, + {QStringLiteral("diagrameditor.copy"), QString(), + QStringLiteral("diagrameditor.paste"), QString(), QString()}); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas), + QStringList({QStringLiteral("diagrameditor.copy"), QString(), + QStringLiteral("diagrameditor.paste")})); + //The bar no longer drives it + ShortcutBarSettings::setIds(ShortcutBarSettings::Canvas, + {QStringLiteral("diagrameditor.cut")}); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas).first(), + QStringLiteral("diagrameditor.copy")); + //Other contexts still follow the bar + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Selection)); + } + + void savingTheDefaultsFollowsTheBarAgain() + { + GestureSettings::setIds(ShortcutBarSettings::Conductor, {QStringLiteral("diagrameditor.cut")}); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Conductor)); + GestureSettings::setIds(ShortcutBarSettings::Conductor, + GestureSettings::defaultIds(ShortcutBarSettings::Conductor)); + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Conductor)); + } + + void directions() + { + GestureSettings::setDirections(4); + QCOMPARE(GestureSettings::directions(), 4); + GestureSettings::setDirections(8); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/gestures/directions"))); + QSettings().setValue(QStringLiteral("diagrameditor/gestures/directions"), 5); + QCOMPARE(GestureSettings::directions(), 8); + } + + void overlayDirectionsAndEmptySlots() + { + QWidget viewport; + viewport.resize(400, 400); + DiagramGestureOverlay overlay(&viewport); + QAction *copy = ShortcutManager::instance().action(QStringLiteral("diagrameditor.copy"), &m_window); + QAction *paste = ShortcutManager::instance().action(QStringLiteral("diagrameditor.paste"), &m_window); + const QPoint c(200, 200); + const QPoint right(260, 200), down(200, 260); + + overlay.showAt(c, {copy, nullptr, paste}, 4); + QCOMPARE(overlay.sectors(), 4); + QCOMPARE(overlay.sectorAt(right), 1); + QCOMPARE(overlay.actionAt(right), nullptr); + QCOMPARE(overlay.actionAt(down), paste); + overlay.setPointer(right); + QVERIFY(!overlay.grab().isNull()); + + //Today's ring: eight directions + overlay.showAt(c, {copy, nullptr, paste}); + QCOMPARE(overlay.sectors(), 8); + QCOMPARE(overlay.sectorAt(right), 2); + QCOMPARE(overlay.actionAt(right), paste); + } + + void pagePlacesAndSaves() + { + { + GesturesConfigPage page; + auto *directions = page.findChild(QStringLiteral("directionsCombo")); + auto *list = page.findChild(QStringLiteral("availableList")); + auto *ring = page.findChild(QStringLiteral("ringEditor")); + QCOMPARE(directions->currentData().toInt(), 8); + QCOMPARE(ring->ids(), GestureSettings::defaultIds(ShortcutBarSettings::Canvas)); + + directions->setCurrentIndex(directions->findData(4)); + ring->setCurrentSlot(1); + list->setCurrentItem(listItem(list, QStringLiteral("diagrameditor.cut"))); + page.findChild(QStringLiteral("placeButton"))->click(); + page.applyConf(); + } + QCOMPARE(GestureSettings::directions(), 4); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas).at(1), + QStringLiteral("diagrameditor.cut")); + //Contexts left alone still follow the bar + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Selection)); + + { + GesturesConfigPage page; + page.findChild(QStringLiteral("followBarButton"))->click(); + page.applyConf(); + } + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + } + + void dropOnASlot() + { + GesturesConfigPage page; + auto *list = page.findChild(QStringLiteral("availableList")); + auto *ring = page.findChild(QStringLiteral("ringEditor")); + ring->resize(ring->sizeHint()); + const QModelIndex index = list->model()->index( + list->row(listItem(list, QStringLiteral("diagrameditor.paste"))), 0); + QMimeData *mime = list->model()->mimeData({index}); + //The right-hand slot of eight + const QPointF target = QRectF(ring->rect()).center() + QPointF(68, 0); + QCOMPARE(ring->slotAt(target.toPoint()), 2); + QDropEvent drop(target, Qt::CopyAction, mime, Qt::LeftButton, Qt::NoModifier); + //Straight to the widget: QApplication drops drag events when + //no real drag is running + QVERIFY(static_cast(ring)->event(&drop)); + QVERIFY(drop.isAccepted()); + QCOMPARE(ring->currentSlot(), 2); + QCOMPARE(ring->ids().value(2), QStringLiteral("diagrameditor.paste")); + //Moved, not copied: paste was already on the default canvas ring + QCOMPARE(ring->ids().count(QStringLiteral("diagrameditor.paste")), 1); + delete mime; + } +}; + +QTEST_MAIN(tst_gesturesettings) +#include "tst_gesturesettings.moc" diff --git a/tests/qttest/tst_toolbarsettings.cpp b/tests/qttest/tst_toolbarsettings.cpp new file mode 100644 index 000000000..184b773d1 --- /dev/null +++ b/tests/qttest/tst_toolbarsettings.cpp @@ -0,0 +1,146 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "toolbarsettings.h" +#include "ui/configpage/toolbarsconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + ToolbarSettings and its configuration page: defaults change nothing, + saved values reach every open window that took them, and a toolbar + inside a panel or a window that never took them is left alone. +*/ +class tst_toolbarsettings : public QObject +{ + Q_OBJECT + + QMainWindow *m_window = nullptr; + QToolBar *m_top = nullptr; + QToolBar *m_left = nullptr; + QToolBar *m_in_dock = nullptr; + QMainWindow *m_other = nullptr; + QToolBar *m_other_bar = nullptr; + QSize m_default_size; + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("toolbars")); + + m_window = new QMainWindow(); + m_default_size = m_window->iconSize(); + m_top = new QToolBar(QStringLiteral("top"), m_window); + m_window->addToolBar(Qt::TopToolBarArea, m_top); + //Created without a parent, as some QET toolbars are + m_left = new QToolBar(QStringLiteral("left")); + m_window->addToolBar(Qt::LeftToolBarArea, m_left); + auto *dock = new QDockWidget(m_window); + m_in_dock = new QToolBar(dock); + dock->setWidget(m_in_dock); + m_window->addDockWidget(Qt::LeftDockWidgetArea, dock); + m_window->show(); + + //A window that never takes the settings, like the print window + m_other = new QMainWindow(); + m_other_bar = new QToolBar(QStringLiteral("other"), m_other); + m_other->addToolBar(Qt::TopToolBarArea, m_other_bar); + m_other->show(); + } + + void cleanupTestCase() + { + delete m_window; + delete m_other; + } + + void defaultsChangeNothing() + { + QCOMPARE(ToolbarSettings::iconSize(), 0); + QCOMPARE(ToolbarSettings::buttonStyle(), Qt::ToolButtonIconOnly); + QCOMPARE(ToolbarSettings::locked(), false); + ToolbarSettings::applyTo(m_window); + QCOMPARE(m_window->iconSize(), m_default_size); + QCOMPARE(m_top->iconSize(), m_default_size); + QCOMPARE(m_top->toolButtonStyle(), Qt::ToolButtonIconOnly); + QVERIFY(m_top->isMovable()); + QVERIFY(m_left->isMovable()); + } + + void pageSavesAndAppliesToOpenWindows() + { + { + ToolbarsConfigPage page; + auto *size = page.findChild(QStringLiteral("iconSizeCombo")); + auto *style = page.findChild(QStringLiteral("buttonStyleCombo")); + size->setCurrentIndex(size->findData(32)); + style->setCurrentIndex(style->findData(int(Qt::ToolButtonTextUnderIcon))); + page.findChild(QStringLiteral("lockedCheck"))->setChecked(true); + page.applyConf(); + } + QCOMPARE(ToolbarSettings::iconSize(), 32); + QCOMPARE(ToolbarSettings::buttonStyle(), Qt::ToolButtonTextUnderIcon); + QVERIFY(ToolbarSettings::locked()); + + QCOMPARE(m_top->iconSize(), QSize(32, 32)); + QCOMPARE(m_left->iconSize(), QSize(32, 32)); + QCOMPARE(m_top->toolButtonStyle(), Qt::ToolButtonTextUnderIcon); + QVERIFY(!m_top->isMovable()); + QVERIFY(!m_left->isMovable()); + //A toolbar inside a panel is not one of the window's toolbars + QVERIFY(m_in_dock->isMovable()); + //Nor is a window that did not take the settings when it opened + QCOMPARE(m_other_bar->iconSize(), m_default_size); + QCOMPARE(m_other_bar->toolButtonStyle(), Qt::ToolButtonIconOnly); + QVERIFY(m_other_bar->isMovable()); + + //A page opened now shows what was saved + ToolbarsConfigPage page; + QCOMPARE(page.findChild(QStringLiteral("iconSizeCombo"))->currentData().toInt(), 32); + QVERIFY(page.findChild(QStringLiteral("lockedCheck"))->isChecked()); + } + + void backToDefaultsRemovesTheKeys() + { + ToolbarSettings::save(0, Qt::ToolButtonIconOnly, false); + QVERIFY(!QSettings().contains(QStringLiteral("toolbars/icon_size"))); + QVERIFY(!QSettings().contains(QStringLiteral("toolbars/button_style"))); + QVERIFY(!QSettings().contains(QStringLiteral("toolbars/locked"))); + ToolbarSettings::applyToAll(); + QCOMPARE(m_top->iconSize(), m_default_size); + QCOMPARE(m_top->toolButtonStyle(), Qt::ToolButtonIconOnly); + QVERIFY(m_top->isMovable()); + } + + void unknownStyleFallsBack() + { + QSettings().setValue(QStringLiteral("toolbars/button_style"), 99); + QCOMPARE(ToolbarSettings::buttonStyle(), Qt::ToolButtonIconOnly); + QSettings().remove(QStringLiteral("toolbars/button_style")); + } +}; + +QTEST_MAIN(tst_toolbarsettings) +#include "tst_toolbarsettings.moc" diff --git a/tests/qttest/tst_unreadableelement.cpp b/tests/qttest/tst_unreadableelement.cpp index 906b8da58..2c0af7031 100644 --- a/tests/qttest/tst_unreadableelement.cpp +++ b/tests/qttest/tst_unreadableelement.cpp @@ -89,6 +89,12 @@ private slots: .contains(QStringLiteral("RESULT placed"))); QVERIFY(QFile::exists(embedded)); +#ifdef Q_OS_WIN + // Qt cannot take read permission away on Windows: it only knows the + // read-only flag there, so this returns false. The check needs a + // file the user cannot read, which Windows cannot give it. + QSKIP("Windows cannot make the file unreadable through QFile"); +#endif QVERIFY(QFile::setPermissions(m_symbol, QFileDevice::Permissions())); QFile probe(m_symbol); if (probe.open(QIODevice::ReadOnly))