Skip to content

Commit 09e9122

Browse files
committed
GROOVY-12003: Add /img command to groovysh for inline image and chart display
1 parent a628cf6 commit 09e9122

16 files changed

Lines changed: 859 additions & 3 deletions

File tree

subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ class Main {
475475
if (!OSUtils.IS_WINDOWS) {
476476
setSpecificHighlighter("/!", SyntaxHighlighter.build(jnanorc, "SH-REPL"))
477477
}
478-
addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', *GROOVY_POSIX_CMDS, '/cd')
478+
addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', '/img', *GROOVY_POSIX_CMDS, '/cd')
479479
addFileHighlight('/classloader', null, ['-a', '--add'])
480480
addExternalHighlighterRefresh(printer::refresh)
481481
addExternalHighlighterRefresh(scriptEngine::refresh)

subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,25 @@ import org.jline.reader.impl.completer.AggregateCompleter
4141
import org.jline.reader.impl.completer.ArgumentCompleter
4242
import org.jline.reader.impl.completer.NullCompleter
4343
import org.jline.reader.impl.completer.StringsCompleter
44+
import org.jline.terminal.Terminal
45+
import org.jline.terminal.impl.TerminalGraphics
46+
import org.jline.terminal.impl.TerminalGraphicsManager
4447
import org.jline.utils.AttributedString
4548

49+
import javax.imageio.ImageIO
50+
import javax.swing.ImageIcon
51+
import javax.swing.JComponent
52+
import javax.swing.JFrame
53+
import javax.swing.JLabel
54+
import javax.swing.SwingUtilities
55+
import javax.swing.WindowConstants
56+
import java.awt.Color
4657
import java.awt.Desktop
58+
import java.awt.Dimension
59+
import java.awt.Graphics2D
4760
import java.awt.event.ActionListener
61+
import java.awt.image.BufferedImage
62+
import java.awt.image.RenderedImage
4863
import java.lang.reflect.Method
4964
import java.nio.charset.Charset
5065
import java.nio.charset.StandardCharsets
@@ -70,6 +85,7 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
7085
'/console' : new Tuple4<>(this::console, this::defCompleter, this::defCmdDesc, ['launch Groovy console']),
7186
'/doc' : new Tuple4<>(this::doc, this::importsCompleter, this::defCmdDesc, ['display documentation']),
7287
'/grab' : new Tuple4<>(this::grab, this::grabCompleter, this::grabCmdDesc, ['add maven repository dependencies to classpath']),
88+
'/img' : new Tuple4<>(this::img, this::imgCompleter, this::imgCmdDesc, ['display image inline (Sixel/Kitty/iTerm2 terminals)']),
7389
'/classloader' : new Tuple4<>(this::classLoader, this::classloaderCompleter, this::classLoaderCmdDesc, ['display/manage Groovy classLoader data']),
7490
'/imports' : new Tuple4<>(this::importsCommand, this::importsCompleter, this::nameDeleteCmdDesc, ['show/delete import statements']),
7591
'/vars' : new Tuple4<>(this::varsCommand, this::varsCompleter, this::nameDeleteCmdDesc, ['show/delete variable declarations']),
@@ -116,6 +132,12 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
116132
commands.remove('/grab')
117133
}
118134

135+
// /img depends on java.desktop (BufferedImage, ImageIO, Swing fallback).
136+
// It's "static transitive" in JLine's module descriptor, so check at runtime.
137+
if (!ClassUtils.lookFor('javax.imageio.ImageIO')) {
138+
commands.remove('/img')
139+
}
140+
119141
def available = commands.collectEntries { name, tuple ->
120142
[name, new CommandMethods((Function)tuple.v1, tuple.v2)]
121143
}
@@ -215,6 +237,214 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
215237
return null
216238
}
217239

