Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ class Main {
if (!OSUtils.IS_WINDOWS) {
setSpecificHighlighter("/!", SyntaxHighlighter.build(jnanorc, "SH-REPL"))
}
addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', *GROOVY_POSIX_CMDS, '/cd')
addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', '/img', *GROOVY_POSIX_CMDS, '/cd')
addFileHighlight('/classloader', null, ['-a', '--add'])
addExternalHighlighterRefresh(printer::refresh)
addExternalHighlighterRefresh(scriptEngine::refresh)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,25 @@ import org.jline.reader.impl.completer.AggregateCompleter
import org.jline.reader.impl.completer.ArgumentCompleter
import org.jline.reader.impl.completer.NullCompleter
import org.jline.reader.impl.completer.StringsCompleter
import org.jline.terminal.Terminal
import org.jline.terminal.impl.TerminalGraphics
import org.jline.terminal.impl.TerminalGraphicsManager
import org.jline.utils.AttributedString

import javax.imageio.ImageIO
import javax.swing.ImageIcon
import javax.swing.JComponent
import javax.swing.JFrame
import javax.swing.JLabel
import javax.swing.SwingUtilities
import javax.swing.WindowConstants
import java.awt.Color
import java.awt.Desktop
import java.awt.Dimension
import java.awt.Graphics2D
import java.awt.event.ActionListener
import java.awt.image.BufferedImage
import java.awt.image.RenderedImage
import java.lang.reflect.Method
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
Expand All @@ -70,6 +85,7 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
'/console' : new Tuple4<>(this::console, this::defCompleter, this::defCmdDesc, ['launch Groovy console']),
'/doc' : new Tuple4<>(this::doc, this::importsCompleter, this::defCmdDesc, ['display documentation']),
'/grab' : new Tuple4<>(this::grab, this::grabCompleter, this::grabCmdDesc, ['add maven repository dependencies to classpath']),
'/img' : new Tuple4<>(this::img, this::imgCompleter, this::imgCmdDesc, ['display image inline (Sixel/Kitty/iTerm2 terminals)']),
'/classloader' : new Tuple4<>(this::classLoader, this::classloaderCompleter, this::classLoaderCmdDesc, ['display/manage Groovy classLoader data']),
'/imports' : new Tuple4<>(this::importsCommand, this::importsCompleter, this::nameDeleteCmdDesc, ['show/delete import statements']),
'/vars' : new Tuple4<>(this::varsCommand, this::varsCompleter, this::nameDeleteCmdDesc, ['show/delete variable declarations']),
Expand Down Expand Up @@ -115,6 +131,12 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
commands.remove('/grab')
}

// /img depends on java.desktop (BufferedImage, ImageIO, Swing fallback).
// It's "static transitive" in JLine's module descriptor, so check at runtime.
if (!ClassUtils.lookFor('javax.imageio.ImageIO')) {
commands.remove('/img')
}

def available = commands.collectEntries { name, tuple ->
[name, new CommandMethods((Function)tuple.v1, tuple.v2)]
}
Expand Down Expand Up @@ -214,6 +236,214 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
return null
}

