Skip to content

Commit cf8535c

Browse files
committed
GROOVY-11932: Allow theme switching light/dark/system in groovyConsole (allow custom themes)
1 parent e0714c7 commit cf8535c

File tree

7 files changed

+286
-71
lines changed

7 files changed

+286
-71
lines changed

subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
845845
swing.lightThemeMenuItem.selected = (currentTheme == 'LIGHT')
846846
swing.darkThemeMenuItem.selected = (currentTheme == 'DARK')
847847
swing.systemThemeMenuItem.selected = (currentTheme == 'SYSTEM')
848-
statusLabel.text = 'Theme changed to: ' + ThemeManager.themeLabel
848+
statusLabel.text = 'Theme changed to: ' + ThemeManager.themeLabel + (ThemeManager.isUsingCustomTheme() ? ' (custom)' : '')
849849

850850
// repaint
851851
inputEditor.textEditor.repaint()
@@ -1279,17 +1279,19 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
12791279
void largeIcons(EventObject evt = null) { applyIconSize(Icons.SIZE_LARGE) }
12801280

12811281
void scaleIconsWithFont(EventObject evt = null) {
1282-
boolean enabled = evt?.source?.isSelected()
1282+
setScaleIconsWithFont(evt?.source?.isSelected() as boolean)
1283+
}
1284+
1285+
void setScaleIconsWithFont(boolean enabled) {
12831286
prefs.putBoolean('scaleIconsWithFont', enabled)
12841287
if (enabled) {
12851288
applyIconSize(iconSizeFromFont(inputArea.font.size))
12861289
} else {
12871290
applyIconSize(prefs.getInt('iconSize', Icons.SIZE_NORMAL))
12881291
}
1289-
updateIconSizeMenuEnabled()
12901292
}
12911293

