@@ -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' ]),
@@ -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 ],
0 commit comments