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 +}