refactor(settings): list the users as rows with their details in a dialog

The Users page shows its accounts in a list card, as Wi-Fi does, the one logged in first. Each row keeps the picture, the names and whether it is logged in, and ends in an info button that opens the account's type, the way to a new password and deleting it.
main
reionwong 1 week ago
parent b0dc37ea12
commit 45a0f687cd

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <algorithm>
#include <memory>
#include <unistd.h>
@ -9,11 +10,13 @@
#include "base/paths.h"
#include "pages/accounts/password.h"
#include "pages/accounts/passwordchanger.h"
#include "pages/accounts/usercard.h"
#include "pages/accounts/userdetailsdialog.h"
#include "pages/accounts/userrow.h"
#include "pages/accounts/userdialogs.h"
#include "ui/dialog_window.h"
#include "ui/file_chooser.h"
#include "ui/cell_layout.h"
#include "widgets/card.h"
#include "widgets/metrics.h"
#include "widgets/standardbutton.h"
#include "window/page.h"
@ -33,8 +36,7 @@ public:
explicit AccountsPage(SettingsWindow& window)
: Page(window, "Users")
{
m_users = content()->addChild<ColumnLayout>();
m_users->setSpacing(metrics::sectionSpacing);
m_users = content()->addChild<Card>(Card::Style::List);
m_users->setLayoutHints({.fillWidth = true});
StandardButton* add = content()->addChild<StandardButton>("Add user");
add->onClicked = [this] {
@ -58,28 +60,33 @@ private:
void updateUsers()
{
// Cards that are still there keep their state, such as being unfolded.
// Rows that are still there are kept; the one logged in goes first.
std::vector<std::unique_ptr<View>> previous;
while (!m_users->children().empty())
previous.push_back(m_users->takeChild(m_users->children().front().get()));
for (const auto& account : m_manager.accounts()) {
std::unique_ptr<View> card;
const auto accounts = loggedInFirst(m_manager.accounts(), int64_t(getuid()));
for (const auto& account : accounts) {
std::unique_ptr<View> row;
for (auto& view : previous) {
if (view && static_cast<UserCard*>(view.get())->account() == account) {
card = std::move(view);
if (view && static_cast<UserRow*>(view.get())->account() == account) {
row = std::move(view);
break;
}
}
if (!card)
card = makeCard(account);
m_users->addChild(std::move(card));
if (!row)
row = makeRow(account);
m_users->addChild(std::move(row));
}
m_users->setVisible(!accounts.empty());
// The details of an account that is gone close with it.
if (m_details && std::find(accounts.begin(), accounts.end(), m_details->account()) == accounts.end())
m_details->close();
}
std::unique_ptr<View> makeCard(const accounts::AccountsManager::AccountPtr& account)
std::unique_ptr<View> makeRow(const accounts::AccountsManager::AccountPtr& account)
{
auto card = std::make_unique<UserCard>(account, int64_t(getuid()));
card->onPictureClicked = [this, account] {
auto row = std::make_unique<UserRow>(account, int64_t(getuid()));
row->onPictureClicked = [this, account] {
m_chooser = std::make_unique<FileChooser>(&settingsWindow().window());
m_chooser->setMode(FileChooser::Mode::OpenFile);
// As QML's FileDialog, window-modal.
@ -95,46 +102,58 @@ private:
account->setIconFileName(paths.front());
// Shown from the file picked while the service copies it.
for (const auto& child : m_users->children()) {
auto* card = static_cast<UserCard*>(child.get());
if (card->account() == account)
card->showPicture(paths.front());
auto* row = static_cast<UserRow*>(child.get());
if (row->account() == account)
row->showPicture(paths.front());
}
};
m_chooser->open();
};
card->onChangePassword = [this, account] {
// The user's own goes through PAM, which asks for the current
// password and updates the login keyring with it.
const bool own = account->userId() == int64_t(getuid());
m_changePassword = std::make_unique<ChangePasswordDialog>(
settingsWindow(), account->userName(), own ? std::make_unique<PasswordChanger>() : nullptr);
m_changePassword->onPasswordSubmitted = [account](const std::string& password) {
account->setPassword(cryptPassword(password));
};
m_changePassword->onClosed = [this] { dispose(m_changePassword); };
m_changePassword->show();
row->onInfo = [this, account] {
m_details = std::make_unique<UserDetailsDialog>(settingsWindow(), account, int64_t(getuid()));
m_details->onChangePassword = [this, account] { changePassword(account); };
m_details->onDelete = [this, account] { deleteUser(account); };
m_details->onClosed = [this] { dispose(m_details); };
m_details->show();
};
card->onDelete = [this, account] {
const int64_t uid = account->userId();
if (uid == int64_t(getuid()))
return;
m_confirm = std::make_unique<ConfirmDialog>(
&settingsWindow().window(), "Delete this user",
"Delete user “" + account->userName()
+ "”? Their home directory and its contents will be permanently deleted. This "
"cannot be undone.");
m_confirm->setCancelText("Cancel");
m_confirm->setConfirmText("Delete");
m_confirm->setDestructive(true);
m_confirm->onAccepted = [this, uid] { m_manager.deleteUser(uid, true); };
m_confirm->show();
return row;
}
void changePassword(const accounts::AccountsManager::AccountPtr& account)
{
// The user's own goes through PAM, which asks for the current
// password and updates the login keyring with it.
const bool own = account->userId() == int64_t(getuid());
m_changePassword = std::make_unique<ChangePasswordDialog>(
settingsWindow(), account->userName(), own ? std::make_unique<PasswordChanger>() : nullptr);
m_changePassword->onPasswordSubmitted = [account](const std::string& password) {
account->setPassword(cryptPassword(password));
};
return card;
m_changePassword->onClosed = [this] { dispose(m_changePassword); };
m_changePassword->show();
}
void deleteUser(const accounts::AccountsManager::AccountPtr& account)
{
const int64_t uid = account->userId();
if (uid == int64_t(getuid()))
return;
m_confirm = std::make_unique<ConfirmDialog>(
&settingsWindow().window(), "Delete this user",
"Delete user “" + account->userName()
+ "”? Their home directory and its contents will be permanently deleted. This "
"cannot be undone.");
m_confirm->setCancelText("Cancel");
m_confirm->setConfirmText("Delete");
m_confirm->setDestructive(true);
m_confirm->onAccepted = [this, uid] { m_manager.deleteUser(uid, true); };
m_confirm->show();
}
accounts::AccountsManager m_manager;
base::Subscription m_accountsChanged;
ColumnLayout* m_users;
Card* m_users;
std::unique_ptr<UserDetailsDialog> m_details;
std::unique_ptr<AddUserDialog> m_addUser;
std::unique_ptr<ChangePasswordDialog> m_changePassword;
std::unique_ptr<ConfirmDialog> m_confirm;

@ -1,477 +0,0 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "pages/accounts/usercard.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <unicode/unistr.h>
#include <unicode/utf16.h>
#include "base/transform.h"
#include "ui/animation.h"
#include "ui/color_provider.h"
#include "ui/icon.h"
#include "ui/image_view.h"
#include "ui/label.h"
#include "ui/layout.h"
#include "ui/cell_layout.h"
#include "ui/painter.h"
#include "ui/path.h"
#include "ui/theme.h"
#include "ui/window.h"
#include "widgets/colors.h"
#include "widgets/metrics.h"
#include "widgets/standardbutton.h"
using namespace cutefish;
using namespace cutefish::ui;
namespace settings {
namespace {
// FishUI's RoundImageButton with a glyph: a round plate on hover and press,
// kept on the glyph's pixel parity so both share a centre.
class DisclosureButton : public View
{
public:
DisclosureButton(double size, double iconMargins)
: m_size(size)
, m_iconSize(std::round(size - iconMargins * 2))
{
m_icon = addChild<Icon>(std::string(), m_iconSize);
}
void setIconName(std::string name) { m_icon->setIconName(std::move(name)); }
const std::string& iconName() const { return m_icon->iconName(); }
double iconSize() const { return m_iconSize; }
SizeF sizeHint() const override { return {m_size, m_size}; }
bool acceptsPointer() const override { return true; }
std::function<void()> onClicked;
protected:
void paint(Painter& painter) override
{
const Rect box = painter.deviceRect(localRect());
const int icon = int(std::lround(m_iconSize * painter.scale()));
if (isEnabled() && (m_hovered || m_pressed)) {
const int margin = int(std::lround(std::round(m_size * 0.1) * painter.scale()));
int plate = std::min(box.width, box.height) - 2 * margin;
if ((plate - icon) % 2 != 0)
plate -= 1;
painter.fillDeviceRoundedRect({box.x + (box.width - plate) / 2, box.y + (box.height - plate) / 2, plate,
plate},
plate / 2.0,
color(m_pressed ? ColorId::RoundButtonPressed : ColorId::RoundButtonHovered));
}
}
void layout() override
{
m_icon->setGeometry({std::round((m_size - m_iconSize) / 2), std::round((m_size - m_iconSize) / 2), m_iconSize,
m_iconSize});
}
void pointerEnter() override
{
m_hovered = true;
requestPaint();
}
void pointerLeave() override
{
m_hovered = false;
requestPaint();
}
void pointerPress(const PointerEvent& event) override
{
if (event.button != MouseButton::Left)
return;
m_pressed = true;
requestPaint();
}
void pointerRelease(const PointerEvent& event) override
{
const bool clicked = m_pressed && localRect().contains(event.position);
m_pressed = false;
requestPaint();
if (clicked && onClicked)
onClicked();
}
private:
double m_size;
double m_iconSize;
Icon* m_icon;
bool m_hovered = false;
bool m_pressed = false;
};
// The account's picture in a circle, or its initial, as on the lock screen,
// for an account with no picture: the icon theme has no avatar that size.
// Hovering dims it under a pencil, as the lock screen's avatar shows its
// swap arrows, since a click changes the picture.
class UserAvatar : public View
{
public:
static constexpr double kSize = 48;
UserAvatar()
{
m_initial = addChild<Label>();
m_initial->setFontSize(22);
// Drawn once decoded; the initial shows until then.
m_picture = addChild<ImageView>();
m_picture->setCornerRadius(kSize / 2);
m_picture->onStatusChanged = [this](ImageView::Status) {
requestLayout();
requestPaint();
};
m_overlay = addChild<EditOverlay>(this);
}
void setUserName(const std::string& name)
{
// The first character, upper case, as QString::charAt(0).toUpper() gives it.
icu::UnicodeString text = icu::UnicodeString::fromUTF8(name);
std::string initial = "?";
if (!text.isEmpty()) {
icu::UnicodeString first = text.tempSubString(0, text.length() > 0 && U16_IS_LEAD(text[0]) ? 2 : 1);
first.toUpper();
initial.clear();
first.toUTF8String(initial);
}
m_initial->setText(initial);
requestLayout();
}
void setPicture(const std::string& path)
{
if (path == m_picture->source())
return;
m_picture->setSource(path);
requestLayout();
requestPaint();
}
SizeF sizeHint() const override { return {kSize, kSize}; }
bool acceptsPointer() const override { return true; }
CursorShape cursorShape() const override { return CursorShape::Pointer; }
std::function<void()> onClicked;
protected:
void layout() override
{
m_initial->setColor(Theme::current().disabledText);
const SizeF size = m_initial->sizeHint();
m_initial->setGeometry({rounding::centeredStart(kSize, size.width), rounding::centeredStart(kSize, size.height), size.width, size.height});
m_initial->setVisible(!hasPicture());
m_picture->setGeometry(localRect());
m_overlay->setGeometry(localRect());
}
void paint(Painter& painter) override
{
// The account's initial, as on the lock screen, for an account with
// no picture: the icon theme has no avatar the size of a picture.
if (!hasPicture())
painter.fillEllipse(localRect(), Theme::current().text.withAlpha(0.12));
}
void pointerRelease(const PointerEvent& event) override
{
if (event.button == MouseButton::Left && localRect().contains(event.position) && onClicked)
onClicked();
}
void pointerEnter() override { setHovered(true); }
void pointerLeave() override { setHovered(false); }
private:
class EditOverlay : public View
{
public:
explicit EditOverlay(UserAvatar* avatar)
: m_avatar(avatar)
{
}
protected:
void paint(Painter& painter) override
{
const double t = m_avatar->m_hover;
if (t <= 0)
return;
painter.fillEllipse(localRect(), Color {0, 0, 0, 255}.withAlpha(0.35 * t));
// The pencil on its 24 by 24 grid, drawn 18 across and centred.
constexpr double kIcon = 18;
Path pencil;
pencil.moveTo(13.26, 3.59997);
pencil.lineTo(5.04997, 12.29);
pencil.cubicTo(4.73997, 12.62, 4.43997, 13.27, 4.37997, 13.72);
pencil.lineTo(4.00997, 16.96);
pencil.cubicTo(3.87997, 18.13, 4.71997, 18.93, 5.87997, 18.73);
pencil.lineTo(9.09997, 18.18);
pencil.cubicTo(9.54997, 18.1, 10.18, 17.77, 10.49, 17.43);
pencil.lineTo(18.7, 8.73997);
pencil.cubicTo(20.12, 7.23997, 20.76, 5.52997, 18.55, 3.43997);
pencil.cubicTo(16.35, 1.36997, 14.68, 2.09997, 13.26, 3.59997);
pencil.close();
pencil.moveTo(11.89, 5.05005);
pencil.cubicTo(12.32, 7.81005, 14.56, 9.92005, 17.34, 10.2);
pencil.moveTo(3, 22);
pencil.lineTo(21, 22);
const double scale = kIcon / 24;
const base::AffineTransform place(scale, 0, 0, scale, rounding::centeredStart(geometry().width, kIcon),
rounding::centeredStart(geometry().height, kIcon));
painter.strokePath(pencil.transformed(place), Color {255, 255, 255, 255}.withAlpha(t), 1.5,
LineCap::Round, LineJoin::Round);
}
private:
UserAvatar* m_avatar;
};
bool hasPicture() const { return m_picture->status() == ImageView::Status::Ready; }
void setHovered(bool hovered)
{
Window* window = this->window();
if (!window) {
setHover(hovered ? 1 : 0);
return;
}
if (!m_hoverAnimation) {
m_hoverAnimation = std::make_unique<Animation>(window);
m_hoverAnimation->setDuration(std::chrono::milliseconds(80));
m_hoverAnimation->setEasing(Animation::Easing::OutQuad);
m_hoverAnimation->onUpdate = [this](double value) {
setHover(m_hoverFrom + (m_hoverTarget - m_hoverFrom) * value);
};
}
// From wherever it is now, as a Behavior retargets.
m_hoverFrom = m_hover;
m_hoverTarget = hovered ? 1 : 0;
m_hoverAnimation->stop();
m_hoverAnimation->start();
}
void setHover(double value)
{
m_hover = value;
// A picture shows through the dim; an initial would cross the pencil.
m_initial->setOpacity(1 - value);
m_overlay->requestPaint();
}
Label* m_initial;
ImageView* m_picture;
EditOverlay* m_overlay;
std::unique_ptr<Animation> m_hoverAnimation;
double m_hover = 0;
double m_hoverFrom = 0;
double m_hoverTarget = 0;
};
} // namespace
// The card's top row: an item as tall as its row plus a margin, holding the
// picture, the user name, the real name, then "Logged in" and the
// disclosure button at the end.
class UserCard::Header : public View
{
public:
Header()
{
row = addChild<RowLayout>();
row->setSpacing(metrics::rowSpacing);
avatar = row->addChild<UserAvatar>();
userName = row->addChild<Label>();
realName = row->addChild<Label>();
row->addChild<View>()->setLayoutHints({.fillWidth = true});
loggedIn = row->addChild<Label>("Logged in");
disclosure = row->addChild<DisclosureButton>(26, 5);
disclosure->setIconName("chevron-down");
}
SizeF sizeHint() const override { return {0, row->sizeHint().height + metrics::m}; }
bool acceptsPointer() const override { return true; }
RowLayout* row;
UserAvatar* avatar;
Label* userName;
Label* realName;
Label* loggedIn;
DisclosureButton* disclosure;
std::function<void()> onDoubleClicked;
protected:
void pointerPress(const PointerEvent& event) override
{
if (event.button == MouseButton::Left && event.clickCount == 2 && onDoubleClicked)
onDoubleClicked();
}
void layout() override
{
const Theme& theme = Theme::current();
realName->setColor(theme.disabledText);
loggedIn->setColor(theme.disabledText);
// As close to the arrow as a row choice's value is to its arrow.
loggedIn->setLayoutHints({.margins = {0, 0, theme.mediumSpacing - metrics::rowSpacing - 5, 0}});
disclosure->setLayoutHints(
{.margins = {0, 0, metrics::trailingGlyphMargin(disclosure->iconName(), disclosure->iconSize(), 5), 0}});
row->setGeometry(localRect());
}
};
Unfold::Unfold()
{
m_content = addChild<ColumnLayout>();
setClipsChildren(true);
setLayoutHints({.fillWidth = true});
}
Unfold::~Unfold() = default;
void Unfold::setShown(bool shown)
{
if (m_shown == shown)
return;
m_shown = shown;
Window* window = this->window();
if (!window) {
m_progress = 1;
requestLayout();
return;
}
if (!m_animation) {
m_animation = std::make_unique<Animation>(window);
m_animation->setDuration(std::chrono::milliseconds(200));
m_animation->setEasing(Animation::Easing::OutSine);
m_animation->onUpdate = [this](double value) {
m_progress = value;
requestLayout();
};
}
// From wherever it is now, as a Behavior retargets.
m_from = m_height;
m_progress = 0;
m_animation->start();
requestLayout();
}
double Unfold::targetHeight(double width) const
{
return m_shown ? m_content->heightForWidth(width) : 0;
}
SizeF Unfold::sizeHint() const
{
return {m_content->sizeHint().width, heightForWidth(geometry().width)};
}
double Unfold::heightForWidth(double width) const
{
return m_from + (targetHeight(width) - m_from) * m_progress;
}
void Unfold::layout()
{
const double width = geometry().width;
m_height = heightForWidth(width);
if (m_progress >= 1)
m_from = m_height;
// Filling the unfolding height, as the anchored layout did: it squeezes
// its rows while short.
m_content->setGeometry({0, 0, width, m_height});
m_content->setVisible(m_height > 0);
}
UserCard::UserCard(std::shared_ptr<accounts::UserAccount> account, int64_t loggedInUid)
: Card(Card::Style::Padded)
, m_account(std::move(account))
, m_loggedInUid(loggedInUid)
{
ColumnLayout* main = addChild<ColumnLayout>();
main->setSpacing(0);
RowLayout* itemRow = main->addChild<RowLayout>();
itemRow->setSpacing(0);
m_header = itemRow->addChild<Header>();
m_header->setLayoutHints({.fillWidth = true});
m_unfold = main->addChild<Unfold>();
m_unfold->content()->setSpacing(metrics::cardSpacing);
GridLayout* grid = m_unfold->content()->addChild<GridLayout>(2);
grid->setColumnSpacing(metrics::formColumnSpacing);
grid->setRowSpacing(metrics::formRowSpacing);
grid->setLayoutHints({.fillWidth = true, .margins = {0, metrics::cardSpacing, 0, 0}});
grid->addChild<Label>("Account type");
m_accountType = grid->addChild<Label>();
m_password = m_unfold->content()->addChild<StandardButton>("Change password");
m_password->onClicked = [this] {
if (onChangePassword)
onChangePassword();
};
m_delete = m_unfold->content()->addChild<StandardButton>("Delete this user");
m_delete->onClicked = [this] {
if (onDelete)
onDelete();
};
m_header->avatar->onClicked = [this] {
if (onPictureClicked)
onPictureClicked();
};
m_header->disclosure->onClicked = [this] {
m_unfold->toggle();
update();
};
m_header->onDoubleClicked = [this] {
m_unfold->toggle();
update();
};
m_changed = m_account->changed.connect([this] { update(); });
update();
}
UserCard::~UserCard() = default;
void UserCard::layout()
{
const Color background = color(SettingsColor::UserCardButtonBackground);
m_password->setBackgroundColor(background);
m_delete->setBackgroundColor(background);
Card::layout();
}
void UserCard::showPicture(const std::string& path)
{
m_header->avatar->setPicture(path);
}
void UserCard::update()
{
const std::string userName = m_account->userName();
const std::string realName = m_account->realName();
m_header->avatar->setUserName(userName);
m_header->avatar->setPicture(m_account->iconFileName());
m_header->userName->setText(userName);
m_header->realName->setText(realName);
m_header->realName->setVisible(realName != userName);
m_header->loggedIn->setVisible(m_account->userId() == m_loggedInUid);
m_header->disclosure->setIconName(m_unfold->isShown() ? "chevron-up" : "chevron-down");
m_accountType->setText(m_account->accountType() == accounts::UserAccount::Standard ? "Standard"
: "Administrator");
m_delete->setEnabled(m_account->userId() != m_loggedInUid);
requestLayout();
}
} // namespace settings

@ -1,88 +0,0 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <functional>
#include <memory>
#include <string>
#include "base/signal.h"
#include "cutefish/accounts/user_account.h"
#include "widgets/card.h"
namespace cutefish::ui {
class Animation;
class ColumnLayout;
class Label;
}
namespace settings {
class StandardButton;
// The part of a card that unfolds below its header, its height animated.
class Unfold : public cutefish::ui::View
{
public:
Unfold();
~Unfold() override;
cutefish::ui::ColumnLayout* content() const { return m_content; }
bool isShown() const { return m_shown; }
void setShown(bool shown);
void toggle() { setShown(!m_shown); }
cutefish::ui::SizeF sizeHint() const override;
double heightForWidth(double width) const override;
protected:
void layout() override;
private:
double targetHeight(double width) const;
cutefish::ui::ColumnLayout* m_content;
std::unique_ptr<cutefish::ui::Animation> m_animation;
bool m_shown = false;
// The height drawn now, and the one the animation started from.
double m_height = 0;
double m_from = 0;
double m_progress = 1;
};
// One account on the Users page: its picture, names and whether it is the
// one logged in, unfolding to its type and the password and delete buttons.
class UserCard : public Card
{
public:
UserCard(std::shared_ptr<cutefish::accounts::UserAccount> account, int64_t loggedInUid);
~UserCard() override;
const std::shared_ptr<cutefish::accounts::UserAccount>& account() const { return m_account; }
// Shows a picture before the account has it.
void showPicture(const std::string& path);
std::function<void()> onPictureClicked;
std::function<void()> onChangePassword;
std::function<void()> onDelete;
protected:
void layout() override;
private:
class Header;
void update();
std::shared_ptr<cutefish::accounts::UserAccount> m_account;
int64_t m_loggedInUid;
cutefish::base::Subscription m_changed;
Header* m_header;
Unfold* m_unfold;
cutefish::ui::Label* m_accountType;
StandardButton* m_password;
StandardButton* m_delete;
};
} // namespace settings

@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "pages/accounts/userdetailsdialog.h"
#include "ui/cell_layout.h"
#include "ui/dialog_window.h"
#include "ui/keys.h"
#include "ui/window.h"
#include "widgets/card.h"
#include "widgets/listrow.h"
#include "widgets/metrics.h"
#include "window/settingswindow.h"
using namespace cutefish;
using namespace cutefish::ui;
namespace settings {
static constexpr double kWidth = 400;
UserDetailsDialog::UserDetailsDialog(SettingsWindow& window, std::shared_ptr<accounts::UserAccount> account,
int64_t loggedInUid)
: m_account(std::move(account))
, m_loggedInUid(loggedInUid)
, m_dialog(std::make_unique<DialogWindow>(&window.window(), m_account->userName(), kWidth))
{
auto content = std::make_unique<ColumnLayout>();
content->setPadding({0, metrics::dialogPadding, 0, 0});
content->setSpacing(metrics::dialogSpacing);
Card* card = content->addChild<Card>(Card::Style::List);
m_type = card->addChild<InfoRow>("Account type");
m_password = card->addChild<ListRow>("Change password…");
m_password->setName("user:changePassword");
m_password->onClicked = [this] {
close();
if (onChangePassword)
onChangePassword();
};
RowLayout* buttons = content->addChild<RowLayout>();
buttons->setSpacing(metrics::dialogButtonSpacing);
buttons->setLayoutHints({.horizontalAlignment = Alignment::End});
m_delete = buttons->addChild<DialogButton>("Delete this user", true);
m_delete->setName("user:delete");
m_delete->setDestructive(true);
m_delete->onClicked = [this] {
close();
if (onDelete)
onDelete();
};
m_dialog->setContentView(std::move(content));
m_dialog->onClosed = [this] {
m_changed.reset();
if (onClosed)
onClosed();
};
m_dialog->window()->addShortcut(NoModifier, key::Escape, [this] { close(); });
m_changed = m_account->changed.connect([this] { update(); });
update();
}
UserDetailsDialog::~UserDetailsDialog() = default;
void UserDetailsDialog::show()
{
m_dialog->show();
}
void UserDetailsDialog::close()
{
m_dialog->close();
}
void UserDetailsDialog::update()
{
m_dialog->setTitle(m_account->userName());
m_type->setValue(m_account->accountType() == accounts::UserAccount::Standard ? "Standard" : "Administrator");
m_delete->setEnabled(m_account->userId() != m_loggedInUid);
}
} // namespace settings

@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <functional>
#include <memory>
#include "base/signal.h"
#include "cutefish/accounts/user_account.h"
namespace cutefish::ui {
class DialogButton;
class DialogWindow;
}
namespace settings {
class InfoRow;
class ListRow;
class SettingsWindow;
// An account's details: its type, the way to a new password, and deleting
// it, which the account logged in cannot do to itself. Either action closes
// the details first.
class UserDetailsDialog
{
public:
UserDetailsDialog(SettingsWindow& window, std::shared_ptr<cutefish::accounts::UserAccount> account,
int64_t loggedInUid);
~UserDetailsDialog();
UserDetailsDialog(const UserDetailsDialog&) = delete;
UserDetailsDialog& operator=(const UserDetailsDialog&) = delete;
const std::shared_ptr<cutefish::accounts::UserAccount>& account() const { return m_account; }
cutefish::ui::DialogWindow* dialog() const { return m_dialog.get(); }
void show();
void close();
std::function<void()> onChangePassword;
std::function<void()> onDelete;
std::function<void()> onClosed;
private:
void update();
std::shared_ptr<cutefish::accounts::UserAccount> m_account;
int64_t m_loggedInUid;
std::unique_ptr<cutefish::ui::DialogWindow> m_dialog;
cutefish::base::Subscription m_changed;
InfoRow* m_type = nullptr;
ListRow* m_password = nullptr;
cutefish::ui::DialogButton* m_delete = nullptr;
};
} // namespace settings

@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "pages/accounts/userrow.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <unicode/unistr.h>
#include <unicode/utf16.h>
#include "base/transform.h"
#include "ui/animation.h"
#include "ui/cell_layout.h"
#include "ui/image_view.h"
#include "ui/label.h"
#include "ui/painter.h"
#include "ui/path.h"
#include "ui/theme.h"
#include "ui/window.h"
#include "widgets/iconbutton.h"
#include "widgets/metrics.h"
using namespace cutefish;
using namespace cutefish::ui;
namespace settings {
// The account's picture in a circle, or its initial, as on the lock screen,
// for an account with no picture: the icon theme has no avatar that size.
// Hovering dims it under a pencil, as the lock screen's avatar shows its
// swap arrows, since a click changes the picture.
class UserAvatar : public View
{
public:
static constexpr double kSize = 48;
UserAvatar()
{
m_initial = addChild<Label>();
m_initial->setFontSize(22);
// Drawn once decoded; the initial shows until then.
m_picture = addChild<ImageView>();
m_picture->setCornerRadius(kSize / 2);
m_picture->onStatusChanged = [this](ImageView::Status) {
requestLayout();
requestPaint();
};
m_overlay = addChild<EditOverlay>(this);
}
void setUserName(const std::string& name)
{
// The first character, upper case, as QString::charAt(0).toUpper() gives it.
icu::UnicodeString text = icu::UnicodeString::fromUTF8(name);
std::string initial = "?";
if (!text.isEmpty()) {
icu::UnicodeString first = text.tempSubString(0, text.length() > 0 && U16_IS_LEAD(text[0]) ? 2 : 1);
first.toUpper();
initial.clear();
first.toUTF8String(initial);
}
m_initial->setText(initial);
requestLayout();
}
void setPicture(const std::string& path)
{
if (path == m_picture->source())
return;
m_picture->setSource(path);
requestLayout();
requestPaint();
}
SizeF sizeHint() const override { return {kSize, kSize}; }
bool acceptsPointer() const override { return true; }
CursorShape cursorShape() const override { return CursorShape::Pointer; }
std::function<void()> onClicked;
protected:
void layout() override
{
m_initial->setColor(Theme::current().disabledText);
const SizeF size = m_initial->sizeHint();
m_initial->setGeometry({rounding::centeredStart(kSize, size.width), rounding::centeredStart(kSize, size.height), size.width, size.height});
m_initial->setVisible(!hasPicture());
m_picture->setGeometry(localRect());
m_overlay->setGeometry(localRect());
}
void paint(Painter& painter) override
{
// The account's initial, as on the lock screen, for an account with
// no picture: the icon theme has no avatar the size of a picture.
if (!hasPicture())
painter.fillEllipse(localRect(), Theme::current().text.withAlpha(0.12));
}
void pointerRelease(const PointerEvent& event) override
{
if (event.button == MouseButton::Left && localRect().contains(event.position) && onClicked)
onClicked();
}
void pointerEnter() override { setHovered(true); }
void pointerLeave() override { setHovered(false); }
private:
class EditOverlay : public View
{
public:
explicit EditOverlay(UserAvatar* avatar)
: m_avatar(avatar)
{
}
protected:
void paint(Painter& painter) override
{
const double t = m_avatar->m_hover;
if (t <= 0)
return;
painter.fillEllipse(localRect(), Color {0, 0, 0, 255}.withAlpha(0.35 * t));
// The pencil on its 24 by 24 grid, drawn 18 across and centred.
constexpr double kIcon = 18;
Path pencil;
pencil.moveTo(13.26, 3.59997);
pencil.lineTo(5.04997, 12.29);
pencil.cubicTo(4.73997, 12.62, 4.43997, 13.27, 4.37997, 13.72);
pencil.lineTo(4.00997, 16.96);
pencil.cubicTo(3.87997, 18.13, 4.71997, 18.93, 5.87997, 18.73);
pencil.lineTo(9.09997, 18.18);
pencil.cubicTo(9.54997, 18.1, 10.18, 17.77, 10.49, 17.43);
pencil.lineTo(18.7, 8.73997);
pencil.cubicTo(20.12, 7.23997, 20.76, 5.52997, 18.55, 3.43997);
pencil.cubicTo(16.35, 1.36997, 14.68, 2.09997, 13.26, 3.59997);
pencil.close();
pencil.moveTo(11.89, 5.05005);
pencil.cubicTo(12.32, 7.81005, 14.56, 9.92005, 17.34, 10.2);
pencil.moveTo(3, 22);
pencil.lineTo(21, 22);
const double scale = kIcon / 24;
const base::AffineTransform place(scale, 0, 0, scale, rounding::centeredStart(geometry().width, kIcon),
rounding::centeredStart(geometry().height, kIcon));
painter.strokePath(pencil.transformed(place), Color {255, 255, 255, 255}.withAlpha(t), 1.5,
LineCap::Round, LineJoin::Round);
}
private:
UserAvatar* m_avatar;
};
bool hasPicture() const { return m_picture->status() == ImageView::Status::Ready; }
void setHovered(bool hovered)
{
Window* window = this->window();
if (!window) {
setHover(hovered ? 1 : 0);
return;
}
if (!m_hoverAnimation) {
m_hoverAnimation = std::make_unique<Animation>(window);
m_hoverAnimation->setDuration(std::chrono::milliseconds(80));
m_hoverAnimation->setEasing(Animation::Easing::OutQuad);
m_hoverAnimation->onUpdate = [this](double value) {
setHover(m_hoverFrom + (m_hoverTarget - m_hoverFrom) * value);
};
}
// From wherever it is now, as a Behavior retargets.
m_hoverFrom = m_hover;
m_hoverTarget = hovered ? 1 : 0;
m_hoverAnimation->stop();
m_hoverAnimation->start();
}
void setHover(double value)
{
m_hover = value;
// A picture shows through the dim; an initial would cross the pencil.
m_initial->setOpacity(1 - value);
m_overlay->requestPaint();
}
Label* m_initial;
ImageView* m_picture;
EditOverlay* m_overlay;
std::unique_ptr<Animation> m_hoverAnimation;
double m_hover = 0;
double m_hoverFrom = 0;
double m_hoverTarget = 0;
};
UserRow::UserRow(std::shared_ptr<accounts::UserAccount> account, int64_t loggedInUid)
: m_account(std::move(account))
, m_loggedInUid(loggedInUid)
{
// As much room above and below the picture as before it.
setMinimumRowHeight(UserAvatar::kSize + 2 * metrics::cardHorizontalPadding);
m_avatar = setLeading<UserAvatar>();
m_avatar->onClicked = [this] {
if (onPictureClicked)
onPictureClicked();
};
m_userName = addContent<Label>();
m_realName = addContent<Label>();
addContent<View>()->setLayoutHints({.fillWidth = true});
m_loggedIn = addContent<Label>("Logged in");
m_info = addContent<IconButton>("info", 20);
m_info->onClicked = [this] {
if (onInfo)
onInfo();
};
onClicked = [this] {
if (onInfo)
onInfo();
};
m_changed = m_account->changed.connect([this] { update(); });
update();
}
UserRow::~UserRow() = default;
void UserRow::showPicture(const std::string& path)
{
m_avatar->setPicture(path);
}
void UserRow::layout()
{
const Theme& theme = Theme::current();
m_realName->setColor(theme.disabledText);
m_loggedIn->setColor(theme.disabledText);
m_info->setColor(theme.highlight);
ListRow::layout();
}
void UserRow::update()
{
const std::string userName = m_account->userName();
const std::string realName = m_account->realName();
setName("user:" + userName);
m_avatar->setUserName(userName);
m_avatar->setPicture(m_account->iconFileName());
m_userName->setText(userName);
m_realName->setText(realName);
m_realName->setVisible(realName != userName);
m_loggedIn->setVisible(m_account->userId() == m_loggedInUid);
requestLayout();
}
std::vector<std::shared_ptr<accounts::UserAccount>>
loggedInFirst(const std::vector<std::shared_ptr<accounts::UserAccount>>& accounts, int64_t loggedInUid)
{
std::vector<std::shared_ptr<accounts::UserAccount>> ordered = accounts;
std::stable_partition(ordered.begin(), ordered.end(),
[loggedInUid](const auto& account) { return account->userId() == loggedInUid; });
return ordered;
}
} // namespace settings

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "base/signal.h"
#include "cutefish/accounts/user_account.h"
#include "widgets/listrow.h"
namespace cutefish::ui {
class Label;
}
namespace settings {
class IconButton;
class UserAvatar;
// One account on the Users page: its picture, names and whether it is the
// one logged in, with an info button at the end for its details.
class UserRow : public ListRow
{
public:
UserRow(std::shared_ptr<cutefish::accounts::UserAccount> account, int64_t loggedInUid);
~UserRow() override;
const std::shared_ptr<cutefish::accounts::UserAccount>& account() const { return m_account; }
// Shows a picture before the account has it.
void showPicture(const std::string& path);
std::function<void()> onPictureClicked;
std::function<void()> onInfo;
protected:
void layout() override;
private:
void update();
std::shared_ptr<cutefish::accounts::UserAccount> m_account;
int64_t m_loggedInUid;
cutefish::base::Subscription m_changed;
UserAvatar* m_avatar;
cutefish::ui::Label* m_userName;
cutefish::ui::Label* m_realName;
cutefish::ui::Label* m_loggedIn;
IconButton* m_info;
};
// The accounts in the service's order, the one logged in first.
std::vector<std::shared_ptr<cutefish::accounts::UserAccount>>
loggedInFirst(const std::vector<std::shared_ptr<cutefish::accounts::UserAccount>>& accounts, int64_t loggedInUid);
} // namespace settings

@ -24,8 +24,6 @@ void addSettingsColors(ColorMixer& mixer, const Theme& theme)
mixer[SettingsColor::RowComboBoxHovered] = withAlpha(ColorId::Text, dark ? 0.08 : 0.06);
mixer[SettingsColor::RowComboBoxOpen] = withAlpha(ColorId::Text, dark ? 0.12 : 0.1);
mixer[SettingsColor::StandardButtonBackground] = dark ? Color::rgb(0x363636) : kWhite;
mixer[SettingsColor::UserCardButtonBackground] = dark ? ColorRecipe(Color::rgb(0x363636))
: ColorRecipe(ColorId::Background);
mixer[SettingsColor::KeyCap] = dark ? lighter(ColorId::SecondBackground, 1.6)
: darker(ColorId::SecondBackground, 1.08);
mixer[SettingsColor::PreferredLanguageTag] = withAlpha(ColorId::Highlight, dark ? 0.2 : 0.12);

@ -19,7 +19,6 @@ enum class SettingsColor : uint16_t {
RowComboBoxHovered,
RowComboBoxOpen,
StandardButtonBackground,
UserCardButtonBackground,
KeyCap,
PreferredLanguageTag,
WallpaperPlaceholder,

@ -9,7 +9,8 @@
#include "cutefish/accounts/accounts_manager.h"
#include "pages/accounts/password.h"
#include "pages/accounts/usercard.h"
#include "pages/accounts/userdetailsdialog.h"
#include "pages/accounts/userrow.h"
#include "pages/accounts/userdialogs.h"
#include "ui/dialog_window.h"
#include "ui/keys.h"
@ -18,6 +19,8 @@
#include "ui/test_input.h"
#include "ui/text_field.h"
#include "ui/window.h"
#include "widgets/card.h"
#include "widgets/iconbutton.h"
#include "window/settingswindow.h"
#include "input_test_support.h"
@ -52,6 +55,17 @@ std::vector<T*> findAll(View* view)
return found;
}
View* findView(View* view, const std::string& name)
{
if (view->name() == name)
return view;
for (const auto& child : view->children()) {
if (View* found = findView(child.get(), name))
return found;
}
return nullptr;
}
PointF centre(View* view)
{
const PointF origin = view->mapToWindow({0, 0});
@ -135,44 +149,113 @@ TEST(Accounts, AddUserShowsTheServicesAnswer)
EXPECT_TRUE(shown);
}
TEST(Accounts, CardUnfoldsToItsDetails)
TEST(Accounts, TheLoggedInAccountComesFirst)
{
auto account = std::make_shared<accounts::UserAccount>("/org/freedesktop/Accounts/User1000", nullptr);
const auto make = [](int uid) {
return std::make_shared<accounts::UserAccount>("/org/freedesktop/Accounts/User" + std::to_string(uid),
nullptr);
};
const std::vector<std::shared_ptr<accounts::UserAccount>> accounts {make(1001), make(1002), make(1000),
make(1003)};
std::vector<int64_t> uids;
for (const auto& account : loggedInFirst(accounts, 1000))
uids.push_back(account->userId());
EXPECT_EQ(uids, (std::vector<int64_t> {1000, 1001, 1002, 1003}));
}
// One account's row in a list card, as the Users page shows it.
struct UserRowFixture
{
UserRowFixture()
{
window.setScale(1.5);
auto root = std::make_unique<Column>();
root->setMargins({24, 56, 24, 24});
Card* card = root->addChild<Card>(Card::Style::List);
card->setLayoutHints({.fillWidth = true});
row = card->addChild<UserRow>(account, 1000);
window.setContentView(std::move(root));
window.resize({670, 600});
window.grab(1'000'000'000);
}
View* avatar() const { return find<Label>(row)->parent(); }
std::shared_ptr<accounts::UserAccount> account =
std::make_shared<accounts::UserAccount>("/org/freedesktop/Accounts/User1000", nullptr);
Window window;
window.setScale(1.5);
auto root = std::make_unique<Column>();
root->setMargins({24, 56, 24, 24});
UserCard* card = root->addChild<UserCard>(account, 1000);
window.setContentView(std::move(root));
window.resize({670, 600});
window.grab(1'000'000'000);
// The picture and a margin; padded as a card is.
EXPECT_EQ(card->geometry().height, 84);
// The card's column, its first row, the header, the header's row.
View* headerRow = card->children()[0]->children()[0]->children()[0]->children()[0].get();
// The disclosure button is the row's last child.
TestInput::click(window, centre(headerRow->children().back().get()));
window.grab(1'100'000'000);
window.grab(1'400'000'000);
// The type on its line below a gap, then the two buttons.
EXPECT_GT(card->geometry().height, 84 + 12 + 17 + 12 + 36 + 12 + 36 - 1);
UserRow* row = nullptr;
};
TEST(Accounts, ARowIsThePictureAndNamesWithAnInfoButton)
{
UserRowFixture fixture;
// As much room above and below the picture as before it.
const RectF row = fixture.row->geometry();
const PointF rowOrigin = fixture.row->mapToWindow({0, 0});
const PointF pictureOrigin = fixture.avatar()->mapToWindow({0, 0});
EXPECT_EQ(row.height, 48 + 2 * 16);
EXPECT_EQ(pictureOrigin.x - rowOrigin.x, 16);
EXPECT_EQ(pictureOrigin.y - rowOrigin.y, 16);
EXPECT_EQ(rowOrigin.y + row.height - (pictureOrigin.y + 48), 16);
IconButton* info = find<IconButton>(fixture.row);
ASSERT_TRUE(info);
// At the end of the row, centred on it as the picture is.
const PointF picture = centre(fixture.avatar());
const PointF button = centre(info);
EXPECT_DOUBLE_EQ(picture.y, button.y);
EXPECT_GT(button.x, 670 - 24 - 16 - 22);
int infoClicks = 0;
fixture.row->onInfo = [&] { ++infoClicks; };
TestInput::click(fixture.window, button);
// The rest of the row opens the details too.
TestInput::click(fixture.window, {button.x - 200, button.y});
EXPECT_EQ(infoClicks, 2);
// The picture is for changing the picture.
int pictureClicks = 0;
fixture.row->onPictureClicked = [&] { ++pictureClicks; };
TestInput::click(fixture.window, picture);
EXPECT_EQ(pictureClicks, 1);
EXPECT_EQ(infoClicks, 2);
}
TEST(Accounts, TheDetailsShowTheTypeAndLeadToTheActions)
{
SettingsWindow window;
auto account = std::make_shared<accounts::UserAccount>("/org/freedesktop/Accounts/User1001", nullptr);
const auto open = [&](int64_t loggedIn) {
auto details = std::make_unique<UserDetailsDialog>(window, account, loggedIn);
details->show();
details->dialog()->window()->grab();
return details;
};
auto details = open(1000);
View* content = details->dialog()->window()->contentView();
bool typeShown = false;
for (Label* label : findAll<Label>(content))
typeShown = typeShown || (label->isVisible() && label->text() == "Standard");
EXPECT_TRUE(typeShown);
View* remove = findView(content, "user:delete");
ASSERT_TRUE(remove);
EXPECT_TRUE(remove->isEnabled());
bool changing = false;
details->onChangePassword = [&] { changing = true; };
TestInput::click(*details->dialog()->window(), centre(findView(content, "user:changePassword")));
EXPECT_TRUE(changing);
// The account logged in cannot delete itself.
auto own = open(1001);
EXPECT_FALSE(findView(own->dialog()->window()->contentView(), "user:delete")->isEnabled());
}
TEST(Accounts, HoveringThePictureDimsItUnderAPencil)
{
auto account = std::make_shared<accounts::UserAccount>("/org/freedesktop/Accounts/User1000", nullptr);
Window window;
window.setScale(1.5);
auto root = std::make_unique<Column>();
root->setMargins({24, 56, 24, 24});
UserCard* card = root->addChild<UserCard>(account, 1000);
window.setContentView(std::move(root));
window.resize({670, 600});
window.grab(1'000'000'000);
// The card's column, its first row, the header, the header's row, and
// the picture first in it.
View* avatar = card->children()[0]->children()[0]->children()[0]->children()[0]->children()[0].get();
UserRowFixture fixture;
Window& window = fixture.window;
View* avatar = fixture.avatar();
Label* initial = find<Label>(avatar);
ASSERT_TRUE(initial);
const PointF middle = centre(avatar);
@ -199,16 +282,10 @@ TEST(Accounts, HoveringThePictureDimsItUnderAPencil)
TEST(Accounts, ThePictureReplacesTheInitialOnceDecoded)
{
auto account = std::make_shared<accounts::UserAccount>("/org/freedesktop/Accounts/User1000", nullptr);
Window window;
window.setScale(1.5);
auto root = std::make_unique<Column>();
UserCard* card = root->addChild<UserCard>(account, 1000);
window.setContentView(std::move(root));
window.resize({670, 300});
window.grab();
UserRowFixture fixture;
Window& window = fixture.window;
const auto initialShown = [&] {
for (Label* label : findAll<Label>(card)) {
for (Label* label : findAll<Label>(fixture.row)) {
if (label->text() == "?")
return label->isVisible();
}
@ -216,7 +293,7 @@ TEST(Accounts, ThePictureReplacesTheInitialOnceDecoded)
};
EXPECT_TRUE(initialShown());
card->showPicture(std::string(SETTINGS_SOURCE_DIR) + "/images/logo.svg");
fixture.row->showPicture(std::string(SETTINGS_SOURCE_DIR) + "/images/logo.svg");
window.grab();
ASSERT_TRUE(settings::testing::waitFor([&] {
window.grab();

Loading…
Cancel
Save