Skip to content

Commit 52f689d

Browse files
committed
GROOVY-12003: Add /img command to groovysh for inline image and chart display
1 parent 620aa31 commit 52f689d

8 files changed

Lines changed: 458 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: 235 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']),
@@ -115,6 +131,12 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
115131
commands.remove('/grab')
116132
}
117133

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

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

1090+
private CmdDesc imgCmdDesc(String name) {
1091+
new CmdDesc([
1092+
new AttributedString("$name [OPTIONS] FILE_OR_URL"),
1093+
], [], [
1094+
'-? --help' : doDescription('Displays command help'),
1095+
'-w --width=COLS' : doDescription('Image width in character columns'),
1096+
' --height=ROWS' : doDescription('Image height in character rows'),
1097+
'-p --no-preserve-aspect-ratio' : doDescription("Don't preserve aspect ratio"),
1098+
'-g --gui' : doDescription('Open in a Swing window instead of inline')
1099+
])
1100+
}
1101+
8741102
private CmdDesc inspectCmdDesc(String name) {
8751103
def optDescs = [
8761104
'-? --help' : doDescription('Displays command help'),
@@ -962,6 +1190,13 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
9621190
new OptionCompleter([new StringsCompleter((Supplier)() -> engine.imports.keySet()), NullCompleter.INSTANCE], this::compileOptDescs, 1))]
9631191
}
9641192

1193+
private List<Completer> imgCompleter(String command) {
1194+
// Hint common image extensions; users can still tab-complete other files.
1195+
[new ArgumentCompleter(NullCompleter.INSTANCE,
1196+
new OptionCompleter(new Completers.FilesCompleter(workDir),
1197+
this::compileOptDescs, 1))]
1198+
}
1199+
9651200
private List<Completer> inspectCompleter(String command) {
9661201
[new ArgumentCompleter(NullCompleter.INSTANCE,
9671202
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)\>"
120 KB
Loading
213 KB
Loading

subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,75 @@ groovy> /imports
720720
import java.util.concurrent.BlockingQueue
721721
--------------
722722
723+
[[GroovyShell-img]]
724+
==== `/img`
725+
726+
The `/img` command displays an image inline using JLine's terminal-graphics
727+
support (Sixel, Kitty, or iTerm2 protocols, auto-detected). The argument is
728+
either a local file path, an `http(s)://` URL, or a Groovy variable
729+
reference using the standard `$` syntax.
730+
731+
[source,jshell]
732+
--------------
733+
groovy> /img chart.png
734+
groovy> /img --width=80 https://example.com/diagram.png
735+
groovy> img = new java.awt.image.BufferedImage(200, 100, 1) // some BufferedImage
736+
groovy> /img $img
737+
--------------
738+
739+
When the argument resolves to a Groovy value (rather than a path), `/img`
740+
will accept any of:
741+
742+
* a `java.awt.image.BufferedImage` — used as-is
743+
* a `java.awt.image.RenderedImage` — drawn into a `BufferedImage`
744+
* anything with a `createBufferedImage(int, int)` method (e.g.
745+
`org.jfree.chart.JFreeChart`) — duck-typed, no compile-time dependency
746+
* anything with a `toBufferedImage(int, int)` method (e.g. Smile's
747+
`smile.plot.swing.Figure`) — duck-typed sibling for libraries that
748+
follow the `to…` rather than `create…` naming convention
749+
* a `javax.swing.JComponent` (e.g. Smile's `Canvas` and `MultiFigurePane`) —
750+
laid out and painted to a `BufferedImage` at the requested size
751+
752+
So once you've grabbed JFreeChart you can render charts in the REPL
753+
without saving to a file:
754+
755+
image:assets/img/repl_img_jfreechart.png[Using /img with JFreeChart]
756+
757+
Smile is similar — `Figure.toBufferedImage(int, int)` matches the second
758+
duck-type, so a fresh `Figure` can be handed straight to `/img`. The
759+
following draws the classic Iris scatter plot:
760+
761+
image:assets/img/repl_img_smile.png[Using /img with Smile]
762+
763+
Smile's `Canvas` and `MultiFigurePane` are themselves `JComponent` subclasses,
764+
so the SPLOM (scatter-plot matrix) goes through the `JComponent` path with no
765+
extra wrapping:
766+
767+
[source,jshell]
768+
--------------
769+
groovy> import smile.plot.swing.MultiFigurePane
770+
groovy> splom = MultiFigurePane.splom(iris, '*' as char, 'class')
771+
groovy> /img $splom --width=900 --height=900
772+
--------------
773+
774+
The `--width` and `--height` options have two distinct meanings depending
775+
on the input. For raw-pixel inputs (file path, `http(s)://` URL,
776+
`BufferedImage`, `RenderedImage`), they control the rendered size in
777+
*terminal character cells*. For inputs that generate the image at the
778+
requested size (`createBufferedImage(int,int)`, `toBufferedImage(int,int)`,
779+
`JComponent`), they are *source-image pixels* — the terminal then renders
780+
the image at its natural fit. Aspect ratio is preserved by default
781+
(override with `--no-preserve-aspect-ratio`).
782+
783+
If the active terminal doesn't speak any of the supported graphics protocols
784+
(common cases: macOS Terminal.app, VS Code's built-in terminal, JetBrains
785+
terminals, plain Windows console), `/img` prints a `[image: WxH, label]`
786+
summary line instead. Pass `--gui` to open a Swing window with the image
787+
regardless of terminal capability.
788+
789+
The command requires the `java.desktop` module at runtime; on a JVM where
790+
that module is unavailable, `/img` is not registered.
791+
723792
[[GroovyShell-inspect]]
724793
==== `/inspect`
725794

0 commit comments

Comments
 (0)