@@ -41,10 +41,25 @@ import org.jline.reader.impl.completer.AggregateCompleter
4141import org.jline.reader.impl.completer.ArgumentCompleter
4242import org.jline.reader.impl.completer.NullCompleter
4343import 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
4447import 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
4657import java.awt.Desktop
58+ import java.awt.Dimension
59+ import java.awt.Graphics2D
4760import java.awt.event.ActionListener
61+ import java.awt.image.BufferedImage
62+ import java.awt.image.RenderedImage
4863import java.lang.reflect.Method
4964import java.nio.charset.Charset
5065import 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 ],
0 commit comments