diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -28,6 +28,7 @@ shadowedtexture.cpp colorutils.cpp pagerouter.cpp + avatar.cpp scenegraph/shadowedrectanglenode.cpp scenegraph/shadowedrectanglematerial.cpp scenegraph/shadowedborderrectanglematerial.cpp diff --git a/src/avatar.h b/src/avatar.h new file mode 100644 --- /dev/null +++ b/src/avatar.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class AvatarPrivate : public QObject { + Q_OBJECT + +public: + Q_INVOKABLE QString initialsFromString(const QString& name); + Q_INVOKABLE QColor colorsFromString(const QString& name); + Q_INVOKABLE bool stringHasNonLatinCharacters(const QString& name); +}; diff --git a/src/avatar.cpp b/src/avatar.cpp new file mode 100644 --- /dev/null +++ b/src/avatar.cpp @@ -0,0 +1,71 @@ +#include "avatar.h" + +auto AvatarPrivate::initialsFromString(const QString& string) -> QString +{ + // "" -> "" + if (string.isEmpty()) return QStringLiteral(""); + + auto normalized = string.normalized(QString::NormalizationForm_D); + // "FirstName Name Name LastName" + if (normalized.contains(QStringLiteral(" "))) { + // "FirstName Name Name LastName" -> "FirstName" "Name" "Name" "LastName" + QStringList split = normalized.split(QStringLiteral(" ")); + // "FirstName" + auto first = split.first(); + // "LastName" + auto last = split.last(); + if (first.isEmpty()) { + // "" "LastName" -> "L" + return QString(last.front()); + } + if (last.isEmpty()) { + // "FirstName" "" -> "F" + return QString(first.front()); + } + // "FirstName" "LastName" -> "FL" + return QString(first.front())+QString(last.front()); + // "OneName" + } else { + // "OneName" -> "O" + return QString(normalized.front()); + } +} + +const QList c_colors = { + QColor("#e93a9a"), + QColor("#e93d58"), + QColor("#e9643a"), + QColor("#ef973c"), + QColor("#e8cb2d"), + QColor("#b6e521"), + QColor("#3dd425"), + QColor("#00d485"), + QColor("#00d3b8"), + QColor("#3daee9"), + QColor("#b875dc"), + QColor("#926ee4"), +}; + +auto AvatarPrivate::colorsFromString(const QString& string) -> QColor +{ + // We use a hash to get a "random" number that's always the same for + // a given string. + auto hash = qHash(string); + // hash modulo the length of the colors list minus one will always get us a valid + // index + auto index = hash % (c_colors.length()-1); + // return a colour + return c_colors[index]; +} + +auto AvatarPrivate::stringHasNonLatinCharacters(const QString& string) -> bool +{ + for (auto character : string) { + if (character.script() != QChar::Script_Common && + character.script() != QChar::Script_Inherited && + character.script() != QChar::Script_Latin) { + return true; + } + } + return false; +} diff --git a/src/controls/Avatar.qml b/src/controls/Avatar.qml new file mode 100644 --- /dev/null +++ b/src/controls/Avatar.qml @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.5 +import org.kde.kirigami 2.13 as Kirigami +import QtQuick.Controls 2.13 as QQC2 +import org.kde.kirigami.private 2.13 +import QtGraphicalEffects 1.0 + +import "templates/private" as P + +/** + * An element that represents a user, either with initials, an icon, or a profile image. + */ +QQC2.Control { + id: avatarRoot + + enum ImageMode { + AlwaysShowImage, + AdaptiveImageOrInitals, + AlwaysShowInitials + } + enum InitialsMode { + UseInitials, + UseIcon + } + + /** + * The given name of a user. + * + * The user's name will be used for generating initials. + */ + property string name + + /** + * The source of the user's profile picture; an image. + */ + property alias source: avatarImage.source + + /** + * How the button should represent the user when there is no image available. + * * `UseInitials` - Use initials when the image is not available + * * `UseIcon` - Use an icon of a user when the image is not available + */ + property int initialsMode: Kirigami.Avatar.InitialsMode.UseInitials + + /** + * Whether the button should always show the image; show the image if one is + * available and show initials when it is not; or always show initials. + * * `AlwaysShowImage` - Always show the image; even if is not value + * * `AdaptiveImageOrInitals` - Show the image if it is valid; or show initials if it is not + * * `AlwaysShowInitials` - Always show initials + */ + property int imageMode: Kirigami.Avatar.ImageMode.AdaptiveImageOrInitals + + property P.BorderPropertiesGroup border: P.BorderPropertiesGroup { + width: 2 + color: Qt.rgba(0,0,0,0.2) + } + + padding: 0 + topPadding: 0 + leftPadding: 0 + rightPadding: 0 + bottomPadding: 0 + + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large + + background: Rectangle { + radius: parent.width / 2 + + color: AvatarPrivate.colorsFromString(name) + } + + QtObject { + id: __private + property bool showImage: { + return (avatarRoot.imageMode == Kirigami.Avatar.ImageMode.AlwaysShowImage) || + (avatarImage.status == Image.Ready && avatarRoot.imageMode == Kirigami.Avatar.ImageMode.AdaptiveImageOrInitals) + } + } + + contentItem: Item { + Kirigami.Heading { + visible: avatarRoot.initialsMode == Kirigami.Avatar.InitialsMode.UseInitials && + !__private.showImage && + !AvatarPrivate.stringHasNonLatinCharacters(avatarRoot.name) + + text: AvatarPrivate.initialsFromString(name) + color: Kirigami.ColorUtils.brightnessForColor(AvatarPrivate.colorsFromString(name)) == Kirigami.ColorUtils.Light + ? "black" + : "white" + + anchors.centerIn: parent + } + Kirigami.Icon { + visible: (avatarRoot.initialsMode == Kirigami.Avatar.InitialsMode.UseIcon && !__private.showImage) || + (AvatarPrivate.stringHasNonLatinCharacters(avatarRoot.name) && !__private.showImage) + + source: "user" + + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + + Kirigami.Theme.textColor: Kirigami.ColorUtils.brightnessForColor(AvatarPrivate.colorsFromString(name)) == Kirigami.ColorUtils.Light + ? "black" + : "white" + } + Image { + id: avatarImage + visible: false + + mipmap: true + smooth: true + + fillMode: Image.PreserveAspectFit + anchors.fill: parent + } + Kirigami.ShadowedTexture { + visible: __private.showImage + + radius: width / 2 + anchors.fill: parent + + source: avatarImage + } + + Rectangle { + color: "transparent" + + radius: width / 2 + anchors.fill: parent + + border { + width: avatarRoot.border.width + color: avatarRoot.border.color + } + } + } +} diff --git a/src/controls/templates/private/BorderPropertiesGroup.qml b/src/controls/templates/private/BorderPropertiesGroup.qml new file mode 100644 --- /dev/null +++ b/src/controls/templates/private/BorderPropertiesGroup.qml @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.5 + +QtObject { + /** + * The color of this border. + */ + property color color + + /** + * The width of this border. + */ + property real width +} diff --git a/src/kirigamiplugin.cpp b/src/kirigamiplugin.cpp --- a/src/kirigamiplugin.cpp +++ b/src/kirigamiplugin.cpp @@ -21,6 +21,7 @@ #include "shadowedtexture.h" #include "colorutils.h" #include "pagerouter.h" +#include "avatar.h" #include #include @@ -255,6 +256,9 @@ qmlRegisterUncreatableType(uri, 2, 12, "PageRouterAttached", QStringLiteral("PageRouterAttached cannot be created")); qmlRegisterType(componentUrl(QStringLiteral("RouterWindow.qml")), uri, 2, 12, "RouterWindow"); + qmlRegisterSingletonType("org.kde.kirigami.private", 2, 13, "AvatarPrivate", [] (QQmlEngine*, QJSEngine*) -> QObject* { return new AvatarPrivate; }); + qmlRegisterType(componentUrl(QStringLiteral("Avatar.qml")), uri, 2, 13, "Avatar"); + qmlProtectModule(uri, 2); }