1292-
private void applyIconSize(int size) {
1294+
void applyIconSize(int size) {
12931295
// only persist the preset when not tracking the font
12941296
if (!prefs.getBoolean('scaleIconsWithFont', false)) {
12951297
prefs.putInt('iconSize', size)
@@ -1299,6 +1301,15 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
12991301
toolbar?.repaint()
13001302
}
13011303

1304+
void reapplyTheme() {
1305+
switchTheme(ThemeManager.currentMode)
1306+
}
1307+
1308+
void reloadThemes() {
1309+
ThemeManager.reloadThemes()
1310+
reapplyTheme()
1311+
}
1312+
13021313
private static int iconSizeFromFont(int fontSize) {
13031314
Math.max(10, Math.min(48, Math.round(fontSize * 1.33f) as int))
13041315
}
@@ -1316,14 +1327,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
13161327
prefs.getInt('iconSize', Icons.SIZE_NORMAL)
13171328
}
13181329

1319-
private void updateIconSizeMenuEnabled() {
1320-
boolean scaling = prefs.getBoolean('scaleIconsWithFont', false)
1321-
swing.smallIconsAction.enabled = !scaling
1322-
swing.normalIconsAction.enabled = !scaling
1323-
swing.largeIconsAction.enabled = !scaling
1324-
}
1325-
1326-
void largerFont(EventObject evt = null) {
1330+
void largerFont(EventObject evt = null) {
13271331
updateFontSize(inputArea.font.size + 2)
13281332
}
13291333

subprojects/groovy-console/src/main/groovy/groovy/console/ui/ConsolePreferences.groovy

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import org.codehaus.groovy.tools.shell.util.MessageSource
2424

2525
import javax.swing.JDialog
2626
import javax.swing.JFileChooser
27+
import javax.swing.JOptionPane
28+
import javax.swing.filechooser.FileNameExtensionFilter
2729
import java.awt.Dimension
2830

2931
class ConsolePreferences {
@@ -38,6 +40,18 @@ class ConsolePreferences {
3840
@Bindable
3941
int loopModeDelay
4042

43+
@Bindable
44+
int iconSize
45+
46+
@Bindable
47+
boolean scaleIconsWithFont
48+
49+
@Bindable
50+
String customLightThemePath
51+
52+
@Bindable
53+
String customDarkThemePath
54+
4155
private final console
4256
private final MessageSource T
4357

@@ -50,6 +64,10 @@ class ConsolePreferences {
5064

5165
maxOutputChars = console.loadMaxOutputChars()
5266
loopModeDelay = console.prefs.getInt('loopModeDelay', DEFAULT_LOOP_MODE_DELAY_MILLIS)
67+
iconSize = console.prefs.getInt('iconSize', Icons.SIZE_NORMAL)
68+
scaleIconsWithFont = console.prefs.getBoolean('scaleIconsWithFont', false)
69+
customLightThemePath = ThemeManager.customLightPath ?: ''
70+
customDarkThemePath = ThemeManager.customDarkPath ?: ''
5371
console.maxOutputChars = maxOutputChars
5472
}
5573

@@ -114,6 +132,79 @@ class ConsolePreferences {
114132

115133
vstrut(12)
116134

135+
vbox(border: compoundBorder([
136+
titledBorder(T['prefs.appearance.settings.title']),
137+
emptyBorder([6, 8, 8, 8])])) {
138+
hbox {
139+
label "${T['prefs.icon.size']}:"
140+
hstrut(8)
141+
buttonGroup(id: 'iconSizeGroup')
142+
radioButton(text: T['prefs.icon.size.small'], id: 'iconSizeSmall',
143+
buttonGroup: iconSizeGroup, selected: iconSize == Icons.SIZE_SMALL,
144+
enabled: !scaleIconsWithFont,
145+
actionPerformed: { iconSize = Icons.SIZE_SMALL })
146+
hstrut(4)
147+
radioButton(text: T['prefs.icon.size.normal'], id: 'iconSizeNormal',
148+
buttonGroup: iconSizeGroup, selected: iconSize == Icons.SIZE_NORMAL,
149+
enabled: !scaleIconsWithFont,
150+
actionPerformed: { iconSize = Icons.SIZE_NORMAL })
151+
hstrut(4)
152+
radioButton(text: T['prefs.icon.size.large'], id: 'iconSizeLarge',
153+
buttonGroup: iconSizeGroup, selected: iconSize == Icons.SIZE_LARGE,
154+
enabled: !scaleIconsWithFont,
155+
actionPerformed: { iconSize = Icons.SIZE_LARGE })
156+
hglue()
157+
}
158+
159+
vstrut(4)
160+
161+
hbox {
162+
checkBox(text: T['prefs.scale.icons.with.font'], id: 'scaleIconsCheckBox',
163+
selected: scaleIconsWithFont,
164+
actionPerformed: this.&onScaleWithFontToggled)
165+
hglue()
166+
}
167+
168+
vstrut(10)
169+
170+
hbox {
171+
label "${T['prefs.custom.light.theme']}:"
172+
hstrut(6)
173+
label(id: 'customLightPathLabel',
174+
text: customLightThemePath ?: T['prefs.no.file.selected'])
175+
hglue()
176+
button(text: T['prefs.select'], actionPerformed: this.&onSelectLightTheme)
177+
hstrut(4)
178+
button(text: T['prefs.clear'], id: 'clearLightButton',
179+
enabled: customLightThemePath as boolean,
180+
actionPerformed: this.&onClearLightTheme)
181+
}
182+
183+
vstrut(4)
184+
185+
hbox {
186+
label "${T['prefs.custom.dark.theme']}:"
187+
hstrut(6)
188+
label(id: 'customDarkPathLabel',
189+
text: customDarkThemePath ?: T['prefs.no.file.selected'])
190+
hglue()
191+
button(text: T['prefs.select'], actionPerformed: this.&onSelectDarkTheme)
192+
hstrut(4)
193+
button(text: T['prefs.clear'], id: 'clearDarkButton',
194+
enabled: customDarkThemePath as boolean,
195+
actionPerformed: this.&onClearDarkTheme)
196+
}
197+
198+
vstrut(8)
199+
200+
hbox {
201+
hglue()
202+
button(text: T['prefs.reload.themes'], actionPerformed: this.&onReloadThemes)
203+
}
204+
}
205+
206+
vstrut(12)
207+
117208
hbox {
118209
button T['prefs.reset.defaults'], id: 'resetPrefsButton', actionPerformed: this.&onReset
119210
hglue()
@@ -144,6 +235,13 @@ class ConsolePreferences {
144235
private void onReset(EventObject event) {
145236
console.swing.txtMaxOutputChars.text = DEFAULT_MAX_OUTPUT_CHARS
146237
console.swing.txtLoopModeDelay.text = DEFAULT_LOOP_MODE_DELAY_MILLIS
238+
iconSize = Icons.SIZE_NORMAL
239+
scaleIconsWithFont = false
240+
console.swing.iconSizeNormal.selected = true
241+
console.swing.scaleIconsCheckBox.selected = false
242+
setIconSizeRadiosEnabled(true)
243+
updateLightPathLabel(null)
244+
updateDarkPathLabel(null)
147245
}
148246

149247
private void onClose(EventObject event) {
@@ -157,6 +255,26 @@ class ConsolePreferences {
157255

158256
console.setOutputPreferences(console.swing.outputFileCheckBox.enabled, outputFile)
159257

258+
// apply appearance settings
259+
boolean previousScale = console.prefs.getBoolean('scaleIconsWithFont', false)
260+
int previousIcon = console.prefs.getInt('iconSize', Icons.SIZE_NORMAL)
261+
if (scaleIconsWithFont != previousScale) {
262+
console.setScaleIconsWithFont(scaleIconsWithFont)
263+
} else if (!scaleIconsWithFont && iconSize != previousIcon) {
264+
console.applyIconSize(iconSize)
265+
}
266+
267+
// apply custom theme paths — reapply theme only if something actually changed
268+
String previousLight = ThemeManager.customLightPath ?: ''
269+
String previousDark = ThemeManager.customDarkPath ?: ''
270+
String newLight = customLightThemePath ?: ''
271+
String newDark = customDarkThemePath ?: ''
272+
if (newLight != previousLight || newDark != previousDark) {
273+
ThemeManager.customLightPath = newLight ?: null
274+
ThemeManager.customDarkPath = newDark ?: null
275+
console.reloadThemes()
276+
}
277+
160278
dialog.dispose()
161279
}
162280

@@ -173,6 +291,59 @@ class ConsolePreferences {
173291
console.swing.outputFileName.text = outputFile.path
174292
}
175293

294+
private void onScaleWithFontToggled(EventObject event) {
295+
scaleIconsWithFont = event.source.selected
296+
setIconSizeRadiosEnabled(!scaleIconsWithFont)
297+
}
298+
299+
private void setIconSizeRadiosEnabled(boolean enabled) {
300+
console.swing.iconSizeSmall.enabled = enabled
301+
console.swing.iconSizeNormal.enabled = enabled
302+
console.swing.iconSizeLarge.enabled = enabled
303+
}
304+
305+
private void onSelectLightTheme(EventObject event) { pickThemeFile { it -> updateLightPathLabel(it) } }
306+
private void onSelectDarkTheme (EventObject event) { pickThemeFile { it -> updateDarkPathLabel(it) } }
307+
private void onClearLightTheme (EventObject event) { updateLightPathLabel(null) }
308+
private void onClearDarkTheme (EventObject event) { updateDarkPathLabel(null) }
309+
310+
private void onReloadThemes(EventObject event) {
311+
// apply any staged path changes first so Reload uses current selections
312+
ThemeManager.customLightPath = customLightThemePath ?: null
313+
ThemeManager.customDarkPath = customDarkThemePath ?: null
314+
console.reloadThemes()
315+
}
316+
317+
private void pickThemeFile(Closure onPicked) {
318+
JFileChooser fileChooser = new JFileChooser()
319+
fileChooser.fileFilter = new FileNameExtensionFilter('Groovy theme files (*.theme)', 'theme')
320+
def groovyDir = new File(System.getProperty('user.home'), '.groovy')
321+
fileChooser.currentDirectory = groovyDir.isDirectory() ? groovyDir : new File(System.getProperty('user.home'))
322+
if (fileChooser.showOpenDialog(dialog) != JFileChooser.APPROVE_OPTION) return
323+
File picked = fileChooser.selectedFile
324+
try {
325+
picked.withReader('UTF-8') { r -> ThemeManager.parseTheme(r) }
326+
} catch (Exception ex) {
327+
JOptionPane.showMessageDialog(dialog,
328+
"Could not parse theme file:\n${picked.absolutePath}\n\n${ex.message}",
329+
'Invalid Theme File', JOptionPane.WARNING_MESSAGE)
330+
return
331+
}
332+
onPicked(picked.absolutePath)
333+
}
334+
335+
private void updateLightPathLabel(String path) {
336+
customLightThemePath = path ?: ''
337+
console.swing.customLightPathLabel.text = path ?: T['prefs.no.file.selected']
338+
console.swing.clearLightButton.enabled = path as boolean
339+
}
340+
341+
private void updateDarkPathLabel(String path) {
342+
customDarkThemePath = path ?: ''
343+
console.swing.customDarkPathLabel.text = path ?: T['prefs.no.file.selected']
344+
console.swing.clearDarkButton.enabled = path as boolean
345+
}
346+
176347
// Useful for testing gui
177348
static void main(args) {
178349
javax.swing.UIManager.setLookAndFeel(javax.swing.UIManager.getSystemLookAndFeelClassName())

subprojects/groovy-console/src/main/groovy/groovy/console/ui/ThemeManager.groovy

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,69 @@ class ThemeManager {
149149
buildSwingStyles(activeTheme, fontFamily)
150150
}
151151

152+
/**
153+
* Returns the attributes for a single theme style key as a plain map
154+
* (foreground/background Colors plus bold/italic/underline Booleans).
155+
* Consumed by SmartDocumentFilter so ANTLR-token-driven highlighting
156+
* picks up the same theme values as the regex-based GroovyFilter styles.
157+
* Returns an empty map if the theme doesn't define the key.
158+
*/
159+
static Map getStyleAttrs(String key) {
160+
if (!key) return [:]
161+
activeTheme.styles[key.toLowerCase()] ?: [:]
162+
}
163+
152164
// --- theme loading + parsing ---
153165

154166
private static final Map<String, Object> themeCache = [:]
155167

168+
static String getCustomLightPath() { prefs.get('themeCustomLightPath', null) }
169+
static String getCustomDarkPath() { prefs.get('themeCustomDarkPath', null) }
170+
171+
static void setCustomLightPath(String path) {
172+
path ? prefs.put('themeCustomLightPath', path) : prefs.remove('themeCustomLightPath')
173+
}
174+
175+
static void setCustomDarkPath(String path) {
176+
path ? prefs.put('themeCustomDarkPath', path) : prefs.remove('themeCustomDarkPath')
177+
}
178+
179+
/** True iff the theme currently in use is loaded from a user-supplied file. */
180+
static boolean isUsingCustomTheme() {
181+
def file = customFileForCurrentMode()
182+
file?.exists() && file.canRead()
183+
}
184+
185+
/** Clear the parsed-theme cache so next lookup re-reads from disk/classpath. */
186+
static void reloadThemes() {
187+
synchronized (themeCache) { themeCache.clear() }
188+
}
189+
190+
private static File customFileForCurrentMode() {
191+
String path = isDark() ? customDarkPath : customLightPath
192+
path ? new File(path) : null
193+
}
194+
156195
private static Map getActiveTheme() {
196+
def file = customFileForCurrentMode()
197+
if (file?.exists() && file.canRead()) {
198+
try {
199+
return themeCache.computeIfAbsent('custom:' + file.absolutePath) { key ->
200+
file.withReader('UTF-8') { r -> parseTheme(r) }
201+
}
202+
} catch (Exception ignored) {
203+
// fall through to bundled on parse failure
204+
}
205+
}
157206
loadBundledTheme(isDark() ? 'dark' : 'light')
158207
}
159208

160209
private static Map loadBundledTheme(String name) {
161-
themeCache.computeIfAbsent(name) { key ->
162-
def resource = ThemeManager.classLoader.getResourceAsStream("groovy/console/ui/themes/${key}.theme")
210+
themeCache.computeIfAbsent('bundled:' + name) { key ->
211+
String resourcePath = "groovy/console/ui/themes/${name}.theme"
212+
def resource = ThemeManager.classLoader.getResourceAsStream(resourcePath)
163213
if (!resource) {
164-
throw new IllegalStateException("Missing bundled theme resource: ${key}.theme")
214+
throw new IllegalStateException("Missing bundled theme resource: ${resourcePath}")
165215
}
166216
resource.withStream { stream ->
167217
parseTheme(new InputStreamReader(stream, 'UTF-8'))

0 commit comments

Comments
 (0)