diff --git a/CMakeLists.txt b/CMakeLists.txt index 92916579..c23d4530 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ set(fcitx_lotus_core_sources ${CMAKE_CURRENT_SOURCE_DIR}/src/lotus-utils.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/lotus-monitor.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/lotus-icon-resolver.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/lotus-plasma-theme.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/emoji.cpp ) diff --git a/src/lotus-engine.cpp b/src/lotus-engine.cpp index 72857bf1..20e6b1ed 100644 --- a/src/lotus-engine.cpp +++ b/src/lotus-engine.cpp @@ -15,6 +15,7 @@ #include "lotus-utils.h" #include "lotus-icon-resolver.h" #include "ack-apps.h" +#include "lotus-plasma-theme.h" #include #include #include @@ -108,12 +109,21 @@ namespace fcitx { lastCheckMs = now; cachedValue = false; + // KDE Plasma: the tray sits on the panel, painted by the Plasma Style, + // while the portal below reports the application colour scheme. The + // two differ in the default Fedora/Kubuntu look (#374). + if (isKdePlasmaSession(getEnv("XDG_CURRENT_DESKTOP"))) { + if (const auto dark = isPlasmaPanelDark(plasmaThemeSearchPathsFromEnv())) { + cachedValue = *dark; + return cachedValue; + } + } + // GTK_THEME is honored by lightweight DEs that lack the settings // portal; covers XFCE, openbox, etc. with a dark theme. - if (const char* theme = std::getenv("GTK_THEME")) { - std::string t(theme); - std::transform(t.begin(), t.end(), t.begin(), ::tolower); - if (t.find("dark") != std::string::npos) { + if (std::string theme = getEnv("GTK_THEME"); !theme.empty()) { + std::transform(theme.begin(), theme.end(), theme.begin(), ::tolower); + if (theme.find("dark") != std::string::npos) { cachedValue = true; return cachedValue; } @@ -148,8 +158,8 @@ namespace fcitx { // GTK settings file — covers DEs where the dark preference is stored // there instead of being exposed via portal/gsettings. - if (const char* home = std::getenv("HOME")) { - std::ifstream settingsFile(std::string(home) + "/.config/gtk-3.0/settings.ini"); + if (std::string home = getEnv("HOME"); !home.empty()) { + std::ifstream settingsFile(home + "/.config/gtk-3.0/settings.ini"); if (settingsFile.is_open()) { std::string line; while (std::getline(settingsFile, line)) { @@ -198,8 +208,8 @@ namespace fcitx { } LotusEngine::LotusEngine(Instance* instance) : instance_(instance), factory_([this](InputContext& ic) { return new LotusState(this, &ic); }) { //NOLINT - const char* desktop = std::getenv("XDG_CURRENT_DESKTOP"); - isGnome_ = (desktop != nullptr) && std::string(desktop).find("GNOME") != std::string::npos; + std::string desktop = getEnv("XDG_CURRENT_DESKTOP"); + isGnome_ = (!desktop.empty()) && desktop.find("GNOME") != std::string::npos; // emptyCustomKeymap_.customKeymap is implicitly initialized to empty by fcitx::Option default value macro. Init(); { @@ -1198,10 +1208,10 @@ namespace fcitx { // On KDE and GNOME, absolute paths work correctly — their compositors // or SNI hosts handle filesystem paths in IconName. static const bool kIsCinnamon = [] { - const char* de = std::getenv("XDG_CURRENT_DESKTOP"); - if (!de) - de = std::getenv("DESKTOP_SESSION"); - return de && (std::string(de) == "cinnamon" || std::string(de) == "X-Cinnamon"); + std::string de = getEnv("XDG_CURRENT_DESKTOP"); + if (de.empty()) + de = getEnv("DESKTOP_SESSION"); + return !de.empty() && (de == "cinnamon" || de == "X-Cinnamon"); }(); if (kIsCinnamon) { diff --git a/src/lotus-plasma-theme.cpp b/src/lotus-plasma-theme.cpp new file mode 100644 index 00000000..dbee0658 --- /dev/null +++ b/src/lotus-plasma-theme.cpp @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "lotus-plasma-theme.h" +#include "lotus-utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fcitx { + + static std::string trim(const std::string& s) { + const auto begin = s.find_first_not_of(" \t\r"); + if (begin == std::string::npos) + return ""; + const auto end = s.find_last_not_of(" \t\r"); + return s.substr(begin, end - begin + 1); + } + + static std::string joinPath(const std::string& dir, const std::string& name) { + if (dir.empty()) + return name; + if (dir.back() == '/') + return dir + name; + return dir + "/" + name; + } + + static std::vector splitPathList(const std::string& list) { + std::vector result; + std::stringstream ss(list); + std::string item; + while (std::getline(ss, item, ':')) { + if (!item.empty()) + result.push_back(item); + } + return result; + } + + static std::string envOr(const std::string& name, const std::string& fallback) { + std::string value = getEnv(name); + return !value.empty() ? value : fallback; + } + + // Reads "key=value" from "[group]" of a KConfig-style INI file. + static std::optional readIniValue(const std::string& path, const std::string& group, const std::string& key) { + std::ifstream file(path); + if (!file.is_open()) + return std::nullopt; + + const std::string header = "[" + group + "]"; + bool inGroup = false; + std::string line; + while (std::getline(file, line)) { + line = trim(line); + if (line.empty() || line[0] == '#') + continue; + if (line[0] == '[') { + // "[Theme][$i]" marks an immutable group; same group. + inGroup = line == header || line == header + "[$i]"; + continue; + } + if (!inGroup) + continue; + const auto eq = line.find('='); + if (eq != std::string::npos && trim(line.substr(0, eq)) == key) + return trim(line.substr(eq + 1)); + } + return std::nullopt; + } + + // KConfig cascade: the user file wins over system files. + static std::optional readConfigValue(const PlasmaThemeSearchPaths& paths, const std::string& fileName, const std::string& group, const std::string& key) { + if (auto value = readIniValue(joinPath(paths.configHome, fileName), group, key)) + return value; + for (const auto& dir : paths.configDirs) { + if (auto value = readIniValue(joinPath(dir, fileName), group, key)) + return value; + } + return std::nullopt; + } + + static std::vector dataSearchDirs(const PlasmaThemeSearchPaths& paths) { + std::vector dirs; + if (!paths.dataHome.empty()) + dirs.push_back(paths.dataHome); + dirs.insert(dirs.end(), paths.dataDirs.begin(), paths.dataDirs.end()); + return dirs; + } + + // Parses "r,g,b" (an optional fourth alpha component is ignored). + static std::optional> parseRgb(const std::string& value) { + std::array rgb{}; + std::stringstream ss(value); + std::string part; + size_t count = 0; + while (std::getline(ss, part, ',')) { + part = trim(part); + if (part.empty() || part.size() > 3 || !std::all_of(part.begin(), part.end(), [](char c) { return c >= '0' && c <= '9'; })) + return std::nullopt; + const int component = std::stoi(part); + if (component > 255) + return std::nullopt; + if (count < rgb.size()) + rgb[count] = component; // NOLINT + ++count; + } + if (count != 3 && count != 4) + return std::nullopt; + return rgb; + } + + // Same rule as xdg-desktop-portal-kde: qGray(background) < 192 is dark. + static std::optional isDarkBackground(const std::optional& value) { + if (!value) + return std::nullopt; + const auto rgb = parseRgb(*value); + if (!rgb) + return std::nullopt; + + const int gray = (((*rgb)[0] * 11) + ((*rgb)[1] * 16) + ((*rgb)[2] * 5)) / 32; + return gray < 192; + } + + static std::optional isDarkSystemScheme(const PlasmaThemeSearchPaths& paths) { + if (auto dark = isDarkBackground(readConfigValue(paths, "kdeglobals", "Colors:Window", "BackgroundNormal"))) + return dark; + + // kdeglobals may only name the scheme; its colours live in a data file. + const auto scheme = readConfigValue(paths, "kdeglobals", "General", "ColorScheme"); + if (!scheme || scheme->empty()) + return std::nullopt; + for (const auto& dir : dataSearchDirs(paths)) { + const std::string file = joinPath(dir, "color-schemes/" + *scheme + ".colors"); + if (access(file.c_str(), R_OK) == 0) + return isDarkBackground(readIniValue(file, "Colors:Window", "BackgroundNormal")); + } + return std::nullopt; + } + + PlasmaThemeSearchPaths plasmaThemeSearchPathsFromEnv() { + const std::string home = envOr("HOME", ""); + + PlasmaThemeSearchPaths paths; + paths.configHome = envOr("XDG_CONFIG_HOME", joinPath(home, ".config")); + paths.configDirs = splitPathList(envOr("XDG_CONFIG_DIRS", "/etc/xdg")); + paths.dataHome = envOr("XDG_DATA_HOME", joinPath(home, ".local/share")); + paths.dataDirs = splitPathList(envOr("XDG_DATA_DIRS", "/usr/local/share:/usr/share")); + + // Plasma's session startup puts kdedefaults first in XDG_CONFIG_DIRS; + // add it when fcitx5 was started with a different environment. + const std::string kdeDefaults = joinPath(paths.configHome, "kdedefaults"); + if (std::find(paths.configDirs.begin(), paths.configDirs.end(), kdeDefaults) == paths.configDirs.end()) + paths.configDirs.insert(paths.configDirs.begin(), kdeDefaults); + + return paths; + } + + bool isKdePlasmaSession(const std::string& xdgCurrentDesktop) { + const auto desktops = splitPathList(xdgCurrentDesktop); + return std::find(desktops.begin(), desktops.end(), "KDE") != desktops.end(); + } + + std::optional isPlasmaPanelDark(const PlasmaThemeSearchPaths& paths) { + auto theme = readConfigValue(paths, "plasmarc", "Theme", "name"); + if (!theme || theme->empty()) + theme = "default"; + + // Plasma uses the first installed copy of the theme. A theme with its + // own colors file paints the panel with it; one without (Breeze + // "default") follows the system colour scheme. + for (const auto& dir : dataSearchDirs(paths)) { + const std::string themeDir = joinPath(dir, "plasma/desktoptheme/" + *theme); + struct stat st{}; + if (stat(themeDir.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) + continue; + const std::string colors = joinPath(themeDir, "colors"); + if (access(colors.c_str(), R_OK) == 0) + return isDarkBackground(readIniValue(colors, "Colors:Window", "BackgroundNormal")); + break; + } + return isDarkSystemScheme(paths); + } + +} // namespace fcitx diff --git a/src/lotus-plasma-theme.h b/src/lotus-plasma-theme.h new file mode 100644 index 00000000..20ac7d57 --- /dev/null +++ b/src/lotus-plasma-theme.h @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +/** + * @file lotus-plasma-theme.h + * @brief Detects whether the KDE Plasma panel is dark. + * + * KDE keeps two independent colour settings: the application colour scheme + * (kdeglobals) and the Plasma Style that paints the panel (plasmarc). The + * settings portal only reports the former, so the default Fedora/Kubuntu + * look — light applications on a dark panel — picked a black tray icon on a + * black panel (issue #374). The tray lives on the panel, so the panel's + * colours are the ones that matter. + * + * Only plain config/data files are read; no subprocess is spawned. + */ + +#ifndef _FCITX5_LOTUS_PLASMA_THEME_H_ +#define _FCITX5_LOTUS_PLASMA_THEME_H_ + +#include +#include +#include + +namespace fcitx { + + struct PlasmaThemeSearchPaths { + // Config directories searched in KConfig cascade order: the user + // directory first, then each system directory. + std::string configHome; + std::vector configDirs; + // Data directories holding plasma/desktoptheme and color-schemes. + std::string dataHome; + std::vector dataDirs; + }; + + /** + * @brief Builds search paths from the XDG environment variables. + * + * Adds "/kdedefaults" (where Plasma 6 stores the Global + * Theme defaults) when the environment does not list it already. + */ + PlasmaThemeSearchPaths plasmaThemeSearchPathsFromEnv(); + + /** + * @brief Tells whether XDG_CURRENT_DESKTOP names KDE. + */ + bool isKdePlasmaSession(const std::string& xdgCurrentDesktop); + + /** + * @brief Reports whether the Plasma panel background is dark. + * + * Uses the Plasma Style's own colours when it ships a colors file, and + * the system colour scheme otherwise (Breeze "default" follows it). + * + * @return true for a dark panel, false for a light one, std::nullopt + * when no colour could be found. + */ + std::optional isPlasmaPanelDark(const PlasmaThemeSearchPaths& paths); + +} // namespace fcitx + +#endif // _FCITX5_LOTUS_PLASMA_THEME_H_ diff --git a/src/lotus-utils.cpp b/src/lotus-utils.cpp index d3638ebd..9ecc5f21 100644 --- a/src/lotus-utils.cpp +++ b/src/lotus-utils.cpp @@ -116,3 +116,8 @@ void eraseLastUtf8Codepoint(std::string& buffer) { } buffer.erase(pos); } + +std::string getEnv(const std::string& name) { + const char* value = std::getenv(name.c_str()); + return ((value != nullptr) && ((*value) != 0)) ? value : ""; +} \ No newline at end of file diff --git a/src/lotus-utils.h b/src/lotus-utils.h index fc95b0d4..71b7463c 100644 --- a/src/lotus-utils.h +++ b/src/lotus-utils.h @@ -116,4 +116,11 @@ struct KeyEntry { uint32_t state; ///< Key state (modifiers) }; +/** + * @brief get environement variable. + * @param name Name of the variable. + * @return Value of the variable. + */ +std::string getEnv(const std::string& name); + #endif // _FCITX5_LOTUS_UTILS_H_ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e53f030f..f74f2ace 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -80,4 +80,4 @@ add_test(NAME emoji_backspace_utf8 COMMAND emoji_backspace_utf8) set_tests_properties(emoji_backspace_utf8 PROPERTIES LABELS "unit;emoji" TIMEOUT 20 -) +) \ No newline at end of file diff --git a/test/emoji-history-page-navigation.cpp b/test/emoji-history-page-navigation.cpp index 4ead217e..4c7ad08c 100644 --- a/test/emoji-history-page-navigation.cpp +++ b/test/emoji-history-page-navigation.cpp @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "lotus-engine.h" +#include "lotus-utils.h" #include "test-input-context.h" #include #include -#include #include #include #include @@ -35,7 +35,7 @@ namespace { int main() { configureTestPaths("fcitx5-lotus-emoji-history-page-navigation"); - const auto historyPath = std::filesystem::path(std::getenv("XDG_CONFIG_HOME")) / "fcitx5/conf/lotus-emoji-history.conf"; + const auto historyPath = std::filesystem::path(getEnv("XDG_CONFIG_HOME")) / "fcitx5/conf/lotus-emoji-history.conf"; std::ofstream history(historyPath); const char* emoji[] = {"😀", "😁", "😂", "😃", "😄", "😅", "😆", "😉", "😊", "😋", "😎", "😍", "😘", "😗", "😙", "😚", "🙂", "🤗"}; for (int i = 0; i < 18; ++i)