From 25e7fbe39c02644ca5d541d20a2c601791af7b8d Mon Sep 17 00:00:00 2001 From: sebthom Date: Tue, 13 Feb 2024 22:48:31 +0100 Subject: [PATCH] feat: add support for loading .tmTheme and VSCode JSON themes --- org.eclipse.tm4e.core/META-INF/MANIFEST.MF | 6 +- .../tm4e/core/internal/theme/FontStyle.java | 27 ++- .../core/internal/theme/StyleAttributes.java | 2 +- .../tm4e/core/internal/theme/Theme.java | 20 ++- .../core/internal/theme/raw/IRawTheme.java | 2 + .../core/internal/theme/raw/RawTheme.java | 28 ++- .../core/internal/utils/MoreCollections.java | 5 + .../java/org/eclipse/tm4e/core/theme/RGB.java | 14 +- .../core/internal/theme/ThemeTypeTest.java | 152 ++++++++++++++++ .../internal/themes/TMTokenProviderTest.java | 170 ++++++++++++++++++ .../preferences/ThemePreferencePage.java | 25 ++- .../themes/AbstractTokenProvider.java | 71 ++++++++ .../internal/themes/TMThemeTokenProvider.java | 125 +++++++++++++ .../org/eclipse/tm4e/ui/themes/Theme.java | 13 +- .../tm4e/ui/themes/css/CSSTokenProvider.java | 46 +---- 15 files changed, 644 insertions(+), 62 deletions(-) create mode 100644 org.eclipse.tm4e.core/src/test/java/org/eclipse/tm4e/core/internal/theme/ThemeTypeTest.java create mode 100644 org.eclipse.tm4e.ui.tests/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMTokenProviderTest.java create mode 100644 org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/AbstractTokenProvider.java create mode 100644 org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMThemeTokenProvider.java diff --git a/org.eclipse.tm4e.core/META-INF/MANIFEST.MF b/org.eclipse.tm4e.core/META-INF/MANIFEST.MF index c3eefaa0b..902e56968 100644 --- a/org.eclipse.tm4e.core/META-INF/MANIFEST.MF +++ b/org.eclipse.tm4e.core/META-INF/MANIFEST.MF @@ -17,12 +17,12 @@ Import-Package: org.w3c.css.sac;resolution:=optional, Bundle-RequiredExecutionEnvironment: JavaSE-17 Export-Package: org.eclipse.tm4e.core, org.eclipse.tm4e.core.grammar, - org.eclipse.tm4e.core.internal.grammar;x-friends:="org.eclipse.tm4e.core.tests", + org.eclipse.tm4e.core.internal.grammar;x-friends:="org.eclipse.tm4e.core.tests,org.eclipse.tm4e.ui", org.eclipse.tm4e.core.internal.grammar.tokenattrs;x-friends:="org.eclipse.tm4e.core.tests", org.eclipse.tm4e.core.internal.matcher;x-friends:="org.eclipse.tm4e.core.tests", org.eclipse.tm4e.core.internal.oniguruma;x-friends:="org.eclipse.tm4e.languageconfiguration", - org.eclipse.tm4e.core.internal.theme;x-friends:="org.eclipse.tm4e.core.tests", - org.eclipse.tm4e.core.internal.theme.raw;x-friends:="org.eclipse.tm4e.core.tests", + org.eclipse.tm4e.core.internal.theme;x-friends:="org.eclipse.tm4e.core.tests,org.eclipse.tm4e.ui", + org.eclipse.tm4e.core.internal.theme.raw;x-friends:="org.eclipse.tm4e.core.tests,org.eclipse.tm4e.ui", org.eclipse.tm4e.core.internal.utils;x-friends:="org.eclipse.tm4e.core.tests,org.eclipse.tm4e.registry,org.eclipse.tm4e.languageconfiguration,org.eclipse.tm4e.markdown,org.eclipse.tm4e.ui,org.eclipse.tm4e.ui.tests", org.eclipse.tm4e.core.model, org.eclipse.tm4e.core.registry, diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/FontStyle.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/FontStyle.java index 6e5aa95e2..b3dbc04c2 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/FontStyle.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/FontStyle.java @@ -7,7 +7,8 @@ * SPDX-License-Identifier: EPL-2.0 * * Contributors: - * Angelo Zerr - initial API and implementation + * - Angelo Zerr - initial API and implementation + * - Sebastian Thomschke - added isBold/isItalic/isStrikethrough/isUnderline */ package org.eclipse.tm4e.core.internal.theme; @@ -38,16 +39,16 @@ public static String fontStyleToString(final int fontStyle) { } final var style = new StringBuilder(); - if ((fontStyle & Italic) == Italic) { + if (isItalic(fontStyle)) { style.append("italic "); } - if ((fontStyle & Bold) == Bold) { + if (isBold(fontStyle)) { style.append("bold "); } - if ((fontStyle & Underline) == Underline) { + if (isUnderline(fontStyle)) { style.append("underline "); } - if ((fontStyle & Strikethrough) == Strikethrough) { + if (isStrikethrough(fontStyle)) { style.append("strikethrough "); } if (style.isEmpty()) { @@ -57,6 +58,22 @@ public static String fontStyleToString(final int fontStyle) { return style.toString(); } + public static boolean isBold(final int fontStyle) { + return (fontStyle & Bold) == Bold; + } + + public static boolean isItalic(final int fontStyle) { + return (fontStyle & Italic) == Italic; + } + + public static boolean isUnderline(final int fontStyle) { + return (fontStyle & Underline) == Underline; + } + + public static boolean isStrikethrough(final int fontStyle) { + return (fontStyle & Strikethrough) == Strikethrough; + } + private FontStyle() { } } diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/StyleAttributes.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/StyleAttributes.java index 77f40c34e..cf601b391 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/StyleAttributes.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/StyleAttributes.java @@ -24,7 +24,7 @@ * github.com/microsoft/vscode-textmate/blob/main/src/theme.ts */ public class StyleAttributes { - private static final StyleAttributes NO_STYLE = new StyleAttributes(-1, 0, 0); + public static final StyleAttributes NO_STYLE = new StyleAttributes(-1, 0, 0); /** @see FontStyle */ public final int fontStyle; diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/Theme.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/Theme.java index 6f1cf8454..4e6c08285 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/Theme.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/Theme.java @@ -40,7 +40,14 @@ public final class Theme { public static Theme createFromRawTheme(@Nullable final IRawTheme source, @Nullable final List colorMap) { - return createFromParsedTheme(parseTheme(source), colorMap); + final var theme = createFromParsedTheme(parseTheme(source), colorMap); + + // custom tm4e code, not from upstream + if (source != null) { + theme.editorColors = source.getEditorColors(); + } + + return theme; } public static Theme createFromParsedTheme(final List source, @Nullable final List colorMap) { @@ -52,6 +59,7 @@ public static Theme createFromParsedTheme(final List source, @N private final ColorMap _colorMap; private final StyleAttributes _defaults; private final ThemeTrieElement _root; + private Map editorColors = Collections.emptyMap(); // custom tm4e code, not from upstream public Theme(final ColorMap colorMap, final StyleAttributes defaults, final ThemeTrieElement root) { this._colorMap = colorMap; @@ -67,6 +75,10 @@ public StyleAttributes getDefaults() { return this._defaults; } + public Map getEditorColors() { // custom tm4e code, not from upstream + return editorColors; + } + public @Nullable StyleAttributes match(@Nullable final ScopeStack scopePath) { if (scopePath == null) { return this._defaults; @@ -153,11 +165,11 @@ public static List parseTheme(@Nullable final IRawTheme source) } int fontStyle = FontStyle.NotSet; - final var settingsFontStyle = entrySetting.getFontStyle(); - if (settingsFontStyle instanceof final String style) { + final String settingsFontStyle = entrySetting.getFontStyle(); + if (settingsFontStyle != null) { fontStyle = FontStyle.None; - final var segments = StringUtils.splitToArray(style, ' '); + final var segments = StringUtils.splitToArray(settingsFontStyle, ' '); for (final var segment : segments) { switch (segment) { case "italic": diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/IRawTheme.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/IRawTheme.java index ae331d84d..6188caa86 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/IRawTheme.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/IRawTheme.java @@ -12,6 +12,7 @@ package org.eclipse.tm4e.core.internal.theme.raw; import java.util.Collection; +import java.util.Map; import org.eclipse.jdt.annotation.Nullable; @@ -27,4 +28,5 @@ public interface IRawTheme { @Nullable Collection getSettings(); + Map getEditorColors(); // custom tm4e code, not from upstream } diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/RawTheme.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/RawTheme.java index 854520dc0..63269cad5 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/RawTheme.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/theme/raw/RawTheme.java @@ -12,6 +12,9 @@ package org.eclipse.tm4e.core.internal.theme.raw; import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.tm4e.core.internal.parser.PropertySettable; @@ -32,7 +35,30 @@ public final class RawTheme extends PropertySettable.HashMap<@Nullable Object> @Override @SuppressWarnings("unchecked") public @Nullable Collection getSettings() { - return (Collection) super.get("settings"); + // vscode themes only + if (get("tokenColors") instanceof final Collection settings) + return settings; + + return (Collection) get("settings"); + } + + // custom tm4e code, not from upstream + @Override + @SuppressWarnings("unchecked") + public Map getEditorColors() { + // vscode themes only + if (get("colors") instanceof final Map colors) + return colors; + + final var settings = getSettings(); + return settings == null + ? Collections.emptyMap() + : settings.stream() + .filter(s -> s.getScope() == null) + .map(s -> ((Map>) s).get("settings")) + .filter(Objects::nonNull) + .findFirst() + .orElse(Collections.emptyMap()); } /* diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/utils/MoreCollections.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/utils/MoreCollections.java index 6ddd96c99..c9d403797 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/utils/MoreCollections.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/internal/utils/MoreCollections.java @@ -13,6 +13,7 @@ package org.eclipse.tm4e.core.internal.utils; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -75,6 +76,10 @@ public static T getLastElement(final List list) { return list.get(list.size() - 1); } + public static Collection nullToEmpty(@Nullable final Collection coll) { + return coll == null ? Collections.emptyList() : coll; + } + public static List nullToEmpty(@Nullable final List list) { return list == null ? Collections.emptyList() : list; } diff --git a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/theme/RGB.java b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/theme/RGB.java index 8052f707b..3f71e46cf 100644 --- a/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/theme/RGB.java +++ b/org.eclipse.tm4e.core/src/main/java/org/eclipse/tm4e/core/theme/RGB.java @@ -8,7 +8,7 @@ * * Contributors: * - Angelo Zerr - initial API and implementation - * - Sebastian Thomschke (Vegard IT) - add hashCode/equals methods + * - Sebastian Thomschke (Vegard IT) - add methods hashCode/equals, fromHex(String) */ package org.eclipse.tm4e.core.theme; @@ -16,6 +16,18 @@ public class RGB { + public static @Nullable RGB fromHex(final @Nullable String hex) { + if (hex == null || hex.isBlank()) + return null; + + final var offset = hex.startsWith("#") ? 1 : 0; + final int r = Integer.parseInt(hex.substring(offset + 0, offset + 2), 16); + final int g = Integer.parseInt(hex.substring(offset + 2, offset + 4), 16); + final int b = Integer.parseInt(hex.substring(offset + 4, offset + 6), 16); + + return new RGB(r, g, b); + } + public final int red; public final int green; public final int blue; diff --git a/org.eclipse.tm4e.core/src/test/java/org/eclipse/tm4e/core/internal/theme/ThemeTypeTest.java b/org.eclipse.tm4e.core/src/test/java/org/eclipse/tm4e/core/internal/theme/ThemeTypeTest.java new file mode 100644 index 000000000..5139409db --- /dev/null +++ b/org.eclipse.tm4e.core/src/test/java/org/eclipse/tm4e/core/internal/theme/ThemeTypeTest.java @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2024 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT) - initial implementation + */ +package org.eclipse.tm4e.core.internal.theme; + +import static org.eclipse.tm4e.core.internal.utils.NullSafetyHelper.castNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; + +import org.eclipse.tm4e.core.internal.grammar.ScopeStack; +import org.eclipse.tm4e.core.internal.theme.raw.RawThemeReader; +import org.eclipse.tm4e.core.registry.IThemeSource; +import org.eclipse.tm4e.core.registry.IThemeSource.ContentType; +import org.junit.jupiter.api.Test; + +class ThemeTypeTest { + + @Test + void testTMPlistTheme() throws Exception { + + final var rawTheme = RawThemeReader.readTheme( + IThemeSource.fromFile(Path.of("../org.eclipse.tm4e.core.tests/src/main/resources/test-cases/themes/QuietLight.tmTheme"))); + + assertEquals("Quiet Light", rawTheme.getName()); + final var theme = Theme.createFromRawTheme(rawTheme, null); + final var colors = theme.getColorMap(); + + final var editorColors = rawTheme.getEditorColors(); + assertEquals("#F5F5F5", editorColors.get("background")); + assertEquals("#000000", editorColors.get("caret")); + assertEquals("#333333", editorColors.get("foreground")); + assertEquals("#AAAAAA", editorColors.get("invisibles")); + assertEquals("#E4F6D4", editorColors.get("lineHighlight")); + assertEquals("#C9D0D9", editorColors.get("selection")); + + var attrs = castNonNull(theme.match(ScopeStack.from())); + assertEquals("#333333", colors.get(attrs.foregroundId)); + assertEquals("#F5F5F5", colors.get(attrs.backgroundId)); + + attrs = castNonNull(theme.match(ScopeStack.from("comment"))); + assertEquals("#AAAAAA", colors.get(attrs.foregroundId)); + assertEquals(FontStyle.Italic, attrs.fontStyle & FontStyle.Italic); + + attrs = castNonNull(theme.match(ScopeStack.from("punctuation.definition.comment"))); + assertEquals("#AAAAAA", colors.get(attrs.foregroundId)); + assertEquals(FontStyle.Italic, attrs.fontStyle & FontStyle.Italic); + + attrs = castNonNull(theme.match(ScopeStack.from("keyword"))); + assertEquals("#4B83CD", colors.get(attrs.foregroundId)); + attrs = castNonNull(theme.match(ScopeStack.from("keyword.operator"))); + assertEquals("#777777", colors.get(attrs.foregroundId)); + } + + @Test + void testTMJsonTheme() throws Exception { + final var rawTheme = RawThemeReader.readTheme( + IThemeSource.fromFile(Path.of("../org.eclipse.tm4e.core.tests/src/main/resources/test-cases/themes/dark_vs.json"))); + + assertEquals("Dark Visual Studio", rawTheme.getName()); + final var theme = Theme.createFromRawTheme(rawTheme, null); + final var colors = theme.getColorMap(); + + final var editorColors = rawTheme.getEditorColors(); + assertEquals("#D4D4D4", editorColors.get("foreground")); + assertEquals("#1E1E1E", editorColors.get("background")); + + var attrs = castNonNull(theme.match(ScopeStack.from())); + assertEquals("#D4D4D4", colors.get(attrs.foregroundId)); + assertEquals("#1E1E1E", colors.get(attrs.backgroundId)); + + attrs = castNonNull(theme.match(ScopeStack.from("comment"))); + assertEquals("#608B4E", colors.get(attrs.foregroundId)); + assertEquals(FontStyle.Italic, attrs.fontStyle & FontStyle.Italic); + + attrs = castNonNull(theme.match(ScopeStack.from("keyword"))); + assertEquals("#569CD6", colors.get(attrs.foregroundId)); + attrs = castNonNull(theme.match(ScopeStack.from("keyword.operator"))); + assertEquals("#D4D4D4", colors.get(attrs.foregroundId)); + attrs = castNonNull(theme.match(ScopeStack.from("keyword.operator.expression"))); + assertEquals("#569CD6", colors.get(attrs.foregroundId)); + } + + @Test + void testVSCodeJsonTheme() throws Exception { + final var rawTheme = RawThemeReader.readTheme( + IThemeSource.fromString(ContentType.JSON, """ + { + "name": "My theme", + "tokenColors": [ + { + "settings": { + "foreground": "#ABCDEF", + "background": "#012345" + } + }, + { + "name": "Comment", + "scope": "comment", + "settings": { + "fontStyle": "italic", + "foreground": "#FF0000" + } + }, + { + "name": "Keyword", + "scope": "keyword", + "settings": { + "foreground": "#00FF00" + } + } + ], + "colors": { + "editor.foreground": "#FFFFFF", + "editor.background": "#000000", + "editor.selectionForeground": "#EEEEEE", + "editor.selectionBackground": "#333333", + "editor.lineHighlightBackground": "#999999" + }, + "semanticHighlighting": true + } + """)); + + assertEquals("My theme", rawTheme.getName()); + final var theme = Theme.createFromRawTheme(rawTheme, null); + final var colors = theme.getColorMap(); + + final var editorColors = rawTheme.getEditorColors(); + assertEquals("#FFFFFF", editorColors.get("editor.foreground")); + assertEquals("#000000", editorColors.get("editor.background")); + + var attrs = castNonNull(theme.match(ScopeStack.from())); + assertEquals("#ABCDEF", colors.get(attrs.foregroundId)); + assertEquals("#012345", colors.get(attrs.backgroundId)); + + attrs = castNonNull(theme.match(ScopeStack.from("comment"))); + assertEquals("#FF0000", colors.get(attrs.foregroundId)); + assertEquals(FontStyle.Italic, attrs.fontStyle & FontStyle.Italic); + + attrs = castNonNull(theme.match(ScopeStack.from("keyword.something"))); + assertEquals("#00FF00", colors.get(attrs.foregroundId)); + } +} diff --git a/org.eclipse.tm4e.ui.tests/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMTokenProviderTest.java b/org.eclipse.tm4e.ui.tests/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMTokenProviderTest.java new file mode 100644 index 000000000..7ceaa2f71 --- /dev/null +++ b/org.eclipse.tm4e.ui.tests/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMTokenProviderTest.java @@ -0,0 +1,170 @@ +/******************************************************************************* + * Copyright (c) 2024 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT) - initial implementation + *******************************************************************************/ +package org.eclipse.tm4e.ui.internal.themes; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.eclipse.jface.text.TextAttribute; +import org.eclipse.swt.SWT; +import org.eclipse.tm4e.core.registry.IThemeSource.ContentType; +import org.eclipse.tm4e.core.theme.RGB; +import org.eclipse.tm4e.ui.themes.ColorManager; +import org.junit.jupiter.api.Test; + +class TMTokenProviderTest { + + private final ColorManager colors = ColorManager.getInstance(); + + @Test + void testTMPlistTheme() throws Exception { + try (var in = Files + .newInputStream(Path.of("../org.eclipse.tm4e.core.tests/src/main/resources/test-cases/themes/QuietLight.tmTheme"))) { + final var theme = new TMThemeTokenProvider(ContentType.XML, in); + + assertEquals(colors.getColor(RGB.fromHex("#333333")), theme.getEditorForeground()); + assertEquals(colors.getColor(RGB.fromHex("#F5F5F5")), theme.getEditorBackground()); + assertEquals(colors.getColor(RGB.fromHex("#E4F6D4")), theme.getEditorCurrentLineHighlight()); + assertEquals(null, theme.getEditorSelectionForeground()); + assertEquals(colors.getColor(RGB.fromHex("#C9D0D9")), theme.getEditorSelectionBackground()); + + if (theme.getToken("comment").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#AAAAAA")), attrs.getForeground()); + assertEquals(SWT.ITALIC, attrs.getStyle() | SWT.ITALIC); + } else { + fail(); + } + + if (theme.getToken("punctuation.definition.comment").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#AAAAAA")), attrs.getForeground()); + assertEquals(SWT.ITALIC, attrs.getStyle() | SWT.ITALIC); + } else { + fail(); + } + + if (theme.getToken("keyword").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#4B83CD")), attrs.getForeground()); + } else { + fail(); + } + + if (theme.getToken("keyword.operator").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#777777")), attrs.getForeground()); + } else { + fail(); + } + } + } + + @Test + void testTMJsonTheme() throws Exception { + try (var in = Files + .newInputStream(Path.of("../org.eclipse.tm4e.core.tests/src/main/resources/test-cases/themes/dark_vs.json"))) { + final var theme = new TMThemeTokenProvider(ContentType.JSON, in); + + assertEquals(colors.getColor(RGB.fromHex("#D4D4D4")), theme.getEditorForeground()); + assertEquals(colors.getColor(RGB.fromHex("#1E1E1E")), theme.getEditorBackground()); + assertEquals(null, theme.getEditorCurrentLineHighlight()); + assertEquals(null, theme.getEditorSelectionForeground()); + assertEquals(null, theme.getEditorSelectionBackground()); + + if (theme.getToken("comment").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#608B4E")), attrs.getForeground()); + assertEquals(SWT.ITALIC, attrs.getStyle() | SWT.ITALIC); + } else { + fail(); + } + + if (theme.getToken("keyword").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#569CD6")), attrs.getForeground()); + } else { + fail(); + } + + if (theme.getToken("keyword.operator").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#D4D4D4")), attrs.getForeground()); + } else { + fail(); + } + + if (theme.getToken("keyword.operator.expression").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#569CD6")), attrs.getForeground()); + } else { + fail(); + } + } + } + + @Test + void testVSCodeJsonTheme() throws Exception { + try (var in = new ByteArrayInputStream(""" + { + "name": "My theme", + "tokenColors": [ + { + "settings": { + "foreground": "#ABCDEF", + "background": "#012345" + } + }, + { + "name": "Comment", + "scope": "comment", + "settings": { + "fontStyle": "italic", + "foreground": "#FF0000" + } + }, + { + "name": "Keyword", + "scope": "keyword", + "settings": { + "foreground": "#00FF00" + } + } + ], + "colors": { + "editor.foreground": "#FFFFFF", + "editor.background": "#000000", + "editor.selectionForeground": "#EEEEEE", + "editor.selectionBackground": "#333333", + "editor.lineHighlightBackground": "#999999" + }, + "semanticHighlighting": true + } + """.getBytes())) { + final var theme = new TMThemeTokenProvider(ContentType.JSON, in); + + assertEquals(colors.getColor(RGB.fromHex("#FFFFFF")), theme.getEditorForeground()); + assertEquals(colors.getColor(RGB.fromHex("#000000")), theme.getEditorBackground()); + assertEquals(colors.getColor(RGB.fromHex("#999999")), theme.getEditorCurrentLineHighlight()); + assertEquals(colors.getColor(RGB.fromHex("#EEEEEE")), theme.getEditorSelectionForeground()); + + assertEquals(colors.getColor(RGB.fromHex("#333333")), theme.getEditorSelectionBackground()); + if (theme.getToken("comment").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#FF0000")), attrs.getForeground()); + assertEquals(SWT.ITALIC, attrs.getStyle() | SWT.ITALIC); + } else { + fail(); + } + + if (theme.getToken("keyword").getData() instanceof TextAttribute attrs) { + assertEquals(colors.getColor(RGB.fromHex("#00FF00")), attrs.getForeground()); + } else { + fail(); + } + } + } +} diff --git a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/preferences/ThemePreferencePage.java b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/preferences/ThemePreferencePage.java index 361383d53..cbd839a16 100644 --- a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/preferences/ThemePreferencePage.java +++ b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/preferences/ThemePreferencePage.java @@ -13,14 +13,15 @@ *******************************************************************************/ package org.eclipse.tm4e.ui.internal.preferences; -import static org.eclipse.tm4e.core.internal.utils.NullSafetyHelper.lazyNonNull; +import static org.eclipse.tm4e.core.internal.utils.NullSafetyHelper.*; -import java.io.File; +import java.nio.file.Path; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.jface.dialogs.MessageDialog; import org.eclipse.jface.layout.GridDataFactory; import org.eclipse.jface.viewers.ArrayContentProvider; import org.eclipse.jface.viewers.ComboViewer; @@ -34,6 +35,8 @@ import org.eclipse.swt.widgets.FileDialog; import org.eclipse.swt.widgets.Label; import org.eclipse.tm4e.core.grammar.IGrammar; +import org.eclipse.tm4e.core.internal.theme.raw.RawThemeReader; +import org.eclipse.tm4e.core.registry.IThemeSource; import org.eclipse.tm4e.registry.IGrammarDefinition; import org.eclipse.tm4e.registry.IGrammarRegistryManager; import org.eclipse.tm4e.registry.TMEclipseRegistryPlugin; @@ -156,14 +159,24 @@ protected void createButtons() { private @Nullable ITheme openBrowseForThemeDialog() { final var dialog = new FileDialog(getShell()); dialog.setText("Select textmate theme file"); - dialog.setFilterExtensions(new String[] { "*.css" }); + dialog.setFilterExtensions(new String[] { "*.css;*.json;*.plist;*.tmTheme;*.YAML-tmTheme" }); final String res = dialog.open(); if (res == null) { return null; } - final var file = new File(res); - final String name = file.getName().substring(0, file.getName().length() - ".css".length()); - return new Theme(name, file.getAbsolutePath(), name, false); + final var themePath = Path.of(res); + final var themeFileName = themePath.getFileName().toString(); + try { + final var rawTheme = RawThemeReader.readTheme(IThemeSource.fromFile(themePath)); + final String name = castNonNull(rawTheme.getName() == null + ? themeFileName.substring(0, themeFileName.lastIndexOf('.')) + : rawTheme.getName()); + return new Theme(name, themePath.toAbsolutePath().toString(), name, false); + } catch (Exception ex) { + MessageDialog.openError(getShell(), "Invalid theme file", "Failed to parse [" + themePath + "]: " + ex); + return null; + } + } }; diff --git a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/AbstractTokenProvider.java b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/AbstractTokenProvider.java new file mode 100644 index 000000000..19aad3cc2 --- /dev/null +++ b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/AbstractTokenProvider.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2015-2017 Angelo ZERR. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * - Angelo Zerr - initial API and implementation + * - Sebastian Thomschke - code extracted from CSSTokenProvider and refactored + */ +package org.eclipse.tm4e.ui.internal.themes; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.TextAttribute; +import org.eclipse.jface.text.rules.IToken; +import org.eclipse.jface.text.rules.Token; +import org.eclipse.swt.SWT; +import org.eclipse.tm4e.core.internal.utils.ScopeNames; +import org.eclipse.tm4e.core.theme.IStyle; +import org.eclipse.tm4e.core.theme.RGB; +import org.eclipse.tm4e.ui.themes.ColorManager; +import org.eclipse.tm4e.ui.themes.ITokenProvider; + +public abstract class AbstractTokenProvider implements ITokenProvider { + + private final Map getJFaceTextTokenReturnValueCache = new HashMap<>(); + private final Map getTokenReturnValueCache = new ConcurrentHashMap<>(); + + @Override + public IToken getToken(final String textMateTokenType) { + if (textMateTokenType.isEmpty()) + return DEFAULT_TOKEN; + + return getTokenReturnValueCache.computeIfAbsent( + ScopeNames.withoutContributor(textMateTokenType), + this::getTokenUncached); + } + + protected IToken getTokenUncached(final String textMateTokenType) { + final IStyle style = getBestStyle(textMateTokenType); + if (style == null) + return DEFAULT_TOKEN; + return getJFaceTextToken(style); + } + + protected IToken getJFaceTextToken(final IStyle style) { + return getJFaceTextTokenReturnValueCache.computeIfAbsent(style, this::getJFaceTextTokenUncached); + } + + private IToken getJFaceTextTokenUncached(IStyle style) { + final @Nullable RGB styleFGColor = style.getColor(); + final @Nullable RGB styleBGColor = style.getBackgroundColor(); + final var colors = ColorManager.getInstance(); + return new Token(new TextAttribute( + styleFGColor == null ? null : colors.getColor(styleFGColor), + styleBGColor == null ? null : colors.getColor(styleBGColor), + SWT.NORMAL + | (style.isBold() ? SWT.BOLD : 0) + | (style.isItalic() ? SWT.ITALIC : 0) + | (style.isUnderline() ? TextAttribute.UNDERLINE : 0) + | (style.isStrikeThrough() ? TextAttribute.STRIKETHROUGH : 0))); + } + + protected abstract @Nullable IStyle getBestStyle(final String textMateTokenType); +} diff --git a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMThemeTokenProvider.java b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMThemeTokenProvider.java new file mode 100644 index 000000000..6f30c9809 --- /dev/null +++ b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/internal/themes/TMThemeTokenProvider.java @@ -0,0 +1,125 @@ +/******************************************************************************* + * Copyright (c) 2024 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT) - initial implementation + *******************************************************************************/ +package org.eclipse.tm4e.ui.internal.themes; + +import static org.eclipse.tm4e.core.internal.utils.NullSafetyHelper.castNullable; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.swt.graphics.Color; +import org.eclipse.tm4e.core.internal.grammar.ScopeStack; +import org.eclipse.tm4e.core.internal.theme.FontStyle; +import org.eclipse.tm4e.core.internal.theme.Style; +import org.eclipse.tm4e.core.internal.theme.StyleAttributes; +import org.eclipse.tm4e.core.internal.theme.Theme; +import org.eclipse.tm4e.core.internal.theme.raw.RawThemeReader; +import org.eclipse.tm4e.core.registry.IThemeSource; +import org.eclipse.tm4e.core.theme.IStyle; +import org.eclipse.tm4e.core.theme.RGB; +import org.eclipse.tm4e.ui.themes.ColorManager; + +/** + * TextMate theme token provider + */ +public class TMThemeTokenProvider extends AbstractTokenProvider { + + private final Theme theme; + private final List colors; + + public TMThemeTokenProvider(final IThemeSource.ContentType contentType, final InputStream in) throws Exception { + final var rawTheme = RawThemeReader + .readTheme(IThemeSource.fromString(contentType, new String(in.readAllBytes(), StandardCharsets.UTF_8))); + theme = Theme.createFromRawTheme(rawTheme, null); + colors = theme.getColorMap(); + } + + @Override + protected @Nullable IStyle getBestStyle(String textMateTokenType) { + StyleAttributes styleAttrs = null; + while (styleAttrs == null) { + styleAttrs = theme.match(ScopeStack.from(textMateTokenType)); + if (styleAttrs == null || styleAttrs.equals(StyleAttributes.NO_STYLE)) { + styleAttrs = null; + final var dotIdx = textMateTokenType.indexOf('.'); + if (dotIdx == -1) { + break; + } + // this is a workaround because org.eclipse.tm4e.core.model.TMTokenizationSupport.decodeTextMateToken(DecodeMap, List) + // simply concatenates scopes into one and here we don't know how to separate them, e.g. "meta.package.java" + "keyword.other" = "meta.package.java.keyword.other" + // this results in style definitions for "keyword.other" are not returned by Theme#match() which only matches like "^keyword\.other.*" + // -> time to upgrade to IGrammar.tokenizeLine2? + textMateTokenType = textMateTokenType.substring(dotIdx + 1); + } + } + + if (styleAttrs != null) { + final var style = new Style(); + if (styleAttrs.foregroundId > 0) + style.setColor(RGB.fromHex(colors.get(styleAttrs.foregroundId))); + if (styleAttrs.backgroundId > 0) + style.setBackgroundColor(RGB.fromHex(colors.get(styleAttrs.backgroundId))); + + if (styleAttrs.fontStyle > 0) { + style.setBold(FontStyle.isBold(styleAttrs.fontStyle)); + style.setItalic(FontStyle.isItalic(styleAttrs.fontStyle)); + style.setUnderline(FontStyle.isUnderline(styleAttrs.fontStyle)); + style.setStrikeThrough(FontStyle.isStrikethrough(styleAttrs.fontStyle)); + } + return style; + } + return null; + } + + protected @Nullable Color getEditorColor(final String... names) { + for (String name : names) { + final String colorHexCode = castNullable(theme.getEditorColors().get(name)); + if (colorHexCode == null) + continue; + final var rgb = RGB.fromHex(colorHexCode); + if (rgb == null) + continue; + return ColorManager.getInstance().getColor(rgb); + } + return null; + } + + // https://code.visualstudio.com/api/references/theme-color#editor-colors + + @Override + public @Nullable Color getEditorForeground() { + return getEditorColor(/*sublime:*/ "foreground", /*vscode:*/ "editor.foreground"); + } + + @Override + public @Nullable Color getEditorBackground() { + return getEditorColor(/*sublime:*/ "background", /*vscode:*/ "editor.background"); + } + + @Override + public @Nullable Color getEditorSelectionForeground() { + return getEditorColor(/*sublime:*/ "selectionForeground", /*vscode:*/ "editor.selectionForeground", "selection.foreground"); + } + + @Override + public @Nullable Color getEditorSelectionBackground() { + return getEditorColor(/*sublime:*/ "selection", /*vscode:*/ "editor.selectionBackground", "editor.selectionHighlightBackground", + "selection.background"); + } + + @Override + public @Nullable Color getEditorCurrentLineHighlight() { + return getEditorColor(/*sublime:*/ "lineHighlight", /*vscode:*/ "editor.lineHighlightBackground"); + } +} diff --git a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/Theme.java b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/Theme.java index acb2a05c8..ed371d500 100644 --- a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/Theme.java +++ b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/Theme.java @@ -19,9 +19,11 @@ import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.graphics.Color; import org.eclipse.tm4e.core.internal.utils.StringUtils; +import org.eclipse.tm4e.core.registry.IThemeSource.ContentType; import org.eclipse.tm4e.registry.TMResource; import org.eclipse.tm4e.registry.XMLConstants; import org.eclipse.tm4e.ui.TMUIPlugin; +import org.eclipse.tm4e.ui.internal.themes.TMThemeTokenProvider; import org.eclipse.tm4e.ui.internal.utils.UI; import org.eclipse.tm4e.ui.themes.css.CSSTokenProvider; import org.eclipse.ui.texteditor.AbstractDecoratedTextEditorPreferenceConstants; @@ -146,7 +148,16 @@ public Color getEditorCurrentLineHighlight() { private ITokenProvider getTokenProvider() { if (tokenProvider == null || isModified()) { try (InputStream in = getInputStream()) { - tokenProvider = new CSSTokenProvider(in); + final var path = getPath(); + final String extension = path.substring(path.lastIndexOf('.') + 1).trim().toLowerCase(); + + tokenProvider = switch (extension) { + case "css" -> new CSSTokenProvider(in); + case "json" -> new TMThemeTokenProvider(ContentType.JSON, in); + case "yaml", "yaml-tmtheme", "yml" -> new TMThemeTokenProvider(ContentType.YAML, in); + case "plist", "tmtheme", "xml" -> new TMThemeTokenProvider(ContentType.XML, in); + default -> throw new IllegalArgumentException("Unsupported file type: " + path); + }; } catch (final Exception ex) { TMUIPlugin.logError(ex); return null; diff --git a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/css/CSSTokenProvider.java b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/css/CSSTokenProvider.java index dfb5dded6..21278ee4c 100644 --- a/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/css/CSSTokenProvider.java +++ b/org.eclipse.tm4e.ui/src/main/java/org/eclipse/tm4e/ui/themes/css/CSSTokenProvider.java @@ -7,32 +7,25 @@ * SPDX-License-Identifier: EPL-2.0 * * Contributors: - * Angelo Zerr - initial API and implementation + * - Angelo Zerr - initial API and implementation + * - Sebastian Thomschke (Vegard IT) - reusable code pushed down to AbstractTokenProvider */ package org.eclipse.tm4e.ui.themes.css; import java.io.InputStream; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jface.text.TextAttribute; -import org.eclipse.jface.text.rules.IToken; -import org.eclipse.jface.text.rules.Token; -import org.eclipse.swt.SWT; import org.eclipse.swt.graphics.Color; import org.eclipse.tm4e.core.internal.utils.StringUtils; import org.eclipse.tm4e.core.theme.IStyle; -import org.eclipse.tm4e.core.theme.RGB; import org.eclipse.tm4e.core.theme.css.CSSParser; import org.eclipse.tm4e.ui.TMUIPlugin; +import org.eclipse.tm4e.ui.internal.themes.AbstractTokenProvider; import org.eclipse.tm4e.ui.themes.ColorManager; -import org.eclipse.tm4e.ui.themes.ITokenProvider; -public class CSSTokenProvider implements ITokenProvider { +public class CSSTokenProvider extends AbstractTokenProvider { private static final class NoopCSSParser extends CSSParser { @Override @@ -46,28 +39,12 @@ public List getStyles() { } } - private final Map tokenMaps = new HashMap<>(); - private final Map getTokenReturnValueCache = new ConcurrentHashMap<>(); - private final CSSParser parser; public CSSTokenProvider(final InputStream in) { CSSParser parser = null; - final var colors = ColorManager.getInstance(); try { parser = new CSSParser(in); - for (final IStyle style : parser.getStyles()) { - final @Nullable RGB styleFGColor = style.getColor(); - final @Nullable RGB styleBGColor = style.getBackgroundColor(); - tokenMaps.put(style, new Token(new TextAttribute( - styleFGColor == null ? null : colors.getColor(styleFGColor), - styleBGColor == null ? null : colors.getColor(styleBGColor), - SWT.NORMAL - | (style.isBold() ? SWT.BOLD : 0) - | (style.isItalic() ? SWT.ITALIC : 0) - | (style.isUnderline() ? TextAttribute.UNDERLINE : 0) - | (style.isStrikeThrough() ? TextAttribute.STRIKETHROUGH : 0)))); - } } catch (final Exception ex) { TMUIPlugin.logError(ex); } @@ -76,19 +53,8 @@ public CSSTokenProvider(final InputStream in) { } @Override - public IToken getToken(final String textMateTokenType) { - if (textMateTokenType.isEmpty()) - return DEFAULT_TOKEN; - - return getTokenReturnValueCache.computeIfAbsent(textMateTokenType, this::getTokenInternal); - } - - private IToken getTokenInternal(final String type) { - final IStyle style = parser.getBestStyle(StringUtils.splitToArray(type, '.')); - if (style == null) - return DEFAULT_TOKEN; - final IToken token = tokenMaps.get(style); - return token == null ? DEFAULT_TOKEN : token; + protected @Nullable IStyle getBestStyle(String textMateTokenType) { + return parser.getBestStyle(StringUtils.splitToArray(textMateTokenType, '.')); } private @Nullable Color getColor(final boolean isForeground, final String... cssClassNames) {