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