/**
* Displays an image inline using JLine's terminal-graphics support
* (Sixel, Kitty, iTerm2). Falls back to a summary line when the
* terminal doesn't speak any supported protocol; the {@code --gui}
* flag opens a Swing window instead.
*
* @param input parsed command input
* @return always {@code null}
*/
def img(CommandInput input) {
// No fixed arg-count cap: a fully-specified invocation
// /img --width 64 --height 32 --no-preserve-aspect-ratio --gui $img
// is already 7 tokens. The parse loop below validates every flag and
// treats the lone non-option token as the positional, which is the
// useful constraint.
if (maybePrintHelp(input, '/img')) return
try {
Integer width = null
Integer height = null
boolean preserveAspect = true
boolean gui = false
Object positional = null
String positionalLabel = null
for (int i = 0; i < input.args().length; i++) {
String a = input.args()[i]
if (a == null) {
// JLine puts null into args() when a $var reference resolves
// to null — usually because the variable isn't defined yet.
throw new IllegalArgumentException(
'/img: variable reference resolved to null ' +
'(undefined or not yet assigned) — define it first, e.g. ' +
"'panel = ScatterPlot.of(...).canvas().panel()'")
}
if (a == '-w' || a == '--width') {
width = Integer.parseInt(requireArgValue(input, ++i, a))
} else if (a.startsWith('--width=')) {
width = Integer.parseInt(a.substring('--width='.length()))
} else if (a == '--height') {
height = Integer.parseInt(requireArgValue(input, ++i, a))
} else if (a.startsWith('--height=')) {
height = Integer.parseInt(a.substring('--height='.length()))
} else if (a == '-p' || a == '--no-preserve-aspect-ratio') {
preserveAspect = false
} else if (a == '-g' || a == '--gui') {
gui = true
} else if (!a.startsWith('-')) {
// Use the resolved value from xargs — for "$myImage" this
// is the variable's value (e.g. a BufferedImage); for a
// plain string ("foo.png") it's the same string.
positional = input.xargs()[i]
positionalLabel = a
}
}
if (positional == null) {
throw new IllegalArgumentException('No image path, URL, or variable provided')
}
BufferedImage image = positional instanceof String
? loadImage((String) positional)
: coerceToImage(positional, width, height)
if (image == null) {
throw new IllegalArgumentException("Not a recognised image: $positionalLabel")
}
// For raw-pixel inputs (file/URL/BufferedImage/RenderedImage), --width
// and --height are terminal-display dimensions (cells). For inputs
// that *generate* the image from those dims (createBufferedImage /
// toBufferedImage / JComponent paint), the values are already
// consumed as source pixels and must NOT also be passed to the
// terminal opts — otherwise e.g. "--width=600" gets reinterpreted
// as 600 character cells and the chart renders blank/clipped.
boolean dimsConsumedByGeneration = !(positional instanceof String
|| positional instanceof BufferedImage
|| positional instanceof RenderedImage)
Terminal terminal = input.terminal()
if (gui) {
showInSwing(image, positionalLabel)
} else if (TerminalGraphicsManager.isGraphicsSupported(terminal)) {
def opts = new TerminalGraphics.ImageOptions().preserveAspectRatio(preserveAspect)
if (!dimsConsumedByGeneration) {
if (width != null) opts.width(width)
if (height != null) opts.height(height)
}
TerminalGraphicsManager.displayImage(terminal, image, opts)
// Reset cursor to column 0 of the next line — Sixel/iTerm2/Kitty
// protocols typically leave the cursor at the right edge of
// the image, which would indent the next prompt.
terminal.writer().println()
terminal.writer().flush()
} else {
// Coerce to String — DefaultPrinter renders unknown Object
// (including GString) as a field table; we want a plain line.
String summary = "[image: ${image.width}x${image.height}, $positionalLabel] " +
"(this terminal doesn't support inline images; try Kitty/iTerm2/WezTerm, or use --gui)"
printer.println(summary)
}
} catch (Exception e) {
saveException(e)
}
return null
}

private static String requireArgValue(CommandInput input, int idx, String flag) {
// Trailing-flag guard for `--width`/`--height` (and friends): without
// this, `/img --width $img` reads past the end of args() and surfaces
// an opaque ArrayIndexOutOfBoundsException via saveException.
if (idx >= input.args().length) {
throw new IllegalArgumentException("/img: missing value for $flag")
}
input.args()[idx]
}

private BufferedImage loadImage(String pathOrUrl) {
if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
return ImageIO.read(URI.create(pathOrUrl).toURL())
}
Path path = workDir.get().resolve(pathOrUrl)
if (!Files.exists(path)) {
throw new IllegalArgumentException("File not found: $pathOrUrl")
}
ImageIO.read(path.toFile())
}

/**
* Converts an arbitrary value into a {@link BufferedImage} for /img.
* Supports:
* <ul>
* <li>{@code BufferedImage} — used as-is</li>
* <li>{@code RenderedImage} — drawn into a fresh {@code BufferedImage}</li>
* <li>anything with {@code createBufferedImage(int, int)} (e.g.
* {@code org.jfree.chart.JFreeChart}) — duck-typed so groovysh
* doesn't take a hard dependency on JFreeChart</li>
* <li>anything with {@code toBufferedImage(int, int)} (e.g.
* {@code smile.plot.swing.Figure}) — duck-typed for Smile's
* parallel naming convention</li>
* <li>{@code JComponent} — laid out and painted to a {@code BufferedImage}</li>
* </ul>
* Other types throw {@link IllegalArgumentException} with a clear message.
*/
private BufferedImage coerceToImage(Object obj, Integer width, Integer height) {
if (obj instanceof BufferedImage) {
return (BufferedImage) obj
}
if (obj instanceof RenderedImage) {
return renderedToBuffered((RenderedImage) obj)
}
// Duck-type: createBufferedImage(int, int) — JFreeChart's signature.
try {
return (BufferedImage) obj.createBufferedImage(width ?: 800, height ?: 600)
} catch (MissingMethodException ignore) {
// Not a JFreeChart-like — fall through.
}
// Duck-type: toBufferedImage(int, int) — Smile Figure's signature.
try {
return (BufferedImage) obj.toBufferedImage(width ?: 800, height ?: 600)
} catch (MissingMethodException ignore) {
// Not a Smile-Figure-like — fall through.
}
if (obj instanceof JComponent) {
return renderJComponent((JComponent) obj, width, height)
}
throw new IllegalArgumentException(
"/img: don't know how to render ${obj.class.name}; supports " +
'BufferedImage, RenderedImage, anything with createBufferedImage(int,int) ' +
'or toBufferedImage(int,int), or JComponent')
}