240+
/**
241+
* Displays an image inline using JLine's terminal-graphics support
242+
* (Sixel, Kitty, iTerm2). Falls back to a summary line when the
243+
* terminal doesn't speak any supported protocol; the {@code --gui}
244+
* flag opens a Swing window instead.
245+
*
246+
* @param input parsed command input
247+
* @return always {@code null}
248+
*/
249+
def img(CommandInput input) {
250+
// No fixed arg-count cap: a fully-specified invocation
251+
// /img --width 64 --height 32 --no-preserve-aspect-ratio --gui $img
252+
// is already 7 tokens. The parse loop below validates every flag and
253+
// treats the lone non-option token as the positional, which is the
254+
// useful constraint.
255+
if (maybePrintHelp(input, '/img')) return
256+
try {
257+
Integer width = null
258+
Integer height = null
259+
boolean preserveAspect = true
260+
boolean gui = false
261+
Object positional = null
262+
String positionalLabel = null
263+
for (int i = 0; i < input.args().length; i++) {
264+
String a = input.args()[i]
265+
if (a == null) {
266+
// JLine puts null into args() when a $var reference resolves
267+
// to null — usually because the variable isn't defined yet.
268+
throw new IllegalArgumentException(
269+
'/img: variable reference resolved to null ' +
270+
'(undefined or not yet assigned) — define it first, e.g. ' +
271+
"'panel = ScatterPlot.of(...).canvas().panel()'")
272+
}
273+
if (a == '-w' || a == '--width') {
274+
width = Integer.parseInt(requireArgValue(input, ++i, a))
275+
} else if (a.startsWith('--width=')) {
276+
width = Integer.parseInt(a.substring('--width='.length()))
277+
} else if (a == '--height') {
278+
height = Integer.parseInt(requireArgValue(input, ++i, a))
279+
} else if (a.startsWith('--height=')) {
280+
height = Integer.parseInt(a.substring('--height='.length()))
281+
} else if (a == '-p' || a == '--no-preserve-aspect-ratio') {
282+
preserveAspect = false
283+
} else if (a == '-g' || a == '--gui') {
284+
gui = true
285+
} else if (!a.startsWith('-')) {
286+
// Use the resolved value from xargs — for "$myImage" this
287+
// is the variable's value (e.g. a BufferedImage); for a
288+
// plain string ("foo.png") it's the same string.
289+
positional = input.xargs()[i]
290+
positionalLabel = a
291+
}
292+
}
293+
if (positional == null) {
294+
throw new IllegalArgumentException('No image path, URL, or variable provided')
295+
}
296+
BufferedImage image = positional instanceof String
297+
? loadImage((String) positional)
298+
: coerceToImage(positional, width, height)
299+
if (image == null) {
300+
throw new IllegalArgumentException("Not a recognised image: $positionalLabel")
301+
}
302+
// For raw-pixel inputs (file/URL/BufferedImage/RenderedImage), --width
303+
// and --height are terminal-display dimensions (cells). For inputs
304+
// that *generate* the image from those dims (createBufferedImage /
305+
// toBufferedImage / JComponent paint), the values are already
306+
// consumed as source pixels and must NOT also be passed to the
307+
// terminal opts — otherwise e.g. "--width=600" gets reinterpreted
308+
// as 600 character cells and the chart renders blank/clipped.
309+
boolean dimsConsumedByGeneration = !(positional instanceof String
310+
|| positional instanceof BufferedImage
311+
|| positional instanceof RenderedImage)
312+
Terminal terminal = input.terminal()
313+
if (gui) {
314+
showInSwing(image, positionalLabel)
315+
} else if (TerminalGraphicsManager.isGraphicsSupported(terminal)) {
316+
def opts = new TerminalGraphics.ImageOptions().preserveAspectRatio(preserveAspect)
317+
if (!dimsConsumedByGeneration) {
318+
if (width != null) opts.width(width)
319+
if (height != null) opts.height(height)
320+
}
321+
TerminalGraphicsManager.displayImage(terminal, image, opts)
322+
// Reset cursor to column 0 of the next line — Sixel/iTerm2/Kitty
323+
// protocols typically leave the cursor at the right edge of
324+
// the image, which would indent the next prompt.
325+
terminal.writer().println()
326+
terminal.writer().flush()
327+
} else {
328+
// Coerce to String — DefaultPrinter renders unknown Object
329+
// (including GString) as a field table; we want a plain line.
330+
String summary = "[image: ${image.width}x${image.height}, $positionalLabel] " +
331+
"(this terminal doesn't support inline images; try Kitty/iTerm2/WezTerm, or use --gui)"
332+
printer.println(summary)
333+
}
334+
} catch (Exception e) {
335+
saveException(e)
336+
}
337+
return null
338+
}
339+
340+
private static String requireArgValue(CommandInput input, int idx, String flag) {
341+
// Trailing-flag guard for `--width`/`--height` (and friends): without
342+
// this, `/img --width $img` reads past the end of args() and surfaces
343+
// an opaque ArrayIndexOutOfBoundsException via saveException.
344+
if (idx >= input.args().length) {
345+
throw new IllegalArgumentException("/img: missing value for $flag")
346+
}
347+
input.args()[idx]
348+
}
349+
350+
private BufferedImage loadImage(String pathOrUrl) {
351+
if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
352+
return ImageIO.read(URI.create(pathOrUrl).toURL())
353+
}
354+
Path path = workDir.get().resolve(pathOrUrl)
355+
if (!Files.exists(path)) {
356+
throw new IllegalArgumentException("File not found: $pathOrUrl")
357+
}
358+
ImageIO.read(path.toFile())
359+
}
360+
361+
/**
362+
* Converts an arbitrary value into a {@link BufferedImage} for /img.
363+
* Supports:
364+
* <ul>
365+
* <li>{@code BufferedImage} — used as-is</li>
366+
* <li>{@code RenderedImage} — drawn into a fresh {@code BufferedImage}</li>
367+
* <li>anything with {@code createBufferedImage(int, int)} (e.g.
368+
* {@code org.jfree.chart.JFreeChart}) — duck-typed so groovysh
369+
* doesn't take a hard dependency on JFreeChart</li>
370+
* <li>anything with {@code toBufferedImage(int, int)} (e.g.
371+
* {@code smile.plot.swing.Figure}) — duck-typed for Smile's
372+
* parallel naming convention</li>
373+
* <li>{@code JComponent} — laid out and painted to a {@code BufferedImage}</li>
374+
* </ul>
375+
* Other types throw {@link IllegalArgumentException} with a clear message.
376+
*/
377+
private BufferedImage coerceToImage(Object obj, Integer width, Integer height) {
378+
if (obj instanceof BufferedImage) {
379+
return (BufferedImage) obj
380+
}
381+
if (obj instanceof RenderedImage) {
382+
return renderedToBuffered((RenderedImage) obj)
383+
}
384+
// Duck-type: createBufferedImage(int, int) — JFreeChart's signature.
385+
try {
386+
return (BufferedImage) obj.createBufferedImage(width ?: 800, height ?: 600)
387+
} catch (MissingMethodException ignore) {
388+
// Not a JFreeChart-like — fall through.
389+
}
390+
// Duck-type: toBufferedImage(int, int) — Smile Figure's signature.
391+
try {
392+
return (BufferedImage) obj.toBufferedImage(width ?: 800, height ?: 600)
393+
} catch (MissingMethodException ignore) {
394+
// Not a Smile-Figure-like — fall through.
395+
}
396+
if (obj instanceof JComponent) {
397+
return renderJComponent((JComponent) obj, width, height)
398+
}
399+
throw new IllegalArgumentException(
400+
"/img: don't know how to render ${obj.class.name}; supports " +
401+
'BufferedImage, RenderedImage, anything with createBufferedImage(int,int) ' +
402+
'or toBufferedImage(int,int), or JComponent')
403+
}
404+
405+
private static BufferedImage renderedToBuffered(RenderedImage src) {
406+
if (src instanceof BufferedImage) return (BufferedImage) src
407+
BufferedImage out = new BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB)
408+
Graphics2D g = out.createGraphics()
409+
try {
410+
g.drawRenderedImage(src, new java.awt.geom.AffineTransform())
411+
} finally {
412+
g.dispose()
413+
}
414+
out
415+
}
416+
417+
private static BufferedImage renderJComponent(JComponent comp, Integer width, Integer height) {
418+
Dimension preferred = comp.preferredSize
419+
int w = width ?: (preferred.width > 0 ? preferred.width : 800)
420+
int h = height ?: (preferred.height > 0 ? preferred.height : 600)
421+
comp.size = new Dimension(w, h)
422+
comp.doLayout()
423+
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
424+
Graphics2D g = image.createGraphics()
425+
try {
426+
// Most plot panels assume a light background; default JComponent
427+
// paints transparent pixels which look broken when displayed.
428+
g.color = Color.WHITE
429+
g.fillRect(0, 0, w, h)
430+
comp.paint(g)
431+
} finally {
432+
g.dispose()
433+
}
434+
image
435+
}
436+
437+
private static void showInSwing(BufferedImage image, String title) {
438+
SwingUtilities.invokeLater {
439+
JFrame frame = new JFrame(title)
440+
frame.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
441+
frame.add(new JLabel(new ImageIcon(image)))
442+
frame.pack()
443+
frame.locationRelativeTo = null
444+
frame.visible = true
445+
}
446+
}
447+
218448
/**
219449
* Clears the current buffer.
220450
*
@@ -874,6 +1104,18 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
8741104
])
8751105
}
8761106

1107+
private CmdDesc imgCmdDesc(String name) {
1108+
new CmdDesc([
1109+
new AttributedString("$name [OPTIONS] (FILE | URL | \$VAR)"),
1110+
], [], [
1111+
'-? --help' : doDescription('Displays command help'),
1112+
'-w --width=N' : doDescription('Width: terminal cells for raw images, source pixels for charts'),
1113+
' --height=N' : doDescription('Height: terminal cells for raw images, source pixels for charts'),
1114+
'-p --no-preserve-aspect-ratio' : doDescription("Don't preserve aspect ratio"),
1115+
'-g --gui' : doDescription('Open in a Swing window instead of inline')
1116+
])
1117+
}
1118+
8771119
private CmdDesc inspectCmdDesc(String name) {
8781120
def optDescs = [
8791121
'-? --help' : doDescription('Displays command help'),
@@ -971,6 +1213,13 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
9711213
new OptionCompleter([new StringsCompleter((Supplier)() -> engine.imports.keySet()), NullCompleter.INSTANCE], this::compileOptDescs, 1))]
9721214
}
9731215

1216+
private List<Completer> imgCompleter(String command) {
1217+
// Hint common image extensions; users can still tab-complete other files.
1218+
[new ArgumentCompleter(NullCompleter.INSTANCE,
1219+
new OptionCompleter(new Completers.FilesCompleter(workDir),
1220+
this::compileOptDescs, 1))]
1221+
}
1222+
9741223
private List<Completer> inspectCompleter(String command) {
9751224
[new ArgumentCompleter(NullCompleter.INSTANCE,
9761225
new OptionCompleter([new StringsCompleter((Supplier) this::variables), NullCompleter.INSTANCE],

subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ syntax "ARGS"
1717

1818
NUMBER: "\<[-]?[0-9]*([Ee][+-]?[0-9]+)?\>" "\<[-]?[0](\.[0-9]+)?\>"
1919
STRING: "[a-zA-Z]+[a-zA-Z0-9]*"
20-
COMMAND: "\</?(alias|classloader|clear|colors|console|del|doc|echo|exit|grab|help|highlighter|history|imports|inspect|keymap|less|load|methods|nano|pipe|prnt|reset|save|setopt|show|slurp|ttop|types|unalias|unsetopt|vars|widget)\>"
20+
COMMAND: "\</?(alias|classloader|clear|colors|console|del|doc|echo|exit|grab|help|highlighter|history|imports|img|inspect|keymap|less|load|methods|nano|pipe|prnt|reset|save|setopt|show|slurp|ttop|types|unalias|unsetopt|vars|widget)\>"
2121
NULL: "\<null\>"
2222
BOOLEAN: "\<(true|false)\>"
2323
VARIABLE: "(\[|,)\s*[a-zA-Z0-9]*\s*:"

subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
syntax "Groovy" "\.(groovy|gradle)$"
1717

18-
TYPE: "\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|var|void)\>"
18+
TYPE: "\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|val|var|void)\>"
1919
KEYWORD: "\<(case|catch|default|do|else|finally|for|if|return|switch|throw|try|while)\>"
2020
KEYWORD: "\<(abstract|class|extends|final|implements|import|instanceof|interface|native|non-sealed|package)\>"
2121
KEYWORD: "\<(permits|private|protected|public|record|sealed|static|strictfp|super|synchronized|throws|trait|volatile)\>"
107 KB
Loading
187 KB
Loading
181 KB
Loading
155 KB
Loading

0 commit comments

Comments
 (0)