Skip to content

Commit a1dc64f

Browse files
authored
Merge pull request #43 from kdroidFilter/improve-darkmode-detection-on-kde
Add Linux DE dark mode detection and reactive monitoring
2 parents 5493f06 + f0ae262 commit a1dc64f

11 files changed

Lines changed: 671 additions & 208 deletions

File tree

README.MD

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ The `isSystemInDarkMode` function determines whether the system is currently in
114114

115115
#### 🔍 How It Works
116116

117-
- On **desktop platforms (Windows, macOS, Linux)**, `isSystemInDarkMode` utilizes [JNA (Java Native Access)](https://github.com/java-native-access/jna) to detect and react to dark mode changes dynamically.
117+
- On **desktop platforms (Windows, macOS, Linux)**, `isSystemInDarkMode` reacts to system theme changes. It uses native APIs on Windows/macOS, and Desktop Environment detection on Linux (GNOME/KDE are reactive).
118118
- On **other platforms (Android, iOS, JVM, JavaScript, WebAssembly)**, `isSystemInDarkMode` falls back to `isSystemInDarkTheme`, ensuring compatibility across all targets.
119119
- This makes `isSystemInDarkMode` a **fully cross-platform and reactive solution** for detecting dark mode preferences in a Kotlin Multiplatform project.
120120

@@ -226,6 +226,86 @@ compose.desktop {
226226

227227
On Linux, the title bar updates correctly with system-wide dark mode settings, so no additional configuration is required.
228228

229+
#### Linux Desktop Environment and Dark Mode Detection
230+
231+
API changes (Linux/KDE) – Summary:
232+
- isLinuxInDarkMode() is now reactive for GNOME and KDE. On KDE it uses the new isKdeInDarkMode().
233+
- getKdeThemeState() is auto-reactive and updated in background; no manual monitoring required.
234+
- rememberKdeDarkModeState() is public for Compose UIs.
235+
236+
The core module includes utilities to detect the running Linux Desktop Environment and whether a dark theme is active. This is used by the dark mode detector on JVM/Linux and can also be used directly if needed.
237+
238+
- Enum of supported environments:
239+
- `GNOME`, `KDE`, `XFCE`, `CINNAMON`, `MATE`, `UNKNOWN`.
240+
- Functions (JVM/Linux):
241+
- `fun detectLinuxDesktopEnvironment(): LinuxDesktopEnvironment?`
242+
- Returns `null` if the OS is not Linux; otherwise detects the DE using `XDG_CURRENT_DESKTOP` and `DESKTOP_SESSION`.
243+
- `fun isLinuxDarkTheme(): Boolean?`
244+
- Returns `true`/`false` depending on whether a dark theme is detected for the current DE.
245+
- Returns `null` if not Linux or if detection fails/unknown.
246+
- `@Composable fun isLinuxInDarkMode(): Boolean`
247+
- Reactive variant for Compose. Uses GNOME/KDE live detectors when available; falls back to one-time checks for other DEs.
248+
249+
Detection strategy per desktop environment:
250+
- KDE/Plasma: reads `kdeglobals` via `kreadconfig5 --file kdeglobals --group General --key ColorScheme`; falls back to `lookandfeeltool --current`.
251+
- GNOME: checks `gsettings get org.gnome.desktop.interface gtk-theme`; if not conclusive, checks `gsettings get org.gnome.desktop.interface color-scheme` for `prefer-dark`.
252+
- XFCE: reads theme name via `xfconf-query -c xsettings -p /Net/ThemeName`.
253+
- Cinnamon: `gsettings get org.cinnamon.desktop.interface gtk-theme`.
254+
- MATE: `gsettings get org.mate.interface gtk-theme`.
255+
256+
Example (JVM/Linux):
257+
```kotlin
258+
// import io.github.kdroidfilter.platformtools.detectLinuxDesktopEnvironment
259+
// import io.github.kdroidfilter.platformtools.isLinuxDarkTheme
260+
261+
val de = detectLinuxDesktopEnvironment()
262+
if (de == null) {
263+
println("Not running on Linux")
264+
} else {
265+
println("DE: $de | Dark: ${isLinuxDarkTheme()}")
266+
}
267+
```
268+
269+
Note: These helpers execute system tools (`gsettings`, `xfconf-query`, `kreadconfig5`, `lookandfeeltool`) when available. If the required tool is missing for a given DE, the function may return `null`.
270+
271+
##### KDE theme details
272+
273+
Note: The KDE APIs below have been enhanced to be reactive out of the box. See the API changes summary just after this section.
274+
275+
In addition to the general dark theme detection, KDE users can get a more granular state out of the box:
276+
277+
- `fun isKdePanelDark(): Boolean?`
278+
- Detects whether the Plasma panel (taskbar) itself is dark. Useful for mixed configurations where windows are light but the panel uses a dark theme.
279+
- Strategy: reads Plasma/Plasmashell theme from `plasmarc`/`plasmashellrc` via `kreadconfig5`; falls back to `kdeglobals` `LookAndFeelPackage`.
280+
- `data class KdeThemeState(val windowTheme: Boolean?, val panelTheme: Boolean?)`
281+
- Convenience data structure with helpers: `isMixed`, `isFullDark`, `isFullLight`.
282+
- `fun getKdeThemeState(): KdeThemeState?`
283+
- Now auto-reactive: returns a cached state that is updated in the background using DBus signals; subsequent calls reflect the latest theme state. Returns `null` if not on KDE.
284+
- `@Composable fun rememberKdeDarkModeState(): KdeThemeState?`
285+
- Compose-friendly helper that exposes the reactive KDE state. Internally manages monitoring lifecycle; recommended for UI.
286+
- `@Composable internal fun isKdeInDarkMode(): Boolean`
287+
- Convenience boolean derived from KDE theme state. Used internally by `isLinuxInDarkMode()` when DE is KDE.
288+
289+
Example (JVM/KDE):
290+
```kotlin
291+
// val state = getKdeThemeState()
292+
val state = getKdeThemeState()
293+
if (state == null) {
294+
println("Not on KDE")
295+
} else {
296+
println("Window dark: ${state.windowTheme} | Panel dark: ${state.panelTheme}")
297+
if (state.isMixed) println("Mixed theme detected")
298+
}
299+
300+
// In Compose UI (recommended):
301+
@Composable
302+
fun KdeInfo() {
303+
val s = rememberKdeDarkModeState()
304+
Text("KDE window dark: ${s?.windowTheme}")
305+
Text("KDE panel dark: ${s?.panelTheme}")
306+
}
307+
```
308+
229309
---
230310

231311
## 📦 Installation
@@ -240,6 +320,10 @@ implementation("io.github.kdroidfilter:platformtools.darkmodedetector:<version>"
240320

241321
With `isSystemInDarkMode`, your Kotlin Multiplatform projects can now dynamically react to dark mode changes on all supported platforms. 🚀
242322

323+
---
324+
325+
For detailed changes in the latest version, see: [RELEASE_NOTES_0.6.0.md](RELEASE_NOTES_0.6.0.md)
326+
243327

244328
## 🔄 RTL Windows Module (JVM only)
245329

platformtools/core/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/LinuxEnvironment.kt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ enum class LinuxDesktopEnvironment {
66
}
77

88
/**
9-
* Detects the Linux Desktop Environment.
9+
* Detect the Linux Desktop Environment.
1010
*
11-
* - Returns **null** if the OS is not Linux († avoids unnecessary computation).
12-
* - Uses common environment variables:
13-
* `XDG_CURRENT_DESKTOP`, `DESKTOP_SESSION`.
11+
* - Returns **null** if the OS is not Linux (avoids unnecessary work).
12+
* - Uses common environment variables: `XDG_CURRENT_DESKTOP`, `DESKTOP_SESSION`.
1413
*/
1514
fun detectLinuxDesktopEnvironment(): LinuxDesktopEnvironment? {
1615
if (getOperatingSystem() != OperatingSystem.LINUX) return null
@@ -21,11 +20,11 @@ fun detectLinuxDesktopEnvironment(): LinuxDesktopEnvironment? {
2120
}.joinToString("|").lowercase()
2221

2322
return when {
24-
"gnome" in combinedEnv -> LinuxDesktopEnvironment.GNOME
23+
"gnome" in combinedEnv -> LinuxDesktopEnvironment.GNOME
2524
"kde" in combinedEnv || "plasma" in combinedEnv -> LinuxDesktopEnvironment.KDE
26-
"xfce" in combinedEnv -> LinuxDesktopEnvironment.XFCE
27-
"cinnamon" in combinedEnv -> LinuxDesktopEnvironment.CINNAMON
28-
"mate" in combinedEnv -> LinuxDesktopEnvironment.MATE
29-
else -> LinuxDesktopEnvironment.UNKNOWN
25+
"xfce" in combinedEnv -> LinuxDesktopEnvironment.XFCE
26+
"cinnamon" in combinedEnv -> LinuxDesktopEnvironment.CINNAMON
27+
"mate" in combinedEnv -> LinuxDesktopEnvironment.MATE
28+
else -> LinuxDesktopEnvironment.UNKNOWN
3029
}
3130
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2+
3+
import java.io.BufferedReader
4+
import java.io.InputStreamReader
5+
6+
internal fun detectCinnamonDarkTheme(): Boolean? {
7+
return try {
8+
val p = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.cinnamon.desktop.interface", "gtk-theme"))
9+
val theme = BufferedReader(InputStreamReader(p.inputStream)).use { it.readLine()?.trim('\'', '"') }
10+
theme?.contains("dark", ignoreCase = true)
11+
} catch (_: Exception) {
12+
null
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.DisposableEffect
5+
import androidx.compose.runtime.mutableStateOf
6+
import androidx.compose.runtime.remember
7+
import co.touchlab.kermit.Logger
8+
import co.touchlab.kermit.Logger.Companion.setMinSeverity
9+
import co.touchlab.kermit.Severity
10+
import java.io.BufferedReader
11+
import java.io.InputStreamReader
12+
import java.util.concurrent.ConcurrentHashMap
13+
import java.util.function.Consumer
14+
15+
// Logger for GNOME
16+
private val gnomeLogger = Logger.withTag("GnomeThemeDetector").apply { setMinSeverity(Severity.Warn) }
17+
18+
/**
19+
* GNOME specific theme detector using gsettings and monitoring.
20+
*/
21+
internal object GnomeThemeDetector {
22+
private const val MONITORING_CMD = "gsettings monitor org.gnome.desktop.interface"
23+
private val GET_CMD = arrayOf(
24+
"gsettings get org.gnome.desktop.interface gtk-theme",
25+
"gsettings get org.gnome.desktop.interface color-scheme"
26+
)
27+
28+
val darkThemeRegex = ".*dark.*".toRegex(RegexOption.IGNORE_CASE)
29+
30+
@Volatile
31+
private var detectorThread: Thread? = null
32+
private val listeners: MutableSet<Consumer<Boolean>> = ConcurrentHashMap.newKeySet()
33+
34+
fun isDark(): Boolean {
35+
return try {
36+
val runtime = Runtime.getRuntime()
37+
for (cmd in GET_CMD) {
38+
val process = runtime.exec(cmd)
39+
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
40+
val line = reader.readLine()
41+
gnomeLogger.d { "Command '$cmd' output: $line" }
42+
if (line != null && isDarkTheme(line)) {
43+
return true
44+
}
45+
}
46+
}
47+
false
48+
} catch (e: Exception) {
49+
gnomeLogger.e(e) { "Couldn't detect GNOME theme" }
50+
false
51+
}
52+
}
53+
54+
private fun startMonitoring() {
55+
if (detectorThread?.isAlive == true) return
56+
detectorThread = object : Thread("GTK Theme Detector Thread") {
57+
private var lastValue: Boolean = isDark()
58+
override fun run() {
59+
gnomeLogger.d { "Starting GTK theme monitoring thread" }
60+
val runtime = Runtime.getRuntime()
61+
val process = try {
62+
runtime.exec(MONITORING_CMD)
63+
} catch (e: Exception) {
64+
gnomeLogger.e(e) { "Couldn't start monitoring process" }
65+
return
66+
}
67+
68+
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
69+
while (!isInterrupted) {
70+
val line = reader.readLine() ?: break
71+
if (!line.contains("gtk-theme", ignoreCase = true) &&
72+
!line.contains("color-scheme", ignoreCase = true)
73+
) continue
74+
75+
gnomeLogger.d { "Monitoring output: $line" }
76+
val currentIsDark = isDarkThemeFromLine(line) ?: isDark()
77+
if (currentIsDark != lastValue) {
78+
lastValue = currentIsDark
79+
gnomeLogger.d { "Detected theme change => dark: $currentIsDark" }
80+
for (listener in listeners) {
81+
try { listener.accept(currentIsDark) } catch (ex: RuntimeException) {
82+
gnomeLogger.e(ex) { "Exception while notifying listener" }
83+
}
84+
}
85+
}
86+
}
87+
gnomeLogger.d { "GTK theme monitoring thread ending" }
88+
if (process.isAlive) {
89+
process.destroy()
90+
gnomeLogger.d { "Monitoring process destroyed" }
91+
}
92+
}
93+
}
94+
}.apply { isDaemon = true; start() }
95+
}
96+
97+
private fun isDarkThemeFromLine(line: String): Boolean? {
98+
val tokens = line.split("\\s+".toRegex())
99+
if (tokens.size < 2) return null
100+
val value = tokens[1].lowercase().replace("'", "")
101+
return if (value.isNotBlank()) isDarkTheme(value) else null
102+
}
103+
104+
private fun isDarkTheme(text: String): Boolean = darkThemeRegex.matches(text)
105+
106+
fun registerListener(listener: Consumer<Boolean>) {
107+
val wasEmpty = listeners.isEmpty()
108+
listeners.add(listener)
109+
if (wasEmpty) {
110+
startMonitoring()
111+
}
112+
}
113+
114+
fun removeListener(listener: Consumer<Boolean>) {
115+
listeners.remove(listener)
116+
if (listeners.isEmpty()) {
117+
detectorThread?.interrupt()
118+
detectorThread = null
119+
}
120+
}
121+
}
122+
123+
internal fun detectGnomeDarkTheme(): Boolean? {
124+
return try {
125+
val p1 = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"))
126+
val theme = BufferedReader(InputStreamReader(p1.inputStream)).use { it.readLine()?.trim('\'', '"') }
127+
if (!theme.isNullOrBlank() && theme.contains("dark", ignoreCase = true)) return true
128+
val p2 = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.gnome.desktop.interface", "color-scheme"))
129+
val scheme = BufferedReader(InputStreamReader(p2.inputStream)).use { it.readLine()?.trim('\'', '"') }
130+
when (scheme?.lowercase()) {
131+
"prefer-dark" -> true
132+
"default", "prefer-light" -> false
133+
else -> null
134+
}
135+
} catch (_: Exception) {
136+
null
137+
}
138+
}
139+
140+
@Composable
141+
internal fun isGnomeInDarkMode(): Boolean {
142+
val darkModeState = remember { mutableStateOf(GnomeThemeDetector.isDark()) }
143+
144+
DisposableEffect(Unit) {
145+
gnomeLogger.d { "Registering GNOME dark mode listener in Compose" }
146+
val listener = Consumer<Boolean> { newValue ->
147+
gnomeLogger.d { "GNOME dark mode updated: $newValue" }
148+
darkModeState.value = newValue
149+
}
150+
GnomeThemeDetector.registerListener(listener)
151+
onDispose {
152+
gnomeLogger.d { "Removing GNOME dark mode listener in Compose" }
153+
GnomeThemeDetector.removeListener(listener)
154+
}
155+
}
156+
return darkModeState.value
157+
}

0 commit comments

Comments
 (0)