diff --git a/app/src/main/kotlin/org/cosmicide/MainActivity.kt b/app/src/main/kotlin/org/cosmicide/MainActivity.kt
index afeb2b804..91999ef76 100644
--- a/app/src/main/kotlin/org/cosmicide/MainActivity.kt
+++ b/app/src/main/kotlin/org/cosmicide/MainActivity.kt
@@ -8,22 +8,28 @@
package org.cosmicide
import android.os.Bundle
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel
import org.cosmicide.app.AppContainer
import org.cosmicide.app.LocalAppContainer
+import org.cosmicide.common.Prefs
+import org.cosmicide.editor.EditorExtensionPoints
import org.cosmicide.editor.lsp.LspEditorLanguageProvider
+import org.cosmicide.plugin.CosmicPluginHost
import org.cosmicide.ui.IDENavigation
import org.cosmicide.ui.donation.DonationPromptTracker
import org.cosmicide.ui.editor.resolveTheme
import org.cosmicide.ui.theme.IDETheme
import org.cosmicide.ui.theme.isDeviceInDarkTheme
+import org.cosmicide.util.PreferenceKeys
import org.eclipse.tm4e.core.registry.IThemeSource
class MainActivity : ComponentActivity() {
@@ -40,26 +46,21 @@ class MainActivity : ComponentActivity() {
setContent {
CompositionLocalProvider(LocalAppContainer provides appContainer) {
IDETheme {
- val isDarkTheme = isDeviceInDarkTheme()
- loadEditorThemes(MaterialTheme.colorScheme)
-
- if (isDarkTheme) {
- ThemeRegistry.getInstance().setTheme("darcula")
- } else {
- ThemeRegistry.getInstance().setTheme("light")
+ val colorScheme = MaterialTheme.colorScheme
+ val darkTheme = isDeviceInDarkTheme()
+ LaunchedEffect(colorScheme, darkTheme) {
+ loadEditorThemes(colorScheme, darkTheme)
}
-
IDENavigation()
}
}
}
}
- private fun loadEditorThemes(colorScheme: ColorScheme) {
- val themes = arrayOf("darcula.json", "QuietLight.tmTheme.json")
+ private fun loadEditorThemes(colorScheme: ColorScheme, darkTheme: Boolean) {
val themeRegistry = ThemeRegistry.getInstance()
- themes.forEach { name ->
+ arrayOf("darcula.json", "QuietLight.tmTheme.json").forEach { name ->
themeRegistry.loadTheme(
ThemeModel(
IThemeSource.fromInputStream(
@@ -71,6 +72,41 @@ class MainActivity : ComponentActivity() {
)
}
+ val enabledThemes = CosmicPluginHost
+ .enabledExtensions(EditorExtensionPoints.THEME_PROVIDER)
+ .mapNotNull { provider ->
+ val theme = runCatching { provider.createTheme() }
+ .onFailure {
+ Log.w(TAG, "Theme provider ${provider.id} failed to build its theme", it)
+ }
+ .getOrNull()
+ theme?.let { provider to it }
+ }
+
+ enabledThemes.forEach { (_, theme) ->
+ runCatching { themeRegistry.loadTheme(theme) }
+ .onFailure {
+ Log.w(TAG, "Failed to register theme '${theme.name}'", it)
+ }
+ }
+
+ applyEditorThemeSelection(themeRegistry, darkTheme)
+
LspEditorLanguageProvider.updateColors(colorScheme)
}
+
+ private companion object {
+ const val TAG = "MainActivity"
+ }
+}
+
+/**
+ * Activates the editor theme selected in settings. The automatic option keeps the bundled
+ * darcula/Quiet Light themes for the active dark/light mode.
+ */
+internal fun applyEditorThemeSelection(themeRegistry: ThemeRegistry, darkTheme: Boolean) {
+ val requested = Prefs.editorTheme
+ val applied = requested != PreferenceKeys.EDITOR_THEME_AUTO && themeRegistry.setTheme(requested)
+ if (applied) return
+ themeRegistry.setTheme(if (darkTheme) "darcula" else "QuietLight")
}
diff --git a/app/src/main/kotlin/org/cosmicide/editor/language/BuiltinEditorExtensions.kt b/app/src/main/kotlin/org/cosmicide/editor/language/BuiltinEditorExtensions.kt
index 95d91b6df..5fc70fb7a 100644
--- a/app/src/main/kotlin/org/cosmicide/editor/language/BuiltinEditorExtensions.kt
+++ b/app/src/main/kotlin/org/cosmicide/editor/language/BuiltinEditorExtensions.kt
@@ -10,12 +10,14 @@ import org.cosmicide.editor.lsp.CustomLspConfigurationStore
import org.cosmicide.editor.lsp.CustomLspServerProvider
import org.cosmicide.editor.lsp.LspEditorLanguageProvider
import org.cosmicide.editor.preview.registerBuiltinPreviewExtensions
+import org.cosmicide.editor.theme.registerBuiltinThemeProviders
import org.cosmicide.plugin.api.MutableExtensionRegistry
import org.cosmicide.plugin.api.PluginIds
fun registerBuiltinEditorExtensions(context: Context, registry: MutableExtensionRegistry) {
registerBuiltinFormatterExtensions(registry)
registerBuiltinPreviewExtensions(registry)
+ registerBuiltinThemeProviders(registry)
registry.register(
point = EditorExtensionPoints.LANGUAGE_PROVIDER,
diff --git a/app/src/main/kotlin/org/cosmicide/editor/theme/PrebuiltThemeProviders.kt b/app/src/main/kotlin/org/cosmicide/editor/theme/PrebuiltThemeProviders.kt
new file mode 100644
index 000000000..b4d5994e4
--- /dev/null
+++ b/app/src/main/kotlin/org/cosmicide/editor/theme/PrebuiltThemeProviders.kt
@@ -0,0 +1,249 @@
+/*
+ * This file is part of Cosmic IDE.
+ * Cosmic IDE is a free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+ * Cosmic IDE is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License along with Cosmic IDE. If not, see .
+ */
+
+package org.cosmicide.editor.theme
+
+import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel
+import org.cosmicide.editor.EditorExtensionPoints
+import org.cosmicide.editor.EditorThemeProvider
+import org.cosmicide.plugin.api.MutableExtensionRegistry
+import org.cosmicide.plugin.api.PluginIds
+import org.eclipse.tm4e.core.registry.IThemeSource
+
+fun registerBuiltinThemeProviders(registry: MutableExtensionRegistry) {
+ listOf(
+ OneDarkThemeProvider, NordThemeProvider, DraculaThemeProvider, MonokaiThemeProvider
+ ).forEach { provider ->
+ registry.register(
+ point = EditorExtensionPoints.THEME_PROVIDER,
+ extension = provider,
+ ownerPluginId = PluginIds.CORE,
+ priority = provider.priority
+ )
+ }
+}
+
+object OneDarkThemeProvider : EditorThemeProvider {
+ override val id = "org.cosmicide.editor.theme.one-dark"
+ override val displayName = "One Dark"
+ override val description = "Atom One Dark color scheme for the code editor."
+ override val isDark = true
+ override val canDisable = false
+
+ override fun createTheme(): ThemeModel = themeModel(
+ name = "One Dark",
+ palette = Palette(
+ background = "#282c34",
+ foreground = "#abb2bf",
+ caret = "#528bff",
+ selection = "#3e4451",
+ lineHighlight = "#2c313c",
+ comment = "#5c6370",
+ keyword = "#c678dd",
+ type = "#e5c07b",
+ function = "#61afef",
+ variable = "#e06c75",
+ constant = "#d19a66",
+ string = "#98c379",
+ escape = "#56b6c2",
+ punctuation = "#abb2bf",
+ separator = "#5c6370",
+ tag = "#e06c75",
+ attribute = "#d19a66",
+ invalid = "#f44747"
+ )
+ )
+}
+
+object NordThemeProvider : EditorThemeProvider {
+ override val id = "org.cosmicide.editor.theme.nord"
+ override val displayName = "Nord"
+ override val description = "Nord color scheme for the code editor."
+ override val isDark = true
+ override val canDisable = false
+
+ override fun createTheme(): ThemeModel = themeModel(
+ name = "Nord",
+ palette = Palette(
+ background = "#2e3440",
+ foreground = "#d8dee9",
+ caret = "#88c0d0",
+ selection = "#434c5e",
+ lineHighlight = "#3b4252",
+ comment = "#616e88",
+ keyword = "#81a1c1",
+ type = "#8fbcbb",
+ function = "#88c0d0",
+ variable = "#d8dee9",
+ constant = "#d08770",
+ string = "#a3be8c",
+ escape = "#ebcb8b",
+ punctuation = "#d8dee9",
+ separator = "#616e88",
+ tag = "#81a1c1",
+ attribute = "#d08770",
+ invalid = "#bf616a"
+ )
+ )
+}
+
+object DraculaThemeProvider : EditorThemeProvider {
+ override val id = "org.cosmicide.editor.theme.dracula"
+ override val displayName = "Dracula"
+ override val description = "Dracula color scheme for the code editor."
+ override val isDark = true
+ override val canDisable = false
+
+ override fun createTheme(): ThemeModel = themeModel(
+ name = "Dracula",
+ palette = Palette(
+ background = "#282a36",
+ foreground = "#f8f8f2",
+ caret = "#f8f8f2",
+ selection = "#44475a",
+ lineHighlight = "#313341",
+ comment = "#6272a4",
+ keyword = "#ff79c6",
+ type = "#8be9fd",
+ function = "#50fa7b",
+ variable = "#f8f8f2",
+ constant = "#bd93f9",
+ string = "#f1fa8c",
+ escape = "#ffb86c",
+ punctuation = "#f8f8f2",
+ separator = "#6272a4",
+ tag = "#ff79c6",
+ attribute = "#50fa7b",
+ invalid = "#ff5555"
+ )
+ )
+}
+
+object MonokaiThemeProvider : EditorThemeProvider {
+ override val id = "org.cosmicide.editor.theme.monokai"
+ override val displayName = "Monokai"
+ override val description = "Monokai color scheme for the code editor."
+ override val isDark = true
+ override val canDisable = false
+
+ override fun createTheme(): ThemeModel = themeModel(
+ name = "Monokai",
+ palette = Palette(
+ background = "#272822",
+ foreground = "#f8f8f2",
+ caret = "#f8f8f0",
+ selection = "#49483e",
+ lineHighlight = "#2e2e27",
+ comment = "#75715e",
+ keyword = "#f92672",
+ type = "#66d9ef",
+ function = "#a6e22e",
+ variable = "#f8f8f2",
+ constant = "#ae81ff",
+ string = "#e6db74",
+ escape = "#fd971f",
+ punctuation = "#f8f8f2",
+ separator = "#75715e",
+ tag = "#f92672",
+ attribute = "#a6e22e",
+ invalid = "#fd971f"
+ )
+ )
+}
+
+private data class Palette(
+ val background: String,
+ val foreground: String,
+ val caret: String,
+ val selection: String,
+ val lineHighlight: String,
+ val comment: String,
+ val keyword: String,
+ val type: String,
+ val function: String,
+ val variable: String,
+ val constant: String,
+ val string: String,
+ val escape: String,
+ val punctuation: String,
+ val separator: String,
+ val tag: String,
+ val attribute: String,
+ val invalid: String
+)
+
+private fun themeModel(name: String, palette: Palette): ThemeModel =
+ ThemeModel(
+ IThemeSource.fromString(IThemeSource.ContentType.JSON, themeJson(name, palette)),
+ name
+ ).apply { isDark = true }
+
+private fun themeJson(name: String, p: Palette): String = buildString {
+ appendLine("{")
+ appendLine(""" "name": "$name",""")
+ appendLine(" \"settings\": [")
+ appendLine(" {")
+ appendLine(" \"settings\": {")
+ appendLine(" \"background\": \"${p.background}\",")
+ appendLine(" \"foreground\": \"${p.foreground}\",")
+ appendLine(" \"caret\": \"${p.caret}\",")
+ appendLine(" \"selection\": \"${p.selection}\",")
+ appendLine(" \"lineHighlight\": \"${p.lineHighlight}\",")
+ appendLine(" \"inactiveSelection\": \"${p.selection}\"")
+ appendLine(" }")
+ appendLine(" },")
+ val tokens = listOf(
+ token("comment", p.comment, italic = true),
+ token("keyword, keyword.control, keyword.operator, storage", p.keyword),
+ token(
+ "storage.type, entity.name.type, entity.name.class, entity.name.struct, support.type",
+ p.type
+ ),
+ token("entity.name.function, entity.name.method, support.function", p.function),
+ token("variable, variable.language, variable.parameter", p.variable),
+ token("constant, constant.language, constant.numeric, constant.character", p.constant),
+ token("constant.character.escape, string.escape", p.escape),
+ token("string, string.quoted, support.constant, constant.other", p.string),
+ token("punctuation, punctuation.definition, meta.brace", p.punctuation),
+ token("punctuation.separator, punctuation.definition.tag", p.separator),
+ token("entity.name.tag, meta.tag", p.tag),
+ token("entity.other.attribute-name", p.attribute),
+ token("invalid, invalid.illegal", p.invalid),
+ token("markup.heading", p.function, bold = true),
+ token("markup.bold", p.foreground, bold = true),
+ token("markup.italic", p.foreground, italic = true),
+ token("markup.quote", p.string),
+ token("markup.deleted", p.invalid),
+ token("markup.inserted", p.keyword),
+ token("markup.changed", p.constant)
+ )
+ tokens.forEachIndexed { index, line ->
+ appendLine(" $line${if (index < tokens.lastIndex) "," else ""}")
+ }
+ appendLine(" ]")
+ appendLine("}")
+}
+
+private fun token(
+ scope: String,
+ foreground: String,
+ italic: Boolean = false,
+ bold: Boolean = false
+): String {
+ val fontStyle = when {
+ bold && italic -> "bold italic"
+ bold -> "bold"
+ italic -> "italic"
+ else -> ""
+ }
+ val styleSuffix = if (fontStyle.isEmpty()) {
+ ""
+ } else {
+ """, "fontStyle": "$fontStyle""""
+ }
+ return """{ "scope": "$scope", "settings": { "foreground": "$foreground"$styleSuffix } }"""
+}
diff --git a/app/src/main/kotlin/org/cosmicide/plugin/CosmicPluginHost.kt b/app/src/main/kotlin/org/cosmicide/plugin/CosmicPluginHost.kt
index fed5bdcc6..4765e8368 100644
--- a/app/src/main/kotlin/org/cosmicide/plugin/CosmicPluginHost.kt
+++ b/app/src/main/kotlin/org/cosmicide/plugin/CosmicPluginHost.kt
@@ -17,6 +17,7 @@ import org.cosmicide.plugin.api.DefaultServiceRegistry
import org.cosmicide.plugin.api.MutableExtensionRegistry
import org.cosmicide.plugin.customproject.CustomProjectTypePlugin
import org.cosmicide.plugin.git.GitPlugin
+import org.cosmicide.plugin.theme.SolarizedThemePlugin
import org.cosmicide.plugin.runtime.AndroidPluginManager
import org.cosmicide.project.IdeServices
import org.cosmicide.project.ProjectExtensionPoints
@@ -66,6 +67,14 @@ object CosmicPluginHost {
throwable
)
}
+ manager.loadBuiltin(SolarizedThemePlugin.descriptor, SolarizedThemePlugin())
+ .onFailure { descriptorId, reason, throwable ->
+ Log.w(
+ TAG,
+ "Failed to load built-in plugin $descriptorId: $reason",
+ throwable
+ )
+ }
manager.loadInstalledPlugins().forEach { result ->
result.onFailure { descriptorId, reason, throwable ->
Log.w(TAG, "Failed to load plugin $descriptorId: $reason", throwable)
@@ -88,6 +97,7 @@ object CosmicPluginHost {
addRegistrations(EditorExtensionPoints.LSP_SERVER_PROVIDER, "Language servers")
addRegistrations(EditorExtensionPoints.FORMATTER_PROVIDER, "Formatters")
addRegistrations(EditorExtensionPoints.PREVIEW_PROVIDER, "Editor previews")
+ addRegistrations(EditorExtensionPoints.THEME_PROVIDER, "Editor themes")
addRegistrations(ProjectExtensionPoints.TYPE_PROVIDER, "Project types")
addRegistrations(ProjectExtensionPoints.CREATION_PROVIDER, "Project creation")
addRegistrations(ProjectExtensionPoints.ACTION_PROVIDER, "Project actions")
diff --git a/app/src/main/kotlin/org/cosmicide/plugin/theme/SolarizedThemePlugin.kt b/app/src/main/kotlin/org/cosmicide/plugin/theme/SolarizedThemePlugin.kt
new file mode 100644
index 000000000..7e3920c6c
--- /dev/null
+++ b/app/src/main/kotlin/org/cosmicide/plugin/theme/SolarizedThemePlugin.kt
@@ -0,0 +1,160 @@
+/*
+ * This file is part of Cosmic IDE.
+ * Cosmic IDE is a free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+ * Cosmic IDE is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License along with Cosmic IDE. If not, see .
+ */
+
+package org.cosmicide.plugin.theme
+
+import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel
+import org.cosmicide.editor.EditorExtensionPoints
+import org.cosmicide.editor.EditorThemeProvider
+import org.cosmicide.plugin.api.CosmicPlugin
+import org.cosmicide.plugin.api.PluginContext
+import org.cosmicide.plugin.api.PluginDescriptor
+import org.eclipse.tm4e.core.registry.IThemeSource
+
+class SolarizedThemePlugin : CosmicPlugin {
+ override fun activate(context: PluginContext) {
+ val owner = context.descriptor.id
+ context.registerDisposable(
+ context.extensions.register(
+ point = EditorExtensionPoints.THEME_PROVIDER,
+ extension = SolarizedDarkThemeProvider,
+ ownerPluginId = owner,
+ priority = SolarizedDarkThemeProvider.priority
+ )
+ )
+ context.registerDisposable(
+ context.extensions.register(
+ point = EditorExtensionPoints.THEME_PROVIDER,
+ extension = SolarizedLightThemeProvider,
+ ownerPluginId = owner,
+ priority = SolarizedLightThemeProvider.priority
+ )
+ )
+ }
+
+ companion object {
+ const val PLUGIN_ID = "org.cosmicide.theme.solarized"
+
+ val descriptor = PluginDescriptor(
+ id = PLUGIN_ID,
+ name = "Solarized themes",
+ version = "1.0.0",
+ entryClass = SolarizedThemePlugin::class.java.name,
+ description = "Solarized dark and light color themes for the code editor.",
+ author = "Cosmic IDE",
+ capabilities = setOf("editor.theme")
+ )
+ }
+}
+
+object SolarizedDarkThemeProvider : EditorThemeProvider {
+ override val id = "org.cosmicide.theme.solarized.dark"
+ override val displayName = "Solarized Dark"
+ override val description = "Solarized dark color scheme for the code editor."
+ override val priority = 100
+ override val isDark = true
+
+ override fun createTheme(): ThemeModel =
+ ThemeModel(
+ IThemeSource.fromString(IThemeSource.ContentType.JSON, SOLARIZED_DARK_JSON),
+ SOLARIZED_DARK_NAME
+ ).apply { isDark = true }
+}
+
+object SolarizedLightThemeProvider : EditorThemeProvider {
+ override val id = "org.cosmicide.theme.solarized.light"
+ override val displayName = "Solarized Light"
+ override val description = "Solarized light color scheme for the code editor."
+ override val priority = 100
+ override val isDark = false
+
+ override fun createTheme(): ThemeModel =
+ ThemeModel(
+ IThemeSource.fromString(IThemeSource.ContentType.JSON, SOLARIZED_LIGHT_JSON),
+ SOLARIZED_LIGHT_NAME
+ ).apply { isDark = false }
+}
+
+private const val SOLARIZED_DARK_NAME = "Solarized Dark"
+private const val SOLARIZED_LIGHT_NAME = "Solarized Light"
+
+private val SOLARIZED_DARK_JSON = """
+ {
+ "name": "$SOLARIZED_DARK_NAME",
+ "settings": [
+ {
+ "settings": {
+ "background": "#002b36",
+ "foreground": "#839496",
+ "caret": "#d33682",
+ "selection": "#073642",
+ "lineHighlight": "#073642",
+ "inactiveSelection": "#073642"
+ }
+ },
+ { "scope": "comment", "settings": { "foreground": "#586e75", "fontStyle": "italic" } },
+ { "scope": ["keyword", "keyword.control", "keyword.operator", "storage"], "settings": { "foreground": "#859900" } },
+ { "scope": ["storage.type", "entity.name.type", "entity.name.class", "entity.name.struct", "support.type"], "settings": { "foreground": "#b58900" } },
+ { "scope": ["entity.name.function", "entity.name.method", "support.function"], "settings": { "foreground": "#268bd2" } },
+ { "scope": ["variable", "variable.language", "variable.parameter"], "settings": { "foreground": "#268bd2" } },
+ { "scope": ["constant", "constant.language", "constant.numeric", "constant.character"], "settings": { "foreground": "#d33682" } },
+ { "scope": ["constant.character.escape", "string.escape"], "settings": { "foreground": "#cb4b16" } },
+ { "scope": ["string", "string.quoted", "support.constant", "constant.other"], "settings": { "foreground": "#2aa198" } },
+ { "scope": ["punctuation", "punctuation.definition", "meta.brace"], "settings": { "foreground": "#839496" } },
+ { "scope": ["punctuation.separator", "punctuation.definition.tag"], "settings": { "foreground": "#93a1a1" } },
+ { "scope": ["entity.name.tag", "meta.tag"], "settings": { "foreground": "#268bd2" } },
+ { "scope": "entity.other.attribute-name", "settings": { "foreground": "#b58900" } },
+ { "scope": ["invalid", "invalid.illegal"], "settings": { "foreground": "#dc322f" } },
+ { "scope": "markup.heading", "settings": { "foreground": "#268bd2", "fontStyle": "bold" } },
+ { "scope": "markup.bold", "settings": { "fontStyle": "bold" } },
+ { "scope": "markup.italic", "settings": { "fontStyle": "italic" } },
+ { "scope": "markup.quote", "settings": { "foreground": "#2aa198" } },
+ { "scope": "markup.link", "settings": { "foreground": "#93a1a1", "fontStyle": "underline" } },
+ { "scope": "markup.deleted", "settings": { "foreground": "#dc322f" } },
+ { "scope": "markup.inserted", "settings": { "foreground": "#859900" } },
+ { "scope": "markup.changed", "settings": { "foreground": "#cb4b16" } }
+ ]
+ }
+""".trimIndent()
+
+private val SOLARIZED_LIGHT_JSON = """
+ {
+ "name": "$SOLARIZED_LIGHT_NAME",
+ "settings": [
+ {
+ "settings": {
+ "background": "#fdf6e3",
+ "foreground": "#657b83",
+ "caret": "#d33682",
+ "selection": "#eee8d5",
+ "lineHighlight": "#eee8d5",
+ "inactiveSelection": "#eee8d5"
+ }
+ },
+ { "scope": "comment", "settings": { "foreground": "#93a1a1", "fontStyle": "italic" } },
+ { "scope": ["keyword", "keyword.control", "keyword.operator", "storage"], "settings": { "foreground": "#859900" } },
+ { "scope": ["storage.type", "entity.name.type", "entity.name.class", "entity.name.struct", "support.type"], "settings": { "foreground": "#b58900" } },
+ { "scope": ["entity.name.function", "entity.name.method", "support.function"], "settings": { "foreground": "#268bd2" } },
+ { "scope": ["variable", "variable.language", "variable.parameter"], "settings": { "foreground": "#268bd2" } },
+ { "scope": ["constant", "constant.language", "constant.numeric", "constant.character"], "settings": { "foreground": "#d33682" } },
+ { "scope": ["constant.character.escape", "string.escape"], "settings": { "foreground": "#cb4b16" } },
+ { "scope": ["string", "string.quoted", "support.constant", "constant.other"], "settings": { "foreground": "#2aa198" } },
+ { "scope": ["punctuation", "punctuation.definition", "meta.brace"], "settings": { "foreground": "#657b83" } },
+ { "scope": ["punctuation.separator", "punctuation.definition.tag"], "settings": { "foreground": "#586e75" } },
+ { "scope": ["entity.name.tag", "meta.tag"], "settings": { "foreground": "#268bd2" } },
+ { "scope": "entity.other.attribute-name", "settings": { "foreground": "#b58900" } },
+ { "scope": ["invalid", "invalid.illegal"], "settings": { "foreground": "#dc322f" } },
+ { "scope": "markup.heading", "settings": { "foreground": "#268bd2", "fontStyle": "bold" } },
+ { "scope": "markup.bold", "settings": { "fontStyle": "bold" } },
+ { "scope": "markup.italic", "settings": { "fontStyle": "italic" } },
+ { "scope": "markup.quote", "settings": { "foreground": "#2aa198" } },
+ { "scope": "markup.deleted", "settings": { "foreground": "#dc322f" } },
+ { "scope": "markup.inserted", "settings": { "foreground": "#859900" } },
+ { "scope": "markup.changed", "settings": { "foreground": "#cb4b16" } }
+ ]
+ }
+""".trimIndent()
diff --git a/app/src/main/kotlin/org/cosmicide/ui/editor/Editor.kt b/app/src/main/kotlin/org/cosmicide/ui/editor/Editor.kt
index cc5235533..9e8d5f00e 100644
--- a/app/src/main/kotlin/org/cosmicide/ui/editor/Editor.kt
+++ b/app/src/main/kotlin/org/cosmicide/ui/editor/Editor.kt
@@ -1,6 +1,7 @@
package org.cosmicide.ui.editor
import android.content.Context
+import android.view.ScaleGestureDetector
import android.view.inputmethod.EditorInfo
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.getValue
@@ -23,6 +24,8 @@ import org.cosmicide.editor.language.configureLanguageFor
import org.cosmicide.extension.setFont
import org.cosmicide.project.Project
import java.io.File
+import kotlin.math.max
+import kotlin.math.min
data class CodeEditorState(
var editor: CodeEditor? = null,
@@ -84,6 +87,7 @@ fun CodeEditor.applyEditorSettings(project: Project, file: File, theme: ColorSch
)
configureLanguageFor(project, file)
+ enablePinchToZoom()
}
private fun CodeEditor.setTooltipImprovements(colorScheme: ColorScheme) {
@@ -94,3 +98,33 @@ private fun CodeEditor.setTooltipImprovements(colorScheme: ColorScheme) {
)
}
}
+
+private const val MIN_TEXT_SIZE_SP = 8f
+private const val MAX_TEXT_SIZE_SP = 40f
+
+fun CodeEditor.enablePinchToZoom() {
+ val editor = this
+ var textSizeSp = Prefs.editorFontSize.toFloat()
+
+ val scaleDetector = ScaleGestureDetector(
+ context,
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ textSizeSp *= detector.scaleFactor
+ textSizeSp = max(MIN_TEXT_SIZE_SP, min(MAX_TEXT_SIZE_SP, textSizeSp))
+ editor.setTextSize(textSizeSp)
+ return true
+ }
+ }
+ )
+
+ setOnTouchListener { v, event ->
+ scaleDetector.onTouchEvent(event)
+ if (scaleDetector.isInProgress) {
+ true
+ } else {
+ v.performClick()
+ false
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/cosmicide/ui/settings/EditorSettingsScreen.kt b/app/src/main/kotlin/org/cosmicide/ui/settings/EditorSettingsScreen.kt
index 5c0bd4760..38ea695d9 100644
--- a/app/src/main/kotlin/org/cosmicide/ui/settings/EditorSettingsScreen.kt
+++ b/app/src/main/kotlin/org/cosmicide/ui/settings/EditorSettingsScreen.kt
@@ -30,13 +30,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
+import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.cosmicide.applyEditorThemeSelection
import org.cosmicide.common.Prefs
+import org.cosmicide.editor.EditorExtensionPoints
+import org.cosmicide.plugin.CosmicPluginHost
import org.cosmicide.ui.settings.components.PreferenceItem
+import org.cosmicide.ui.settings.components.SingleChoicePreference
import org.cosmicide.ui.settings.components.SliderPreference
import org.cosmicide.ui.settings.components.SwitchPreference
+import org.cosmicide.ui.theme.isDeviceInDarkTheme
import org.cosmicide.util.PreferenceKeys
import java.io.File
import kotlin.math.roundToInt
@@ -104,6 +110,43 @@ fun EditorSettingsScreen(
.padding(padding)
.verticalScroll(rememberScrollState())
) {
+ val themeThemes = remember {
+ CosmicPluginHost
+ .enabledExtensions(EditorExtensionPoints.THEME_PROVIDER)
+ .mapNotNull { provider ->
+ val theme = runCatching { provider.createTheme() }.getOrNull()
+ theme?.let { provider to it }
+ }
+ }
+ val themeOptions = remember(themeThemes) {
+ listOf(PreferenceKeys.EDITOR_THEME_AUTO to "Auto (match system)") +
+ themeThemes.map { (provider, theme) ->
+ theme.name to provider.displayName
+ }
+ }
+ val darkTheme = isDeviceInDarkTheme()
+ val currentTheme = Prefs.editorTheme
+ val currentThemeLabel = themeOptions
+ .firstOrNull { it.first == currentTheme }
+ ?.second
+ ?: currentTheme
+
+ SingleChoicePreference(
+ title = "Theme",
+ summary = currentThemeLabel,
+ selectedItem = currentTheme,
+ items = themeOptions,
+ onItemSelected = { value ->
+ prefs.edit { putString(PreferenceKeys.EDITOR_THEME, value) }
+ val registry = ThemeRegistry.getInstance()
+ val theme = themeThemes.firstOrNull { it.second.name == value }?.second
+ if (theme != null) {
+ runCatching { registry.loadTheme(theme) }
+ }
+ applyEditorThemeSelection(registry, darkTheme)
+ }
+ )
+
SliderPreference(
title = "Font size",
summary = "Set the font size for the editor",
diff --git a/app/src/main/kotlin/org/cosmicide/util/PreferenceKeys.kt b/app/src/main/kotlin/org/cosmicide/util/PreferenceKeys.kt
index c74e4629d..2d0817cf3 100644
--- a/app/src/main/kotlin/org/cosmicide/util/PreferenceKeys.kt
+++ b/app/src/main/kotlin/org/cosmicide/util/PreferenceKeys.kt
@@ -25,6 +25,8 @@ object PreferenceKeys {
const val BRACKET_PAIR_AUTOCOMPLETE = "bracket_pair_autocomplete"
const val QUICK_DELETE = "quick_delete"
const val STICKY_SCROLL = "sticky_scroll"
+ const val EDITOR_THEME = "editor_theme"
+ const val EDITOR_THEME_AUTO = "auto"
const val PLUGIN_REPOSITORY = "plugin_repository"
const val CUSTOM_LSP_CONFIGURATIONS = "custom_lsp_configurations"
diff --git a/common/src/main/java/org/cosmicide/common/Prefs.kt b/common/src/main/java/org/cosmicide/common/Prefs.kt
index 9d1420ded..0a5f624d4 100644
--- a/common/src/main/java/org/cosmicide/common/Prefs.kt
+++ b/common/src/main/java/org/cosmicide/common/Prefs.kt
@@ -113,6 +113,9 @@ object Prefs {
val editorFont: String
get() = prefs.getString("editor_font", "") ?: ""
+ val editorTheme: String
+ get() = prefs.getString("editor_theme", "auto") ?: "auto"
+
val repositories: String
get() = prefs.getString("repos", "") ?: """
Maven Central: https://repo1.maven.org/maven2
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 2eeb03a23..b7511db66 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -12,6 +12,8 @@ The first supported extension surfaces are:
- editor language providers;
- Language Server Protocol (LSP) server providers;
- editor formatter providers;
+- editor theme providers;
+- editor preview providers;
- project creation providers;
- project action providers.
@@ -40,6 +42,7 @@ This module defines IDE-facing extension contracts. It currently owns:
- `EditorLanguageProvider` for advanced editor integrations;
- `LspServerProvider` for standard LSP-based language support;
- `EditorFormatterProvider` for document and range formatting;
+- `EditorThemeProvider` for TextMate editor themes;
- request, result, connection, and server-definition data types;
- `EditorExtensionPoints`, the canonical extension point identifiers;
- declarative project forms, progress events, terminal setup actions, and command execution;
@@ -367,6 +370,31 @@ Register it at `EditorExtensionPoints.PREVIEW_PROVIDER`. Higher-priority provide
Cosmic IDE never loads or saves the file through the text editor. Providers should release WebViews,
decoders, or other owned resources in `releaseView`.
+## Editor theme providers
+
+Theme providers contribute TextMate themes to the Sora editor. Register them at
+`EditorExtensionPoints.THEME_PROVIDER`. Each provider builds a `ThemeModel` whose model name must
+exactly match the `name` field inside the TextMate theme JSON. Cosmic IDE keys themes by that name:
+a mismatch means the theme loads under one name but is selected under another.
+
+```kotlin
+class SolarizedDarkThemeProvider : EditorThemeProvider {
+ override val id = "com.example.theme.solarized-dark"
+ override val displayName = "Solarized Dark"
+ override val description = "A dark Solarized color scheme"
+ override val isDark = true
+
+ override fun createTheme(): ThemeModel = ThemeModel(
+ IThemeSource.fromString(IThemeSource.ContentType.JSON, themeJson),
+ "Solarized Dark"
+ )
+}
+```
+
+`isDark` tells Cosmic IDE which side of the automatic (match system) choice the theme belongs to.
+The editor applies the theme selected in Settings > Editor > Theme; the automatic option keeps the
+bundled darcula/Quiet Light pair. Themes are looked up by their model name, so it must stay stable.
+
## Project creation and action providers
Project UI extensions are declarative. `ProjectCreationProvider.fields` and
diff --git a/ide-api/src/main/java/org/cosmicide/editor/EditorExtensionPoints.kt b/ide-api/src/main/java/org/cosmicide/editor/EditorExtensionPoints.kt
index 9370f8e48..59b00536b 100644
--- a/ide-api/src/main/java/org/cosmicide/editor/EditorExtensionPoints.kt
+++ b/ide-api/src/main/java/org/cosmicide/editor/EditorExtensionPoints.kt
@@ -33,4 +33,10 @@ object EditorExtensionPoints {
"org.cosmicide.editor.previewProvider",
EditorPreviewProvider::class.java
)
+
+ @JvmField
+ val THEME_PROVIDER = ExtensionPoint(
+ "org.cosmicide.editor.themeProvider",
+ EditorThemeProvider::class.java
+ )
}
diff --git a/ide-api/src/main/java/org/cosmicide/editor/EditorThemeProvider.kt b/ide-api/src/main/java/org/cosmicide/editor/EditorThemeProvider.kt
new file mode 100644
index 000000000..8797ddcd1
--- /dev/null
+++ b/ide-api/src/main/java/org/cosmicide/editor/EditorThemeProvider.kt
@@ -0,0 +1,32 @@
+/*
+ * This file is part of Cosmic IDE.
+ * Cosmic IDE is a free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+ * Cosmic IDE is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License along with Cosmic IDE. If not, see .
+ */
+
+package org.cosmicide.editor
+
+import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel
+import org.cosmicide.plugin.api.ConfigurableExtension
+
+/**
+ * Contributes a TextMate color theme to the code editor.
+ *
+ * The returned [ThemeModel] must carry a stable, globally unique name so the app can select
+ * the theme and replace it across reloads. The name passed to the two-argument constructor
+ * `ThemeModel(source, name)` should match the theme's internal `name` field so repeated
+ * registration deduplicates in the theme registry.
+ */
+interface EditorThemeProvider : ConfigurableExtension {
+ /** Higher values take precedence when several themes match the active mode. */
+ val priority: Int
+ get() = 0
+
+ /** Whether this theme is designed for dark mode. */
+ val isDark: Boolean
+ get() = false
+
+ /** Builds the TextMate theme to register in the editor's theme registry. */
+ fun createTheme(): ThemeModel
+}