From 614208e03418ab36ffee41c2b94a60b1f048c33d Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 10 May 2026 15:34:14 +0200 Subject: [PATCH] feat(settings): add session panel settings to the Panels tab --- assets/translations/en.json | 41 +- meson.build | 1 + src/config/config_overrides.cpp | 28 +- src/config/config_service.cpp | 54 +++ src/config/config_types.cpp | 9 + src/config/config_types.h | 25 +- src/shell/session/session_panel.cpp | 307 ++++++++++--- src/shell/session/session_panel.h | 37 +- .../settings/session_actions_editor_popup.cpp | 192 ++++++++ .../settings/session_actions_editor_popup.h | 53 +++ src/shell/settings/settings_content.cpp | 428 +++++++++++++++++- src/shell/settings/settings_content.h | 9 + src/shell/settings/settings_registry.cpp | 5 + src/shell/settings/settings_registry.h | 8 +- src/shell/settings/settings_window.cpp | 207 ++++++++- src/shell/settings/settings_window.h | 3 + 16 files changed, 1314 insertions(+), 93 deletions(-) create mode 100644 src/shell/settings/session_actions_editor_popup.cpp create mode 100644 src/shell/settings/session_actions_editor_popup.h diff --git a/assets/translations/en.json b/assets/translations/en.json index cceac0b6a..798896613 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -6,7 +6,8 @@ }, "actions": { "cancel": "Cancel", - "apply": "Apply" + "apply": "Apply", + "remove": "Remove" } }, "clipboard": { @@ -404,7 +405,8 @@ "lock": "Lock", "logout": "Log Out", "reboot": "Reboot", - "shutdown": "Shut Down" + "shutdown": "Shut Down", + "custom": "Custom" }, "errors": { "logout-title": "Logout unavailable", @@ -587,6 +589,32 @@ "add-entry-placeholder": "Add entry…" } }, + "session-actions": { + "entry-heading": "Menu item {n}", + "icon-label": "Icon", + "kind-section-label": "Behavior", + "glyph-picker-title": "Session menu icon", + "clear-glyph": "Clear", + "enabled": "Shown", + "show-in-menu": "Show", + "command-label": "Shell command", + "command-placeholder": "Optional bash command, leave empty for built-in behavior", + "label-field": "Label", + "label-placeholder": "Optional button text", + "glyph-field": "Glyph", + "glyph-placeholder": "Optional icon id", + "destructive-label": "Warn style", + "move-up": "Up", + "move-down": "Down", + "add": "Add action", + "kind": { + "lock": "Lock", + "logout": "Log out", + "reboot": "Reboot", + "shutdown": "Shut down", + "command": "Custom command" + } + }, "dialogs": { "color-picker": { "title": "Pick a Color" @@ -764,7 +792,8 @@ "weather": "Weather", "wallpaper": "Wallpaper", "widget-list": "Widget List", - "widgets": "Widgets" + "widgets": "Widgets", + "session-panel": "Session Menu" } }, "options": { @@ -1388,6 +1417,12 @@ "attach-wallpaper": { "label": "Attach to Bar", "description": "Attach the Wallpaper panel to the bar when a suitable bar is available" + }, + "session-actions": { + "label": "Entries", + "description": "Power menu entries, order, custom commands, and icons.", + "configure": "Configure…", + "entry-settings": "Details…" } }, "backdrop": { diff --git a/meson.build b/meson.build index 2deea60c6..ea2e96ea5 100644 --- a/meson.build +++ b/meson.build @@ -480,6 +480,7 @@ _noctalia_sources = files( 'src/shell/settings/widget_settings_registry.cpp', 'src/shell/settings/widget_add_popup.cpp', 'src/shell/settings/search_picker_popup.cpp', + 'src/shell/settings/session_actions_editor_popup.cpp', 'src/shell/settings/settings_window.cpp', 'src/shell/test/test_panel.cpp', 'src/shell/tray/tray_drawer_panel.cpp', diff --git a/src/config/config_overrides.cpp b/src/config/config_overrides.cpp index 5669b4d49..1d924837e 100644 --- a/src/config/config_overrides.cpp +++ b/src/config/config_overrides.cpp @@ -298,7 +298,8 @@ namespace { a.panel.attachLauncher == b.panel.attachLauncher && a.panel.attachClipboard == b.panel.attachClipboard && a.panel.attachControlCenter == b.panel.attachControlCenter && a.panel.attachWallpaper == b.panel.attachWallpaper && a.screenCorners.enabled == b.screenCorners.enabled && - a.screenCorners.size == b.screenCorners.size && a.mpris.blacklist == b.mpris.blacklist; + a.screenCorners.size == b.screenCorners.size && a.mpris.blacklist == b.mpris.blacklist && + a.session.actions == b.session.actions; } bool notificationConfigEqual(const NotificationConfig& a, const NotificationConfig& b) { @@ -385,6 +386,30 @@ namespace { array.push_back(std::move(shortcut)); } table.insert_or_assign(key, std::move(array)); + } else if constexpr (std::is_same_v>) { + toml::array array; + for (const auto& item : concrete) { + if (item.action.empty()) { + continue; + } + toml::table row; + row.insert_or_assign("action", item.action); + row.insert_or_assign("enabled", item.enabled); + if (item.command.has_value() && !item.command->empty()) { + row.insert_or_assign("command", *item.command); + } + if (item.label.has_value() && !item.label->empty()) { + row.insert_or_assign("label", *item.label); + } + if (item.glyph.has_value() && !item.glyph->empty()) { + row.insert_or_assign("glyph", *item.glyph); + } + if (item.destructive) { + row.insert_or_assign("destructive", true); + } + array.push_back(std::move(row)); + } + table.insert_or_assign(key, std::move(array)); } else { table.insert_or_assign(key, concrete); } @@ -613,6 +638,7 @@ std::optional ConfigService::configForOverrides(const toml::table& overr }); parsed.bars.push_back(BarConfig{}); parsed.controlCenter.shortcuts = defaultControlCenterShortcuts(); + parsed.shell.session.actions = defaultSessionPanelActions(); return parsed; } diff --git a/src/config/config_service.cpp b/src/config/config_service.cpp index d8d4db34c..d3cd446ab 100644 --- a/src/config/config_service.cpp +++ b/src/config/config_service.cpp @@ -885,6 +885,7 @@ void ConfigService::loadAll() { }); m_config.bars.push_back(BarConfig{}); m_config.controlCenter.shortcuts = defaultControlCenterShortcuts(); + m_config.shell.session.actions = defaultSessionPanelActions(); setConfigParseError(m_overridesParseError); return; } @@ -1273,6 +1274,59 @@ void ConfigService::parseTableInto(const toml::table& tbl, Config& config, bool if (auto v = (*shellTbl)["clipboard_image_action_command"].value()) { shell.clipboardImageActionCommand = *v; } + + bool sessionActionsKeyPresent = false; + if (const auto* sessionTbl = (*shellTbl)["session"].as_table()) { + if (sessionTbl->contains("actions")) { + sessionActionsKeyPresent = true; + shell.session.actions.clear(); + if (const auto* actionsArr = (*sessionTbl)["actions"].as_array()) { + for (const auto& entry : *actionsArr) { + auto* entryTbl = entry.as_table(); + if (entryTbl == nullptr) { + continue; + } + SessionPanelActionConfig row{}; + if (auto v = (*entryTbl)["action"].value()) { + row.action = StringUtils::toLower(StringUtils::trim(*v)); + } + if (row.action.empty()) { + continue; + } + if (auto v = (*entryTbl)["enabled"].value()) { + row.enabled = *v; + } + if (const auto* cmdNode = entryTbl->get("command")) { + if (auto s = cmdNode->value()) { + row.command = StringUtils::trim(*s); + if (row.command->empty()) { + row.command = std::nullopt; + } + } + } + if (auto v = (*entryTbl)["label"].value()) { + row.label = StringUtils::trim(*v); + if (row.label->empty()) { + row.label = std::nullopt; + } + } + if (auto v = (*entryTbl)["glyph"].value()) { + row.glyph = StringUtils::trim(*v); + if (row.glyph->empty()) { + row.glyph = std::nullopt; + } + } + if (auto v = (*entryTbl)["destructive"].value()) { + row.destructive = *v; + } + shell.session.actions.push_back(std::move(row)); + } + } + } + } + if (!sessionActionsKeyPresent && shell.session.actions.empty()) { + shell.session.actions = defaultSessionPanelActions(); + } } // Parse [theme] diff --git a/src/config/config_types.cpp b/src/config/config_types.cpp index 9ff28cd8c..ee35e5384 100644 --- a/src/config/config_types.cpp +++ b/src/config/config_types.cpp @@ -38,6 +38,15 @@ std::vector defaultControlCenterShortcuts() { }; } +std::vector defaultSessionPanelActions() { + return { + SessionPanelActionConfig{"lock", true, std::nullopt, std::nullopt, std::nullopt, false}, + SessionPanelActionConfig{"logout", true, std::nullopt, std::nullopt, std::nullopt, false}, + SessionPanelActionConfig{"reboot", true, std::nullopt, std::nullopt, std::nullopt, false}, + SessionPanelActionConfig{"shutdown", true, std::nullopt, std::nullopt, std::nullopt, true}, + }; +} + std::string WidgetConfig::getString(const std::string& key, const std::string& fallback) const { auto it = settings.find(key); if (it == settings.end()) { diff --git a/src/config/config_types.h b/src/config/config_types.h index ca79d8a42..c5a81ea43 100644 --- a/src/config/config_types.h +++ b/src/config/config_types.h @@ -103,11 +103,31 @@ struct ShortcutConfig { bool operator==(const ShortcutConfig&) const = default; }; +struct SessionPanelActionConfig { + // "lock" | "logout" | "reboot" | "shutdown" | "command" + std::string action; + bool enabled = true; + // When set, runs via `process::runAsync` (shell string) instead of the built-in handler. + std::optional command; + std::optional label; + std::optional glyph; + bool destructive = false; + + bool operator==(const SessionPanelActionConfig&) const = default; +}; + +struct ShellSessionConfig { + std::vector actions; + + bool operator==(const ShellSessionConfig&) const = default; +}; + [[nodiscard]] std::vector defaultControlCenterShortcuts(); +[[nodiscard]] std::vector defaultSessionPanelActions(); using WidgetSettingValue = std::variant>; -using ConfigOverrideValue = - std::variant, std::vector>; +using ConfigOverrideValue = std::variant, + std::vector, std::vector>; // Optional rounded “capsule” behind a bar widget (see `[widget.*] capsule_*` in CONFIG.md). // Corner shape (pill), border width, and edge softness are fixed in the shell code; padding is configurable. @@ -387,6 +407,7 @@ struct ShellConfig { PanelConfig panel; ScreenCornersConfig screenCorners; MprisConfig mpris; + ShellSessionConfig session; }; struct WeatherConfig { diff --git a/src/shell/session/session_panel.cpp b/src/shell/session/session_panel.cpp index a02e46153..7ce5164d5 100644 --- a/src/shell/session/session_panel.cpp +++ b/src/shell/session/session_panel.cpp @@ -12,7 +12,9 @@ #include "shell/panel/panel_manager.h" #include "ui/controls/button.h" #include "ui/controls/flex.h" +#include "ui/controls/grid_view.h" #include "ui/style.h" +#include "util/string_utils.h" #include #include @@ -27,20 +29,6 @@ namespace { constexpr Logger kLog("session"); - struct ActionSpec { - SessionPanel::ActionId id; - const char* labelKey; - const char* glyph; - ButtonVariant variant; - }; - - constexpr std::array kActionSpecs{{ - {SessionPanel::ActionId::Lock, "session.actions.lock", "lock", ButtonVariant::Default}, - {SessionPanel::ActionId::Logout, "session.actions.logout", "logout", ButtonVariant::Default}, - {SessionPanel::ActionId::Reboot, "session.actions.reboot", "reboot", ButtonVariant::Default}, - {SessionPanel::ActionId::Shutdown, "session.actions.shutdown", "shutdown", ButtonVariant::Destructive}, - }}; - [[nodiscard]] const char* valueOrUnset(const char* value) { return value != nullptr && value[0] != '\0' ? value : ""; } @@ -130,16 +118,157 @@ namespace { }).detach(); } + void runShellCommand(std::function hook, std::string command, std::string_view actionName) { + std::thread([hook = std::move(hook), command = std::move(command), actionName = std::string(actionName)]() mutable { + if (hook && !hook()) { + kLog.warn("{} cancelled because a configured hook failed", actionName); + return; + } + if (!process::runAsync(command)) { + kLog.warn("{}: command failed", actionName); + } + }).detach(); + } + + [[nodiscard]] bool isKnownAction(std::string_view action) { + return action == "lock" || action == "logout" || action == "reboot" || action == "shutdown" || action == "command"; + } + + [[nodiscard]] const char* labelKeyForAction(std::string_view action) { + if (action == "lock") { + return "session.actions.lock"; + } + if (action == "logout") { + return "session.actions.logout"; + } + if (action == "reboot") { + return "session.actions.reboot"; + } + if (action == "shutdown") { + return "session.actions.shutdown"; + } + return "session.actions.custom"; + } + + [[nodiscard]] const char* defaultGlyphForAction(std::string_view action) { + if (action == "lock") { + return "lock"; + } + if (action == "logout") { + return "logout"; + } + if (action == "reboot") { + return "reboot"; + } + if (action == "shutdown") { + return "shutdown"; + } + return "terminal"; + } + + [[nodiscard]] ButtonVariant variantFor(const SessionPanelActionConfig& cfg) { + if (cfg.destructive) { + return ButtonVariant::Destructive; + } + if (cfg.action == "shutdown" && !cfg.command.has_value()) { + return ButtonVariant::Destructive; + } + return ButtonVariant::Default; + } + } // namespace +std::vector SessionPanel::effectiveActions() const { + std::vector src; + if (m_config != nullptr) { + src = m_config->config().shell.session.actions; + } + if (src.empty()) { + src = defaultSessionPanelActions(); + } + + std::vector out; + out.reserve(src.size()); + for (const auto& row : src) { + if (!row.enabled) { + continue; + } + if (!isKnownAction(row.action)) { + kLog.warn("session panel: skipping unknown action \"{}\"", row.action); + continue; + } + if (row.action == "command" && (!row.command.has_value() || StringUtils::trim(*row.command).empty())) { + kLog.warn("session panel: skipping \"command\" entry with no command"); + continue; + } + out.push_back(row); + } + return out; +} + +std::function SessionPanel::hookFor(const std::string& action) const { + if (action == "logout") { + return m_actionHooks.onLogout; + } + if (action == "reboot") { + return m_actionHooks.onReboot; + } + if (action == "shutdown") { + return m_actionHooks.onShutdown; + } + return {}; +} + +float SessionPanel::preferredWidth() const { + const std::size_t n = visibleColumnCount(); + const float gap = Style::spaceSm; + const float w = kButtonMinWidth * static_cast(n) + gap * static_cast(n > 1 ? n - 1 : 0) + + Style::panelPadding * 2.0f; + return scaled(std::max(kPanelMinWidth, w)); +} + +float SessionPanel::preferredHeight() const { + const std::size_t rows = visibleRowCount(); + const float gap = Style::spaceSm; + const float h = kActionButtonMinHeight * static_cast(rows) + + gap * static_cast(rows > 1 ? rows - 1 : 0) + Style::panelPadding * 2.0f; + return std::ceil(scaled(h)); +} + +std::size_t SessionPanel::entryCountForLayout() const { + if (!m_visibleEntries.empty()) { + return m_visibleEntries.size(); + } + return effectiveActions().size(); +} + +std::size_t SessionPanel::visibleColumnCount() const { + const std::size_t n = std::max(1, entryCountForLayout()); + if (n <= kMaxColumns) { + return n; + } + return std::min(kMaxColumns, (n + 1) / 2); +} + +std::size_t SessionPanel::visibleRowCount() const { + const std::size_t n = std::max(1, entryCountForLayout()); + const std::size_t columns = visibleColumnCount(); + return (n + columns - 1) / columns; +} + void SessionPanel::create() { const float scale = contentScale(); + m_visibleEntries = effectiveActions(); + const std::size_t columns = visibleColumnCount(); - auto rootLayout = std::make_unique(); - rootLayout->setDirection(FlexDirection::Horizontal); - rootLayout->setAlign(FlexAlign::Stretch); - rootLayout->setGap(Style::spaceSm * scale); - rootLayout->setJustify(FlexJustify::Start); + auto rootLayout = std::make_unique(); + rootLayout->setColumns(columns); + rootLayout->setColumnGap(Style::spaceSm * scale); + rootLayout->setRowGap(Style::spaceSm * scale); + rootLayout->setStretchItems(true); + rootLayout->setUniformCellSize(true); + rootLayout->setMinCellWidth(kButtonMinWidth * scale); + rootLayout->setMinCellHeight(kActionButtonMinHeight * scale); m_rootLayout = rootLayout.get(); auto focusArea = std::make_unique(); @@ -152,13 +281,15 @@ void SessionPanel::create() { }); m_focusArea = static_cast(rootLayout->addChild(std::move(focusArea))); - for (const auto& spec : kActionSpecs) { - const std::size_t visualIndex = rootLayout->children().size() - (m_focusArea != nullptr ? 1U : 0U); - if (auto* button = createActionButton(spec.id, scale); button != nullptr) { - m_actionOrder[visualIndex] = spec.id; - rootLayout->addChild(std::unique_ptr