diff --git a/cmake/fetch_kdeaddons.cmake b/cmake/fetch_kdeaddons.cmake index 76df0a3f4..561ed3550 100644 --- a/cmake/fetch_kdeaddons.cmake +++ b/cmake/fetch_kdeaddons.cmake @@ -23,8 +23,31 @@ if(BUILD_WITH_KF) if(BUILD_KF) - if(NOT DEFINED KF_GIT_TAG) - # this is a more or less random version, taken as an conservative approach + # v6.10.0 is a more or less random version, taken as an conservative + # approach. Pinned to the commits v6.10.0 points at, not to the tags + # themselves; see the note in fetch_pugixml.cmake. Each module lives in its + # own repository, so the same v6.10.0 release is a different commit in + # each. + # + # KDE uses annotated tags, so "git ls-remote 'refs/tags/v6.10.0*'" + # prints two hashes per module: refs/tags/v6.10.0 is the tag object (the + # tagger, the date and the tag message) and refs/tags/v6.10.0^{} is the + # commit that object points at. The hashes below are the "^{}" ones, i.e. + # the commits. Lightweight tags, such as pugixml's v1.15 and + # SingleApplication's v3.2.0, have no tag object and print only the + # commit line. + set(KF_ECM_GIT_COMMIT 7dd28cc56c339c3f8fb356f7c53c0e8f61433d81) # v6.10.0 + set(KF_KCOREADDONS_GIT_COMMIT c569f974dab24b4784ad186a3db4b76b2fa36612) # v6.10.0 + set(KF_KWIDGETSADDONS_GIT_COMMIT 1abbed8a280d6626c59fb197f2c4667d2b1e7445) # v6.10.0 + + if(DEFINED KF_GIT_TAG) + # Explicit override: -DKF_GIT_TAG= selects one ref for all three + # modules, unpinned, exactly as it did before. + set(KF_ECM_GIT_COMMIT ${KF_GIT_TAG}) + set(KF_KCOREADDONS_GIT_COMMIT ${KF_GIT_TAG}) + set(KF_KWIDGETSADDONS_GIT_COMMIT ${KF_GIT_TAG}) + else() + # Keep KF_GIT_TAG defined: define_definitions.cmake reports it. set(KF_GIT_TAG v6.10.0) endif() # using a function in order to limit the scope of the variables @@ -53,19 +76,19 @@ if(BUILD_WITH_KF) FetchContent_Declare( ecm GIT_REPOSITORY https://invent.kde.org/frameworks/extra-cmake-modules.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_ECM_GIT_COMMIT}) FetchContent_MakeAvailable(ecm) FetchContent_Declare( kcoreaddons GIT_REPOSITORY https://invent.kde.org/frameworks/kcoreaddons.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_KCOREADDONS_GIT_COMMIT}) FetchContent_MakeAvailable(kcoreaddons) FetchContent_Declare( kwidgetsaddons GIT_REPOSITORY https://invent.kde.org/frameworks/kwidgetsaddons.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_KWIDGETSADDONS_GIT_COMMIT}) FetchContent_MakeAvailable(kwidgetsaddons) endfunction() qet_make_kf_available() diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 6aef219f7..25f235570 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -22,10 +22,51 @@ option(BUILD_PUGIXML "Build pugixml library, use system one otherwise" YES) if(BUILD_PUGIXML) + # Pinned to the commit v1.15 points at, not to the tag itself. + # + # A git tag is only a named pointer to a commit, and anyone with push access + # to the upstream repository can move it (git push --force) to any other + # commit. FetchContent fetches whatever the tag points at when the build + # runs, so if a maintainer account or CI token is compromised, the attacker + # can retarget a well-known release tag to malicious code: every fresh build + # of QElectroTech then compiles it, while nothing changes in this repository + # and the tag name still looks correct. A commit hash cannot be moved, because + # it is derived from the content: different code always has a different hash. + # + # This attack has been used in the wild: + # - March 2025, tj-actions/changed-files (CVE-2025-30066): tags v1 through + # v45.0.7 were retargeted to a commit that dumped CI secrets into build + # logs, affecting more than 23,000 repositories. + # - March 2026, aquasecurity/trivy-action (CVE-2026-33634): 76 of 77 + # version tags were force-pushed to a credential stealer and stayed + # malicious for about 12 hours. + # Both were GitHub Actions rather than CMake dependencies, but the mechanism + # is the same one FetchContent relies on here: resolving a git tag at build + # time. + # + # To upgrade, look up the commit the new tag points at with + # git ls-remote 'refs/tags/*', check that it is the release you + # expect, and update both the hash and the trailing tag comment. + # + # How many lines that prints depends on which of the two kinds of tag + # upstream created: + # - A lightweight tag is nothing but a ref pointing straight at the commit, + # so ls-remote prints a single line, "refs/tags/", and its hash is + # the commit to pin. pugixml tags this way, which is why the v1.15 hash + # below is what "git ls-remote ... refs/tags/v1.15" reports directly; + # SingleApplication (v3.2.0) does the same. + # - An annotated tag is a git object in its own right, carrying a tagger, + # a date, a message and optionally a GPG signature, and pointing at the + # commit. ls-remote then prints two lines: "refs/tags/" is the tag + # object and "refs/tags/^{}" is that object dereferenced, i.e. the + # commit. The KDE Frameworks modules tag this way, so for them it is the + # "^{}" hash that belongs in the pin; the other hash identifies the tag + # object itself, which is not the source revision and changes whenever + # upstream re-creates the tag, even over the very same commit. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git - GIT_TAG v1.15) + GIT_TAG ee86beb30e4973f5feffe3ce63bfa4fbadf72f38) # v1.15 set(PUGIXML_INSTALL OFF CACHE INTERNAL "") FetchContent_MakeAvailable(pugixml) else() diff --git a/cmake/fetch_singleapplication.cmake b/cmake/fetch_singleapplication.cmake index c54de59ef..6983dfc2b 100644 --- a/cmake/fetch_singleapplication.cmake +++ b/cmake/fetch_singleapplication.cmake @@ -31,9 +31,15 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/SingleApplication/CMakeLists.txt") set(FETCHCONTENT_SOURCE_DIR_SINGLEAPPLICATION "${CMAKE_SOURCE_DIR}/SingleApplication") endif() +# Pinned to the commit v3.2.0 points at, not to the tag itself; see the note in +# fetch_pugixml.cmake. v3.2.0 is a lightweight tag, a ref pointing straight at +# the commit, so "git ls-remote refs/tags/v3.2.0" prints that commit and +# nothing else. An annotated tag, as KDE uses in fetch_kdeaddons.cmake, would +# print the tag object under refs/tags/v3.2.0 as well, with the commit on the +# refs/tags/v3.2.0^{} line. FetchContent_Declare( SingleApplication GIT_REPOSITORY https://github.com/itay-grudev/SingleApplication.git - GIT_TAG v3.2.0) + GIT_TAG aede311d28d20179216c5419b581087be2a8409f) # v3.2.0 set(QT_DEFAULT_MAJOR_VERSION 6) FetchContent_MakeAvailable(SingleApplication) diff --git a/ico/128x128/configure-shortcuts.png b/ico/128x128/configure-shortcuts.png new file mode 100644 index 000000000..6046a6286 Binary files /dev/null and b/ico/128x128/configure-shortcuts.png differ diff --git a/ico/128x128/configure-shortcuts.svg b/ico/128x128/configure-shortcuts.svg new file mode 100644 index 000000000..c7f494a89 --- /dev/null +++ b/ico/128x128/configure-shortcuts.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ico/128x128/terminalstrip.png b/ico/128x128/terminalstrip.png new file mode 100644 index 000000000..d950de7a0 Binary files /dev/null and b/ico/128x128/terminalstrip.png differ diff --git a/ico/128x128/terminalstrip.svg b/ico/128x128/terminalstrip.svg new file mode 100644 index 000000000..ec2b9c651 --- /dev/null +++ b/ico/128x128/terminalstrip.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ico/copyright b/ico/copyright index 811717a37..bd84f9ad4 100644 --- a/ico/copyright +++ b/ico/copyright @@ -216,6 +216,7 @@ Clarification: ico/128x128/diagram.png by the QElectroTech team (License CC BY-ND 3.0) ico/128x128/document-export.png by the QElectroTech team (License CC BY-ND 3.0) ico/128x128/project.png by the QElectroTech team (License CC BY-ND 3.0) + ico/128x128/terminalstrip.png and configure-shortcuts.png by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), rendered from the .svg files beside them ico/scalable/pdf-import.svg by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), laid out like ico/22x22/insert-image.png ico/scalable/diagram.svg, folio-new.svg, folio-delete.svg, folio-properties.svg, label.svg by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), the folio icons redrawn in the same style ico/256x256/* by Nuri from the QElectroTech team (License CC BY-ND 3.0) diff --git a/ico/icon-themes.qrc b/ico/icon-themes.qrc index 3f3d2d710..377a7d4b0 100644 --- a/ico/icon-themes.qrc +++ b/ico/icon-themes.qrc @@ -259,12 +259,14 @@ 48x48/user-away-extended.png 48x48/user-away.png 48x48/view-pim-journal.png + 128x128/configure-shortcuts.png 128x128/diagram.png 128x128/document-export.png 128x128/plasmagik.png 128x128/printer.png 128x128/project.png 128x128/settings.png + 128x128/terminalstrip.png scalable/diagram.svg scalable/folio-delete.svg scalable/folio-new.svg @@ -469,5 +471,7 @@ themes/qet-dark/scalable/ellipse-to-bezier.svg themes/qet-dark/scalable/rect-to-bezier.svg themes/qet-dark/scalable/rect-to-polyline.svg + 128x128/document-export.png + 128x128/printer.png diff --git a/misc/make_icon_themes.py b/misc/make_icon_themes.py index 320101877..455f6a9e6 100644 --- a/misc/make_icon_themes.py +++ b/misc/make_icon_themes.py @@ -34,6 +34,16 @@ expects, so QIcon::fromTheme("name") finds them. The dark theme only holds the icons that need a dark variant: black line art. Colored icons are not touched; the dark theme inherits them from the light one. +Qt inherits by name, not by size: once a name has any file in the dark +theme, the parent theme is never consulted for that name, and a size the +dark theme lacks is served by scaling the nearest dark file. An icon that +is line art at 22 pixels and colored at 128 (the printer) would then come +out as the 22 pixel copy scaled up on a dark palette. So for every name +the dark theme holds, the .qrc also aliases the light files of the sizes +the dark theme does not have, when they read on the dark window (3:1, +measured as tests/qttest/tst_qeticons.cpp does); a light file that does +not is left out, and Qt scales the nearest dark copy as before. + An icon counts as line art when fewer than 20% of its visible pixels are saturated. Its dark copy keeps hue and alpha and inverts lightness, scaled so pure black becomes (220, 220, 220), the dark palette's text color. @@ -101,6 +111,8 @@ WHITE_LIGHTNESS = 0.85 # a visible pixel this light counts as white WHITE_FRACTION = 0.30 # at or above this an icon is "light art" INK = 220 / 255.0 # lightness of pure black after inversion SVG_INK = "#dcdcdc" +DARK_WINDOW = (53, 53, 53) # QET::Palette::fusionDark() window color +DARK_RATIO = 3.0 # what tst_qeticons requires of a dark theme file def visible_pixels(image): @@ -131,6 +143,28 @@ def has_light_fill(image): return white / len(pixels) >= WHITE_FRACTION +def relative_luminance(rgb): + def linear(c): + c /= 255.0 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + r, g, b = rgb + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + + +def reads_on_dark(image): + """True when the icon's mean visible color reaches DARK_RATIO against + the dark window, the test tst_qeticons applies to every dark file.""" + pixels = visible_pixels(image) + if not pixels: + return False + n = len(pixels) + mean = (sum(p[0] for p in pixels) // n, sum(p[1] for p in pixels) // n, + sum(p[2] for p in pixels) // n) + lighter, darker = sorted((relative_luminance(mean), relative_luminance(DARK_WINDOW)), + reverse=True) + return (lighter + 0.05) / (darker + 0.05) >= DARK_RATIO + + def invert_lightness(image): """Invert lightness, keeping hue, saturation and alpha. @@ -240,6 +274,20 @@ def main(): changed += write_if_changed(target, text) dark.append(f"themes/qet-dark/scalable/{name}") + # Complete each dark name with the light files of its other sizes + # that read on a dark window (see the module docstring): aliases, no + # copies. + dark_sizes = {} + for path in dark: + size, name = path.split("/")[2:] + dark_sizes.setdefault(name, set()).add(size) + dark_aliases = [] + for alias, source in light: + size, name = alias.split("/")[2:] + if name in dark_sizes and size not in dark_sizes[name] \ + and reads_on_dark(Image.open(ICO / source).convert("RGBA")): + dark_aliases.append((f"themes/qet-dark/{size}/{name}", source)) + # Drop dark files from an earlier run that are no longer generated, so # a reclassified icon falls back to the light theme instead of keeping # a stale copy. @@ -264,6 +312,8 @@ def main(): qrc.append(f' {source}') for path in dark: qrc.append(f" {path}") + for alias, source in dark_aliases: + qrc.append(f' {source}') qrc += [" ", "", ""] changed += write_if_changed(QRC, "\n".join(qrc)) diff --git a/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp b/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp index 8bf56732a..ad17037dd 100644 --- a/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp +++ b/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp @@ -35,13 +35,17 @@ AddTerminalToStripCommand::AddTerminalToStripCommand(QSharedPointerlabel(); const auto ts_name = strip->name(); - const auto str_1 = t_label.isEmpty() ? QObject::tr("Ajouter une borne") : - QObject::tr("Ajouter la borne %1").arg(t_label); - - const auto str_2 = ts_name.isEmpty() ? QObject::tr("à un groupe de bornes") : - QObject::tr("au groupe de bornes %1").arg(ts_name); - - setText(str_1 % " " % str_2); + QString text; + if (ts_name.isEmpty()) { + text = t_label.isEmpty() + ? QObject::tr("Ajouter une borne à un groupe de bornes") + : QObject::tr("Ajouter la borne %1 à un groupe de bornes").arg(t_label); + } else { + text = t_label.isEmpty() + ? QObject::tr("Ajouter une borne au groupe de bornes %1").arg(ts_name) + : QObject::tr("Ajouter la borne %1 au groupe de bornes %2").arg(t_label, ts_name); + } + setText(text); } AddTerminalToStripCommand::AddTerminalToStripCommand(QVector> terminals, TerminalStrip *strip, QUndoCommand *parent) : @@ -50,14 +54,11 @@ AddTerminalToStripCommand::AddTerminalToStripCommand(QVectorname(); + const auto count = m_terminal.size(); - const auto str_1 = m_terminal.size() > 1 ? QObject::tr("Ajouter %1 bornes").arg(m_terminal.size()) : - QObject::tr("Ajouter une borne"); - - const auto str_2 = ts_name.isEmpty() ? QObject::tr("à un groupe de bornes") : - QObject::tr("au groupe de bornes %1").arg(ts_name); - - setText(str_1 % " " % str_2); + setText(ts_name.isEmpty() + ? QObject::tr("Ajouter %n borne(s) à un groupe de bornes", "", count) + : QObject::tr("Ajouter %n borne(s) au groupe de bornes %1", "", count).arg(ts_name)); } @@ -137,13 +138,11 @@ void RemoveTerminalFromStripCommand::redo() void RemoveTerminalFromStripCommand::setCommandTitle() { const auto strip_name = m_strip->name(); + const auto count = m_terminals.size(); - const auto str_1 = m_terminals.size()>1 ? QObject::tr("Enlever %1 bornes").arg(m_terminals.size()): - QObject::tr("Enlever une borne"); - - const auto str_2 = strip_name.isEmpty() ? QObject::tr("d'un groupe de bornes") : - QObject::tr("du groupe de bornes %1").arg(strip_name); - setText(str_1 % " " % str_2); + setText(strip_name.isEmpty() + ? QObject::tr("Enlever %n borne(s) d'un groupe de bornes", "", count) + : QObject::tr("Enlever %n borne(s) du groupe de bornes %1", "", count).arg(strip_name)); } /** @@ -166,19 +165,30 @@ MoveTerminalCommand::MoveTerminalCommand(QSharedPointer termin t_label.append(", "); t_label.append(real_t->label()); } + const auto strip_name = old_strip->name(); + const auto new_strip_name = new_strip->name(); - auto strip_name = old_strip->name(); - auto new_strip_name = new_strip->name(); - - auto str_1 = t_label.isEmpty() ? QObject::tr("Déplacer une borne") : - QObject::tr("Déplacer la borne %1").arg(t_label); - - auto str_2 = strip_name.isEmpty() ? QObject::tr(" d'un groupe de bornes") : - QObject::tr(" du groupe de bornes %1").arg(strip_name); - - auto str_3 = new_strip_name.isEmpty() ? QObject::tr("vers un groupe de bornes") : - QObject::tr("vers le groupe de bornes %1").arg(new_strip_name); - setText(str_1 % " " % str_2 % " " % str_3); + QString text; + if (t_label.isEmpty()) { + if (strip_name.isEmpty() && new_strip_name.isEmpty()) + text = QObject::tr("Déplacer une borne d'un groupe de bornes vers un groupe de bornes"); + else if (strip_name.isEmpty()) + text = QObject::tr("Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1").arg(new_strip_name); + else if (new_strip_name.isEmpty()) + text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers un groupe de bornes").arg(strip_name); + else + text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2").arg(strip_name, new_strip_name); + } else { + if (strip_name.isEmpty() && new_strip_name.isEmpty()) + text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers un groupe de bornes").arg(t_label); + else if (strip_name.isEmpty()) + text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2").arg(t_label, new_strip_name); + else if (new_strip_name.isEmpty()) + text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers un groupe de bornes").arg(t_label, strip_name); + else + text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3").arg(t_label, strip_name, new_strip_name); + } + setText(text); } MoveTerminalCommand::MoveTerminalCommand(QVector> terminals, TerminalStrip *old_strip, @@ -191,17 +201,18 @@ MoveTerminalCommand::MoveTerminalCommand(QVectorname(); const auto new_strip_name = new_strip->name(); + const auto count = m_terminal.size(); - const auto str_1 = m_terminal.size() > 1 ? QObject::tr("Déplacer des bornes") : - QObject::tr("Déplacer une borne"); - - const auto str_2 = strip_name.isEmpty() ? QObject::tr(" d'un groupe de bornes") : - QObject::tr(" du groupe de bornes %1").arg(strip_name); - - const auto str_3 = new_strip_name.isEmpty() ? QObject::tr("vers un groupe de bornes") : - QObject::tr("vers le groupe de bornes %1").arg(new_strip_name); - - setText(str_1 % " " % str_2 % " " % str_3); + QString text; + if (strip_name.isEmpty() && new_strip_name.isEmpty()) + text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers un groupe de bornes", "", count); + else if (strip_name.isEmpty()) + text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1", "", count).arg(new_strip_name); + else if (new_strip_name.isEmpty()) + text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers un groupe de bornes", "", count).arg(strip_name); + else + text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2", "", count).arg(strip_name, new_strip_name); + setText(text); } void MoveTerminalCommand::undo() diff --git a/sources/autoNum/numerotationcontext.cpp b/sources/autoNum/numerotationcontext.cpp index 3355a1bb4..e23578186 100644 --- a/sources/autoNum/numerotationcontext.cpp +++ b/sources/autoNum/numerotationcontext.cpp @@ -275,3 +275,80 @@ QString NumerotationContext::formatValue(const QStringList &item) return QString("%1").arg(value.toInt(), 3, 10, QChar('0')); return QString::number(value.toInt()); } + +/** + @brief NumerotationContext::saveToSettings + Save a hash of named NumerotationContexts to QSettings. + @param contexts : the named rules to save + @param currentRule : the name of the currently active rule + @param settings : QSettings instance + @param prefix : settings key prefix (e.g. "autonum/conductor") +*/ +void NumerotationContext::saveToSettings( + const QHash &contexts, + const QString ¤tRule, + QSettings &settings, + const QString &prefix) +{ + settings.setValue(prefix + "/current", currentRule); + + // Clear stale array entries before writing (beginWriteArray does not + // remove entries beyond the new size). + settings.remove(prefix + "/rules"); + + QStringList names = contexts.keys(); + settings.beginWriteArray(prefix + "/rules", names.size()); + for (int i = 0; i < names.size(); ++i) { + settings.setArrayIndex(i); + const QString &name = names.at(i); + const NumerotationContext &nc = contexts.value(name); + + settings.setValue("name", name); + + // Serialize context to XML string + QDomDocument doc; + NumerotationContext nc_copy = nc; + QDomElement root = nc_copy.toXml(doc, "context"); + doc.appendChild(root); + settings.setValue("xml", doc.toString()); + } + settings.endArray(); +} + +/** + @brief NumerotationContext::loadFromSettings + Load named NumerotationContexts from QSettings. + @param settings : QSettings instance + @param prefix : settings key prefix (e.g. "autonum/conductor") + @return pair of (hash of named rules, name of current rule) +*/ +QPair, QString> NumerotationContext::loadFromSettings( + QSettings &settings, + const QString &prefix) +{ + QPair, QString> result; + QHash &contexts = result.first; + QString ¤tRule = result.second; + + currentRule = settings.value(prefix + "/current").toString(); + + int size = settings.beginReadArray(prefix + "/rules"); + for (int i = 0; i < size; ++i) { + settings.setArrayIndex(i); + QString name = settings.value("name").toString(); + QString xmlStr = settings.value("xml").toString(); + + if (name.isEmpty() || xmlStr.isEmpty()) continue; + + QDomDocument doc; + if (!doc.setContent(xmlStr)) continue; + + QDomElement root = doc.documentElement(); + NumerotationContext nc; + nc.fromXml(root); + contexts.insert(name, nc); + } + settings.endArray(); + + return result; +} diff --git a/sources/autoNum/numerotationcontext.h b/sources/autoNum/numerotationcontext.h index b1575e468..74b6a5fb4 100644 --- a/sources/autoNum/numerotationcontext.h +++ b/sources/autoNum/numerotationcontext.h @@ -21,6 +21,8 @@ #include #include #include +#include +#include /** This class represents a numerotation context, i.e. the data (type, value, increase) @@ -60,6 +62,14 @@ class NumerotationContext /// UI preview of a part's value matches what actually gets rendered. static QString formatValue(const QStringList &item); + static void saveToSettings(const QHash &contexts, + const QString ¤tRule, + QSettings &settings, + const QString &prefix); + static QPair, QString> loadFromSettings( + QSettings &settings, + const QString &prefix); + private: QStringList content_; }; diff --git a/sources/elementtextsmover.cpp b/sources/elementtextsmover.cpp index 7efb04eb5..16026a112 100644 --- a/sources/elementtextsmover.cpp +++ b/sources/elementtextsmover.cpp @@ -137,25 +137,14 @@ void ElementTextsMover::endMovement() QString ElementTextsMover::undoText() const { - QString undo_text; - - if(m_text_count == 1) - undo_text.append(QObject::tr("Déplacer un texte d'élément")); - else if(m_text_count > 1) - undo_text.append(QObject::tr("Déplacer %1 textes d'élément").arg(m_items_hash.size())); - - if(m_group_count >= 1) - { - if(undo_text.isEmpty()) - undo_text.append(QObject::tr("Déplacer")); - else - undo_text.append(QObject::tr(" et")); - - if(m_group_count == 1) - undo_text.append(QObject::tr(" un groupe de texte")); - else - undo_text.append(QObject::tr((" %1 groupes de textes")).arg(m_group_count)); - } - - return undo_text; + QStringList parts; + if (m_text_count) + parts << QObject::tr("%n texte(s) d'élément", "", m_text_count); + if (m_group_count) + parts << QObject::tr("%n groupe(s) de textes", "", m_group_count); + + if (parts.isEmpty()) + return QString(); // should never occur + + return QObject::tr("Déplacer %1").arg(QLocale().createSeparatedList(parts)); } diff --git a/sources/palettegraphicsview.cpp b/sources/palettegraphicsview.cpp index bd5247e0d..b18bf725b 100644 --- a/sources/palettegraphicsview.cpp +++ b/sources/palettegraphicsview.cpp @@ -21,36 +21,40 @@ #include #include -#include #include #include #include #include +namespace { + /** + QGraphicsScene::drawItems() is protected, and QGraphicsView::drawItems() + hands the scene the viewport only when the painter is on it. The + view paints into an image, and still needs the scene to get the + viewport: that is what makes the scene record where it painted + each item, which is where the item is erased from when it moves. + Naming the member through a derived class is the standard way to + a pointer to a protected member; a call through it dispatches to + the scene's own override, if any. + */ + struct SceneAccess : QGraphicsScene + { + using DrawItems = void (QGraphicsScene::*)(QPainter *, int, QGraphicsItem *[], + const QStyleOptionGraphicsItem[], QWidget *); + static DrawItems drawItemsPointer() { return &SceneAccess::drawItems; } + }; +} + PaletteGraphicsView::PaletteGraphicsView(QWidget *parent) : QGraphicsView(parent) { qApp->installEventFilter(this); - setViewportUpdateMode(QGraphicsView::FullViewportUpdate); } PaletteGraphicsView::PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent) : QGraphicsView(scene, parent) { qApp->installEventFilter(this); - setViewportUpdateMode(QGraphicsView::FullViewportUpdate); - listenToScene(scene); -} - -/** - @brief PaletteGraphicsView::setScene - Same as QGraphicsView::setScene, and keeps the scene's updates flowing - (see the class description). -*/ -void PaletteGraphicsView::setScene(QGraphicsScene *scene) -{ - QGraphicsView::setScene(scene); - listenToScene(scene); } /** @@ -72,21 +76,6 @@ void PaletteGraphicsView::paintingInverted(bool inverted) Q_UNUSED(inverted) } -/** - @brief PaletteGraphicsView::listenToScene - Connect a receiver to the scene's changed() signal, dropping the one - on the previous scene. Any receiver does; this one has nothing to do. - The connection dies with the view. -*/ -void PaletteGraphicsView::listenToScene(QGraphicsScene *scene) -{ - disconnect(m_scene_connection); - m_scene_connection = QMetaObject::Connection(); - if (scene) - m_scene_connection = connect(scene, &QGraphicsScene::changed, - this, [](const QList &) {}); -} - /** @brief PaletteGraphicsView::eventFilter Repaint the whole viewport when the application palette changes. Qt @@ -111,57 +100,121 @@ bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event) void PaletteGraphicsView::paintEvent(QPaintEvent *event) { if (invertsLightness()) - paintInverted(event->rect()); - else - QGraphicsView::paintEvent(event); + { + paintInverted(event); + return; + } + m_buffer = QImage(); + QGraphicsView::paintEvent(event); } /** @brief PaletteGraphicsView::paintInverted - Render \a area of the viewport into an off-screen image, invert the - lightness of that image between the palette's Base and Text colors and - blit it to the viewport. Inverting the finished rendering turns the - white sheet dark and the black ink light in one pass, and keeps the - hue of colored strokes. - @param area the part of the viewport to repaint, in viewport coordinates + Run QGraphicsView::paintEvent() with the drawing hooks redirected to + an off-screen image of the viewport, then invert the lightness of the + exposed part of that image between the palette's Base and Text colors + and blit it to the viewport. Inverting the finished rendering turns + the white sheet dark and the black ink light in one pass, and keeps + the hue of colored strokes. The image is in viewport coordinates, so + the hooks paint with the view's own transform and the scene records + the items' places in the viewport, as it does on a light palette. + @param event the paint event, for the exposed area */ -void PaletteGraphicsView::paintInverted(const QRect &area) +void PaletteGraphicsView::paintInverted(QPaintEvent *event) { - const QRect rect = area.intersected(viewport()->rect()); - if (rect.isEmpty()) + const QRect exposed = event->rect().intersected(viewport()->rect()); + if (exposed.isEmpty()) return; const qreal ratio = viewport()->devicePixelRatioF(); - QImage buffer(qCeil(rect.width() * ratio), qCeil(rect.height() * ratio), - QImage::Format_RGB32); - buffer.setDevicePixelRatio(ratio); - // render() paints only what the scene draws; what it leaves blank is + 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.setDevicePixelRatio(ratio); + } + + m_buffer_painter.begin(&m_buffer); + // The hooks paint only what the scene draws; what they leave blank is // the white sheet, which the inversion turns into the Base color. - buffer.fill(Qt::white); + m_buffer_painter.fillRect(exposed, Qt::white); + m_buffer_painter.setClipRect(exposed); + m_buffer_painter.setRenderHints(renderHints()); + m_buffer_painter.setWorldTransform(viewportTransform()); - QPainter buffer_painter(&buffer); - buffer_painter.setRenderHints(renderHints()); + m_inverting = true; paintingInverted(true); - render(&buffer_painter, QRectF(QPointF(0, 0), QSizeF(rect.size())), - rect, Qt::IgnoreAspectRatio); + const OptimizationFlags flags = optimizationFlags(); + setOptimizationFlag(QGraphicsView::IndirectPainting, true); + QGraphicsView::paintEvent(event); + setOptimizationFlags(flags); paintingInverted(false); - buffer_painter.end(); + m_inverting = false; + m_buffer_painter.end(); + blitInverted(exposed); +} + +/** + @brief PaletteGraphicsView::drawBackground + Into the off-screen image while painting inverted, else as + QGraphicsView. +*/ +void PaletteGraphicsView::drawBackground(QPainter *painter, const QRectF &rect) +{ + QGraphicsView::drawBackground(m_inverting ? &m_buffer_painter : painter, rect); +} + +/** + @brief PaletteGraphicsView::drawItems + Into the off-screen image while painting inverted, with the viewport + as the scene's widget (see SceneAccess), else as QGraphicsView. +*/ +void PaletteGraphicsView::drawItems(QPainter *painter, int count, QGraphicsItem *items[], + const QStyleOptionGraphicsItem options[]) +{ + if (m_inverting && scene()) + (scene()->*SceneAccess::drawItemsPointer())(&m_buffer_painter, count, items, options, viewport()); + else + QGraphicsView::drawItems(painter, count, items, options); +} + +/** + @brief PaletteGraphicsView::drawForeground + Into the off-screen image while painting inverted, else as + QGraphicsView. +*/ +void PaletteGraphicsView::drawForeground(QPainter *painter, const QRectF &rect) +{ + QGraphicsView::drawForeground(m_inverting ? &m_buffer_painter : painter, rect); +} + +/** + @brief PaletteGraphicsView::blitInverted + Invert the lightness of \a area of the off-screen image and draw it on + the viewport, then the selection rubber band on top: the one + QGraphicsView::paintEvent() drew went under the blit. + @param area the part of the viewport to blit, in viewport coordinates +*/ +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()); + part.setDevicePixelRatio(ratio); // The application palette, for the reason given in invertsLightness(). const QPalette application_palette = QApplication::palette(); - QET::Palette::invertLightness(buffer, application_palette.color(QPalette::Base), + QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), application_palette.color(QPalette::Text)); QPainter painter(viewport()); - painter.drawImage(rect.topLeft(), buffer); + painter.drawImage(area.topLeft(), part); drawRubberBand(painter); } /** @brief PaletteGraphicsView::drawRubberBand - Draw the selection rubber band the way QGraphicsView::paintEvent does. - Rendering the view into an off-screen image skips it, so it is drawn - here instead, after the inversion, in the palette colors. + Draw the selection rubber band the way QGraphicsView::paintEvent does, + after the inversion, in the palette colors. @param painter a painter on the viewport */ void PaletteGraphicsView::drawRubberBand(QPainter &painter) diff --git a/sources/palettegraphicsview.h b/sources/palettegraphicsview.h index 77924c356..512bba283 100644 --- a/sources/palettegraphicsview.h +++ b/sources/palettegraphicsview.h @@ -19,38 +19,39 @@ #define PALETTE_GRAPHICS_VIEW_H #include - -class QPainter; +#include +#include /** - A QGraphicsView that shows its scene with inverted lightness while its - palette is dark: white becomes the palette's Base color, black its Text - color, and colored strokes keep their hue. The scene itself is left as - drawn, so printing and exporting it still give black on white. On a - light palette the view paints exactly as QGraphicsView does. + A QGraphicsView that shows its scene with inverted lightness while the + application palette is dark: white becomes the palette's Base color, + black its Text color, and colored strokes keep their hue. The scene + itself is left as drawn, so printing and exporting it still give black + on white. On a light palette the view paints exactly as QGraphicsView + does. - The view paints through QGraphicsView::render() into an image and blits - the inverted image. QGraphicsView delivers scene updates straight to - its viewport when nobody listens to QGraphicsScene::changed(), and in - that mode the scene clears its "update everything" flag only when the - items are painted straight onto the viewport, which never happens - here: from the second QGraphicsScene::update() on, every scene update - and every item update would wait for an unrelated repaint. So the view - listens to changed() on every scene it is given, which makes the scene - clear the flag before it emits. Set the scene through this class, not - through a QGraphicsView pointer. + On a dark palette the view still runs QGraphicsView::paintEvent(), with + the IndirectPainting flag set for the duration of that call, so that + the background, the items and the foreground come through the + drawBackground(), drawItems() and drawForeground() hooks. (That flag + selects Qt's older item-painting algorithm, which first builds a list + of the exposed items and their style options; it is set only while + the view paints inverted.) The hooks + paint into an off-screen image the size of the viewport, in viewport + coordinates; paintEvent() then inverts the lightness of the exposed + part of that image and blits it. Going through the real paint event, + and handing the scene the viewport when the items are drawn, keeps the + view on QGraphicsView's default update path, which erases a moved item + where it was last painted, children included, even a child whose + geometry is set while its parent is painted (a terminal's help lines). + The alternative, rendering with QGraphicsView::render(), needed a + receiver on QGraphicsScene::changed() to keep the scene's updates + flowing, and that receiver puts the scene on its Qt 4.4 compatibility + path, which erases only the moved item's own old rectangle: a moved + element then left its terminals' help lines, which span the whole + sheet, behind at every step (#954). - The constructor also forces QGraphicsView::FullViewportUpdate in place - of the default MinimalViewportUpdate. #954 shipped with the default - kept, and moving an item then left conductor-shaped ghosts behind on - both a light and a dark palette, so the cause is shared code, not - paintInverted(): most likely listening to changed() at all, above, - changes which of QGraphicsScene's two update paths a view is on, and - MinimalViewportUpdate's job of turning the scene's reported dirty - rects into the smallest correct viewport region is where that would - show up first. FullViewportUpdate removes the need to get that region - right by repainting the whole viewport on every update; the class's - own benchmark already shows that cost is small next to a frame budget. + The CacheBackground cache mode is not supported on the inverted path. */ class PaletteGraphicsView : public QGraphicsView { @@ -60,14 +61,17 @@ class PaletteGraphicsView : public QGraphicsView explicit PaletteGraphicsView(QWidget *parent = nullptr); explicit PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent = nullptr); - void setScene(QGraphicsScene *scene); bool invertsLightness() const; protected: bool eventFilter(QObject *watched, QEvent *event) override; void paintEvent(QPaintEvent *event) override; + void drawBackground(QPainter *painter, const QRectF &rect) override; + void drawItems(QPainter *painter, int count, QGraphicsItem *items[], + const QStyleOptionGraphicsItem options[]) override; + void drawForeground(QPainter *painter, const QRectF &rect) override; /** - Called with true right before the scene is rendered for an + Called with true right before the scene is painted for an inverted display and with false right after, so a scene can adapt what it draws (a softer grid, for instance). Does nothing by default. @@ -75,11 +79,17 @@ class PaletteGraphicsView : public QGraphicsView virtual void paintingInverted(bool inverted); private: - void listenToScene(QGraphicsScene *scene); - void paintInverted(const QRect &area); + void paintInverted(QPaintEvent *event); + void blitInverted(const QRect &area); void drawRubberBand(QPainter &painter); - QMetaObject::Connection m_scene_connection; + /// 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; + QPainter m_buffer_painter; + /// True while paintEvent() paints for an inverted display. + bool m_inverting = false; }; #endif diff --git a/sources/qet.cpp b/sources/qet.cpp index f253fc114..e6a05afad 100644 --- a/sources/qet.cpp +++ b/sources/qet.cpp @@ -271,84 +271,88 @@ QString QET::ElementsAndConductorsSentence( int tables_count, int terminal_strip_count) { - QString text; + QStringList parts; if (elements_count) { - text += QObject::tr( - "%n élément(s)", - "part of a sentence listing the content of a diagram", - elements_count + parts.append( + QObject::tr( + "%n élément(s)", + "part of a enumerative partial sentence listing the content of a diagram", + elements_count + ) ); } if (conductors_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n conducteur(s)", - "part of a sentence listing the content of a diagram", - conductors_count + parts.append( + QObject::tr( + "%n conducteur(s)", + "part of a enumerative partial sentence listing the content of a diagram", + conductors_count + ) ); } if (texts_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n champ(s) de texte", - "part of a sentence listing the content of a diagram", - texts_count + parts.append( + QObject::tr( + "%n champ(s) de texte", + "part of a enumerative partial sentence listing the content of a diagram", + texts_count + ) ); } if (images_count) { - if (!text.isEmpty()) text += ", "; - // Qt's %n only selects a grammatical singular/plural form (the - // "(s)" convention used by every other count here) -- it never - // spells the number out as a word, so getting "une image" - // instead of the literal "1 image" for the single-item case - // means handling that count outside %n entirely, with its own - // fixed string. - text += images_count == 1 - ? QObject::tr("une image", "part of a sentence listing the content of a diagram") - : QObject::tr( - "%n images", - "part of a sentence listing the content of a diagram", - images_count - ); + parts.append( + QObject::tr( + "%n image(s)", + "part of a enumerative partial sentence listing the content of a diagram", + images_count + ) + ); } if (shapes_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n forme(s)", - "part of a sentence listing the content of a diagram", - shapes_count + parts.append( + QObject::tr( + "%n forme(s)", + "part of a enumerative partial sentence listing the content of a diagram", + shapes_count + ) ); } if (element_text_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n texte(s) d'élément", - "part of a sentence listing the content of a diagram", - element_text_count); + parts.append( + QObject::tr( + "%n texte(s) d'élément", + "part of a enumerative partial sentence listing the content of a diagram", + element_text_count + ) + ); } if (tables_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n tableau(s)", - "part of a sentence listing the content of diagram", - tables_count); + parts.append( + QObject::tr( + "%n tableau(s)", + "part of a enumerative partial sentence listing the content of diagram", + tables_count + ) + ); } if (terminal_strip_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n plan de bornes", - "part of a sentence listing the content of a diagram", - terminal_strip_count); + parts.append( + QObject::tr( + "%n plan(s) de bornes", + "part of a enumerative partial sentence listing the content of a diagram", + terminal_strip_count + ) + ); } - return(text); + return QLocale().createSeparatedList(parts); } /** diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 6ad06fb94..f24aade63 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -235,6 +235,8 @@ QString QETApp::loadedQtTranslationFile() */ void QETApp::setLanguage(const QString &desired_language) { QString languages_path = languagesPath(); + + QLocale::setDefault(QLocale(desired_language)); // load Qt library translations QString qt_l10n_path = QLibraryInfo::path(QLibraryInfo::TranslationsPath); @@ -1808,6 +1810,10 @@ void QETApp::useSystemPalette(bool use) { file.close(); } } + // Widgets with their own style sheet keep the palette they were + // polished with; after a live light/dark switch they would stay in + // the old colors (see QET::Palette::refreshStyleSheets). + QET::Palette::refreshStyleSheets(); } /** diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index a1494f8f0..b6d48975b 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -629,7 +629,13 @@ void DynamicElementTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) int diffx = qRound(current_parent_pos.x() - button_down_parent_pos.x()); int diffy = qRound(current_parent_pos.y() - button_down_parent_pos.y()); QPointF new_pos = m_initial_position + QPointF(diffx, diffy); - setPos(new_pos); + //Snap to the grid, Ctrl to place freely -- the same line + //ElementTextItemGroup::mouseMoveEvent() and + //ElementTextsMover::continueMovement() already use, and + //DiagramTextItem::mouseMoveEvent() for independent texts. + //Without it this was the only text move in the editor that + //ignored the grid. + event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos)); if(diagram()) diagram()->elementTextsMover().continueMovement(event); diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index 39030275c..a605c74a7 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -37,6 +37,7 @@ namespace QET { QIcon ConductorEdit; QIcon ConductorSettings; QIcon Configure; + QIcon ConfigureShortcuts; QIcon ConfigureToolbars; QIcon IC_CopyFile; QIcon DefaultConductor; @@ -408,6 +409,7 @@ void QET::Icons::initIcons() ConductorEdit = QIcon::fromTheme("conductor-edit"); ConductorSettings = QIcon::fromTheme("conductor-reset"); Configure = QIcon::fromTheme("configure"); + ConfigureShortcuts = QIcon::fromTheme("configure-shortcuts"); ConfigureToolbars = QIcon::fromTheme("configure-toolbars"); IC_CopyFile = QIcon::fromTheme("item-copy"); DiagramAdd = QIcon::fromTheme("folio-new"); diff --git a/sources/qeticons.h b/sources/qeticons.h index d77b897fc..af21e8e54 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -44,6 +44,7 @@ namespace QET { extern QIcon ConductorEdit; extern QIcon ConductorSettings; extern QIcon Configure; + extern QIcon ConfigureShortcuts; extern QIcon ConfigureToolbars; extern QIcon IC_CopyFile; extern QIcon DefaultConductor; diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index 9cf56c7cd..8b080eeff 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -17,10 +17,11 @@ */ #include "qetpalette.h" +#include #include #include #include - +#include #include namespace { @@ -296,3 +297,14 @@ QPixmap QET::Palette::forPalette(const QPixmap &pixmap, const QPalette &palette) result.setDevicePixelRatio(pixmap.devicePixelRatio()); return result; } + +void QET::Palette::refreshStyleSheets() +{ + // Setting the same sheet again is not a no-op: QWidget::setStyleSheet() + // asks QStyleSheetStyle to repolish the widget, which recomputes its + // palette from the application palette now in force. + const QWidgetList widgets = QApplication::allWidgets(); + for (QWidget *widget : widgets) + if (!widget->styleSheet().isEmpty()) + widget->setStyleSheet(widget->styleSheet()); +} diff --git a/sources/qetpalette.h b/sources/qetpalette.h index 536b660d2..e1a5cabcd 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -135,6 +135,18 @@ namespace QET { left alone either way. */ QPixmap forPalette(const QPixmap &pixmap, const QPalette &palette); + + /** + Make every widget that carries a style sheet take the current + application palette. QApplication::setPalette() reaches plain + widgets, but a widget with a style sheet keeps the palette + QStyleSheetStyle resolved when the sheet was applied, so after a + live light/dark switch it is drawn in the old colors (the folio + tab bar and its buttons, the element info widgets, several + configuration pages). Re-applying each widget's own sheet makes + QStyleSheetStyle resolve it again. Call after setPalette(). + */ + void refreshStyleSheets(); } } diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 6d0601b94..49b4a6520 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -83,6 +83,30 @@ m_project_properties_handler{this} m_default_guides.append(g); } settings.endArray(); + + //Load global auto-numbering defaults from QSettings + { + auto conductorData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/conductor")); + for (auto it = conductorData.first.constBegin(); it != conductorData.first.constEnd(); ++it) { + addConductorAutoNum(it.key(), it.value()); + } + if (!conductorData.second.isEmpty()) { + setCurrentConductorAutoNum(conductorData.second); + } + + auto elementData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/element")); + for (auto it = elementData.first.constBegin(); it != elementData.first.constEnd(); ++it) { + addElementAutoNum(it.key(), it.value()); + } + if (!elementData.second.isEmpty()) { + setCurrrentElementAutonum(elementData.second); + } + + auto folioData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/folio")); + for (auto it = folioData.first.constBegin(); it != folioData.first.constEnd(); ++it) { + addFolioAutoNum(it.key(), it.value()); + } + } } ProjectPropertiesHandler &QETProject::projectPropertiesHandler() diff --git a/sources/qetstyle.cpp b/sources/qetstyle.cpp index 9dc63ac13..d8a292240 100644 --- a/sources/qetstyle.cpp +++ b/sources/qetstyle.cpp @@ -67,20 +67,27 @@ bool QETStyle::isLineArt(const QImage &image) /** @brief QETStyle::hoverColor The palette's highlight color is the accent users already know from - selections. On a dark palette it is too dark to read on a hovered - button face, so it is lightened, a step at a time, until it reaches + selections, moved away from the hovered button face until it reaches 3:1 (WCAG 1.4.11) against the Light role: Fusion paints a hovered auto-raise button with a gradient that runs from Button up to about - that color, and the icon has to read on the lightest part of it. + that color, and the icon has to read on the lightest part of it. On a + dark face the accent is lightened, a step at a time; on a light face it + is darkened, which keeps a pale accent (macOS's green or yellow + selection color, which comes with black selection text) from being + pushed to white. Should twenty steps not get there, the button text + color serves, which reads on the face by construction. */ QColor QETStyle::hoverColor(const QPalette &palette) { const QColor face = palette.color(QPalette::Active, QPalette::Light); + const bool light_face = face.lightnessF() > 0.5; QColor ink = palette.color(QPalette::Active, QPalette::Highlight); // 3.5 rather than 3.0: the top of Fusion's hover gradient is a shade // lighter than the Light role, so the icon needs some headroom there. for (int step = 0; step < 20 && QET::Palette::contrastRatio(ink, face) < 3.5; ++step) - ink = ink.lighter(110); + ink = light_face ? ink.darker(110) : ink.lighter(110); + if (QET::Palette::contrastRatio(ink, face) < 3.5) + ink = palette.color(QPalette::Active, QPalette::ButtonText); return ink; } diff --git a/sources/ui/configpage/configpages.cpp b/sources/ui/configpage/configpages.cpp index 8c027ccac..7c2db969d 100644 --- a/sources/ui/configpage/configpages.cpp +++ b/sources/ui/configpage/configpages.cpp @@ -31,6 +31,8 @@ #include "../titleblockpropertieswidget.h" #include "../xrefpropertieswidget.h" #include "guidespropertieswidget.h" +#include "../autoNum/numerotationcontext.h" +#include "../autoNum/ui/selectautonumw.h" #include #include #include @@ -101,6 +103,32 @@ NewDiagramPage::NewDiagramPage(QETProject *project, } m_gpw->setGuides(loaded_guides); + // global auto-numbering defaults (only when editing global settings, not a project) + if (!m_project) { + auto saw_conductor = new SelectAutonumW(1); + auto saw_element = new SelectAutonumW(0); + auto saw_folio = new SelectAutonumW(2); + + initAutoNumTab(m_autonum_conductor, saw_conductor, QStringLiteral("autonum/conductor")); + initAutoNumTab(m_autonum_element, saw_element, QStringLiteral("autonum/element")); + initAutoNumTab(m_autonum_folio, saw_folio, QStringLiteral("autonum/folio")); + + QSettings autonum_settings; + loadAutoNumTab(m_autonum_conductor, autonum_settings); + loadAutoNumTab(m_autonum_element, autonum_settings); + loadAutoNumTab(m_autonum_folio, autonum_settings); + + // Intercept Return key in the combo line edits so it doesn't + // activate the dialog's default button (OK). + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + if (QComboBox *combo = tab->widget->contextComboBox()) { + if (combo->lineEdit()) { + combo->lineEdit()->installEventFilter(this); + } + } + } + } + //If there is a project, we edit his properties if (m_project) { bpw -> setProperties (m_project -> defaultBorderProperties()); @@ -114,6 +142,7 @@ NewDiagramPage::NewDiagramPage(QETProject *project, // main tab widget QTabWidget *tab_widget = new QTabWidget(this); + m_tab_widget = tab_widget; QWidget *diagram_widget = new QWidget(); QVBoxLayout *diagram_layout = new QVBoxLayout(diagram_widget); @@ -127,6 +156,19 @@ NewDiagramPage::NewDiagramPage(QETProject *project, tab_widget -> addTab (xrefpw, tr("Références croisées")); tab_widget -> addTab (m_gpw, tr("Guides")); + // add auto-numbering tab only for global settings (not per project) + if (!m_project) { + QWidget *autonum_widget = new QWidget(); + QVBoxLayout *autonum_layout = new QVBoxLayout(autonum_widget); + autonum_layout->addWidget(new QLabel(tr("Définir les règles de numérotation automatique par défaut pour les nouveaux projets :"))); + QTabWidget *autonum_inner_tab = new QTabWidget(); + autonum_inner_tab->addTab(m_autonum_conductor.widget, tr("Conducteurs")); + autonum_inner_tab->addTab(m_autonum_element.widget, tr("Eléments")); + autonum_inner_tab->addTab(m_autonum_folio.widget, tr("Folios")); + autonum_layout->addWidget(autonum_inner_tab); + tab_widget -> addTab (autonum_widget, tr("Numérotation auto")); + } + QVBoxLayout *vlayout1 = new QVBoxLayout(); vlayout1->addWidget(tab_widget); @@ -230,6 +272,9 @@ void NewDiagramPage::applyConf() settings.setValue(QStringLiteral("color"), current_guides[i].color.name()); } settings.endArray(); + + // save global auto-numbering defaults + persistAutonumSettings(); } } @@ -295,6 +340,134 @@ void NewDiagramPage::loadSavedTbp() applyConf(); } +/** + @brief NewDiagramPage::isPlaceholder + Return true if @a name matches the combo box's built-in placeholder text + (first item). This is locale-independent because it reads the actual item text. +*/ +bool NewDiagramPage::isPlaceholder(QComboBox *combo, const QString &name) +{ + return !combo->count() || name == combo->itemText(0); +} + +/** + @brief NewDiagramPage::initAutoNumTab + Initialise an AutoNumTab struct and connect its signals. +*/ +void NewDiagramPage::initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix) +{ + tab.widget = w; + tab.prefix = prefix; + + connect(w, &SelectAutonumW::applyPressed, this, [this, &tab]() { saveAutoNumContext(tab); }); + connect(w, &SelectAutonumW::removeClicked, this, [this, &tab]() { removeAutoNumContext(tab); }); + connect(w->contextComboBox(), &QComboBox::activated, this, [this, &tab](int index) { + if (index >= 0) { + QString name = tab.widget->contextComboBox()->itemText(index); + if (tab.contexts.contains(name)) { + tab.widget->setContext(tab.contexts.value(name)); + } + } + }); +} + +/** + @brief NewDiagramPage::loadAutoNumTab + Load saved rules from QSettings into an AutoNumTab. +*/ +void NewDiagramPage::loadAutoNumTab(AutoNumTab &tab, QSettings &settings) +{ + auto data = NumerotationContext::loadFromSettings(settings, tab.prefix); + tab.contexts = data.first; + for (auto it = tab.contexts.constBegin(); it != tab.contexts.constEnd(); ++it) { + tab.widget->contextComboBox()->addItem(it.key()); + } + if (!tab.contexts.isEmpty() && !data.second.isEmpty() + && tab.contexts.contains(data.second)) { + tab.widget->contextComboBox()->setCurrentText(data.second); + tab.widget->setContext(tab.contexts.value(data.second)); + } +} + +/** + @brief NewDiagramPage::saveAutoNumContext + Save the current context from an AutoNumTab's widget into its hash and + persist to QSettings immediately. +*/ +void NewDiagramPage::saveAutoNumContext(AutoNumTab &tab) +{ + QString name = tab.widget->contextComboBox()->currentText().trimmed(); + if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { + return; + } + tab.contexts.insert(name, tab.widget->toNumContext()); + if (tab.widget->contextComboBox()->findText(name) == -1) { + tab.widget->contextComboBox()->addItem(name); + } + persistAutonumSettings(); +} + +/** + @brief NewDiagramPage::removeAutoNumContext + Remove the current context from an AutoNumTab's hash and persist. +*/ +void NewDiagramPage::removeAutoNumContext(AutoNumTab &tab) +{ + QString name = tab.widget->contextComboBox()->currentText().trimmed(); + if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { + return; + } + int idx = tab.widget->contextComboBox()->findText(name); + if (idx == -1) return; + tab.contexts.remove(name); + tab.widget->contextComboBox()->removeItem(idx); + tab.widget->contextComboBox()->setCurrentText(QString()); + tab.widget->setContext(NumerotationContext()); + persistAutonumSettings(); +} + +/** + @brief NewDiagramPage::persistAutonumSettings + Save all autonum contexts to QSettings immediately. +*/ +void NewDiagramPage::persistAutonumSettings() +{ + QSettings settings; + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + QString current; + QComboBox *combo = tab->widget->contextComboBox(); + if (!isPlaceholder(combo, combo->currentText().trimmed()) + && tab->contexts.contains(combo->currentText().trimmed())) { + current = combo->currentText().trimmed(); + } + NumerotationContext::saveToSettings(tab->contexts, current, + settings, tab->prefix); + } +} + +/** + @brief NewDiagramPage::eventFilter + Intercept Return/Enter in combo box line edits so it doesn't close the + settings dialog. +*/ +bool NewDiagramPage::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if ((ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter)) { + // Check if this is a line edit inside one of our autonum combos + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + if (QComboBox *combo = tab->widget->contextComboBox()) { + if (combo->lineEdit() && combo->lineEdit() == obj) { + return true; // eat the event + } + } + } + } + } + return ConfigPage::eventFilter(obj, event); +} + /** Constructeur @param parent QWidget parent diff --git a/sources/ui/configpage/configpages.h b/sources/ui/configpage/configpages.h index 0a4740f90..9ddb2a523 100644 --- a/sources/ui/configpage/configpages.h +++ b/sources/ui/configpage/configpages.h @@ -20,6 +20,7 @@ #include "configpage.h" #include "../projectpropertiesdialog.h" #include "../titleblockpropertieswidget.h" +#include "../autoNum/numerotationcontext.h" #include #include @@ -32,6 +33,7 @@ class XRefPropertiesWidget; class GuidesPropertiesWidget; class QETProject; class TitleBlockProperties; +class SelectAutonumW; /** @brief The NewDiagramPage class @@ -48,6 +50,7 @@ class NewDiagramPage : public ConfigPage { ~NewDiagramPage() override; private: NewDiagramPage(const NewDiagramPage &); + bool eventFilter(QObject *obj, QEvent *event) override; public slots: void changeToAutoFolioTab(); void setFolioAutonum(QString); @@ -72,7 +75,25 @@ public slots: XRefPropertiesWidget *xrefpw; ///< Widget to edit default xref properties GuidesPropertiesWidget *m_gpw; ///< Widget to edit guides TitleBlockProperties savedTbp; ///< Used to save current TBP and retrieve later + QTabWidget *m_tab_widget; ///< Main tab widget (stored for later access) + // auto-numbering tab data + struct AutoNumTab { + SelectAutonumW *widget = nullptr; + QHash contexts; + QString prefix; + }; + AutoNumTab m_autonum_conductor; + AutoNumTab m_autonum_element; + AutoNumTab m_autonum_folio; + + void initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix); + void loadAutoNumTab(AutoNumTab &tab, QSettings &settings); + void saveAutoNumContext(AutoNumTab &tab); + void removeAutoNumContext(AutoNumTab &tab); + void persistAutonumSettings(); + + static bool isPlaceholder(QComboBox *combo, const QString &name); }; /** diff --git a/sources/ui/configpage/shortcutsconfigpage.cpp b/sources/ui/configpage/shortcutsconfigpage.cpp index e7871af22..242958fcd 100644 --- a/sources/ui/configpage/shortcutsconfigpage.cpp +++ b/sources/ui/configpage/shortcutsconfigpage.cpp @@ -381,5 +381,5 @@ QString ShortcutsConfigPage::title() const QIcon ShortcutsConfigPage::icon() const { - return QET::Icons::ConfigureToolbars; + return QET::Icons::ConfigureShortcuts; } diff --git a/sources/undocommand/rotatetextscommand.cpp b/sources/undocommand/rotatetextscommand.cpp index 7239d1b98..33a2dde8b 100644 --- a/sources/undocommand/rotatetextscommand.cpp +++ b/sources/undocommand/rotatetextscommand.cpp @@ -55,21 +55,13 @@ m_diagram(diagram) { openDialog(); - QString text; - if(texts_list.count()) - text.append(QObject::tr("Pivoter %1 textes").arg(texts_list.count())); - if(groups_list.count()) - { - if(text.isEmpty()) - text.append(QObject::tr("Pivoter")); - else - text.append(QObject::tr(" et")); - - text.append(QObject::tr(" %1 groupes de textes").arg(groups_list.count())); - } - if(!text.isNull()) - setText(text); - + QStringList parts; + if (texts_list.count()) + parts << QObject::tr("%n texte(s)", "", texts_list.count()); + if (groups_list.count()) + parts << QObject::tr("%n groupe(s) de textes", "", groups_list.count()); + setText(QObject::tr("Pivoter %1").arg(QLocale().createSeparatedList(parts))); + for(DiagramTextItem *dti : texts_list) setupAnimation(dti, "rotation", dti->rotation(), m_rotation); for(ElementTextItemGroup *grp : groups_list) @@ -77,7 +69,6 @@ m_diagram(diagram) } else setObsolete(true); - } void RotateTextsCommand::undo() diff --git a/tests/qttest/tst_qeticons.cpp b/tests/qttest/tst_qeticons.cpp index e40d506dc..5d0d7f0a8 100644 --- a/tests/qttest/tst_qeticons.cpp +++ b/tests/qttest/tst_qeticons.cpp @@ -63,10 +63,13 @@ class tst_qeticons : public QObject void toolbarIconIsReadable(); void hoverChangesTheIcon_data(); void hoverChangesTheIcon(); + void hoverInkReadsWithAnyAccent_data(); + void hoverInkReadsWithAnyAccent(); void coloredIconKeepsItsColorsOnHover(); void menuIconReadsOnHighlight_data(); void menuIconReadsOnHighlight(); void panelProjectIconStaysSmall(); + void configPageIconsComeAtPageSize(); }; namespace { @@ -106,6 +109,17 @@ namespace { } const QStringList kSizes = {"16x16", "22x22", "32x32", "48x48", "128x128"}; + + /// palette with the given selection colors, as QET::Palette::withPlatformAccent leaves them. + QPalette withAccent(QPalette palette, const QColor &highlight, const QColor &highlighted_text) + { + for (QPalette::ColorGroup group : {QPalette::Active, QPalette::Inactive}) + { + palette.setColor(group, QPalette::Highlight, highlight); + palette.setColor(group, QPalette::HighlightedText, highlighted_text); + } + return palette; + } } void tst_qeticons::initTestCase() @@ -333,6 +347,12 @@ void tst_qeticons::hoverChangesTheIcon_data() // nothing in the frame, so the icon is the only hover signal there. QTest::newRow("light-checked") << "qet" << QET::Palette::fusionLight() << true; QTest::newRow("dark-checked") << "qet-dark" << QET::Palette::fusionDark() << true; + // A platform accent QET keeps because its selection text reads on + // it: macOS's green selection color comes with black text. Lightening + // it toward 3:1 against a white face can only end at white, which + // made every hovered line-art icon vanish on a light palette. + QTest::newRow("light-pale-accent") << "qet" << withAccent(QET::Palette::fusionLight(), QColor(198, 231, 188), Qt::black) << false; + QTest::newRow("dark-pale-accent") << "qet-dark" << withAccent(QET::Palette::fusionDark(), QColor(198, 231, 188), Qt::black) << false; } /** @@ -472,6 +492,65 @@ void tst_qeticons::panelProjectIconStaysSmall() #endif } + +/** + The configuration dialogs list their pages with 64 or 128 pixel icons + (ConfigDialog::ConfigDialog). A page whose icon exists at 22 pixels + only comes out as a 22 pixel stamp among 128 pixel neighbors, which + the terminal-strip page of the project dialog and the shortcuts page + of the settings dialog did. +*/ +void tst_qeticons::configPageIconsComeAtPageSize() +{ + const QStringList pages = {"settings", "project", "diagram", "plasmagik", "printer", + "document-export", "terminalstrip", "configure-shortcuts"}; + QStringList small; + for (const QString &theme : {"qet", "qet-dark"}) + { + QIcon::setThemeName(theme); + for (const QString &name : pages) + { + const QIcon icon = QIcon::fromTheme(name); + QVERIFY2(!icon.isNull(), qPrintable(QString("%1 missing in theme %2").arg(name, theme))); + const QSize size = icon.actualSize(QSize(128, 128)); + if (size.width() < 128 || size.height() < 128) + small << QString("%1 in %2 at %3x%4").arg(name, theme).arg(size.width()).arg(size.height()); + } + } + QIcon::setThemeName("qet"); + QVERIFY2(small.isEmpty(), qPrintable("page icons short of 128 pixels: " + small.join(", "))); +} + +/** + Whatever accent the platform hands QET, the hover ink must read on the + hovered button face of both palettes: a sweep over hues at every + lightness, including the pale and the near-black ones. +*/ +void tst_qeticons::hoverInkReadsWithAnyAccent_data() +{ + QTest::addColumn("palette"); + for (int lightness = 10; lightness <= 250; lightness += 40) + for (int hue = 0; hue < 360; hue += 60) + { + const QColor accent = QColor::fromHsl(hue, 200, lightness); + const QColor text = QET::Palette::contrastRatio(accent, Qt::white) >= 4.5 ? Qt::white : Qt::black; + QTest::newRow(qPrintable(QString("light-%1").arg(accent.name()))) + << withAccent(QET::Palette::fusionLight(), accent, text); + QTest::newRow(qPrintable(QString("dark-%1").arg(accent.name()))) + << withAccent(QET::Palette::fusionDark(), accent, text); + } +} + +void tst_qeticons::hoverInkReadsWithAnyAccent() +{ + QFETCH(QPalette, palette); + const QColor face = palette.color(QPalette::Active, QPalette::Light); + const QColor ink = QETStyle::hoverColor(palette); + const double contrast = QET::Palette::contrastRatio(ink, face); + QVERIFY2(contrast >= kIconRatio, + qPrintable(QString("hover ink %1 reads %2:1 on the face %3").arg(ink.name()).arg(contrast).arg(face.name()))); +} + int main(int argc, char **argv) { if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) diff --git a/tests/qttest/tst_qetpalette.cpp b/tests/qttest/tst_qetpalette.cpp index 4ced9d5a1..c15ebdeb0 100644 --- a/tests/qttest/tst_qetpalette.cpp +++ b/tests/qttest/tst_qetpalette.cpp @@ -29,11 +29,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include "inkcontrast.h" #include "ElementsCollection/elementpreviewdelegate.h" @@ -84,6 +86,11 @@ class tst_qetpalette : public QObject void paletteViewDrawsTheRubberBand(); void paletteViewFollowsTheApplicationUnderAStyleSheet(); void paletteViewFillsWhatTheSceneLeavesBlank(); + void paletteViewErasesMovedChildren_data(); + void paletteViewErasesMovedChildren(); + void paletteViewErasesChildrenMovedWhilePainting_data(); + void paletteViewErasesChildrenMovedWhilePainting(); + void styleSheetWidgetsFollowPaletteChange(); private: static void addPaletteRows(); @@ -571,6 +578,37 @@ namespace { } }; + /** + A parent that, like a Terminal with its help lines, gives its child + line a new geometry from inside paint(): the line always runs + across the sheet at the parent's height, whatever the parent's + position. Where the child is painted is therefore only known once + the parent has been painted. + */ + class PaintTimeHelpLine : public QGraphicsRectItem + { + public: + explicit PaintTimeHelpLine(const QRectF &sheet) : + QGraphicsRectItem(0, 0, 20, 20), + m_sheet(sheet), + m_line(new QGraphicsLineItem(this)) + { + setPen(Qt::NoPen); + setBrush(Qt::black); + m_line->setPen(QPen(Qt::black, 2)); + } + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override + { + QGraphicsRectItem::paint(painter, option, widget); + const qreal y = scenePos().y() + 10; + m_line->setLine(QLineF(m_line->mapFromScene(QPointF(m_sheet.left(), y)), + m_line->mapFromScene(QPointF(m_sheet.right(), y)))); + } + private: + QRectF m_sheet; + QGraphicsLineItem *m_line; + }; + /** A small folio: a white sheet with a black line and a red box. Returns the box, which is selectable. @@ -801,6 +839,112 @@ void tst_qetpalette::paletteViewFillsWhatTheSceneLeavesBlank() QCOMPARE(image.pixelColor(100, 100), base); } +/** + Moving an item must erase it where it was, children included, even a + child far bigger than its parent: a terminal's help lines span the + whole sheet. QGraphicsView does that on its default update path by + remembering where each item was last painted. A receiver on + QGraphicsScene::changed() switches the scene to its Qt 4.4 + compatibility path, which erases only the parent's own old rect and + leaves the children's trails behind (reported on #954). The check + reads the window's backing store, not a fresh rendering, and makes + sure the repaint after the move was a partial one: a full repaint of + the viewport would hide the bug, not fix it. +*/ +void tst_qetpalette::paletteViewErasesMovedChildren_data() +{ + addPaletteRows(); +} + +void tst_qetpalette::paletteViewErasesMovedChildren() +{ + QFETCH(QPalette, palette); + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(palette); + + QGraphicsScene scene(0, 0, 200, 120); + scene.setBackgroundBrush(Qt::white); + QGraphicsRectItem *parent = scene.addRect(0, 0, 20, 20, QPen(Qt::NoPen), QBrush(Qt::black)); + parent->setPos(20, 20); + QGraphicsLineItem *line = new QGraphicsLineItem(-10, 10, 170, 10, parent); + line->setPen(QPen(Qt::black, 2)); + + ProbeView view(&scene); + showAsSheet(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + QTRY_VERIFY(view.paints >= 1); + QScreen *screen = view.screen(); + const WId window = view.window()->winId(); + const QImage before = screen->grabWindow(window, 0, 0, 200, 120).toImage(); + const QColor sheet = sheetColor(before); + QVERIFY2(before.pixelColor(120, 30) != sheet, "the child line is not drawn"); + QVERIFY2(before.pixelColor(30, 30) != sheet, "the parent is not drawn"); + + const int from = view.paint_rects.size(); + parent->setPos(20, 70); + QTRY_VERIFY(view.paint_rects.size() > from); + QVERIFY2(!view.fullyRepaintedSince(from), "the move repainted the whole viewport"); + + const QImage after = screen->grabWindow(window, 0, 0, 200, 120).toImage(); + QVERIFY2(after.pixelColor(120, 30) == sheet, + qPrintable(QString("the child line left a trail: %1").arg(after.pixelColor(120, 30).name()))); + QVERIFY2(after.pixelColor(30, 30) == sheet, + qPrintable(QString("the parent left a trail: %1").arg(after.pixelColor(30, 30).name()))); + QVERIFY2(after.pixelColor(120, 80) != sheet, "the child line is missing at its new place"); + QVERIFY2(after.pixelColor(30, 80) != sheet, "the parent is missing at its new place"); +} + +/** + The same, for a child whose geometry is set while its parent is + painted, as a Terminal does with its help lines: the view must record + where the child was painted, not where it was before the paint, or + the next move erases the wrong place. Two moves, because the first + paint after a move is where the child gets its new geometry. +*/ +void tst_qetpalette::paletteViewErasesChildrenMovedWhilePainting_data() +{ + addPaletteRows(); +} + +void tst_qetpalette::paletteViewErasesChildrenMovedWhilePainting() +{ + QFETCH(QPalette, palette); + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(palette); + + QGraphicsScene scene(0, 0, 200, 120); + scene.setBackgroundBrush(Qt::white); + PaintTimeHelpLine *parent = new PaintTimeHelpLine(scene.sceneRect()); + scene.addItem(parent); + parent->setPos(20, 20); + + ProbeView view(&scene); + showAsSheet(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + QTRY_VERIFY(view.paints >= 1); + QScreen *screen = view.screen(); + const WId window = view.window()->winId(); + const QColor sheet = sheetColor(screen->grabWindow(window, 0, 0, 200, 120).toImage()); + + int from = view.paint_rects.size(); + parent->setPos(20, 50); + QTRY_VERIFY(view.paint_rects.size() > from); + QTRY_VERIFY2(screen->grabWindow(window, 0, 0, 200, 120).toImage().pixelColor(120, 60) != sheet, + "the line did not follow the parent"); + + from = view.paint_rects.size(); + parent->setPos(20, 80); + QTRY_VERIFY(view.paint_rects.size() > from); + QVERIFY2(!view.fullyRepaintedSince(from), "the move repainted the whole viewport"); + QImage after; + QTRY_VERIFY2((after = screen->grabWindow(window, 0, 0, 200, 120).toImage()).pixelColor(120, 90) != sheet, + "the line is missing at its new place"); + QVERIFY2(after.pixelColor(120, 60) == sheet, + qPrintable(QString("the line left a trail: %1").arg(after.pixelColor(120, 60).name()))); + QVERIFY2(after.pixelColor(120, 30) == sheet, + qPrintable(QString("the first line was never erased: %1").arg(after.pixelColor(120, 30).name()))); +} + /** The inversion runs on every repaint of the folio, so a 4K viewport has to cost a few milliseconds. Reported, not asserted: the bound @@ -815,6 +959,64 @@ void tst_qetpalette::invertLightnessSpeed() } } +/** + A widget with a style sheet keeps the palette QStyleSheetStyle + resolved when the sheet was applied: after QApplication::setPalette() + it is still drawn in the old colors, which is what the folio tab bar + showed after a live light/dark switch. refreshStyleSheets() brings + it in line. Both directions are checked. +*/ +void tst_qetpalette::styleSheetWidgetsFollowPaletteChange() +{ + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(QET::Palette::fusionLight()); + + QWidget top; + auto *layout = new QHBoxLayout(&top); + auto *tabs = new QTabWidget; + tabs->addTab(new QWidget, "1"); + tabs->setStyleSheet("QTabBar::scroller {width: 0px;}"); // as sources/projectview.cpp + auto *button = new QToolButton; + button->setText("+"); + button->setAutoRaise(true); + tabs->setCornerWidget(button, Qt::TopRightCorner); + auto *plain = new QLabel("plain"); + plain->setAutoFillBackground(true); + layout->addWidget(tabs); + layout->addWidget(plain); + top.resize(300, 120); + top.show(); + QVERIFY(QTest::qWaitForWindowExposed(&top)); + + // The most frequent color of a widget's rendering: its background. + auto background = [](QWidget *w) { + const QImage image = w->grab().toImage(); + QHash histogram; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + ++histogram[image.pixel(x, y)]; + QRgb best = 0; + int count = -1; + for (auto it = histogram.cbegin(); it != histogram.cend(); ++it) + if (it.value() > count) { count = it.value(); best = it.key(); } + return best; + }; + auto window = [](const QPalette &p) { return p.color(QPalette::Active, QPalette::Window).rgb(); }; + + for (const QPalette &palette : {QET::Palette::fusionDark(), QET::Palette::fusionLight()}) + { + QApplication::setPalette(palette); + QTest::qWait(50); + QCOMPARE(background(plain), window(palette)); + // Qt leaves the style-sheet widget behind; this is the defect. + QVERIFY2(background(button) != window(palette), "Qt now updates style-sheet widgets itself; refreshStyleSheets() is redundant"); + + QET::Palette::refreshStyleSheets(); + QTest::qWait(50); + QCOMPARE(background(button), window(palette)); + } +} + int main(int argc, char **argv) { // Widgets are painted for real, on Qt's offscreen platform so the test