diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d94c59fb8..de0de9039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,8 @@ jobs: cairo pango \ libxkbcommon \ sdbus-cpp libpipewire \ - pam curl libwebp + pam curl libwebp \ + librsvg polkit - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 45641416c..696a74b43 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ sudo dnf install meson gcc-c++ just \ cairo-devel pango-devel \ libxkbcommon-devel glib2-devel \ sdbus-cpp-devel pipewire-devel \ - pam-devel polkit-devel libcurl-devel libwebp-devel + pam-devel polkit-devel libcurl-devel libwebp-devel librsvg2-devel ``` ### Arch @@ -29,7 +29,7 @@ sudo pacman -S meson gcc just \ cairo pango \ libxkbcommon glib2 \ sdbus-cpp libpipewire polkit \ - pam curl libwebp + pam curl libwebp librsvg ``` ### Debian / Ubuntu @@ -43,7 +43,7 @@ sudo apt install meson g++ just \ libxkbcommon-dev libglib2.0-dev \ libsdbus-c++-dev libpipewire-0.3-dev \ libpam0g-dev libpolkit-agent-1-dev libpolkit-gobject-1-dev \ - libcurl4-openssl-dev libwebp-dev + libcurl4-openssl-dev libwebp-dev librsvg2-dev ``` Vendored dependencies, with no system package needed: `Wuffs`, `nanosvg`, `tomlplusplus`, `tinyexpr`, diff --git a/assets/translations/en.json b/assets/translations/en.json index 8d9a4c2fe..8825a766d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -675,6 +675,8 @@ "picker": { "placeholder": "Search widgets...", "empty": "No widgets found", + "instance-toggle": "New Instance", + "instance-description": "New instance for type \"{type}\"", "create-label": "New {label} Instance", "create-description": "Choose an id for type \"{type}\"" }, @@ -691,6 +693,7 @@ "inspector": { "edit-title": "Editing widget", "add-title": "Add widget to {lane}", + "add-instance-title": "Add {widget} widget to {lane}", "move-to-lane": "Move to {lane}" }, "detail": { diff --git a/meson.build b/meson.build index ebe11b8ce..a75d76409 100644 --- a/meson.build +++ b/meson.build @@ -551,7 +551,6 @@ _noctalia_sources = files( 'src/ui/controls/label.cpp', 'src/ui/controls/list_editor.cpp', 'src/ui/controls/progress_bar.cpp', - 'src/ui/controls/popup_window.cpp', 'src/ui/controls/radio_button.cpp', 'src/ui/controls/scroll_view.cpp', 'src/ui/controls/search_picker.cpp', diff --git a/src/app/application.cpp b/src/app/application.cpp index 2f414cfa0..c01606f6e 100644 --- a/src/app/application.cpp +++ b/src/app/application.cpp @@ -30,7 +30,6 @@ #include "system/distro_info.h" #include "time/time_format.h" #include "ui/controls/input.h" -#include "ui/controls/popup_window.h" #include "ui/dialogs/color_picker_dialog.h" #include "ui/dialogs/file_dialog.h" #include "ui/dialogs/glyph_picker_dialog.h" @@ -403,6 +402,8 @@ void Application::initServices() { m_configService.addReloadCallback([this]() { m_idleManager.reload(m_configService.config().idle); }); m_hookManager.setCommandRunner([this](const std::string& command) { return runUserCommand(command); }); + m_hookManager.setBlockingCommandRunner( + [this](const std::string& command) { return runUserCommandBlocking(command); }); m_hookManager.reload(m_configService.config().hooks); m_configService.addReloadCallback([this]() { m_hookManager.reload(m_configService.config().hooks); }); m_nightLightManager.reload(m_configService.config().nightlight); @@ -757,9 +758,9 @@ void Application::initUi() { m_lockScreen.setSessionHooks([this]() { m_hookManager.fire(HookKind::SessionLocked); }, [this]() { m_hookManager.fire(HookKind::SessionUnlocked); }); - m_sessionActionHooks.onLogout = [this]() { m_hookManager.fire(HookKind::LoggingOut); }; - m_sessionActionHooks.onReboot = [this]() { m_hookManager.fire(HookKind::Rebooting); }; - m_sessionActionHooks.onShutdown = [this]() { m_hookManager.fire(HookKind::ShuttingDown); }; + m_sessionActionHooks.onLogout = [this]() { return m_hookManager.fireBlocking(HookKind::LoggingOut); }; + m_sessionActionHooks.onReboot = [this]() { return m_hookManager.fireBlocking(HookKind::Rebooting); }; + m_sessionActionHooks.onShutdown = [this]() { return m_hookManager.fireBlocking(HookKind::ShuttingDown); }; m_wayland.setPointerEventCallback([this](const PointerEvent& event) { if (m_lockScreen.isActive()) { @@ -809,11 +810,7 @@ void Application::initUi() { m_fileDialogPopup.onKeyboardEvent(event); return; } - if (PopupWindow::dispatchKeyboardEvent(m_wayland.lastKeyboardSurface(), event)) { - return; - } - if (m_settingsWindow.isOpen() && m_settingsWindow.wlSurface() != nullptr && - m_wayland.lastKeyboardSurface() == m_settingsWindow.wlSurface()) { + if (m_settingsWindow.ownsKeyboardSurface(m_wayland.lastKeyboardSurface())) { m_settingsWindow.onKeyboardEvent(event); return; } @@ -1165,6 +1162,26 @@ bool Application::runUserCommand(const std::string& command) { return true; } +bool Application::runUserCommandBlocking(const std::string& command) { + constexpr std::string_view prefix = "noctalia:"; + + if (command.rfind(prefix, 0) == 0) { + const std::string response = m_ipcService.execute(command.substr(prefix.size())); + if (response.rfind("error:", 0) == 0) { + kLog.warn("IPC command '{}' failed: {}", command, response.substr(0, response.find('\n'))); + return false; + } + return true; + } + + const auto result = process::runSync(command); + if (!result) { + kLog.warn("command failed: {} exit_code={} stderr={}", command, result.exitCode, result.err); + return false; + } + return true; +} + bool Application::runIdleCommand(const std::string& command) { return runUserCommand(command); } void Application::onIconThemeChanged() { diff --git a/src/app/application.h b/src/app/application.h index 98325b19d..e4f38e066 100644 --- a/src/app/application.h +++ b/src/app/application.h @@ -110,6 +110,7 @@ private: void syncNotificationDaemon(); void syncPolkitAgent(); bool runUserCommand(const std::string& command); + bool runUserCommandBlocking(const std::string& command); bool runIdleCommand(const std::string& command); void onIconThemeChanged(); void onUpowerStateChangedForHooks(); diff --git a/src/config/config_service.cpp b/src/config/config_service.cpp index ad5e02c3c..2e1462dcb 100644 --- a/src/config/config_service.cpp +++ b/src/config/config_service.cpp @@ -1,6 +1,7 @@ #include "config/config_service.h" #include "core/build_info.h" +#include "core/deferred_call.h" #include "core/log.h" #include "ipc/ipc_service.h" #include "notification/notification_manager.h" @@ -314,9 +315,19 @@ void ConfigService::addReloadCallback(ReloadCallback callback) { m_reloadCallbac void ConfigService::setNotificationManager(NotificationManager* manager) { m_notificationManager = manager; if (m_notificationManager != nullptr && !m_pendingError.empty()) { - m_configErrorNotificationId = - m_notificationManager->addInternal("Noctalia", "Config parse error", m_pendingError, Urgency::Critical, 0); + const std::string pendingError = std::move(m_pendingError); m_pendingError.clear(); + DeferredCall::callLater([this, pendingError]() { + if (m_notificationManager == nullptr) { + m_pendingError = pendingError; + return; + } + if (m_configErrorNotificationId != 0) { + m_notificationManager->close(m_configErrorNotificationId); + } + m_configErrorNotificationId = + m_notificationManager->addInternal("Noctalia", "Config parse error", pendingError, Urgency::Critical, 0); + }); } } @@ -677,6 +688,7 @@ void ConfigService::loadOverridesFromFile() { m_defaultWallpaperPath.clear(); m_monitorWallpaperPaths.clear(); m_setupWizardCompleted = false; + m_overridesParseError.clear(); if (m_overridesPath.empty() || !std::filesystem::exists(m_overridesPath)) { return; @@ -686,7 +698,12 @@ void ConfigService::loadOverridesFromFile() { try { m_overridesTable = toml::parse_file(m_overridesPath); } catch (const toml::parse_error& e) { - kLog.warn("parse error in {}: {}", m_overridesPath, e.what()); + const auto& src = e.source(); + kLog.warn("parse error in {} at line {}, column {}: {}", m_overridesPath, src.begin.line, src.begin.column, + e.description()); + m_overridesParseError = + std::format("{} line {}, column {}: {}", std::filesystem::path(m_overridesPath).filename().string(), + src.begin.line, src.begin.column, e.description()); m_overridesTable = toml::table{}; return; } @@ -695,6 +712,28 @@ void ConfigService::loadOverridesFromFile() { extractWallpaperFromOverrides(); } +void ConfigService::setConfigParseError(std::string parseError) { + if (parseError.empty()) { + // Dismiss any previous config-error notification. + if (m_notificationManager != nullptr && m_configErrorNotificationId != 0) { + m_notificationManager->close(m_configErrorNotificationId); + m_configErrorNotificationId = 0; + } + m_pendingError.clear(); + return; + } + + if (m_notificationManager != nullptr) { + if (m_configErrorNotificationId != 0) { + m_notificationManager->close(m_configErrorNotificationId); + } + m_configErrorNotificationId = + m_notificationManager->addInternal("Noctalia", "Config parse error", parseError, Urgency::Critical, 0); + } else { + m_pendingError = std::move(parseError); + } +} + void ConfigService::deepMerge(toml::table& base, const toml::table& overlay) { for (const auto& [k, v] : overlay) { if (const auto* overlayTbl = v.as_table()) { @@ -846,6 +885,7 @@ void ConfigService::loadAll() { }); m_config.bars.push_back(BarConfig{}); m_config.controlCenter.shortcuts = defaultControlCenterShortcuts(); + setConfigParseError(m_overridesParseError); return; } @@ -857,25 +897,10 @@ void ConfigService::loadAll() { kLog.warn("config parse error: {}", semanticError); } - const std::string parseError = !firstError.empty() ? firstError : semanticError; - if (parseError.empty()) { - // Dismiss any previous config-error notification. - if (m_notificationManager != nullptr && m_configErrorNotificationId != 0) { - m_notificationManager->close(m_configErrorNotificationId); - m_configErrorNotificationId = 0; - } - m_pendingError.clear(); - } else { - if (m_notificationManager != nullptr) { - if (m_configErrorNotificationId != 0) { - m_notificationManager->close(m_configErrorNotificationId); - } - m_configErrorNotificationId = - m_notificationManager->addInternal("Noctalia", "Config parse error", parseError, Urgency::Critical, 0); - } else { - m_pendingError = parseError; - } - } + const std::string parseError = !firstError.empty() ? firstError + : !m_overridesParseError.empty() ? m_overridesParseError + : semanticError; + setConfigParseError(parseError); } void ConfigService::parseTable(const toml::table& tbl) { parseTableInto(tbl, m_config, true); } diff --git a/src/config/config_service.h b/src/config/config_service.h index 8d101cba1..cf3ce1391 100644 --- a/src/config/config_service.h +++ b/src/config/config_service.h @@ -97,6 +97,7 @@ private: void setupWatch(); void fireReloadCallbacks(); void loadOverridesFromFile(); + void setConfigParseError(std::string parseError); bool writeOverridesToFile(); void extractWallpaperFromOverrides(); @@ -116,6 +117,7 @@ private: bool m_setupWizardCompleted = false; mutable std::unordered_map m_effectiveOverrideCache; + std::string m_overridesParseError; std::string m_pendingError; // parse error from initial load, sent as notification once manager is wired up uint32_t m_configErrorNotificationId = 0; // ID of the active config-error notification, 0 if none NotificationManager* m_notificationManager = nullptr; diff --git a/src/hooks/hook_manager.cpp b/src/hooks/hook_manager.cpp index ef4c29e55..2236597ad 100644 --- a/src/hooks/hook_manager.cpp +++ b/src/hooks/hook_manager.cpp @@ -12,23 +12,34 @@ namespace { void HookManager::setCommandRunner(CommandRunner runner) { m_runner = std::move(runner); } +void HookManager::setBlockingCommandRunner(CommandRunner runner) { m_blockingRunner = std::move(runner); } + void HookManager::reload(const HooksConfig& config) { m_config = config; } -void HookManager::fire(HookKind kind) const { - if (kind == HookKind::Count || !m_runner) { - return; +void HookManager::fire(HookKind kind) const { (void)fireWithRunner(kind, m_runner); } + +bool HookManager::fireBlocking(HookKind kind) const { + return fireWithRunner(kind, m_blockingRunner ? m_blockingRunner : m_runner); +} + +bool HookManager::fireWithRunner(HookKind kind, const CommandRunner& runner) const { + if (kind == HookKind::Count || !runner) { + return false; } const auto& cmds = m_config.commands[static_cast(kind)]; if (cmds.empty()) { - return; + return true; } const std::string_view name = hookKindKey(kind); kLog.debug("hook '{}' running {} command(s)", name, cmds.size()); + bool ok = true; for (const auto& cmd : cmds) { - if (!m_runner(cmd)) { + if (!runner(cmd)) { kLog.warn("hook '{}' command failed: {}", name, cmd); + ok = false; } } + return ok; } void HookManager::fire(HookKind kind, std::initializer_list env) const { diff --git a/src/hooks/hook_manager.h b/src/hooks/hook_manager.h index bfb5dd0cc..299ac27d0 100644 --- a/src/hooks/hook_manager.h +++ b/src/hooks/hook_manager.h @@ -13,13 +13,18 @@ public: using EnvVar = std::pair; void setCommandRunner(CommandRunner runner); + void setBlockingCommandRunner(CommandRunner runner); void reload(const HooksConfig& config); void fire(HookKind kind) const; + [[nodiscard]] bool fireBlocking(HookKind kind) const; void fire(HookKind kind, std::initializer_list env) const; [[nodiscard]] const HooksConfig& config() const noexcept { return m_config; } private: + [[nodiscard]] bool fireWithRunner(HookKind kind, const CommandRunner& runner) const; + HooksConfig m_config; CommandRunner m_runner; + CommandRunner m_blockingRunner; }; diff --git a/src/shell/bar/widgets/clock_widget.cpp b/src/shell/bar/widgets/clock_widget.cpp index 1aacddbaf..780676dfc 100644 --- a/src/shell/bar/widgets/clock_widget.cpp +++ b/src/shell/bar/widgets/clock_widget.cpp @@ -146,6 +146,9 @@ void ClockWidget::doLayout(Renderer& renderer, float containerWidth, float conta renderer.measureText(m_lastPrimaryText, primaryFontSize, true, 0.0f, 1, TextAlign::Start); const auto secondaryMetrics = renderer.measureText(m_lastSecondaryText, secondaryFontSize, false, 0.0f, 1, TextAlign::Start); + const float primaryInkWidth = std::max(0.0f, primaryMetrics.inkRight - primaryMetrics.inkLeft); + const float secondaryInkWidth = std::max(0.0f, secondaryMetrics.inkRight - secondaryMetrics.inkLeft); + width = std::max({width, primaryInkWidth, secondaryInkWidth}); const float centerX = width * 0.5f; const float primaryInkCenterX = (primaryMetrics.inkLeft + primaryMetrics.inkRight) * 0.5f; const float secondaryInkCenterX = (secondaryMetrics.inkLeft + secondaryMetrics.inkRight) * 0.5f; diff --git a/src/shell/session/session_panel.cpp b/src/shell/session/session_panel.cpp index fc934783b..a02e46153 100644 --- a/src/shell/session/session_panel.cpp +++ b/src/shell/session/session_panel.cpp @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include #include @@ -116,6 +118,18 @@ namespace { return true; } + void runPowerAction(std::function hook, bool (*action)(), std::string_view actionName) { + std::thread([hook = std::move(hook), action, actionName = std::string(actionName)]() mutable { + if (hook && !hook()) { + kLog.warn("{} cancelled because a configured hook failed", actionName); + return; + } + if (!action()) { + kLog.warn("{} failed after hooks completed", actionName); + } + }).detach(); + } + } // namespace void SessionPanel::create() { @@ -227,28 +241,13 @@ void SessionPanel::activateSelected() { void SessionPanel::invokeAction(ActionId id) { switch (id) { case ActionId::Logout: - if (m_actionHooks.onLogout) { - m_actionHooks.onLogout(); - } - if (!doLogout()) { - notify::error("Noctalia", i18n::tr("session.errors.logout-title"), i18n::tr("session.errors.logout-body")); - } + runPowerAction(m_actionHooks.onLogout, doLogout, "logout"); break; case ActionId::Reboot: - if (m_actionHooks.onReboot) { - m_actionHooks.onReboot(); - } - if (!doReboot()) { - notify::error("Noctalia", i18n::tr("session.errors.reboot-title"), i18n::tr("session.errors.reboot-body")); - } + runPowerAction(m_actionHooks.onReboot, doReboot, "reboot"); break; case ActionId::Shutdown: - if (m_actionHooks.onShutdown) { - m_actionHooks.onShutdown(); - } - if (!doShutdown()) { - notify::error("Noctalia", i18n::tr("session.errors.shutdown-title"), i18n::tr("session.errors.shutdown-body")); - } + runPowerAction(m_actionHooks.onShutdown, doShutdown, "shutdown"); break; case ActionId::Lock: if (!doLock()) { diff --git a/src/shell/session/session_panel.h b/src/shell/session/session_panel.h index 9ba26223d..77145b549 100644 --- a/src/shell/session/session_panel.h +++ b/src/shell/session/session_panel.h @@ -15,9 +15,9 @@ class Renderer; class ConfigService; struct SessionActionHooks { - std::function onLogout; - std::function onReboot; - std::function onShutdown; + std::function onLogout; + std::function onReboot; + std::function onShutdown; }; class SessionPanel : public Panel { diff --git a/src/shell/settings/settings_window.cpp b/src/shell/settings/settings_window.cpp index 10f886ae1..f03d8c89f 100644 --- a/src/shell/settings/settings_window.cpp +++ b/src/shell/settings/settings_window.cpp @@ -49,7 +49,6 @@ namespace { constexpr Logger kLog("settings"); constexpr std::int32_t kActionSupportReport = 1; constexpr std::int32_t kActionFlattenedConfig = 2; - constexpr std::string_view kCreateInstancePrefix = "create-instance:"; constexpr float kWindowWidth = 1080.0f; constexpr float kWindowHeight = 600.0f; @@ -112,27 +111,6 @@ namespace { return key; } - bool isCreateInstanceValue(std::string_view value) { return value.starts_with(kCreateInstancePrefix); } - - std::string createInstanceTypeFromValue(std::string_view value) { - if (!isCreateInstanceValue(value)) { - return {}; - } - value.remove_prefix(kCreateInstancePrefix.size()); - return std::string(value); - } - - std::string pathKey(const std::vector& path) { - std::string out; - for (const auto& part : path) { - if (!out.empty()) { - out.push_back('.'); - } - out += part; - } - return out; - } - bool isBarWidgetListPath(const std::vector& path) { if (path.size() < 3 || path.front() != "bar") { return false; @@ -199,6 +177,16 @@ float SettingsWindow::uiScale() const { return std::max(0.1f, m_config->config().shell.uiScale); } +bool SettingsWindow::ownsKeyboardSurface(wl_surface* surface) const noexcept { + if (!isOpen() || surface == nullptr || m_surface == nullptr) { + return false; + } + if (surface == m_surface->wlSurface()) { + return true; + } + return m_widgetAddPopup != nullptr && m_widgetAddPopup->wlSurface() == surface; +} + void SettingsWindow::open() { if (m_wayland == nullptr || m_renderContext == nullptr || !m_wayland->hasXdgShell()) { return; @@ -483,8 +471,10 @@ void SettingsWindow::openBarWidgetAddPopup(const std::vector& laneP } if (m_widgetAddPopup == nullptr) { - m_widgetAddPopup = std::make_unique(*m_wayland, *m_renderContext); - m_widgetAddPopup->setOnSelect([this](const std::vector& selectedLanePath, const std::string& value) { + m_widgetAddPopup = std::make_unique(); + m_widgetAddPopup->initialize(*m_wayland, *m_config, *m_renderContext); + m_widgetAddPopup->setOnSelect([this](const std::vector& selectedLanePath, const std::string& value, + const std::string& newInstanceType, const std::string& newInstanceId) { if (value.empty() || m_config == nullptr) { return; } @@ -497,10 +487,11 @@ void SettingsWindow::openBarWidgetAddPopup(const std::vector& laneP m_renamingWidgetName.clear(); m_editingWidgetName.clear(); - if (const auto type = createInstanceTypeFromValue(value); !type.empty()) { - m_creatingWidgetType = type; - m_openWidgetPickerPath = pathKey(selectedLanePath); - requestSceneRebuild(); + if (!newInstanceType.empty() && !newInstanceId.empty()) { + laneItems.push_back(newInstanceId); + m_creatingWidgetType.clear(); + m_openWidgetPickerPath.clear(); + setSettingOverrides({{{"widget", newInstanceId, "type"}, newInstanceType}, {selectedLanePath, laneItems}}); return; } @@ -516,8 +507,8 @@ void SettingsWindow::openBarWidgetAddPopup(const std::vector& laneP output = m_output; } - m_widgetAddPopup->open(m_surface->xdgSurface(), output, m_wayland->lastInputSerial(), anchorButton, lanePath, - m_config->config(), uiScale(), PopupWindow::AnchorMode::CenterOnAnchor); + m_widgetAddPopup->open(m_surface->xdgSurface(), output, m_wayland->lastInputSerial(), anchorButton, + m_surface->wlSurface(), lanePath, m_config->config(), uiScale()); } void SettingsWindow::saveSupportReport() { diff --git a/src/shell/settings/settings_window.h b/src/shell/settings/settings_window.h index 573806623..79329948a 100644 --- a/src/shell/settings/settings_window.h +++ b/src/shell/settings/settings_window.h @@ -41,6 +41,7 @@ public: [[nodiscard]] wl_surface* wlSurface() const noexcept { return m_surface != nullptr ? m_surface->wlSurface() : nullptr; } + [[nodiscard]] bool ownsKeyboardSurface(wl_surface* surface) const noexcept; [[nodiscard]] bool onPointerEvent(const PointerEvent& event); void onKeyboardEvent(const KeyboardEvent& event); diff --git a/src/shell/settings/widget_add_popup.cpp b/src/shell/settings/widget_add_popup.cpp index e92724ecd..d9861bc18 100644 --- a/src/shell/settings/widget_add_popup.cpp +++ b/src/shell/settings/widget_add_popup.cpp @@ -1,27 +1,34 @@ #include "shell/settings/widget_add_popup.h" #include "config/config_service.h" +#include "core/deferred_call.h" #include "i18n/i18n.h" +#include "render/render_context.h" +#include "render/scene/input_area.h" #include "render/scene/node.h" #include "shell/settings/widget_settings_registry.h" #include "ui/controls/button.h" #include "ui/controls/flex.h" +#include "ui/controls/input.h" #include "ui/controls/label.h" +#include "ui/controls/toggle.h" #include "ui/palette.h" #include "ui/style.h" #include "wayland/wayland_connection.h" -#include "wayland/wayland_seat.h" +#include "xdg-shell-client-protocol.h" #include +#include +#include +#include #include #include +#include #include namespace settings { namespace { - constexpr std::string_view kCreateInstancePrefix = "create-instance:"; - std::string laneLabel(std::string_view lane) { if (lane == "start") { return i18n::tr("settings.entities.widget.lanes.start"); @@ -44,43 +51,172 @@ namespace settings { return label; } + std::string toLowerAscii(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return text; + } + + void sortSearchOptions(std::vector& options) { + std::sort(options.begin(), options.end(), [](const SearchPickerOption& a, const SearchPickerOption& b) { + const std::string aLabel = toLowerAscii(a.label); + const std::string bLabel = toLowerAscii(b.label); + if (aLabel == bLabel) { + return a.value < b.value; + } + return aLabel < bLabel; + }); + } + + void collectWidgetReferenceNames(const std::vector& widgets, std::unordered_set& seen) { + for (const auto& widget : widgets) { + seen.insert(widget); + } + } + + bool widgetReferenceNameExists(const Config& cfg, std::string_view name) { + const std::string key(name); + if (isBuiltInWidgetType(name) || cfg.widgets.contains(key)) { + return true; + } + + std::unordered_set seen; + for (const auto& bar : cfg.bars) { + collectWidgetReferenceNames(bar.startWidgets, seen); + collectWidgetReferenceNames(bar.centerWidgets, seen); + collectWidgetReferenceNames(bar.endWidgets, seen); + for (const auto& ovr : bar.monitorOverrides) { + if (ovr.startWidgets.has_value()) { + collectWidgetReferenceNames(*ovr.startWidgets, seen); + } + if (ovr.centerWidgets.has_value()) { + collectWidgetReferenceNames(*ovr.centerWidgets, seen); + } + if (ovr.endWidgets.has_value()) { + collectWidgetReferenceNames(*ovr.endWidgets, seen); + } + } + } + return seen.contains(key); + } + + bool isValidWidgetInstanceId(std::string_view id) { + if (id.empty()) { + return false; + } + for (const unsigned char c : id) { + if (!std::isalnum(c) && c != '_' && c != '-') { + return false; + } + } + return true; + } + + std::string normalizedWidgetInstanceBase(std::string_view type) { + std::string out; + out.reserve(type.size()); + bool lastUnderscore = false; + for (const unsigned char c : type) { + if (std::isalnum(c)) { + out.push_back(static_cast(std::tolower(c))); + lastUnderscore = false; + } else if (!lastUnderscore && !out.empty()) { + out.push_back('_'); + lastUnderscore = true; + } + } + while (!out.empty() && out.back() == '_') { + out.pop_back(); + } + return out.empty() ? std::string("widget") : out; + } + + std::string nextWidgetInstanceId(const Config& cfg, std::string_view type) { + const std::string base = normalizedWidgetInstanceBase(type); + for (std::size_t index = 2; index < 10000; ++index) { + const std::string candidate = base + "_" + std::to_string(index); + if (!widgetReferenceNameExists(cfg, candidate)) { + return candidate; + } + } + return base + "_custom"; + } + + std::string trimmedText(std::string_view text) { + std::size_t start = 0; + while (start < text.size() && std::isspace(static_cast(text[start]))) { + ++start; + } + + std::size_t end = text.size(); + while (end > start && std::isspace(static_cast(text[end - 1]))) { + --end; + } + + return std::string(text.substr(start, end - start)); + } + + PopupSurfaceConfig makePopupConfig(std::int32_t anchorX, std::int32_t anchorY, std::int32_t anchorWidth, + std::int32_t anchorHeight, std::uint32_t width, std::uint32_t height, + std::uint32_t serial, std::int32_t offsetX = 0, std::int32_t offsetY = 0, + bool grab = true) { + PopupSurfaceConfig cfg{ + .anchorX = anchorX, + .anchorY = anchorY, + .anchorWidth = std::max(1, anchorWidth), + .anchorHeight = std::max(1, anchorHeight), + .width = std::max(1, width), + .height = std::max(1, height), + .anchor = XDG_POSITIONER_ANCHOR_BOTTOM, + .gravity = XDG_POSITIONER_GRAVITY_BOTTOM, + .constraintAdjustment = + XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_SLIDE_X | XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_SLIDE_Y | + XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_FLIP_X | XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_FLIP_Y, + .offsetX = offsetX, + .offsetY = offsetY, + .serial = serial, + .grab = grab, + }; + + cfg.anchorX += cfg.anchorWidth / 2; + cfg.anchorY += cfg.anchorHeight / 2; + cfg.anchorWidth = 1; + cfg.anchorHeight = 1; + return cfg; + } + } // namespace - WidgetAddPopup::WidgetAddPopup(WaylandConnection& wayland, RenderContext& renderContext) - : m_wayland(wayland), m_renderContext(renderContext), - m_popup(std::make_unique(wayland, renderContext)) { - m_popup->setOnDismissed([this]() { - m_lanePath.clear(); - m_searchPicker = nullptr; - m_focusSearchOnOpen = false; - if (m_onDismissed) { - m_onDismissed(); - } - }); - } + WidgetAddPopup::~WidgetAddPopup() { destroyPopup(); } - WidgetAddPopup::~WidgetAddPopup() = default; + void WidgetAddPopup::initialize(WaylandConnection& wayland, ConfigService& config, RenderContext& renderContext) { + initializeBase(wayland, config, renderContext); + } void WidgetAddPopup::setOnSelect(SelectCallback callback) { m_onSelect = std::move(callback); } void WidgetAddPopup::setOnDismissed(std::function callback) { m_onDismissed = std::move(callback); } void WidgetAddPopup::open(xdg_surface* parentXdgSurface, wl_output* output, std::uint32_t serial, - Button* anchorButton, const std::vector& lanePath, const Config& config, - float scale, PopupWindow::AnchorMode anchorMode) { - if (m_popup == nullptr || parentXdgSurface == nullptr || anchorButton == nullptr) { + Button* anchorButton, wl_surface* parentWlSurface, const std::vector& lanePath, + const Config& config, float scale) { + if (parentXdgSurface == nullptr || parentWlSurface == nullptr || anchorButton == nullptr) { return; } const auto pickerEntries = widgetPickerEntries(config); - std::vector options; - options.reserve(pickerEntries.size() * 2); + std::vector normalOptions; + std::vector instanceOptions; + normalOptions.reserve(pickerEntries.size()); + instanceOptions.reserve(pickerEntries.size()); + for (const auto& entry : pickerEntries) { - options.push_back(SearchPickerOption{.value = entry.value, - .label = entry.label, - .description = entry.description, - .category = entry.category, - .enabled = true}); + normalOptions.push_back(SearchPickerOption{.value = entry.value, + .label = entry.label, + .description = entry.description, + .category = entry.category, + .enabled = true}); + if (entry.kind != WidgetReferenceKind::BuiltIn) { continue; } @@ -88,148 +224,390 @@ namespace settings { if (spec.type != entry.value || !spec.supportsMultipleInstances) { continue; } - options.push_back(SearchPickerOption{ - .value = std::string(kCreateInstancePrefix) + entry.value, - .label = i18n::tr("settings.entities.widget.picker.create-label", "label", entry.label), - .description = i18n::tr("settings.entities.widget.picker.create-description", "type", entry.value), - .category = i18n::tr("settings.entities.widget.kinds.new-instance"), + instanceOptions.push_back(SearchPickerOption{ + .value = entry.value, + .label = entry.label, + .description = i18n::tr("settings.entities.widget.picker.instance-description", "type", entry.value), + .category = {}, .enabled = true, }); break; } } - if (options.empty()) { + if (normalOptions.empty()) { return; } + sortSearchOptions(normalOptions); + sortSearchOptions(instanceOptions); + + if (isOpen()) { + close(); + } + + m_scale = std::max(0.1f, scale); + m_config = &config; + m_normalOptions = std::move(normalOptions); + m_instanceOptions = std::move(instanceOptions); m_lanePath = lanePath; + m_root = nullptr; + m_headerRow = nullptr; + m_body = nullptr; + m_createActions = nullptr; m_searchPicker = nullptr; + m_createTitle = nullptr; + m_instanceInput = nullptr; + m_instanceModeEnabled = false; + m_createFormVisible = false; + m_createType.clear(); + m_createLabel.clear(); - const float panelPadding = Style::spaceSm * scale; - const float panelGap = Style::spaceSm * scale; - const float panelWidth = 520.0f * scale; - const float panelHeight = 420.0f * scale; + m_parentXdgSurface = parentXdgSurface; + m_parentWlSurface = parentWlSurface; + m_output = output; + m_serial = serial; - m_popup->setContentBuilder([this, options, panelPadding, panelGap, scale](float width, float height) { - auto root = std::make_unique(); - root->setDirection(FlexDirection::Vertical); - root->setAlign(FlexAlign::Stretch); - root->setGap(panelGap); - root->setPadding(panelPadding); - root->setFill(colorSpecFromRole(ColorRole::Surface)); - root->setBorder(colorSpecFromRole(ColorRole::Outline, 0.35f), Style::borderWidth); - root->setRadius(Style::radiusLg); - root->setSize(width, height); + Node::absolutePosition(anchorButton, m_anchorAbsX, m_anchorAbsY); + m_anchorWidth = std::max(1, static_cast(anchorButton->width())); + m_anchorHeight = std::max(1, static_cast(anchorButton->height())); - auto header = std::make_unique(); - header->setDirection(FlexDirection::Horizontal); - header->setAlign(FlexAlign::Center); - header->setGap(Style::spaceSm * scale); - header->addChild(makeLabel(i18n::tr("settings.entities.widget.inspector.add-title", "lane", - laneLabel(m_lanePath.empty() ? "" : m_lanePath.back())), - Style::fontSizeBody * scale, colorSpecFromRole(ColorRole::OnSurface), true)); + reopenForCurrentMode(); + } - auto spacer = std::make_unique(); - spacer->setFlexGrow(1.0f); - header->addChild(std::move(spacer)); + void WidgetAddPopup::close() { destroyPopup(); } - auto closeBtn = std::make_unique