Skip to content

Commit 2e534af

Browse files
authored
fix(ui): make tmTheme JSON/YAML/PLIST selectors match TextMate scopes (#984)
1 parent faeb7ef commit 2e534af

File tree

12 files changed

+335
-33
lines changed

12 files changed

+335
-33
lines changed

docs/adopter-guide.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,22 @@ TM4E also defines commands for toggling line comments and adding or removing blo
134134

135135
## Contributing Themes
136136

137-
TM4E ships with built-in Light and Dark themes that are linked to the Eclipse appearance themes, but plugins can contribute additional CSS-based themes through the `org.eclipse.tm4e.ui.themes` extension point.
137+
TM4E ships with built-in Light and Dark themes that are linked to the Eclipse appearance themes, but plugins can contribute additional themes through the `org.eclipse.tm4e.ui.themes` extension point.
138138

139139
```xml
140140
<extension point="org.eclipse.tm4e.ui.themes">
141-
<theme
141+
<theme
142142
id="com.example.MyTheme"
143143
name="MyTheme"
144144
path="themes/MyTheme.css"/>
145145
</extension>
146146
```
147147

148+
The `path` attribute can point to:
149+
150+
- a CSS theme file (`*.css`), or
151+
- a TextMate theme file (for example `*.tmTheme`, `*.plist`, `*.json`, `*.yaml`, `*.yml`).
152+
148153
Themes can be flagged as more suitable for light or dark appearances and can be associated with specific grammar scopes so that, for example, a dedicated theme applies whenever a particular language is active.
149154
You declare one or more `<theme>` elements and then add `themeAssociation` elements that link themes to one or more scopes and optional dark/light variants.
150155
The exact attributes and options are described in the `themes` extension point schema.

org.eclipse.tm4e.ui.tests/build.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ bin.includes = META-INF/,\
66
grammars/,\
77
plugin.xml,\
88
about.html
9+
10+
# JDT Null Analysis for Eclipse
11+
additional.bundles = org.eclipse.jdt.annotation,assertj-core
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Sebastian Thomschke (Vegard IT GmbH) - initial implementation
11+
*/
12+
package org.eclipse.tm4e.ui.tests.internal.themes;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
19+
import org.eclipse.jface.text.TextAttribute;
20+
import org.eclipse.swt.SWT;
21+
import org.eclipse.tm4e.core.theme.RGB;
22+
import org.eclipse.tm4e.ui.themes.ColorManager;
23+
import org.eclipse.tm4e.ui.themes.css.CSSTokenProvider;
24+
import org.junit.jupiter.api.Test;
25+
26+
class CSSThemeTokenProviderTest {
27+
28+
private final ColorManager colors = ColorManager.getInstance();
29+
30+
private TextAttribute getTextAttribute(final CSSTokenProvider provider, final String tokenType) {
31+
final var tokenData = provider.getToken(tokenType).getData();
32+
assertThat(tokenData).isInstanceOf(TextAttribute.class);
33+
return (TextAttribute) tokenData;
34+
}
35+
36+
@Test
37+
void testBuiltInDarkCssTheme() throws Exception {
38+
try (var in = Files.newInputStream(Path.of("../org.eclipse.tm4e.ui/themes/Dark.css"))) {
39+
final var provider = new CSSTokenProvider(in);
40+
41+
assertThat(provider.getEditorForeground()).isEqualTo(colors.getColor(new RGB(212, 212, 212)));
42+
assertThat(provider.getEditorBackground()).isEqualTo(colors.getColor(new RGB(30, 30, 30)));
43+
assertThat(provider.getEditorCurrentLineHighlight()).isEqualTo(colors.getColor(new RGB(40, 40, 40)));
44+
45+
assertThat(getTextAttribute(provider, "entity.other.attribute-name").getForeground())
46+
.isEqualTo(colors.getColor(new RGB(156, 220, 254)));
47+
48+
assertThat(getTextAttribute(provider, "storage.type.java").getForeground())
49+
.isEqualTo(colors.getColor(new RGB(78, 201, 176)));
50+
51+
assertThat(provider.getToken("this.selector.does.not.exist").getData()).isNull();
52+
}
53+
}
54+
55+
@Test
56+
void testBuiltInMonokaiCssTheme() throws Exception {
57+
try (var in = Files.newInputStream(Path.of("../org.eclipse.tm4e.ui/themes/Monokai.css"))) {
58+
final var provider = new CSSTokenProvider(in);
59+
60+
assertThat(provider.getEditorForeground()).isEqualTo(colors.getColor(new RGB(248, 248, 242)));
61+
assertThat(provider.getEditorBackground()).isEqualTo(colors.getColor(new RGB(39, 40, 34)));
62+
63+
assertThat(getTextAttribute(provider, "keyword").getForeground())
64+
.isEqualTo(colors.getColor(new RGB(249, 38, 114)));
65+
66+
final var storageTypeAttrs = getTextAttribute(provider, "storage.type");
67+
assertThat(storageTypeAttrs.getForeground()).isEqualTo(colors.getColor(new RGB(102, 217, 239)));
68+
assertThat(storageTypeAttrs.getStyle() & SWT.ITALIC).isEqualTo(SWT.ITALIC);
69+
70+
final var inheritedClassAttrs = getTextAttribute(provider, "entity.other.inherited-class.java");
71+
assertThat(inheritedClassAttrs.getForeground()).isEqualTo(colors.getColor(new RGB(166, 226, 46)));
72+
assertThat(inheritedClassAttrs.getStyle() & SWT.ITALIC).isEqualTo(SWT.ITALIC);
73+
assertThat(inheritedClassAttrs.getStyle() & TextAttribute.UNDERLINE).isEqualTo(TextAttribute.UNDERLINE);
74+
}
75+
}
76+
}

org.eclipse.tm4e.ui.tests/src/main/java/org/eclipse/tm4e/ui/tests/internal/themes/TMTokenProviderTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
import java.io.ByteArrayInputStream;
1717
import java.nio.file.Files;
1818
import java.nio.file.Path;
19+
import java.util.List;
1920

2021
import org.eclipse.jface.text.TextAttribute;
2122
import org.eclipse.swt.SWT;
23+
import org.eclipse.tm4e.core.model.TMToken;
2224
import org.eclipse.tm4e.core.registry.IThemeSource.ContentType;
2325
import org.eclipse.tm4e.core.theme.RGB;
2426
import org.eclipse.tm4e.ui.internal.themes.TMThemeTokenProvider;
@@ -157,4 +159,43 @@ void testVSCodeJsonTheme() throws Exception {
157159
assertThat(attrs.getForeground()).isEqualTo(colors.getColor(RGB.fromHex("#00FF00")));
158160
}
159161
}
162+
163+
@Test
164+
void testTMThemeMatchesAgainstScopesStack() throws Exception {
165+
try (var in = new ByteArrayInputStream("""
166+
{
167+
"name": "Scopes test",
168+
"tokenColors": [
169+
{
170+
"settings": {
171+
"foreground": "#000000"
172+
}
173+
},
174+
{
175+
"scope": "entity.other",
176+
"settings": {
177+
"foreground": "#D197D9"
178+
}
179+
}
180+
]
181+
}
182+
""".getBytes())) {
183+
final var theme = new TMThemeTokenProvider(ContentType.JSON, in);
184+
185+
final var token = new TMToken(
186+
0,
187+
"meta.java.other.definition.class.entity.inherited.classes.inherited-class",
188+
List.of(
189+
190+
"meta.class.java",
191+
"meta.definition.class.inherited.classes.java",
192+
"entity.other.inherited-class.java"),
193+
null);
194+
195+
final var data = theme.getToken(token).getData();
196+
assertThat(data).isInstanceOf(TextAttribute.class);
197+
final var attrs = (TextAttribute) data;
198+
assertThat(attrs.getForeground()).isEqualTo(colors.getColor(RGB.fromHex("#D197D9")));
199+
}
200+
}
160201
}