private static BufferedImage renderedToBuffered(RenderedImage src) {
if (src instanceof BufferedImage) return (BufferedImage) src
BufferedImage out = new BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB)
Graphics2D g = out.createGraphics()
try {
g.drawRenderedImage(src, new java.awt.geom.AffineTransform())
} finally {
g.dispose()
}
out
}

private static BufferedImage renderJComponent(JComponent comp, Integer width, Integer height) {
Dimension preferred = comp.preferredSize
int w = width ?: (preferred.width > 0 ? preferred.width : 800)
int h = height ?: (preferred.height > 0 ? preferred.height : 600)
comp.size = new Dimension(w, h)
comp.doLayout()
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
Graphics2D g = image.createGraphics()
try {
// Most plot panels assume a light background; default JComponent
// paints transparent pixels which look broken when displayed.
g.color = Color.WHITE
g.fillRect(0, 0, w, h)
comp.paint(g)
} finally {
g.dispose()
}
image
}

private static void showInSwing(BufferedImage image, String title) {
SwingUtilities.invokeLater {
JFrame frame = new JFrame(title)
frame.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
frame.add(new JLabel(new ImageIcon(image)))
frame.pack()
frame.locationRelativeTo = null
frame.visible = true
}
}

/**
* Clears the current buffer.
*
Expand Down Expand Up @@ -871,6 +1101,18 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
])
}

private CmdDesc imgCmdDesc(String name) {
new CmdDesc([
new AttributedString("$name [OPTIONS] (FILE | URL | \$VAR)"),
], [], [
'-? --help' : doDescription('Displays command help'),
'-w --width=N' : doDescription('Width: terminal cells for raw images, source pixels for charts'),
' --height=N' : doDescription('Height: terminal cells for raw images, source pixels for charts'),
'-p --no-preserve-aspect-ratio' : doDescription("Don't preserve aspect ratio"),
'-g --gui' : doDescription('Open in a Swing window instead of inline')
])
}

private CmdDesc inspectCmdDesc(String name) {
def optDescs = [
'-? --help' : doDescription('Displays command help'),
Expand Down Expand Up @@ -962,6 +1204,13 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
new OptionCompleter([new StringsCompleter((Supplier)() -> engine.imports.keySet()), NullCompleter.INSTANCE], this::compileOptDescs, 1))]
}

private List<Completer> imgCompleter(String command) {
// Hint common image extensions; users can still tab-complete other files.
[new ArgumentCompleter(NullCompleter.INSTANCE,
new OptionCompleter(new Completers.FilesCompleter(workDir),
this::compileOptDescs, 1))]
}

private List<Completer> inspectCompleter(String command) {
[new ArgumentCompleter(NullCompleter.INSTANCE,
new OptionCompleter([new StringsCompleter((Supplier) this::variables), NullCompleter.INSTANCE],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ syntax "ARGS"

NUMBER: "\<[-]?[0-9]*([Ee][+-]?[0-9]+)?\>" "\<[-]?[0](\.[0-9]+)?\>"
STRING: "[a-zA-Z]+[a-zA-Z0-9]*"
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)\>"
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)\>"
NULL: "\<null\>"
BOOLEAN: "\<(true|false)\>"
VARIABLE: "(\[|,)\s*[a-zA-Z0-9]*\s*:"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

syntax "Groovy" "\.(groovy|gradle)$"

TYPE: "\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|var|void)\>"
TYPE: "\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|val|var|void)\>"
KEYWORD: "\<(case|catch|default|do|else|finally|for|if|return|switch|throw|try|while)\>"
KEYWORD: "\<(abstract|class|extends|final|implements|import|instanceof|interface|native|non-sealed|package)\>"
KEYWORD: "\<(permits|private|protected|public|record|sealed|static|strictfp|super|synchronized|throws|trait|volatile)\>"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading