Skip to content

Commit 48b9e0f

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

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

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

1207+
private List<Completer> imgCompleter(String command) {
1208+
// Hint common image extensions; users can still tab-complete other files.
1209+
[new ArgumentCompleter(NullCompleter.INSTANCE,
1210+
new OptionCompleter(new Completers.FilesCompleter(workDir),
1211+
this::compileOptDescs, 1))]
1212+
}
1213+
9651214
private List<Completer> inspectCompleter(String command) {
9661215
[new ArgumentCompleter(NullCompleter.INSTANCE,
9671216
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)