Clear cruft
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 26s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Failing after 1m1s

This commit is contained in:
2026-09-07 20:16:00 -04:00
parent 50a4279446
commit dbcb0aa1b6
66 changed files with 5579 additions and 443 deletions
@@ -70,13 +70,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
setLocationRelativeTo(null);
setMinimumSize(new Dimension(640, 400));
// Status refresh timer — also ensures focus stays on terminal
// Status refresh timer — also ensures focus stays on terminal (guarding against focus stealing)
refreshTimer = new Timer(100, e -> {
if (client != null) {
statusBar.updateStatus();
syncModeMenuItems();
if (isActive() && !terminalPanel.hasFocus()) {
terminalPanel.requestFocusInWindow();
if (!isAnyChildDialogActive() && KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow() == this) {
if (!terminalPanel.hasFocus()) {
terminalPanel.requestFocusInWindow();
}
}
}
});
@@ -96,11 +98,30 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
@Override
public void windowActivated(WindowEvent e) {
terminalPanel.requestFocusInWindow();
if (!isAnyChildDialogActive()) {
terminalPanel.requestFocusInWindow();
}
}
});
}
private boolean isAnyChildDialogActive() {
Window focusedWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow();
if (focusedWindow != null && focusedWindow != this) {
return true;
}
if (findDialog != null && findDialog.isVisible()) return true;
if (scriptDialog != null && scriptDialog.isVisible()) return true;
if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true;
if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true;
for (Window owned : getOwnedWindows()) {
if (owned != null && owned.isVisible()) {
return true;
}
}
return false;
}
private void buildUI() {
terminalPanel = new TerminalPanel();
statusBar = new StatusBar();
@@ -743,13 +764,26 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
fileTransfer.cancel();
fileTransfer = null;
}
client.disconnect();
Telnet3270Client c = client;
client = null;
terminalPanel.setClient(null);
statusBar.setClient(null, terminalPanel);
syncModeMenuItems();
terminalPanel.repaint();
setTitle("j3270 — Java TN3270 Terminal Emulator");
new Thread(() -> {
try {
c.disconnect();
} catch (Exception ignored) {}
}, "Disconnect-Thread").start();
Runnable uiReset = () -> {
terminalPanel.setClient(null);
statusBar.setClient(null, terminalPanel);
syncModeMenuItems();
terminalPanel.repaint();
setTitle("j3270 — Java TN3270 Terminal Emulator");
};
if (SwingUtilities.isEventDispatchThread()) {
uiReset.run();
} else {
SwingUtilities.invokeLater(uiReset);
}
}
}
@@ -790,7 +824,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
statusBar.updateStatus();
terminalPanel.repaint();
if (newState.isFullSession() && !oldState.isFullSession()) {
if (newState == ConnectionState.RECONNECTING) {
setTitle("j3270 — " + lastHost + ":" + lastPort + " [Reconnecting...]");
} else if (newState == ConnectionState.NOT_CONNECTED) {
setTitle("j3270 — Java TN3270 Terminal Emulator");
} else if (newState.isFullSession() && !oldState.isFullSession()) {
String tlsIndicator = (client != null && client.getConfig().isUseTls()) ?
(client.getConfig().isTlsVerifyCert() ? " [TLS]" : " [TLS/Unverified]") : "";
String dev = (client != null && client.getTelnetFSM() != null && client.getTelnetFSM().getConnectedLu() != null) ?
" [" + client.getTelnetFSM().getConnectedLu() + "]" : "";
setTitle("j3270 — " + lastHost + ":" + lastPort + tlsIndicator + dev);
terminalPanel.guardedPack();
terminalPanel.requestFocusInWindow();
}
@@ -847,6 +890,19 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
});
}
@Override
public void onCursorMoved(int oldAddress, int newAddress) {
SwingUtilities.invokeLater(() -> {
terminalPanel.repaint();
statusBar.updateStatus();
});
}
@Override
public void onKeyboardUnlocked() {
SwingUtilities.invokeLater(statusBar::updateStatus);
}
// ========== Help dialogs ==========
private void showKeyMappings() {
@@ -949,6 +1005,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
boolean cliTls = false;
boolean cliNoVerifyCert = false;
Boolean cliTn3270e = null;
Boolean cliAutoSysUnlock = null;
GraphicsMode cliGraphicsMode = null;
String configFile = null;
@@ -965,6 +1022,10 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
cliTn3270e = false;
} else if ("--tn3270e".equals(arg)) {
cliTn3270e = true;
} else if ("--auto-sys-unlock".equals(arg)) {
cliAutoSysUnlock = true;
} else if ("--no-auto-sys-unlock".equals(arg)) {
cliAutoSysUnlock = false;
} else if (arg.startsWith("--graphics=")) {
cliGraphicsMode = GraphicsMode.fromString(arg.substring(11));
} else if (("--graphics".equals(arg) || "-g".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) {
@@ -1041,6 +1102,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
final boolean finalTls = cliTls;
final boolean finalNoVerify = cliNoVerifyCert;
final Boolean finalTn3270e = cliTn3270e;
final Boolean finalAutoSysUnlock = cliAutoSysUnlock;
final GraphicsMode finalGraphicsMode = cliGraphicsMode;
SwingUtilities.invokeLater(() -> {
@@ -1077,6 +1139,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (finalTn3270e != null) {
config.setTn3270eEnabled(finalTn3270e);
}
if (finalAutoSysUnlock != null) {
config.setAutoSysUnlock(finalAutoSysUnlock);
} else {
config.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
}
if (finalGraphicsMode != null) {
config.setGraphicsMode(finalGraphicsMode);
} else {
@@ -1100,6 +1167,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4, tls);
config.setTlsVerifyCert(verify);
config.setTn3270eEnabled(finalTn3270e != null ? finalTn3270e : tn3270e);
config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
if (finalGraphicsMode != null) {
config.setGraphicsMode(finalGraphicsMode);
} else {
@@ -105,6 +105,51 @@ public class Settings {
prefs.putBoolean("autoConnectTn3270e", tn3270e);
}
public static boolean getAutoSysUnlock() {
return prefs.getBoolean("autoSysUnlock", true);
}
public static void setAutoSysUnlock(boolean autoSysUnlock) {
prefs.putBoolean("autoSysUnlock", autoSysUnlock);
flushPrefs();
}
public static boolean getAutoConnectKeepAlive() {
return prefs.getBoolean("autoConnectKeepAlive", true);
}
public static void setAutoConnectKeepAlive(boolean keepAlive) {
prefs.putBoolean("autoConnectKeepAlive", keepAlive);
flushPrefs();
}
public static int getAutoConnectKeepAliveInterval() {
return prefs.getInt("autoConnectKeepAliveInterval", 120);
}
public static void setAutoConnectKeepAliveInterval(int interval) {
prefs.putInt("autoConnectKeepAliveInterval", interval);
flushPrefs();
}
public static boolean getAutoConnectAutoReconnect() {
return prefs.getBoolean("autoConnectAutoReconnect", false);
}
public static void setAutoConnectAutoReconnect(boolean autoReconnect) {
prefs.putBoolean("autoConnectAutoReconnect", autoReconnect);
flushPrefs();
}
public static int getAutoConnectReconnectMaxRetries() {
return prefs.getInt("autoConnectReconnectMaxRetries", 5);
}
public static void setAutoConnectReconnectMaxRetries(int retries) {
prefs.putInt("autoConnectReconnectMaxRetries", retries);
flushPrefs();
}
public static haus.nightmare.lib3270j.graphics.GraphicsMode getGraphicsMode() {
String modeStr = prefs.get("graphicsMode", haus.nightmare.lib3270j.graphics.GraphicsMode.BOTH.name());
return haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(modeStr);
@@ -311,6 +356,26 @@ public class Settings {
prefs.putBoolean("blockSelectMode", block);
}
// ========== Clipboard & Tabular Paste ==========
public static boolean getEnablePasteFromExcel() {
return prefs.getBoolean("enablePasteFromExcel", true);
}
public static void setEnablePasteFromExcel(boolean val) {
prefs.putBoolean("enablePasteFromExcel", val);
flushPrefs();
}
public static boolean getPasteStopAtProtectedLine() {
return prefs.getBoolean("pasteStopAtProtectedLine", false);
}
public static void setPasteStopAtProtectedLine(boolean val) {
prefs.putBoolean("pasteStopAtProtectedLine", val);
flushPrefs();
}
// ========== Crosshair Ruler ==========
public static boolean getCrosshairRuler() {
@@ -509,11 +574,42 @@ public class Settings {
setDynamicCols(Integer.parseInt(value));
break;
case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break;
case "autoSysUnlock":
case "auto_sys_unlock":
setAutoSysUnlock(Boolean.parseBoolean(value));
break;
case "enablePasteFromExcel":
case "pasteFromExcel":
case "excelPaste":
setEnablePasteFromExcel(Boolean.parseBoolean(value));
break;
case "pasteStopAtProtectedLine":
case "stopAtProtected":
case "pasteStopAtProtected":
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown behavior/connection key: " + key);
}
break;
case "clipboard":
switch (key) {
case "enablePasteFromExcel":
case "pasteFromExcel":
case "excelPaste":
setEnablePasteFromExcel(Boolean.parseBoolean(value));
break;
case "pasteStopAtProtectedLine":
case "stopAtProtected":
case "pasteStopAtProtected":
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown clipboard key: " + key);
}
break;
case "colors":
if (key.startsWith("color_")) {
int index = Integer.parseInt(key.substring(6));
@@ -633,6 +729,9 @@ public class Settings {
w.println("dynamicRows = " + getDynamicRows());
w.println("dynamicCols = " + getDynamicCols());
w.println("blockSelectMode = " + getBlockSelectMode());
w.println("autoSysUnlock = " + getAutoSysUnlock());
w.println("enablePasteFromExcel = " + getEnablePasteFromExcel());
w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine());
w.println();
// [entryassist]
@@ -0,0 +1,88 @@
package haus.nightmare.j3270.ui;
import haus.nightmare.lib3270j.graphics.DefaultPixelBuffer;
import haus.nightmare.lib3270j.graphics.PixelBuffer;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBuffer;
import java.awt.image.DataBufferInt;
import java.awt.image.DirectColorModel;
import java.awt.image.Raster;
import java.awt.image.SinglePixelPackedSampleModel;
import java.awt.image.WritableRaster;
/**
* Desktop Swing/AWT bridge for converting between lib3270j PixelBuffer and java.awt BufferedImage.
*/
public final class AwtPixelBufferBridge {
private AwtPixelBufferBridge() {}
/**
* Converts a PixelBuffer to a BufferedImage.
* Uses TYPE_INT_ARGB with backed raster for optimal Java2D hardware acceleration.
*/
public static BufferedImage toBufferedImage(PixelBuffer buffer) {
if (buffer == null) return null;
int w = buffer.getWidth();
int h = buffer.getHeight();
if (w <= 0 || h <= 0) {
return new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
}
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
copyIntoBufferedImage(buffer, img);
return img;
}
/**
* Copies pixels from a PixelBuffer directly into an existing BufferedImage.
*/
public static void copyIntoBufferedImage(PixelBuffer buffer, BufferedImage target) {
if (buffer == null || target == null) return;
int w = Math.min(buffer.getWidth(), target.getWidth());
int h = Math.min(buffer.getHeight(), target.getHeight());
if (w <= 0 || h <= 0) return;
int[] srcPixels = buffer.getPixels();
if (srcPixels == null) return;
if (target.getType() == BufferedImage.TYPE_INT_ARGB || target.getType() == BufferedImage.TYPE_INT_RGB) {
if (target.getRaster().getDataBuffer() instanceof DataBufferInt) {
int[] dstPixels = ((DataBufferInt) target.getRaster().getDataBuffer()).getData();
int srcW = buffer.getWidth();
int dstW = target.getWidth();
if (srcW == dstW && srcW == w && srcPixels.length >= w * h && dstPixels.length >= w * h) {
System.arraycopy(srcPixels, 0, dstPixels, 0, w * h);
return;
}
for (int y = 0; y < h; y++) {
System.arraycopy(srcPixels, y * srcW, dstPixels, y * dstW, w);
}
return;
}
}
// Fallback for custom or incompatible image formats
target.setRGB(0, 0, w, h, srcPixels, 0, buffer.getWidth());
}
/**
* Wraps or converts a java.awt BufferedImage into a PixelBuffer.
*/
public static PixelBuffer toPixelBuffer(BufferedImage img) {
if (img == null) return null;
int w = img.getWidth();
int h = img.getHeight();
int[] pixels;
if ((img.getType() == BufferedImage.TYPE_INT_ARGB || img.getType() == BufferedImage.TYPE_INT_RGB)
&& img.getRaster().getDataBuffer() instanceof DataBufferInt) {
pixels = ((DataBufferInt) img.getRaster().getDataBuffer()).getData();
} else {
pixels = new int[w * h];
img.getRGB(0, 0, w, h, pixels, 0, w);
}
return new DefaultPixelBuffer(w, h, pixels);
}
}
@@ -24,6 +24,8 @@ public class ConnectDialog extends JDialog {
private JCheckBox tlsCheckBox;
private JCheckBox verifyCertCheckBox;
private JCheckBox tn3270eCheckBox;
private JCheckBox keepAliveCheckBox;
private JCheckBox autoReconnectCheckBox;
private boolean confirmed;
private ConnectionConfig result;
@@ -246,6 +248,24 @@ public class ConnectDialog extends JDialog {
tn3270eCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e());
mainPanel.add(tn3270eCheckBox, gbc);
// Keep-Alive Checkbox
gbc.gridx = 1;
gbc.gridy = 10;
keepAliveCheckBox = new JCheckBox("Enable Keep-Alive Heartbeat (NOP)");
ThemeManager.styleCheckBox(keepAliveCheckBox);
keepAliveCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
keepAliveCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAlive());
mainPanel.add(keepAliveCheckBox, gbc);
// Auto-Reconnect Checkbox
gbc.gridx = 1;
gbc.gridy = 11;
autoReconnectCheckBox = new JCheckBox("Auto-Reconnect on Disconnect");
ThemeManager.styleCheckBox(autoReconnectCheckBox);
autoReconnectCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
autoReconnectCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect());
mainPanel.add(autoReconnectCheckBox, gbc);
// Buttons
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4));
@@ -266,7 +286,7 @@ public class ConnectDialog extends JDialog {
buttonPanel.add(connectBtn);
gbc.gridx = 0;
gbc.gridy = 10;
gbc.gridy = 12;
gbc.gridwidth = 2;
mainPanel.add(buttonPanel, gbc);
@@ -328,6 +348,18 @@ public class ConnectDialog extends JDialog {
result.setUseTls(tlsCheckBox.isSelected());
result.setTlsVerifyCert(verifyCertCheckBox.isSelected());
result.setTn3270eEnabled(tn3270eCheckBox.isSelected());
result.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
boolean ka = keepAliveCheckBox.isSelected();
result.setKeepAliveEnabled(ka);
result.setKeepAliveIntervalSeconds(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAliveInterval());
haus.nightmare.j3270.config.Settings.setAutoConnectKeepAlive(ka);
boolean ar = autoReconnectCheckBox.isSelected();
result.setAutoReconnect(ar);
result.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries());
haus.nightmare.j3270.config.Settings.setAutoConnectAutoReconnect(ar);
confirmed = true;
dispose();
}
@@ -0,0 +1,427 @@
package haus.nightmare.j3270.ui;
import java.io.*;
import java.util.*;
import java.util.logging.Logger;
/**
* Keyboard mapping profile (.kmp) file importer and keybinding utility.
* Parses keyboard mapping profile files, translates key scan codes and mnemonics
* to j3270 action handlers.
*/
public class KeyBindings {
private static final Logger log = Logger.getLogger(KeyBindings.class.getName());
// IBM scan code to standard Java KeyStroke key name mapping
private static final Map<Integer, String> SCAN_CODE_MAP = new HashMap<>();
// Mnemonic to j3270 action mapping
private static final Map<String, String> MNEMONIC_MAP = new HashMap<>();
static {
// Alphanumeric and symbol keys
SCAN_CODE_MAP.put(1, "BACK_QUOTE");
SCAN_CODE_MAP.put(2, "1");
SCAN_CODE_MAP.put(3, "2");
SCAN_CODE_MAP.put(4, "3");
SCAN_CODE_MAP.put(5, "4");
SCAN_CODE_MAP.put(6, "5");
SCAN_CODE_MAP.put(7, "6");
SCAN_CODE_MAP.put(8, "7");
SCAN_CODE_MAP.put(9, "8");
SCAN_CODE_MAP.put(10, "9");
SCAN_CODE_MAP.put(11, "0");
SCAN_CODE_MAP.put(12, "MINUS");
SCAN_CODE_MAP.put(13, "EQUALS");
SCAN_CODE_MAP.put(14, "BACK_SPACE");
SCAN_CODE_MAP.put(15, "TAB");
SCAN_CODE_MAP.put(16, "Q");
SCAN_CODE_MAP.put(17, "W");
SCAN_CODE_MAP.put(18, "E");
SCAN_CODE_MAP.put(19, "R");
SCAN_CODE_MAP.put(20, "T");
SCAN_CODE_MAP.put(21, "Y");
SCAN_CODE_MAP.put(22, "U");
SCAN_CODE_MAP.put(23, "I");
SCAN_CODE_MAP.put(24, "O");
SCAN_CODE_MAP.put(25, "P");
SCAN_CODE_MAP.put(26, "OPEN_BRACKET");
SCAN_CODE_MAP.put(27, "CLOSE_BRACKET");
SCAN_CODE_MAP.put(28, "BACK_SLASH");
SCAN_CODE_MAP.put(29, "CAPS_LOCK");
SCAN_CODE_MAP.put(30, "A");
SCAN_CODE_MAP.put(31, "S");
SCAN_CODE_MAP.put(32, "D");
SCAN_CODE_MAP.put(33, "F");
SCAN_CODE_MAP.put(34, "G");
SCAN_CODE_MAP.put(35, "H");
SCAN_CODE_MAP.put(36, "J");
SCAN_CODE_MAP.put(37, "K");
SCAN_CODE_MAP.put(38, "L");
SCAN_CODE_MAP.put(39, "SEMICOLON");
SCAN_CODE_MAP.put(40, "QUOTE");
SCAN_CODE_MAP.put(41, "BACK_QUOTE");
SCAN_CODE_MAP.put(42, "ENTER");
SCAN_CODE_MAP.put(43, "ENTER");
SCAN_CODE_MAP.put(45, "Z");
SCAN_CODE_MAP.put(46, "X");
SCAN_CODE_MAP.put(47, "C");
SCAN_CODE_MAP.put(48, "V");
SCAN_CODE_MAP.put(49, "B");
SCAN_CODE_MAP.put(50, "N");
SCAN_CODE_MAP.put(51, "M");
SCAN_CODE_MAP.put(52, "COMMA");
SCAN_CODE_MAP.put(53, "PERIOD");
SCAN_CODE_MAP.put(54, "SLASH");
SCAN_CODE_MAP.put(57, "SPACE");
// Editing & Navigation keys
SCAN_CODE_MAP.put(75, "INSERT");
SCAN_CODE_MAP.put(76, "DELETE");
SCAN_CODE_MAP.put(79, "LEFT");
SCAN_CODE_MAP.put(80, "HOME");
SCAN_CODE_MAP.put(81, "END");
SCAN_CODE_MAP.put(83, "UP");
SCAN_CODE_MAP.put(84, "DOWN");
SCAN_CODE_MAP.put(85, "PAGE_UP");
SCAN_CODE_MAP.put(86, "PAGE_DOWN");
SCAN_CODE_MAP.put(89, "RIGHT");
// Numeric Keypad
SCAN_CODE_MAP.put(90, "NUM_LOCK");
SCAN_CODE_MAP.put(91, "NUMPAD7");
SCAN_CODE_MAP.put(92, "NUMPAD4");
SCAN_CODE_MAP.put(93, "NUMPAD1");
SCAN_CODE_MAP.put(95, "DIVIDE");
SCAN_CODE_MAP.put(96, "NUMPAD8");
SCAN_CODE_MAP.put(97, "NUMPAD5");
SCAN_CODE_MAP.put(98, "NUMPAD2");
SCAN_CODE_MAP.put(99, "NUMPAD0");
SCAN_CODE_MAP.put(100, "MULTIPLY");
SCAN_CODE_MAP.put(101, "NUMPAD9");
SCAN_CODE_MAP.put(102, "NUMPAD6");
SCAN_CODE_MAP.put(103, "NUMPAD3");
SCAN_CODE_MAP.put(104, "DECIMAL");
SCAN_CODE_MAP.put(105, "SUBTRACT");
SCAN_CODE_MAP.put(106, "ADD");
SCAN_CODE_MAP.put(108, "ENTER");
// Function keys & Escape
SCAN_CODE_MAP.put(110, "ESCAPE");
SCAN_CODE_MAP.put(112, "F1");
SCAN_CODE_MAP.put(113, "F2");
SCAN_CODE_MAP.put(114, "F3");
SCAN_CODE_MAP.put(115, "F4");
SCAN_CODE_MAP.put(116, "F5");
SCAN_CODE_MAP.put(117, "F6");
SCAN_CODE_MAP.put(118, "F7");
SCAN_CODE_MAP.put(119, "F8");
SCAN_CODE_MAP.put(120, "F9");
SCAN_CODE_MAP.put(121, "F10");
SCAN_CODE_MAP.put(122, "F11");
SCAN_CODE_MAP.put(123, "F12");
// Mnemonics mapping
MNEMONIC_MAP.put("enter", "ENTER");
MNEMONIC_MAP.put("enterreset", "ENTER");
MNEMONIC_MAP.put("newline", "NEWLINE");
MNEMONIC_MAP.put("tab", "TAB");
MNEMONIC_MAP.put("backtab", "shift TAB");
MNEMONIC_MAP.put("reset", "ESCAPE");
MNEMONIC_MAP.put("clear", "CLEAR");
MNEMONIC_MAP.put("eraseeof", "END");
MNEMONIC_MAP.put("eof", "END");
MNEMONIC_MAP.put("erasefld", "ERASE_INPUT");
MNEMONIC_MAP.put("erinp", "ERASE_INPUT");
MNEMONIC_MAP.put("eraseinput", "ERASE_INPUT");
MNEMONIC_MAP.put("dup", "DUP");
MNEMONIC_MAP.put("fieldmark", "FIELD_MARK");
MNEMONIC_MAP.put("fldmark", "FIELD_MARK");
MNEMONIC_MAP.put("fldext", "NEWLINE");
MNEMONIC_MAP.put("field-exit", "NEWLINE");
MNEMONIC_MAP.put("field+", "TAB");
MNEMONIC_MAP.put("field-", "TAB");
MNEMONIC_MAP.put("attn", "ATTN");
MNEMONIC_MAP.put("sysreq", "SYSREQ");
MNEMONIC_MAP.put("cursel", "CURSEL");
MNEMONIC_MAP.put("copy", "COPY");
MNEMONIC_MAP.put("paste", "PASTE");
MNEMONIC_MAP.put("selectall", "SELECTALL");
MNEMONIC_MAP.put("insert", "INSERT");
MNEMONIC_MAP.put("delete", "DELETE");
MNEMONIC_MAP.put("backspace", "BACK_SPACE");
MNEMONIC_MAP.put("home", "HOME");
MNEMONIC_MAP.put("end", "END");
MNEMONIC_MAP.put("up", "UP");
MNEMONIC_MAP.put("down", "DOWN");
MNEMONIC_MAP.put("left", "LEFT");
MNEMONIC_MAP.put("right", "RIGHT");
MNEMONIC_MAP.put("pageup", "PAGE_UP");
MNEMONIC_MAP.put("pagedn", "PAGE_DOWN");
MNEMONIC_MAP.put("pagedown", "PAGE_DOWN");
MNEMONIC_MAP.put("docmode", "DOCMODE");
MNEMONIC_MAP.put("wordwrap", "WORDWRAP");
MNEMONIC_MAP.put("apl", "APL");
MNEMONIC_MAP.put("rule", "CROSSHAIR_RULER");
MNEMONIC_MAP.put("statusbar", "STATUS_BAR");
for (int i = 1; i <= 24; i++) {
MNEMONIC_MAP.put("pf" + i, "PF" + i);
}
for (int i = 1; i <= 3; i++) {
MNEMONIC_MAP.put("pa" + i, "PA" + i);
}
// Shifted PF keys 1-12 = PF13-PF24
for (int i = 1; i <= 12; i++) {
MNEMONIC_MAP.put("spf" + i, "PF" + (i + 12));
}
}
/**
* Parse a .kmp file from a File object.
*
* @param file the .kmp file to parse
* @return Map of j3270 action names to key stroke bindings
* @throws IOException on I/O error
*/
public static Map<String, String> parseKmp(File file) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
return parseKmp(reader);
}
}
/**
* Parse a .kmp file from an InputStream.
*
* @param in the InputStream to parse
* @return Map of j3270 action names to key stroke bindings
* @throws IOException on I/O error
*/
public static Map<String, String> parseKmp(InputStream in) throws IOException {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
return parseKmp(reader);
}
}
/**
* Parse a .kmp file from a Reader.
* Supports both INI-style KEY<n>=action formats and bind statements.
*
* @param reader the reader to parse from
* @return Map of j3270 action names to key stroke bindings
* @throws IOException on I/O error
*/
public static Map<String, String> parseKmp(Reader reader) throws IOException {
BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader);
Map<String, List<String>> actionToBindings = new LinkedHashMap<>();
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
// Strip comments
int commentIdx = line.indexOf(';');
if (commentIdx >= 0) {
line = line.substring(0, commentIdx).trim();
}
commentIdx = line.indexOf('#');
if (commentIdx >= 0) {
line = line.substring(0, commentIdx).trim();
}
if (line.isEmpty() || line.startsWith("[")) {
continue; // Skip section headers and blank lines
}
// Syntax 1: bind [mnemonic] KeyCombination OR bind action KeyCombination
if (line.toLowerCase().startsWith("bind ")) {
parseBindStatement(line.substring(5).trim(), actionToBindings);
continue;
}
// Syntax 2: KEY<n>=action OR Modifier-KEY<n>=action OR KeyStroke=[mnemonic]
int eq = line.indexOf('=');
if (eq > 0) {
String left = line.substring(0, eq).trim();
String right = line.substring(eq + 1).trim();
parseKeyAssignment(left, right, actionToBindings);
}
}
// Format into combined comma-separated bindings map
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : actionToBindings.entrySet()) {
String action = entry.getKey();
List<String> bindings = entry.getValue();
if (!bindings.isEmpty()) {
result.put(action, String.join(", ", bindings));
}
}
return result;
}
private static void parseBindStatement(String stmt, Map<String, List<String>> actionToBindings) {
String[] parts = stmt.split("\\s+", 2);
if (parts.length < 2) return;
String target = parts[0].trim();
String keyStrokeStr = parts[1].trim();
String action = resolveMnemonic(target);
if (action == null) {
// Check if parts[1] was the mnemonic instead (e.g. "bind KeyCombination [mnemonic]")
String altAction = resolveMnemonic(keyStrokeStr);
if (altAction != null) {
action = altAction;
keyStrokeStr = target;
}
}
if (action != null) {
String normKeyStroke = normalizeKeyStroke(keyStrokeStr);
if (normKeyStroke != null && !normKeyStroke.isEmpty()) {
addBinding(actionToBindings, action, normKeyStroke);
}
}
}
private static void parseKeyAssignment(String left, String right, Map<String, List<String>> actionToBindings) {
// Case A: left is KEY<n> or Modifier-KEY<n>, right is mnemonic
String action = resolveMnemonic(right);
String keyStroke = null;
if (action != null) {
keyStroke = parseScanCodeEntry(left);
} else {
// Case B: left is mnemonic, right is KeyStroke
action = resolveMnemonic(left);
if (action != null) {
keyStroke = normalizeKeyStroke(right);
} else {
// Case C: left is KeyStroke, right is mnemonic
action = resolveMnemonic(right);
if (action != null) {
keyStroke = normalizeKeyStroke(left);
}
}
}
if (action != null && keyStroke != null && !keyStroke.isEmpty()) {
addBinding(actionToBindings, action, keyStroke);
}
}
/**
* Parse scan code entry like "KEY43", "S-KEY112", "C-KEY43", "CS-KEY85", etc.
*/
public static String parseScanCodeEntry(String entry) {
if (entry == null || entry.isEmpty()) return null;
String upper = entry.toUpperCase().trim();
int keyPos = upper.indexOf("KEY");
if (keyPos < 0) {
return normalizeKeyStroke(entry);
}
String prefix = upper.substring(0, keyPos);
if (prefix.endsWith("-")) {
prefix = prefix.substring(0, prefix.length() - 1);
}
String numPart = upper.substring(keyPos + 3);
int scanCode;
try {
scanCode = Integer.parseInt(numPart.trim());
} catch (NumberFormatException e) {
return null;
}
String baseKey = SCAN_CODE_MAP.get(scanCode);
if (baseKey == null) {
return null;
}
StringBuilder sb = new StringBuilder();
if (prefix.contains("C")) sb.append("ctrl ");
if (prefix.contains("A") || prefix.contains("2")) sb.append("alt ");
if (prefix.contains("S")) sb.append("shift ");
if (prefix.contains("M")) sb.append("meta ");
sb.append(baseKey);
return sb.toString().trim();
}
/**
* Normalize key stroke expression (e.g. "Ctrl+Shift+C" -> "ctrl shift C").
*/
public static String normalizeKeyStroke(String stroke) {
if (stroke == null || stroke.trim().isEmpty()) return null;
String s = stroke.trim();
// Check if it is an IBM scan code
if (s.toUpperCase().contains("KEY")) {
String fromScan = parseScanCodeEntry(s);
if (fromScan != null) return fromScan;
}
// Replace '+' with space
s = s.replace("+", " ");
String[] parts = s.split("\\s+");
StringBuilder mods = new StringBuilder();
String mainKey = null;
for (String p : parts) {
String lp = p.toLowerCase();
if ("ctrl".equals(lp) || "control".equals(lp)) {
mods.append("ctrl ");
} else if ("shift".equals(lp)) {
mods.append("shift ");
} else if ("alt".equals(lp)) {
mods.append("alt ");
} else if ("meta".equals(lp) || "cmd".equals(lp) || "command".equals(lp)) {
mods.append("meta ");
} else {
mainKey = p.toUpperCase();
}
}
if (mainKey == null) return null;
return (mods.toString() + mainKey).trim();
}
/**
* Resolve a mnemonic token (e.g. "[enter]", "enter", "[pf12]", "eraseeof")
* into a j3270 action name.
*/
public static String resolveMnemonic(String token) {
if (token == null) return null;
String clean = token.trim();
if (clean.startsWith("[") && clean.endsWith("]")) {
clean = clean.substring(1, clean.length() - 1);
}
clean = clean.toLowerCase();
String direct = MNEMONIC_MAP.get(clean);
if (direct != null) {
return direct;
}
// Case-insensitive match on existing action names
String upper = clean.toUpperCase();
if (MNEMONIC_MAP.containsValue(upper)) {
return upper;
}
return null;
}
private static void addBinding(Map<String, List<String>> map, String action, String keyStroke) {
List<String> list = map.computeIfAbsent(action, k -> new ArrayList<>());
if (!list.contains(keyStroke)) {
list.add(keyStroke);
}
}
}
@@ -7,6 +7,7 @@ import haus.nightmare.j3270.config.Settings;
import java.awt.*;
import java.awt.event.*;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import javax.swing.table.DefaultTableModel;
@@ -33,6 +34,8 @@ public class SettingsDialog extends JDialog {
private JCheckBox blockSelectCheck;
private JSpinner dynamicRowsSpinner;
private JSpinner dynamicColsSpinner;
private JCheckBox enablePasteFromExcelCheck;
private JCheckBox pasteStopAtProtectedCheck;
// Entry Assist & Modes tab
private JCheckBox docModeCheck;
@@ -291,10 +294,18 @@ public class SettingsDialog extends JDialog {
gbc.gridx = 1;
panel.add(dynDimPanel, gbc);
// Placeholder for potentially more behavior options below
// Clipboard & Tabular Paste options
gbc.gridx = 0;
gbc.gridy = 4;
gbc.gridwidth = 2;
enablePasteFromExcelCheck = new JCheckBox("Enable Excel / Tabular Paste (advance with tabs & newlines)", Settings.getEnablePasteFromExcel());
panel.add(enablePasteFromExcelCheck, gbc);
gbc.gridy = 5;
pasteStopAtProtectedCheck = new JCheckBox("Stop Paste at Protected Boundary", Settings.getPasteStopAtProtectedLine());
panel.add(pasteStopAtProtectedCheck, gbc);
gbc.gridy = 6;
gbc.weighty = 1.0;
panel.add(Box.createGlue(), gbc);
@@ -653,6 +664,45 @@ public class SettingsDialog extends JDialog {
});
btnPanel.add(btnReset);
// Import Keymap — import from .kmp file
JButton btnImportKmp = new JButton("Import Keymap...");
ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT);
btnImportKmp.addActionListener(e -> {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Import Keymap Profile");
chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("Keyboard Map Profile (*.kmp)", "kmp"));
int ret = chooser.showOpenDialog(this);
if (ret == JFileChooser.APPROVE_OPTION) {
File f = chooser.getSelectedFile();
try {
Map<String, String> imported = KeyBindings.parseKmp(f);
int updatedCount = 0;
for (Map.Entry<String, String> entry : imported.entrySet()) {
String action = entry.getKey();
String binding = entry.getValue();
tempKeyBindings.put(action, binding);
for (int row = 0; row < keymapModel.getRowCount(); row++) {
if (action.equalsIgnoreCase((String) keymapModel.getValueAt(row, 0))) {
keymapModel.setValueAt(binding, row, 1);
updatedCount++;
break;
}
}
}
JOptionPane.showMessageDialog(this,
"Successfully imported " + updatedCount + " key bindings from " + f.getName(),
"Keymap Imported",
JOptionPane.INFORMATION_MESSAGE);
} catch (Exception ex) {
JOptionPane.showMessageDialog(this,
"Failed to import keymap: " + ex.getMessage(),
"Import Error",
JOptionPane.ERROR_MESSAGE);
}
}
});
btnPanel.add(btnImportKmp);
main.add(new JScrollPane(table), BorderLayout.CENTER);
main.add(btnPanel, BorderLayout.SOUTH);
@@ -705,6 +755,14 @@ public class SettingsDialog extends JDialog {
// Block select mode
Settings.setBlockSelectMode(blockSelectCheck.isSelected());
// Clipboard & Tabular Paste options
if (enablePasteFromExcelCheck != null) {
Settings.setEnablePasteFromExcel(enablePasteFromExcelCheck.isSelected());
}
if (pasteStopAtProtectedCheck != null) {
Settings.setPasteStopAtProtectedLine(pasteStopAtProtectedCheck.isSelected());
}
// Default Dynamic screen dimensions
if (dynamicRowsSpinner != null && dynamicColsSpinner != null) {
Settings.setDynamicRows((Integer) dynamicRowsSpinner.getValue());
@@ -137,12 +137,20 @@ public class StatusBar extends JPanel {
}
public void applyTheme(UITheme theme) {
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(() -> applyTheme(theme));
return;
}
setBackground(ThemeManager.getStatusBarBg(theme));
setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, ThemeManager.getStatusBarBorder(theme)));
updateStatus();
}
public void updateStatus() {
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(this::updateStatus);
return;
}
UITheme theme = ThemeManager.getTheme();
if (client == null) {
connectionStatus.setText("Not Connected");
@@ -170,6 +178,10 @@ public class StatusBar extends JPanel {
connectionStatus.setText("Not Connected");
connectionStatus.setForeground(ThemeManager.getOiaFgDim(theme));
break;
case RECONNECTING:
connectionStatus.setText("Reconnecting...");
connectionStatus.setForeground(ThemeManager.getOiaFgAlert(theme));
break;
case TCP_PENDING:
case TELNET_PENDING:
connectionStatus.setText("Connecting...");
@@ -494,8 +494,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
int cols = sb != null ? sb.getDisplayCols() : 80;
int curRow = cols > 0 ? curPos / cols : 0;
int curCol = cols > 0 ? curPos % cols : 0;
boolean excelPaste = haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel();
boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine();
if (client.getPS() != null && (text.contains("\n") || text.contains("\r"))) {
if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || text.contains("\t") || text.contains("\n") || text.contains("\r"))) {
client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected);
} else if (client.getPS() != null) {
client.getPS().pasteString(text, curRow, curCol);
} else {
for (char ch : text.toCharArray()) {
@@ -603,6 +607,10 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
public void guardedPack() {
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(this::guardedPack);
return;
}
resizeGuard = true;
revalidate();
Container top = getTopLevelAncestor();
@@ -1163,10 +1171,19 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
if (ip != null) {
ip.setBellEnabled(haus.nightmare.j3270.config.Settings.getEntryAssistBell());
ip.setBellColumn(Math.max(0, haus.nightmare.j3270.config.Settings.getEntryAssistBellCol() - 1));
if (ip.getBellListener() == null) {
ip.setBellListener(() -> {
SwingUtilities.invokeLater(() -> Toolkit.getDefaultToolkit().beep());
});
}
ip.setInsertOffOnAid(haus.nightmare.j3270.config.Settings.getInsertOffOnAid());
ip.setNumericFieldLock(haus.nightmare.j3270.config.Settings.getNumericFieldLock());
ip.setAutoSkipEnabled(haus.nightmare.j3270.config.Settings.getAutoSkipEnabled());
}
if (client.getPS() != null) {
client.getPS().setEnablePasteFromExcel(haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel());
client.getPS().setPasteStopAtProtectedLine(haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine());
}
}
if (statusBar != null) {
statusBar.updateStatus();
@@ -1432,8 +1449,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
if (rgb != null && gWidth > 0 && gHeight > 0) {
long currentUpdateCount = client.getGraphicsPlane().getUpdateCount();
if (cachedGraphicsImage == null || currentUpdateCount != lastGraphicsUpdateCount || cachedGraphicsImage.getWidth() != gWidth || cachedGraphicsImage.getHeight() != gHeight) {
cachedGraphicsImage = new java.awt.image.BufferedImage(gWidth, gHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB);
cachedGraphicsImage.setRGB(0, 0, gWidth, gHeight, rgb, 0, gWidth);
cachedGraphicsImage = AwtPixelBufferBridge.toBufferedImage(client.getGraphicsPlane());
lastGraphicsUpdateCount = currentUpdateCount;
}
if (gWidth == gridW && gHeight == gridH) {
@@ -1548,7 +1564,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
haus.nightmare.lib3270j.graphics.ProgramSymbolSet.SymbolSlot slot = client.getProgramSymbolManager().getSymbol(cs, ea.ec & 0xFF);
if (slot != null) {
int symBg = (bgIsExplicit || reverse) ? bgColor.getRGB() : 0;
java.awt.image.BufferedImage img = slot.getScaledImage(cellWidth, cellHeight, fgColor.getRGB(), symBg);
haus.nightmare.lib3270j.graphics.PixelBuffer symPb = slot.getScaledPixelBuffer(cellWidth, cellHeight, fgColor.getRGB(), symBg);
java.awt.image.BufferedImage img = AwtPixelBufferBridge.toBufferedImage(symPb);
if (img != null) {
g2.drawImage(img, x, y, null);
drawnAsPs = true;
@@ -1691,7 +1708,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
public void setHodWallpaper(haus.nightmare.lib3270j.graphics.HODWallpaper wallpaper) {
this.hodWallpaper = wallpaper;
if (wallpaper != null) {
this.wallpaperImage = wallpaper.getHODImage();
Object hodImg = wallpaper.getHODImage();
if (hodImg instanceof Image) {
this.wallpaperImage = (Image) hodImg;
} else if (hodImg instanceof haus.nightmare.lib3270j.graphics.PixelBuffer) {
this.wallpaperImage = AwtPixelBufferBridge.toBufferedImage((haus.nightmare.lib3270j.graphics.PixelBuffer) hodImg);
}
}
repaint();
}
@@ -0,0 +1,105 @@
package haus.nightmare.j3270.ui;
import org.junit.jupiter.api.Test;
import java.io.StringReader;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for Phase 13.3: Keyboard Remap (.kmp) File Importer.
* Tests parsing of scan codes, modifier combinations, bind statements,
* mnemonic actions, and comments.
*/
public class KmpKeymapImportTest {
@Test
public void testParseScanCodeAssignments() throws Exception {
String kmpContent =
"# Sample keyboard profile mapping\n" +
"; IBM scan code mappings\n" +
"KEY43=[enter]\n" +
"S-KEY112=[pf13]\n" +
"C-KEY43=[newline]\n" +
"A-KEY110=[reset]\n" +
"KEY15=[tab]\n" +
"S-KEY15=[backtab]\n";
Map<String, String> imported = KeyBindings.parseKmp(new StringReader(kmpContent));
assertNotNull(imported);
// KEY43 is ENTER -> ENTER
assertEquals("ENTER", imported.get("ENTER"));
// S-KEY112 is Shift+F1 -> PF13
assertEquals("shift F1", imported.get("PF13"));
// C-KEY43 is Ctrl+ENTER -> NEWLINE
assertEquals("ctrl ENTER", imported.get("NEWLINE"));
// A-KEY110 is Alt+ESCAPE -> ESCAPE
assertEquals("alt ESCAPE", imported.get("ESCAPE"));
// KEY15 is TAB -> TAB
assertEquals("TAB", imported.get("TAB"));
// S-KEY15 is Shift+TAB -> shift TAB
assertEquals("shift TAB", imported.get("shift TAB"));
}
@Test
public void testParseBindStatements() throws Exception {
String kmpContent =
"bind [enter] Ctrl+Enter\n" +
"bind [pf1] F1\n" +
"bind [pf24] Shift+F12\n" +
"bind [eraseeof] End\n" +
"bind [clear] Pause\n";
Map<String, String> imported = KeyBindings.parseKmp(new StringReader(kmpContent));
assertNotNull(imported);
assertEquals("ctrl ENTER", imported.get("ENTER"));
assertEquals("F1", imported.get("PF1"));
assertEquals("shift F12", imported.get("PF24"));
assertEquals("END", imported.get("END"));
assertEquals("PAUSE", imported.get("CLEAR"));
}
@Test
public void testScanCodeWithMultipleModifiers() {
// C-S-KEY85 -> Ctrl+Shift+PAGE_UP
String res = KeyBindings.parseScanCodeEntry("CS-KEY85");
assertEquals("ctrl shift PAGE_UP", res);
// 2-KEY80 -> Alt+HOME (2- prefix is Alt in scan code syntax)
String altRes = KeyBindings.parseScanCodeEntry("2-KEY80");
assertEquals("alt HOME", altRes);
}
@Test
public void testMnemonicResolution() {
assertEquals("ENTER", KeyBindings.resolveMnemonic("[enter]"));
assertEquals("ENTER", KeyBindings.resolveMnemonic("enterreset"));
assertEquals("NEWLINE", KeyBindings.resolveMnemonic("[newline]"));
assertEquals("NEWLINE", KeyBindings.resolveMnemonic("[field-exit]"));
assertEquals("PF1", KeyBindings.resolveMnemonic("[pf1]"));
assertEquals("PF12", KeyBindings.resolveMnemonic("[pf12]"));
assertEquals("PF13", KeyBindings.resolveMnemonic("[spf1]"));
assertEquals("ESCAPE", KeyBindings.resolveMnemonic("[reset]"));
assertEquals("ERASE_INPUT", KeyBindings.resolveMnemonic("[erasefld]"));
assertEquals("DOCMODE", KeyBindings.resolveMnemonic("[docmode]"));
assertEquals("WORDWRAP", KeyBindings.resolveMnemonic("[wordwrap]"));
}
@Test
public void testCommentsAndBlankLinesIgnored() throws Exception {
String kmpContent =
"[ProfileHeader]\n" +
" \n" +
"# Full line comment\n" +
"; Another comment\n" +
"KEY42=[enter] ; inline comment\n" +
" \n";
Map<String, String> imported = KeyBindings.parseKmp(new StringReader(kmpContent));
assertEquals(1, imported.size());
assertEquals("ENTER", imported.get("ENTER"));
}
}