org.eclipse.tm4e.ui.tests/src/main/java/org/eclipse/tm4e/ui/tests/support/StyleRangesCollector.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ public void onUninstalled() {
4545
public void onColorized(final TextPresentation presentation, final Throwable error) {
4646
add(presentation);
4747
if (waitForToLineNumber != null) {
48-
final int offset = presentation.getExtent().getOffset() + presentation.getExtent().getLength();
48+
final int endOffsetExclusive = presentation.getExtent().getOffset() + presentation.getExtent().getLength();
49+
// The extent end offset is exclusive. If it points to the start of the next line,
50+
// document.getLineOfOffset(endOffsetExclusive) would already return that next line
51+
// even though the presentation does not actually cover it.
52+
// We therefore check the line number of the last included character.
53+
final int offset = presentation.getExtent().getLength() > 0 ? endOffsetExclusive - 1 : endOffsetExclusive;
4954
try {
5055
if (waitForToLineNumber != document.getLineOfOffset(offset)) {
5156
return;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Sebastian Thomschke (Vegard IT GmbH) - initial implementation
11+
*/
12+
package org.eclipse.tm4e.ui.tests.themes;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
19+
import org.eclipse.tm4e.core.grammar.IGrammar;
20+
import org.eclipse.tm4e.core.registry.IGrammarSource;
21+
import org.eclipse.tm4e.core.registry.Registry;
22+
import org.eclipse.tm4e.ui.tests.support.TMEditor;
23+
import org.eclipse.tm4e.ui.tests.support.TestUtils;
24+
import org.eclipse.tm4e.ui.themes.css.CSSTokenProvider;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.BeforeEach;
27+
import org.junit.jupiter.api.Test;
28+
29+
class CSSThemeColorizationTest {
30+
31+
private static final String SAMPLE_TEXT = "let a = '';\nlet b = 10;\nlet c = true;";
32+
33+
private IGrammar grammar;
34+
private TMEditor editor;
35+
36+
@BeforeEach
37+
void setup() throws Exception {
38+
TestUtils.assertNoTM4EThreadsRunning();
39+
grammar = new Registry().addGrammar(IGrammarSource.fromResource(getClass(), "/grammars/TypeScript.tmLanguage.json"));
40+
}
41+
42+
@AfterEach
43+
void tearDown() throws Exception {
44+
if (editor != null) {
45+
editor.dispose();
46+
editor = null;
47+
}
48+
TestUtils.assertNoTM4EThreadsRunning();
49+
}
50+
51+
private static void assertStyleRange(
52+
final String styleRanges,
53+
final int offset,
54+
final int length,
55+
final String expectedFontStyle,
56+
final String expectedForegroundColor) {
57+
assertThat(styleRanges).contains(
58+
"StyleRange {" + offset + ", " + length + ", fontStyle=" + expectedFontStyle + ", foreground=Color {"
59+
+ expectedForegroundColor + ", 255}}");
60+
}
61+
62+
@Test
63+
void darkCssThemeColorsAreApplied() throws Exception {
64+
try (var in = Files.newInputStream(Path.of("../org.eclipse.tm4e.ui/themes/Dark.css"))) {
65+
final var theme = new CSSTokenProvider(in);
66+
editor = new TMEditor(grammar, theme, SAMPLE_TEXT);
67+
68+
final var commands = editor.execute();
69+
assertThat(commands).hasSize(1);
70+
final String ranges = commands.get(0).getStyleRanges();
71+
72+
// let -> storage (matches .storage / .storage.type)
73+
assertStyleRange(ranges, 0, 3, "normal", "86, 156, 214");
74+
// '' -> string
75+
assertStyleRange(ranges, 8, 2, "normal", "206, 145, 120");
76+
// 10 -> constant.numeric
77+
assertStyleRange(ranges, 20, 2, "normal", "181, 206, 168");
78+
// true -> constant.language
79+
assertStyleRange(ranges, 32, 4, "normal", "86, 156, 214");
80+
}
81+
}
82+
83+
@Test
84+
void monokaiCssThemeColorsAreApplied() throws Exception {
85+
try (var in = Files.newInputStream(Path.of("../org.eclipse.tm4e.ui/themes/Monokai.css"))) {
86+
final var theme = new CSSTokenProvider(in);
87+
editor = new TMEditor(grammar, theme, SAMPLE_TEXT);
88+
89+
final var commands = editor.execute();
90+
assertThat(commands).hasSize(1);
91+
final String ranges = commands.get(0).getStyleRanges();
92+
93+
// let -> storage.type
94+
assertStyleRange(ranges, 0, 3, "italic", "102, 217, 239");
95+
// '' -> string
96+
assertStyleRange(ranges, 8, 2, "normal", "230, 219, 116");
97+
// 10 -> constant.numeric
98+
assertStyleRange(ranges, 20, 2, "normal", "174, 129, 255");
99+
// true -> constant.language
100+
assertStyleRange(ranges, 32, 4, "normal", "174, 129, 255");
101+
}
102+
}
103+
}

0 commit comments

Comments
 (0)