diff --git a/build_all.sh b/build_all.sh index aa7072f..522b0de 100755 --- a/build_all.sh +++ b/build_all.sh @@ -118,7 +118,7 @@ public class TestRunner { public static void main(String[] args) { LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() .selectors( - selectPackage("haus.nightmare.lib3270j") + selectPackage("haus.nightmare") ) .build(); diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index 0543ca9..bb0e177 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -53,6 +53,8 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate buildUI(); buildMenuBar(); + ThemeManager.addThemeChangeListener(this::onThemeChanged); + pack(); setLocationRelativeTo(null); setMinimumSize(new Dimension(640, 400)); @@ -97,10 +99,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate getContentPane().add(statusBar, BorderLayout.SOUTH); } + private void onThemeChanged(UITheme theme) { + buildMenuBar(); + statusBar.applyTheme(theme); + ThemeManager.applyThemeToWindow(this); + terminalPanel.repaint(); + } + private void buildMenuBar() { JMenuBar menuBar = new JMenuBar(); - menuBar.setBackground(new Color(30, 30, 30)); - menuBar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(50, 50, 50))); + ThemeManager.styleMenuBar(menuBar); int shortcutMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); @@ -145,6 +153,31 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate })); viewMenu.addSeparator(); + // UI Theme Submenu + JMenu themeMenu = createMenu("UI Theme"); + ButtonGroup themeGroup = new ButtonGroup(); + + JRadioButtonMenuItem darkThemeItem = new JRadioButtonMenuItem("Dark Mode", ThemeManager.isDark()); + ThemeManager.styleMenuItem(darkThemeItem); + darkThemeItem.addActionListener(e -> { + haus.nightmare.j3270.config.Settings.setJavaUiTheme(UITheme.DARK); + ThemeManager.setTheme(UITheme.DARK); + }); + themeGroup.add(darkThemeItem); + themeMenu.add(darkThemeItem); + + JRadioButtonMenuItem lightThemeItem = new JRadioButtonMenuItem("Light Mode", ThemeManager.isLight()); + ThemeManager.styleMenuItem(lightThemeItem); + lightThemeItem.addActionListener(e -> { + haus.nightmare.j3270.config.Settings.setJavaUiTheme(UITheme.LIGHT); + ThemeManager.setTheme(UITheme.LIGHT); + }); + themeGroup.add(lightThemeItem); + themeMenu.add(lightThemeItem); + + viewMenu.add(themeMenu); + viewMenu.addSeparator(); + // CodePage Submenu JMenu cpMenu = createMenu("Code Page"); String[] codePages = { @@ -174,8 +207,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate for (String cpItem : codePages) { String cpId = cpItem.split(" -")[0].trim(); JMenuItem cpMi = new JMenuItem(cpItem); - cpMi.setBackground(new Color(40, 40, 40)); - cpMi.setForeground(new Color(200, 200, 200)); + ThemeManager.styleMenuItem(cpMi); cpMi.addActionListener(e -> changeCodePage(cpId)); cpMenu.add(cpMi); } @@ -185,8 +217,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate JMenu gfxMenu = createMenu("Graphics Mode"); for (GraphicsMode gm : GraphicsMode.values()) { JMenuItem gmMi = new JMenuItem(gm.name()); - gmMi.setBackground(new Color(40, 40, 40)); - gmMi.setForeground(new Color(200, 200, 200)); + ThemeManager.styleMenuItem(gmMi); gmMi.addActionListener(e -> changeGraphicsMode(gm)); gfxMenu.add(gmMi); } @@ -257,14 +288,12 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate private JMenu createMenu(String name) { JMenu menu = new JMenu(name); - menu.setForeground(new Color(200, 200, 200)); - return menu; + return ThemeManager.styleMenu(menu); } private JMenuItem createMenuItem(String name, int mnemonic, Runnable action, boolean useAlt) { JMenuItem item = new JMenuItem(name); - item.setBackground(new Color(40, 40, 40)); - item.setForeground(new Color(200, 200, 200)); + ThemeManager.styleMenuItem(item); if (mnemonic > 0) { int modifier = useAlt ? KeyEvent.ALT_DOWN_MASK : Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); item.setAccelerator(KeyStroke.getKeyStroke(mnemonic, modifier)); @@ -693,9 +722,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate JTextArea area = new JTextArea(text); area.setEditable(false); area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); - area.setBackground(new Color(30, 30, 30)); - area.setForeground(new Color(200, 200, 200)); + ThemeManager.styleTextArea(area); JScrollPane sp = new JScrollPane(area); + ThemeManager.styleScrollPane(sp); sp.setPreferredSize(new Dimension(380, 430)); JOptionPane.showMessageDialog(this, sp, "Key Mappings", JOptionPane.INFORMATION_MESSAGE); } @@ -726,9 +755,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate JTextArea area = new JTextArea(text); area.setEditable(false); area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); - area.setBackground(new Color(30, 30, 30)); - area.setForeground(new Color(200, 200, 200)); + ThemeManager.styleTextArea(area); JScrollPane sp = new JScrollPane(area); + ThemeManager.styleScrollPane(sp); sp.setPreferredSize(new Dimension(440, 430)); JOptionPane.showMessageDialog(this, sp, "Mnemonic Keystroke Reference", JOptionPane.INFORMATION_MESSAGE); } @@ -758,45 +787,47 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate Boolean cliTn3270e = null; GraphicsMode cliGraphicsMode = null; String configFile = null; + java.util.List remainingArgs = new java.util.ArrayList<>(); for (int i = 0; i < args.length; i++) { - if ("--debug".equals(args[i]) || "-d".equals(args[i])) { + String arg = args[i]; + if ("-d".equals(arg) || "--debug".equals(arg)) { debug = true; - } else if ("--tls".equals(args[i]) || "--ssl".equals(args[i]) || "-s".equals(args[i])) { + } else if ("-s".equals(arg) || "--tls".equals(arg) || "--ssl".equals(arg)) { cliTls = true; - } else if ("--no-verify-cert".equals(args[i]) || "--insecure".equals(args[i]) || "-k".equals(args[i])) { + } else if ("--no-verify-cert".equals(arg) || "--insecure".equals(arg) || "-k".equals(arg)) { cliNoVerifyCert = true; - } else if ("--no-tn3270e".equals(args[i]) || "--plain-tn3270".equals(args[i]) || "--plain".equals(args[i]) || "-P".equals(args[i]) || "-p".equals(args[i]) || "--non-e".equals(args[i])) { + } else if ("--no-tn3270e".equals(arg) || "--plain-tn3270".equals(arg) || "--plain".equals(arg) || "-P".equals(arg) || "-p".equals(arg) || "--non-e".equals(arg)) { cliTn3270e = false; - } else if ("--tn3270e".equals(args[i])) { + } else if ("--tn3270e".equals(arg)) { cliTn3270e = true; - } else if (args[i].startsWith("--graphics=")) { - cliGraphicsMode = GraphicsMode.fromString(args[i].substring(11)); - } else if (("--graphics".equals(args[i]) || "-g".equals(args[i])) && i + 1 < args.length && !args[i + 1].startsWith("-")) { + } 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("-")) { cliGraphicsMode = GraphicsMode.fromString(args[++i]); - } else if ("--no-graphics".equals(args[i])) { + } else if ("--no-graphics".equals(arg)) { cliGraphicsMode = GraphicsMode.NONE; - } else if (("-c".equals(args[i]) || "--config".equals(args[i])) && i + 1 < args.length) { + } else if (("-c".equals(arg) || "--config".equals(arg)) && i + 1 < args.length) { configFile = args[++i]; + } else if (arg.startsWith("-")) { + System.err.println("Unknown option: " + arg); } else { - remainingArgs.add(args[i]); + remainingArgs.add(arg); } } - Level logLevel = debug ? Level.ALL : Level.INFO; + Level logLevel = debug ? Level.FINE : Level.INFO; + Logger globalRoot = Logger.getLogger(""); for (java.util.logging.Handler h : globalRoot.getHandlers()) { globalRoot.removeHandler(h); } - globalRoot.setLevel(Level.ALL); - java.util.logging.Filter appFilter = record -> { - String name = record.getLoggerName(); - return name != null && (name.startsWith("haus.nightmare.lib3270j") || name.startsWith("haus.nightmare.j3270")); - }; + java.util.logging.Filter appFilter = record -> record.getLoggerName() != null && + (record.getLoggerName().startsWith("haus.nightmare") || record.getLoggerName().startsWith("org.pubvm")); ConsoleHandler consoleHandler = new ConsoleHandler(); - consoleHandler.setLevel(logLevel); + consoleHandler.setLevel(Level.ALL); consoleHandler.setFormatter(new SimpleFormatter()); consoleHandler.setFilter(appFilter); globalRoot.addHandler(consoleHandler); @@ -837,6 +868,8 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate } catch (Exception e) { log.fine("Could not set system look and feel"); } + ThemeManager.applyUIManagerDefaults(haus.nightmare.j3270.config.Settings.getJavaUiTheme()); + ThemeManager.setTheme(haus.nightmare.j3270.config.Settings.getJavaUiTheme()); System.setProperty("apple.laf.useScreenMenuBar", "true"); System.setProperty("apple.awt.application.name", "j3270"); diff --git a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java index 036ba70..e751893 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java +++ b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java @@ -1,5 +1,6 @@ package haus.nightmare.j3270.config; +import haus.nightmare.j3270.ui.UITheme; import java.util.prefs.Preferences; import java.awt.Color; import java.io.*; @@ -17,6 +18,15 @@ public class Settings { AUTO_CONNECT } + public static UITheme getJavaUiTheme() { + String themeStr = prefs.get("javaUiTheme", UITheme.DARK.name()); + return UITheme.fromString(themeStr); + } + + public static void setJavaUiTheme(UITheme theme) { + prefs.put("javaUiTheme", (theme != null ? theme : UITheme.DARK).name()); + } + public static String getFontFamily() { return prefs.get("fontFamily", "Monospaced"); } @@ -229,6 +239,11 @@ public class Settings { switch (key) { case "fontFamily": setFontFamily(value); break; case "fontSize": setFontSize(Integer.parseInt(value)); break; + case "javaUiTheme": + case "theme": + case "uiTheme": + setJavaUiTheme(UITheme.fromString(value)); + break; default: log.warning("Unknown appearance key: " + key); } @@ -322,6 +337,7 @@ public class Settings { // [appearance] w.println("[appearance]"); + w.println("javaUiTheme = " + getJavaUiTheme().name()); w.println("fontFamily = " + getFontFamily()); w.println("fontSize = " + getFontSize()); w.println(); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java index 44b7453..f64620e 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java @@ -1,6 +1,7 @@ package haus.nightmare.j3270.ft; import haus.nightmare.j3270.ui.HostDirectoryDialog; +import haus.nightmare.j3270.ui.ThemeManager; import haus.nightmare.lib3270j.ft.FTConfig; import haus.nightmare.lib3270j.ft.FTConstants; @@ -88,6 +89,7 @@ public class FileTransferDialog extends JDialog { // Local File localFileField = new JTextField(20); browseLocalButton = new JButton("Browse..."); + ThemeManager.styleButton(browseLocalButton, ThemeManager.ButtonVariant.DEFAULT); browseLocalButton.addActionListener(e -> browseLocalFile()); JPanel localPanel = new JPanel(new BorderLayout(5, 0)); localPanel.setOpaque(false); @@ -98,6 +100,7 @@ public class FileTransferDialog extends JDialog { // Host File hostFileField = new JTextField(20); browseHostButton = new JButton("Browse Host..."); + ThemeManager.styleButton(browseHostButton, ThemeManager.ButtonVariant.DEFAULT); browseHostButton.addActionListener(e -> browseHostDirectory()); JPanel hostPanel = new JPanel(new BorderLayout(5, 0)); hostPanel.setOpaque(false); @@ -154,10 +157,7 @@ public class FileTransferDialog extends JDialog { // Host-specific options panel (TSO dataset allocation) JPanel hostOptsPanel = new JPanel(new GridLayout(2, 4, 5, 5)); hostOptsPanel.setOpaque(false); - hostOptsPanel.setBorder(BorderFactory.createTitledBorder( - BorderFactory.createLineBorder(new Color(60, 60, 60)), - "TSO Allocation Options (Send Only)")); - ((javax.swing.border.TitledBorder)hostOptsPanel.getBorder()).setTitleColor(new Color(180, 180, 180)); + hostOptsPanel.setBorder(ThemeManager.createTitledBorder("TSO Allocation Options (Send Only)")); hostOptsPanel.add(new JLabel("RECFM:")); recfmField = new JTextField(5); @@ -181,10 +181,7 @@ public class FileTransferDialog extends JDialog { // CMS options JPanel vmOptsPanel = new JPanel(new BorderLayout(5, 0)); vmOptsPanel.setOpaque(false); - vmOptsPanel.setBorder(BorderFactory.createTitledBorder( - BorderFactory.createLineBorder(new Color(60, 60, 60)), - "CMS Options")); - ((javax.swing.border.TitledBorder)vmOptsPanel.getBorder()).setTitleColor(new Color(180, 180, 180)); + vmOptsPanel.setBorder(ThemeManager.createTitledBorder("CMS Options")); optionsField = new JTextField(20); vmOptsPanel.add(new JLabel("Additional Options: "), BorderLayout.WEST); @@ -200,9 +197,11 @@ public class FileTransferDialog extends JDialog { buttonPanel.setOpaque(false); transferButton = new JButton("Start Transfer"); + ThemeManager.styleButton(transferButton, ThemeManager.ButtonVariant.PRIMARY); transferButton.addActionListener(e -> startTransfer()); cancelButton = new JButton("Close"); + ThemeManager.styleButton(cancelButton, ThemeManager.ButtonVariant.CANCEL); cancelButton.addActionListener(e -> dispose()); buttonPanel.add(cancelButton); @@ -212,7 +211,7 @@ public class FileTransferDialog extends JDialog { setContentPane(mainPanel); - applyTheme(mainPanel); + ThemeManager.applyThemeToWindow(this); updateOptionStates(); } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java index 0e700b9..f3a029c 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java @@ -34,24 +34,21 @@ public class ConnectDialog extends JDialog { private void buildUI() { JPanel mainPanel = new JPanel(new GridBagLayout()); mainPanel.setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); - mainPanel.setBackground(new Color(30, 30, 30)); GridBagConstraints gbc = new GridBagConstraints(); - gbc.insets = new Insets(4, 4, 4, 4); + gbc.insets = new Insets(5, 6, 5, 6); gbc.fill = GridBagConstraints.HORIZONTAL; - Font labelFont = new Font(Font.SANS_SERIF, Font.PLAIN, 14); - Color fg = new Color(200, 200, 200); + Font labelFont = new Font(Font.SANS_SERIF, Font.PLAIN, 13); // Host gbc.gridx = 0; gbc.gridy = 0; JLabel hostLabel = new JLabel("Host:"); - hostLabel.setForeground(fg); hostLabel.setFont(labelFont); mainPanel.add(hostLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; - hostField = createDarkField(20); + hostField = createField(20); mainPanel.add(hostField, gbc); // Port @@ -59,12 +56,11 @@ public class ConnectDialog extends JDialog { gbc.gridy = 1; gbc.weightx = 0; JLabel portLabel = new JLabel("Port:"); - portLabel.setForeground(fg); portLabel.setFont(labelFont); mainPanel.add(portLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; - portField = createDarkField(6); + portField = createField(6); portField.setText("23"); mainPanel.add(portField, gbc); @@ -73,16 +69,14 @@ public class ConnectDialog extends JDialog { gbc.gridy = 2; gbc.weightx = 0; JLabel modelLabel = new JLabel("Model:"); - modelLabel.setForeground(fg); modelLabel.setFont(labelFont); mainPanel.add(modelLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; modelCombo = new JComboBox<>(TerminalModel.values()); modelCombo.setSelectedItem(TerminalModel.IBM_3279_4); - modelCombo.setBackground(new Color(45, 45, 45)); - modelCombo.setForeground(fg); modelCombo.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + ThemeManager.styleComboBox(modelCombo); mainPanel.add(modelCombo, gbc); // LU Name @@ -90,12 +84,11 @@ public class ConnectDialog extends JDialog { gbc.gridy = 3; gbc.weightx = 0; JLabel luLabel = new JLabel("LU Name:"); - luLabel.setForeground(fg); luLabel.setFont(labelFont); mainPanel.add(luLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; - luField = createDarkField(12); + luField = createField(12); mainPanel.add(luField, gbc); // Graphics Mode @@ -103,16 +96,14 @@ public class ConnectDialog extends JDialog { gbc.gridy = 4; gbc.weightx = 0; JLabel graphicsLabel = new JLabel("Graphics:"); - graphicsLabel.setForeground(fg); graphicsLabel.setFont(labelFont); mainPanel.add(graphicsLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; graphicsCombo = new JComboBox<>(haus.nightmare.lib3270j.graphics.GraphicsMode.values()); graphicsCombo.setSelectedItem(haus.nightmare.j3270.config.Settings.getGraphicsMode()); - graphicsCombo.setBackground(new Color(45, 45, 45)); - graphicsCombo.setForeground(fg); graphicsCombo.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + ThemeManager.styleComboBox(graphicsCombo); mainPanel.add(graphicsCombo, gbc); // Code Page @@ -120,7 +111,6 @@ public class ConnectDialog extends JDialog { gbc.gridy = 5; gbc.weightx = 0; JLabel cpLabel = new JLabel("Code Page:"); - cpLabel.setForeground(fg); cpLabel.setFont(labelFont); mainPanel.add(cpLabel, gbc); gbc.gridx = 1; @@ -158,9 +148,8 @@ public class ConnectDialog extends JDialog { break; } } - codePageCombo.setBackground(new Color(45, 45, 45)); - codePageCombo.setForeground(fg); codePageCombo.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + ThemeManager.styleComboBox(codePageCombo); mainPanel.add(codePageCombo, gbc); // TLS / SSL Checkbox @@ -168,10 +157,8 @@ public class ConnectDialog extends JDialog { gbc.gridy = 6; gbc.weightx = 1.0; tlsCheckBox = new JCheckBox("Enable TLS/SSL"); - tlsCheckBox.setBackground(new Color(30, 30, 30)); - tlsCheckBox.setForeground(fg); + ThemeManager.styleCheckBox(tlsCheckBox); tlsCheckBox.setFont(labelFont); - tlsCheckBox.setFocusPainted(false); tlsCheckBox.addActionListener(e -> { boolean isTls = tlsCheckBox.isSelected(); verifyCertCheckBox.setEnabled(isTls); @@ -188,38 +175,31 @@ public class ConnectDialog extends JDialog { gbc.gridx = 1; gbc.gridy = 7; verifyCertCheckBox = new JCheckBox("Verify Server Certificate"); - verifyCertCheckBox.setBackground(new Color(30, 30, 30)); - verifyCertCheckBox.setForeground(new Color(160, 160, 160)); + ThemeManager.styleCheckBox(verifyCertCheckBox); verifyCertCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); verifyCertCheckBox.setSelected(true); verifyCertCheckBox.setEnabled(false); - verifyCertCheckBox.setFocusPainted(false); mainPanel.add(verifyCertCheckBox, gbc); // TN3270E Checkbox gbc.gridx = 1; gbc.gridy = 8; tn3270eCheckBox = new JCheckBox("Enable TN3270E (Extended 3270)"); - tn3270eCheckBox.setBackground(new Color(30, 30, 30)); - tn3270eCheckBox.setForeground(fg); + ThemeManager.styleCheckBox(tn3270eCheckBox); tn3270eCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); tn3270eCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e()); - tn3270eCheckBox.setFocusPainted(false); mainPanel.add(tn3270eCheckBox, gbc); // Buttons - JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); - buttonPanel.setBackground(new Color(30, 30, 30)); + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4)); JButton connectBtn = new JButton("Connect"); - connectBtn.setBackground(new Color(50, 120, 50)); - connectBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(connectBtn, ThemeManager.ButtonVariant.PRIMARY); connectBtn.setFont(labelFont); connectBtn.addActionListener(e -> onConnect()); JButton cancelBtn = new JButton("Cancel"); - cancelBtn.setBackground(new Color(60, 60, 60)); - cancelBtn.setForeground(fg); + ThemeManager.styleButton(cancelBtn, ThemeManager.ButtonVariant.CANCEL); cancelBtn.setFont(labelFont); cancelBtn.addActionListener(e -> { confirmed = false; @@ -235,20 +215,16 @@ public class ConnectDialog extends JDialog { mainPanel.add(buttonPanel, gbc); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); // Enter key triggers connect getRootPane().setDefaultButton(connectBtn); } - private JTextField createDarkField(int cols) { + private JTextField createField(int cols) { JTextField field = new JTextField(cols); - field.setBackground(new Color(45, 45, 45)); - field.setForeground(new Color(200, 200, 200)); - field.setCaretColor(new Color(200, 200, 200)); field.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); - field.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(new Color(60, 60, 60)), - BorderFactory.createEmptyBorder(4, 6, 4, 6))); + ThemeManager.styleTextField(field); return field; } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/FieldInspectorDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/FieldInspectorDialog.java index 44d55dd..4de1862 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/FieldInspectorDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/FieldInspectorDialog.java @@ -28,12 +28,6 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene private JTable table; private JLabel countLabel; - private static final Color DARK_BG = new Color(35, 35, 35); - private static final Color DARK_FIELD_BG = new Color(45, 45, 45); - private static final Color DARK_FG = new Color(220, 220, 220); - private static final Color DARK_BORDER = new Color(65, 65, 65); - private static final Color DARK_SELECTION = new Color(75, 110, 175); - public FieldInspectorDialog(Frame parent, Telnet3270Client client, TerminalPanel terminalPanel) { super(parent, "3270 Presentation Space Field Inspector", false); this.client = client; @@ -61,19 +55,16 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene private void buildUI() { JPanel mainPanel = new JPanel(new BorderLayout(8, 8)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); - mainPanel.setBackground(DARK_BG); // Top info bar JPanel topPanel = new JPanel(new BorderLayout()); topPanel.setOpaque(false); countLabel = new JLabel("0 fields detected on screen"); - countLabel.setForeground(DARK_FG); countLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 13)); topPanel.add(countLabel, BorderLayout.WEST); JButton refreshBtn = new JButton("Refresh"); - refreshBtn.setBackground(new Color(55, 55, 55)); - refreshBtn.setForeground(DARK_FG); + ThemeManager.styleButton(refreshBtn, ThemeManager.ButtonVariant.DEFAULT); refreshBtn.addActionListener(e -> refreshFields()); topPanel.add(refreshBtn, BorderLayout.EAST); mainPanel.add(topPanel, BorderLayout.NORTH); @@ -91,18 +82,12 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene }; table = new JTable(tableModel); - table.setBackground(DARK_FIELD_BG); - table.setForeground(DARK_FG); - table.setSelectionBackground(DARK_SELECTION); - table.setSelectionForeground(Color.WHITE); - table.setGridColor(DARK_BORDER); + ThemeManager.styleTable(table); table.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setRowHeight(20); JTableHeader header = table.getTableHeader(); - header.setBackground(new Color(50, 50, 50)); - header.setForeground(DARK_FG); header.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12)); // Column widths @@ -125,8 +110,7 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene }); JScrollPane scrollPane = new JScrollPane(table); - scrollPane.setBorder(new LineBorder(DARK_BORDER)); - scrollPane.getViewport().setBackground(DARK_FIELD_BG); + ThemeManager.styleScrollPane(scrollPane); mainPanel.add(scrollPane, BorderLayout.CENTER); // Bottom panel @@ -134,8 +118,7 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene bottomPanel.setOpaque(false); JButton jumpBtn = new JButton("Jump to Selected Field"); - jumpBtn.setBackground(new Color(50, 100, 160)); - jumpBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(jumpBtn, ThemeManager.ButtonVariant.PRIMARY); jumpBtn.addActionListener(e -> { int row = table.getSelectedRow(); if (row >= 0) { @@ -144,8 +127,7 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene }); JButton closeBtn = new JButton("Close"); - closeBtn.setBackground(new Color(55, 55, 55)); - closeBtn.setForeground(DARK_FG); + ThemeManager.styleButton(closeBtn, ThemeManager.ButtonVariant.CANCEL); closeBtn.addActionListener(e -> dispose()); bottomPanel.add(jumpBtn); @@ -153,6 +135,7 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene mainPanel.add(bottomPanel, BorderLayout.SOUTH); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); } public synchronized void refreshFields() { diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/FindDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/FindDialog.java index b8da128..0668f33 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/FindDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/FindDialog.java @@ -42,7 +42,6 @@ public class FindDialog extends JDialog { private void buildUI() { JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); mainPanel.setBorder(new EmptyBorder(12, 14, 12, 14)); - mainPanel.setBackground(new Color(35, 35, 35)); // Form JPanel formPanel = new JPanel(new GridBagLayout()); @@ -51,14 +50,12 @@ public class FindDialog extends JDialog { gbc.insets = new Insets(4, 4, 4, 4); gbc.fill = GridBagConstraints.HORIZONTAL; - Color fg = new Color(220, 220, 220); Font labelFont = new Font(Font.SANS_SERIF, Font.PLAIN, 13); // Search text gbc.gridx = 0; gbc.gridy = 0; JLabel findLabel = new JLabel("Find what:"); - findLabel.setForeground(fg); findLabel.setFont(labelFont); formPanel.add(findLabel, gbc); @@ -66,23 +63,16 @@ public class FindDialog extends JDialog { gbc.weightx = 1.0; searchField = new JTextField(20); searchField.setText(lastSearchText); - searchField.setBackground(new Color(50, 50, 50)); - searchField.setForeground(fg); - searchField.setCaretColor(fg); searchField.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); - searchField.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(new Color(70, 70, 70)), - BorderFactory.createEmptyBorder(3, 6, 3, 6))); + ThemeManager.styleTextField(searchField); formPanel.add(searchField, gbc); // Options gbc.gridx = 1; gbc.gridy = 1; matchCaseCheck = new JCheckBox("Match case", lastMatchCase); - matchCaseCheck.setForeground(fg); - matchCaseCheck.setOpaque(false); + ThemeManager.styleCheckBox(matchCaseCheck); matchCaseCheck.setFont(labelFont); - matchCaseCheck.setFocusPainted(false); formPanel.add(matchCaseCheck, gbc); // Direction @@ -91,16 +81,11 @@ public class FindDialog extends JDialog { JPanel dirPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); dirPanel.setOpaque(false); JLabel dirLabel = new JLabel("Direction: "); - dirLabel.setForeground(fg); dirLabel.setFont(labelFont); forwardRadio = new JRadioButton("Down", lastForward); backwardRadio = new JRadioButton("Up", !lastForward); - forwardRadio.setForeground(fg); - backwardRadio.setForeground(fg); - forwardRadio.setOpaque(false); - backwardRadio.setOpaque(false); - forwardRadio.setFocusPainted(false); - backwardRadio.setFocusPainted(false); + ThemeManager.styleRadioButton(forwardRadio); + ThemeManager.styleRadioButton(backwardRadio); ButtonGroup bg = new ButtonGroup(); bg.add(forwardRadio); @@ -116,7 +101,7 @@ public class FindDialog extends JDialog { gbc.gridy = 3; gbc.gridwidth = 2; statusLabel = new JLabel(" "); - statusLabel.setForeground(new Color(255, 120, 120)); + statusLabel.setForeground(ThemeManager.getOiaFgAlert()); statusLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); formPanel.add(statusLabel, gbc); @@ -127,14 +112,12 @@ public class FindDialog extends JDialog { buttonPanel.setOpaque(false); JButton findNextBtn = new JButton("Find Next"); - findNextBtn.setBackground(new Color(60, 63, 65)); - findNextBtn.setForeground(fg); + ThemeManager.styleButton(findNextBtn, ThemeManager.ButtonVariant.PRIMARY); findNextBtn.setFont(labelFont); findNextBtn.addActionListener(e -> findNext()); JButton closeBtn = new JButton("Close"); - closeBtn.setBackground(new Color(60, 63, 65)); - closeBtn.setForeground(fg); + ThemeManager.styleButton(closeBtn, ThemeManager.ButtonVariant.CANCEL); closeBtn.setFont(labelFont); closeBtn.addActionListener(e -> dispose()); @@ -144,6 +127,7 @@ public class FindDialog extends JDialog { mainPanel.add(buttonPanel, BorderLayout.SOUTH); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); getRootPane().setDefaultButton(findNextBtn); searchField.addKeyListener(new KeyAdapter() { diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java index b009864..2f462d0 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java @@ -49,9 +49,7 @@ public class HostDirectoryDialog extends JDialog { private void buildUI(String initialQuery) { JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); mainPanel.setBorder(new EmptyBorder(12, 12, 12, 12)); - mainPanel.setBackground(new Color(35, 35, 35)); - Color fg = new Color(220, 220, 220); Font font = new Font(Font.SANS_SERIF, Font.PLAIN, 13); // Top Query bar @@ -62,7 +60,6 @@ public class HostDirectoryDialog extends JDialog { gbc.fill = GridBagConstraints.HORIZONTAL; JLabel envLabel = new JLabel("System:"); - envLabel.setForeground(fg); envLabel.setFont(font); topPanel.add(envLabel, gbc); @@ -70,33 +67,25 @@ public class HostDirectoryDialog extends JDialog { hostTypeCombo = new JComboBox<>(new FTConfig.HostType[]{FTConfig.HostType.TSO, FTConfig.HostType.CMS}); if (initialHostType == FTConfig.HostType.CMS) hostTypeCombo.setSelectedItem(FTConfig.HostType.CMS); else hostTypeCombo.setSelectedItem(FTConfig.HostType.TSO); - hostTypeCombo.setBackground(new Color(50, 50, 50)); - hostTypeCombo.setForeground(fg); + ThemeManager.styleComboBox(hostTypeCombo); topPanel.add(hostTypeCombo, gbc); gbc.gridx = 2; JLabel queryLabel = new JLabel("Query Pattern / Text:"); - queryLabel.setForeground(fg); queryLabel.setFont(font); topPanel.add(queryLabel, gbc); gbc.gridx = 3; gbc.weightx = 1.0; queryField = new JTextField(initialQuery != null ? initialQuery : "", 16); - queryField.setBackground(new Color(50, 50, 50)); - queryField.setForeground(fg); - queryField.setCaretColor(fg); queryField.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); - queryField.setBorder(BorderFactory.createCompoundBorder( - new LineBorder(new Color(70, 70, 70)), - new EmptyBorder(3, 5, 3, 5))); + ThemeManager.styleTextField(queryField); topPanel.add(queryField, gbc); gbc.gridx = 4; gbc.weightx = 0; JButton parseBtn = new JButton("Query / Parse"); - parseBtn.setBackground(new Color(55, 55, 55)); - parseBtn.setForeground(fg); + ThemeManager.styleButton(parseBtn, ThemeManager.ButtonVariant.DEFAULT); parseBtn.setFont(font); parseBtn.addActionListener(e -> runQuery()); topPanel.add(parseBtn, gbc); @@ -106,18 +95,12 @@ public class HostDirectoryDialog extends JDialog { // Table tableModel = new DefaultTableModel(); table = new JTable(tableModel); - table.setBackground(new Color(45, 45, 45)); - table.setForeground(fg); - table.setSelectionBackground(new Color(75, 110, 175)); - table.setSelectionForeground(Color.WHITE); - table.setGridColor(new Color(65, 65, 65)); + ThemeManager.styleTable(table); table.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setRowHeight(20); JTableHeader header = table.getTableHeader(); - header.setBackground(new Color(50, 50, 50)); - header.setForeground(fg); header.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12)); table.addMouseListener(new MouseAdapter() { @@ -130,8 +113,7 @@ public class HostDirectoryDialog extends JDialog { }); JScrollPane scrollPane = new JScrollPane(table); - scrollPane.setBorder(new LineBorder(new Color(65, 65, 65))); - scrollPane.getViewport().setBackground(new Color(45, 45, 45)); + ThemeManager.styleScrollPane(scrollPane); mainPanel.add(scrollPane, BorderLayout.CENTER); // Bottom @@ -139,7 +121,7 @@ public class HostDirectoryDialog extends JDialog { bottomPanel.setOpaque(false); statusLabel = new JLabel("Enter a dataset pattern or parse active screen."); - statusLabel.setForeground(new Color(170, 170, 170)); + statusLabel.setForeground(ThemeManager.getFgMuted()); statusLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); bottomPanel.add(statusLabel, BorderLayout.WEST); @@ -147,20 +129,17 @@ public class HostDirectoryDialog extends JDialog { btnPanel.setOpaque(false); JButton pasteScreenBtn = new JButton("Parse Current Screen"); - pasteScreenBtn.setBackground(new Color(50, 50, 50)); - pasteScreenBtn.setForeground(fg); + ThemeManager.styleButton(pasteScreenBtn, ThemeManager.ButtonVariant.DEFAULT); pasteScreenBtn.setFont(font); pasteScreenBtn.addActionListener(e -> parseCurrentScreen()); JButton selectBtn = new JButton("Select Dataset"); - selectBtn.setBackground(new Color(50, 120, 50)); - selectBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(selectBtn, ThemeManager.ButtonVariant.PRIMARY); selectBtn.setFont(font); selectBtn.addActionListener(e -> onConfirmSelection()); JButton cancelBtn = new JButton("Cancel"); - cancelBtn.setBackground(new Color(60, 60, 60)); - cancelBtn.setForeground(fg); + ThemeManager.styleButton(cancelBtn, ThemeManager.ButtonVariant.CANCEL); cancelBtn.setFont(font); cancelBtn.addActionListener(e -> { confirmed = false; @@ -176,6 +155,7 @@ public class HostDirectoryDialog extends JDialog { mainPanel.add(bottomPanel, BorderLayout.SOUTH); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); // Initial setup setupTableColumns(); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/PrinterSessionDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/PrinterSessionDialog.java index f1393fd..8180ed5 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/PrinterSessionDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/PrinterSessionDialog.java @@ -45,10 +45,6 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene // Spool Display private JTextArea spoolArea; - private static final Color DARK_BG = new Color(35, 35, 35); - private static final Color DARK_FG = new Color(220, 220, 220); - private static final Color DARK_FIELD_BG = new Color(45, 45, 45); - public PrinterSessionDialog(Frame parent, String defaultHost, int defaultPort, boolean defaultTls) { super(parent, "IBM 3287 Printer Session Manager", false); this.parent = parent; @@ -65,14 +61,11 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene private void buildUI() { JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); mainPanel.setBorder(new EmptyBorder(12, 14, 12, 14)); - mainPanel.setBackground(DARK_BG); // Top: Configuration Panel JPanel topPanel = new JPanel(new GridBagLayout()); topPanel.setOpaque(false); - topPanel.setBorder(BorderFactory.createTitledBorder( - new LineBorder(new Color(65, 65, 65)), "Printer Session Configuration")); - ((TitledBorder) topPanel.getBorder()).setTitleColor(DARK_FG); + topPanel.setBorder(ThemeManager.createTitledBorder("Printer Session Configuration")); GridBagConstraints gbc = new GridBagConstraints(); gbc.insets = new Insets(3, 4, 3, 4); @@ -83,60 +76,59 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene // Host / Port gbc.gridx = 0; gbc.gridy = 0; JLabel hLbl = new JLabel("Host:"); - hLbl.setForeground(DARK_FG); hLbl.setFont(labelFont); + hLbl.setFont(labelFont); topPanel.add(hLbl, gbc); gbc.gridx = 1; gbc.weightx = 1.0; - hostField = createDarkField(config.getHost() != null ? config.getHost() : "localhost", 14); + hostField = createField(config.getHost() != null ? config.getHost() : "localhost", 14); topPanel.add(hostField, gbc); gbc.gridx = 2; gbc.weightx = 0; JLabel pLbl = new JLabel("Port:"); - pLbl.setForeground(DARK_FG); pLbl.setFont(labelFont); + pLbl.setFont(labelFont); topPanel.add(pLbl, gbc); gbc.gridx = 3; gbc.weightx = 0.5; - portField = createDarkField(String.valueOf(config.getPort()), 5); + portField = createField(String.valueOf(config.getPort()), 5); topPanel.add(portField, gbc); // Printer LU / Display LU gbc.gridx = 0; gbc.gridy = 1; gbc.weightx = 0; JLabel pluLbl = new JLabel("Printer LU:"); - pluLbl.setForeground(DARK_FG); pluLbl.setFont(labelFont); + pluLbl.setFont(labelFont); topPanel.add(pluLbl, gbc); gbc.gridx = 1; gbc.weightx = 1.0; - printerLuField = createDarkField("", 10); + printerLuField = createField("", 10); topPanel.add(printerLuField, gbc); gbc.gridx = 2; gbc.weightx = 0; JLabel assocLbl = new JLabel("Assoc LU:"); - assocLbl.setForeground(DARK_FG); assocLbl.setFont(labelFont); + assocLbl.setFont(labelFont); topPanel.add(assocLbl, gbc); gbc.gridx = 3; gbc.weightx = 0.5; - displayLuField = createDarkField("", 10); + displayLuField = createField("", 10); topPanel.add(displayLuField, gbc); // CodePage & TLS gbc.gridx = 0; gbc.gridy = 2; gbc.weightx = 0; JLabel cpLbl = new JLabel("CodePage:"); - cpLbl.setForeground(DARK_FG); cpLbl.setFont(labelFont); + cpLbl.setFont(labelFont); topPanel.add(cpLbl, gbc); gbc.gridx = 1; gbc.weightx = 1.0; codePageCombo = new JComboBox<>(new String[]{"037", "1047", "500", "273", "277", "278", "280", "284", "285", "297", "870", "1140"}); - codePageCombo.setBackground(DARK_FIELD_BG); - codePageCombo.setForeground(DARK_FG); + ThemeManager.styleComboBox(codePageCombo); topPanel.add(codePageCombo, gbc); gbc.gridx = 2; gbc.gridwidth = 2; JPanel tlsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); tlsPanel.setOpaque(false); tlsCheck = new JCheckBox("TLS/SSL", config.isUseTls()); - tlsCheck.setOpaque(false); tlsCheck.setForeground(DARK_FG); tlsCheck.setFocusPainted(false); + ThemeManager.styleCheckBox(tlsCheck); verifyCertCheck = new JCheckBox("Verify Cert", config.isTlsVerifyCert()); - verifyCertCheck.setOpaque(false); verifyCertCheck.setForeground(DARK_FG); verifyCertCheck.setFocusPainted(false); + ThemeManager.styleCheckBox(verifyCertCheck); tlsPanel.add(tlsCheck); tlsPanel.add(verifyCertCheck); topPanel.add(tlsPanel, gbc); @@ -144,23 +136,22 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene // Destination Type & Target gbc.gridx = 0; gbc.gridy = 3; gbc.gridwidth = 1; JLabel destLbl = new JLabel("Destination:"); - destLbl.setForeground(DARK_FG); destLbl.setFont(labelFont); + destLbl.setFont(labelFont); topPanel.add(destLbl, gbc); gbc.gridx = 1; destinationCombo = new JComboBox<>(PrinterConfig.DestinationType.values()); destinationCombo.setSelectedItem(PrinterConfig.DestinationType.MEMORY); - destinationCombo.setBackground(DARK_FIELD_BG); - destinationCombo.setForeground(DARK_FG); + ThemeManager.styleComboBox(destinationCombo); topPanel.add(destinationCombo, gbc); gbc.gridx = 2; JLabel tgtLbl = new JLabel("Target Path:"); - tgtLbl.setForeground(DARK_FG); tgtLbl.setFont(labelFont); + tgtLbl.setFont(labelFont); topPanel.add(tgtLbl, gbc); gbc.gridx = 3; - targetField = createDarkField("printer_output.txt", 12); + targetField = createField("printer_output.txt", 12); topPanel.add(targetField, gbc); // Connect / Disconnect Buttons @@ -169,13 +160,11 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene connBtnPan.setOpaque(false); connectBtn = new JButton("Start Printer Session"); - connectBtn.setBackground(new Color(50, 120, 50)); - connectBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(connectBtn, ThemeManager.ButtonVariant.PRIMARY); connectBtn.addActionListener(e -> startPrinterSession()); disconnectBtn = new JButton("Stop Session"); - disconnectBtn.setBackground(new Color(120, 50, 50)); - disconnectBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(disconnectBtn, ThemeManager.ButtonVariant.DANGER); disconnectBtn.setEnabled(false); disconnectBtn.addActionListener(e -> stopPrinterSession()); @@ -188,9 +177,7 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene // Center: Spool Area & Status JPanel centerPanel = new JPanel(new BorderLayout(6, 6)); centerPanel.setOpaque(false); - centerPanel.setBorder(BorderFactory.createTitledBorder( - new LineBorder(new Color(65, 65, 65)), "Printer Spool & Status")); - ((TitledBorder) centerPanel.getBorder()).setTitleColor(DARK_FG); + centerPanel.setBorder(ThemeManager.createTitledBorder("Printer Spool & Status")); // Status Header JPanel statusHeader = new JPanel(new GridLayout(1, 4, 10, 0)); @@ -198,19 +185,16 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene statusHeader.setBorder(new EmptyBorder(0, 4, 4, 4)); statusLabel = new JLabel("Status: Disconnected"); - statusLabel.setForeground(new Color(180, 180, 180)); + statusLabel.setForeground(ThemeManager.getFgMuted()); statusLabel.setFont(labelFont); sessionTypeLabel = new JLabel("Session: -"); - sessionTypeLabel.setForeground(DARK_FG); sessionTypeLabel.setFont(labelFont); pagesLabel = new JLabel("Pages: 0"); - pagesLabel.setForeground(DARK_FG); pagesLabel.setFont(labelFont); bytesLabel = new JLabel("Bytes: 0"); - bytesLabel.setForeground(DARK_FG); bytesLabel.setFont(labelFont); statusHeader.add(statusLabel); @@ -221,14 +205,12 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene // Spool text area spoolArea = new JTextArea(); - spoolArea.setBackground(new Color(25, 25, 25)); - spoolArea.setForeground(new Color(100, 255, 100)); - spoolArea.setCaretColor(Color.WHITE); spoolArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); spoolArea.setEditable(false); + ThemeManager.styleTextArea(spoolArea); JScrollPane spoolScroll = new JScrollPane(spoolArea); - spoolScroll.setBorder(new LineBorder(new Color(60, 60, 60))); + ThemeManager.styleScrollPane(spoolScroll); centerPanel.add(spoolScroll, BorderLayout.CENTER); mainPanel.add(centerPanel, BorderLayout.CENTER); @@ -238,8 +220,7 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene bottomPanel.setOpaque(false); JButton clearSpoolBtn = new JButton("Clear Spool"); - clearSpoolBtn.setBackground(new Color(50, 50, 50)); - clearSpoolBtn.setForeground(DARK_FG); + ThemeManager.styleButton(clearSpoolBtn, ThemeManager.ButtonVariant.DEFAULT); clearSpoolBtn.addActionListener(e -> { spoolArea.setText(""); if (printerSession != null && printerSession.getPD() != null) { @@ -250,18 +231,15 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene }); JButton saveSpoolBtn = new JButton("Save Spool As..."); - saveSpoolBtn.setBackground(new Color(50, 50, 50)); - saveSpoolBtn.setForeground(DARK_FG); + ThemeManager.styleButton(saveSpoolBtn, ThemeManager.ButtonVariant.DEFAULT); saveSpoolBtn.addActionListener(e -> saveSpool()); JButton printSpoolBtn = new JButton("Print Spool..."); - printSpoolBtn.setBackground(new Color(50, 100, 160)); - printSpoolBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(printSpoolBtn, ThemeManager.ButtonVariant.DEFAULT); printSpoolBtn.addActionListener(e -> printSpool()); JButton closeBtn = new JButton("Close"); - closeBtn.setBackground(new Color(60, 60, 60)); - closeBtn.setForeground(DARK_FG); + ThemeManager.styleButton(closeBtn, ThemeManager.ButtonVariant.CANCEL); closeBtn.addActionListener(e -> dispose()); bottomPanel.add(clearSpoolBtn); @@ -273,17 +251,13 @@ public class PrinterSessionDialog extends JDialog implements PrintSessionListene mainPanel.add(bottomPanel, BorderLayout.SOUTH); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); } - private JTextField createDarkField(String text, int cols) { + private JTextField createField(String text, int cols) { JTextField tf = new JTextField(text, cols); - tf.setBackground(DARK_FIELD_BG); - tf.setForeground(DARK_FG); - tf.setCaretColor(DARK_FG); tf.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); - tf.setBorder(BorderFactory.createCompoundBorder( - new LineBorder(new Color(65, 65, 65)), - new EmptyBorder(2, 4, 2, 4))); + ThemeManager.styleTextField(tf); return tf; } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ScriptDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ScriptDialog.java index 9a3b376..ac95f63 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ScriptDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ScriptDialog.java @@ -34,9 +34,7 @@ public class ScriptDialog extends JDialog { private void buildUI() { JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); mainPanel.setBorder(new EmptyBorder(12, 14, 12, 14)); - mainPanel.setBackground(new Color(35, 35, 35)); - Color fg = new Color(220, 220, 220); Font labelFont = new Font(Font.SANS_SERIF, Font.PLAIN, 13); // Header @@ -44,31 +42,24 @@ public class ScriptDialog extends JDialog { topPanel.setOpaque(false); JLabel descLabel = new JLabel("Compose keystrokes and ECL bracketed mnemonics to stream to the mainframe.
" + "Example: TSO[enter]USER[tab]PASSWORD[enter] or [pf3][clear]"); - descLabel.setForeground(new Color(180, 180, 180)); descLabel.setFont(labelFont); topPanel.add(descLabel, BorderLayout.CENTER); mainPanel.add(topPanel, BorderLayout.NORTH); // Script area scriptArea = new JTextArea(); - scriptArea.setBackground(new Color(25, 25, 25)); - scriptArea.setForeground(new Color(50, 205, 50)); - scriptArea.setCaretColor(Color.WHITE); scriptArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); scriptArea.setLineWrap(true); scriptArea.setWrapStyleWord(false); + ThemeManager.styleTextArea(scriptArea); JScrollPane scrollPane = new JScrollPane(scriptArea); - scrollPane.setBorder(BorderFactory.createCompoundBorder( - new LineBorder(new Color(60, 60, 60)), - new EmptyBorder(2, 2, 2, 2))); + ThemeManager.styleScrollPane(scrollPane); // Mnemonic helper buttons JPanel tokenPanel = new JPanel(new WrapLayout(FlowLayout.LEFT, 4, 4)); tokenPanel.setOpaque(false); - tokenPanel.setBorder(BorderFactory.createTitledBorder( - new LineBorder(new Color(60, 60, 60)), "Insert Keystroke Token")); - ((TitledBorder) tokenPanel.getBorder()).setTitleColor(new Color(180, 180, 180)); + tokenPanel.setBorder(ThemeManager.createTitledBorder("Insert Keystroke Token")); String[] tokens = { "[enter]", "[tab]", "[backtab]", "[clear]", "[reset]", @@ -84,8 +75,7 @@ public class ScriptDialog extends JDialog { for (String token : tokens) { JButton btn = new JButton(token); btn.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); - btn.setBackground(new Color(50, 50, 50)); - btn.setForeground(fg); + ThemeManager.styleButton(btn, ThemeManager.ButtonVariant.DEFAULT); btn.setFocusable(false); btn.setMargin(new Insets(2, 4, 2, 4)); btn.addActionListener(e -> insertToken(token)); @@ -103,7 +93,7 @@ public class ScriptDialog extends JDialog { bottomPanel.setOpaque(false); statusLabel = new JLabel("Ready"); - statusLabel.setForeground(new Color(160, 160, 160)); + statusLabel.setForeground(ThemeManager.getFgMuted()); statusLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); bottomPanel.add(statusLabel, BorderLayout.WEST); @@ -111,26 +101,22 @@ public class ScriptDialog extends JDialog { buttonPanel.setOpaque(false); JButton loadBtn = new JButton("Load Script..."); - loadBtn.setBackground(new Color(50, 50, 50)); - loadBtn.setForeground(fg); + ThemeManager.styleButton(loadBtn, ThemeManager.ButtonVariant.DEFAULT); loadBtn.setFont(labelFont); loadBtn.addActionListener(e -> loadScript()); JButton saveBtn = new JButton("Save Script..."); - saveBtn.setBackground(new Color(50, 50, 50)); - saveBtn.setForeground(fg); + ThemeManager.styleButton(saveBtn, ThemeManager.ButtonVariant.DEFAULT); saveBtn.setFont(labelFont); saveBtn.addActionListener(e -> saveScript()); JButton runBtn = new JButton("Execute"); - runBtn.setBackground(new Color(50, 120, 50)); - runBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(runBtn, ThemeManager.ButtonVariant.PRIMARY); runBtn.setFont(labelFont); runBtn.addActionListener(e -> executeScript()); JButton closeBtn = new JButton("Close"); - closeBtn.setBackground(new Color(60, 60, 60)); - closeBtn.setForeground(fg); + ThemeManager.styleButton(closeBtn, ThemeManager.ButtonVariant.CANCEL); closeBtn.setFont(labelFont); closeBtn.addActionListener(e -> dispose()); @@ -144,6 +130,7 @@ public class ScriptDialog extends JDialog { mainPanel.add(bottomPanel, BorderLayout.SOUTH); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); } private void insertToken(String token) { diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java index 894db5e..e3f02c0 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java @@ -19,16 +19,8 @@ public class SettingsDialog extends JDialog { private final J3270App parentApp; - // Dark theme colors - private static final Color DARK_BG = new Color(43, 43, 43); - private static final Color DARK_BG_LIGHTER = new Color(55, 55, 55); - private static final Color DARK_FG = new Color(224, 224, 224); - private static final Color DARK_BORDER = new Color(70, 70, 70); - private static final Color DARK_SELECTION = new Color(75, 110, 175); - private static final Color DARK_BUTTON_BG = new Color(60, 63, 65); - private static final Color DARK_FIELD_BG = new Color(50, 50, 50); - // Appearance tab + private JComboBox uiThemeBox; private JComboBox fontBox; private JSpinner fontSizeSpinner; private JComboBox graphicsModeBox; @@ -51,22 +43,30 @@ public class SettingsDialog extends JDialog { this.parentApp = parent; initComponents(); - setSize(550, 450); + setSize(560, 480); setLocationRelativeTo(parent); } private void initComponents() { JTabbedPane tabbedPane = new JTabbedPane(); + ThemeManager.styleTabbedPane(tabbedPane); tabbedPane.addTab("Appearance", createAppearancePanel()); tabbedPane.addTab("Behavior", createBehaviorPanel()); tabbedPane.addTab("Advanced", createAdvancedPanel()); - JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8)); JButton btnExport = new JButton("Export Config..."); - JButton btnOk = new JButton("OK"); + ThemeManager.styleButton(btnExport, ThemeManager.ButtonVariant.DEFAULT); + JButton btnApply = new JButton("Apply"); + ThemeManager.styleButton(btnApply, ThemeManager.ButtonVariant.DEFAULT); + JButton btnCancel = new JButton("Cancel"); + ThemeManager.styleButton(btnCancel, ThemeManager.ButtonVariant.CANCEL); + + JButton btnOk = new JButton("OK"); + ThemeManager.styleButton(btnOk, ThemeManager.ButtonVariant.PRIMARY); btnExport.addActionListener((ActionEvent e) -> { exportConfig(); @@ -96,132 +96,11 @@ public class SettingsDialog extends JDialog { getContentPane().add(tabbedPane, BorderLayout.CENTER); getContentPane().add(buttonPanel, BorderLayout.SOUTH); - // Apply dark theme to all components for cross-platform readability - applyDarkTheme(getContentPane()); - applyDarkTheme(tabbedPane); - applyDarkTheme(buttonPanel); - getContentPane().setBackground(DARK_BG); + ThemeManager.applyThemeToWindow(this); } - // ========== Dark Theme Utility ========== - - /** - * Recursively apply dark theme to a component and all its children. - * Ensures the Settings dialog is readable on Windows, Linux, and macOS. - */ private void applyDarkTheme(Component comp) { - if (comp instanceof JTabbedPane) { - JTabbedPane tp = (JTabbedPane) comp; - tp.setBackground(DARK_BG); - tp.setForeground(DARK_FG); - for (int i = 0; i < tp.getTabCount(); i++) { - applyDarkTheme(tp.getComponentAt(i)); - } - return; - } - - if (comp instanceof JTable) { - JTable table = (JTable) comp; - table.setBackground(DARK_FIELD_BG); - table.setForeground(DARK_FG); - table.setSelectionBackground(DARK_SELECTION); - table.setSelectionForeground(Color.WHITE); - table.setGridColor(DARK_BORDER); - JTableHeader header = table.getTableHeader(); - if (header != null) { - header.setBackground(DARK_BG_LIGHTER); - header.setForeground(DARK_FG); - DefaultTableCellRenderer headerRenderer = new DefaultTableCellRenderer(); - headerRenderer.setBackground(DARK_BG_LIGHTER); - headerRenderer.setForeground(DARK_FG); - header.setDefaultRenderer(headerRenderer); - } - return; - } - - if (comp instanceof JScrollPane) { - JScrollPane sp = (JScrollPane) comp; - sp.setBackground(DARK_BG); - sp.getViewport().setBackground(DARK_FIELD_BG); - applyDarkTheme(sp.getViewport().getView()); - return; - } - - if (comp instanceof JButton) { - JButton btn = (JButton) comp; - btn.setBackground(DARK_BUTTON_BG); - btn.setForeground(DARK_FG); - btn.setFocusPainted(false); - btn.setBorder(BorderFactory.createCompoundBorder( - new LineBorder(DARK_BORDER, 1), - BorderFactory.createEmptyBorder(3, 10, 3, 10))); - btn.setOpaque(true); - return; - } - - if (comp instanceof JComboBox) { - JComboBox cb = (JComboBox) comp; - cb.setBackground(DARK_FIELD_BG); - cb.setForeground(DARK_FG); - return; - } - - if (comp instanceof JSpinner) { - JSpinner sp = (JSpinner) comp; - sp.setBackground(DARK_FIELD_BG); - sp.setForeground(DARK_FG); - JComponent editor = sp.getEditor(); - if (editor instanceof JSpinner.DefaultEditor) { - JTextField tf = ((JSpinner.DefaultEditor) editor).getTextField(); - tf.setBackground(DARK_FIELD_BG); - tf.setForeground(DARK_FG); - tf.setCaretColor(DARK_FG); - } - return; - } - - if (comp instanceof JTextField) { - JTextField tf = (JTextField) comp; - tf.setBackground(DARK_FIELD_BG); - tf.setForeground(DARK_FG); - tf.setCaretColor(DARK_FG); - return; - } - - if (comp instanceof JCheckBox) { - JCheckBox cb = (JCheckBox) comp; - cb.setBackground(DARK_BG); - cb.setForeground(DARK_FG); - return; - } - - if (comp instanceof JLabel) { - comp.setForeground(DARK_FG); - return; - } - - // Generic panel / container - // Skip color swatch panels — their background IS the color - if (comp instanceof JPanel && "colorSwatch".equals(comp.getName())) { - return; - } - comp.setBackground(DARK_BG); - comp.setForeground(DARK_FG); - - if (comp instanceof JPanel) { - JPanel panel = (JPanel) comp; - // Style titled borders - if (panel.getBorder() instanceof TitledBorder) { - TitledBorder tb = (TitledBorder) panel.getBorder(); - tb.setTitleColor(DARK_FG); - } - } - - if (comp instanceof Container) { - for (Component child : ((Container) comp).getComponents()) { - applyDarkTheme(child); - } - } + ThemeManager.applyTheme(comp); } // ========== Tab Panels ========== @@ -232,9 +111,22 @@ public class SettingsDialog extends JDialog { gbc.insets = new Insets(10, 10, 10, 10); gbc.anchor = GridBagConstraints.WEST; - // Font family + // UI Theme gbc.gridx = 0; gbc.gridy = 0; + JLabel themeLabel = new JLabel("Java UI Theme:"); + panel.add(themeLabel, gbc); + + uiThemeBox = new JComboBox<>(UITheme.values()); + uiThemeBox.setSelectedItem(Settings.getJavaUiTheme()); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(uiThemeBox, gbc); + + // Font family + gbc.gridx = 0; + gbc.gridy = 1; + gbc.fill = GridBagConstraints.NONE; JLabel fontLabel = new JLabel("Terminal Font:"); panel.add(fontLabel, gbc); @@ -254,7 +146,7 @@ public class SettingsDialog extends JDialog { // Font size gbc.gridx = 0; - gbc.gridy = 1; + gbc.gridy = 2; gbc.fill = GridBagConstraints.NONE; JLabel sizeLabel = new JLabel("Font Size:"); panel.add(sizeLabel, gbc); @@ -267,7 +159,7 @@ public class SettingsDialog extends JDialog { // Graphics Mode gbc.gridx = 0; - gbc.gridy = 2; + gbc.gridy = 3; gbc.fill = GridBagConstraints.NONE; JLabel graphicsLabel = new JLabel("Graphics Mode:"); panel.add(graphicsLabel, gbc); @@ -279,7 +171,7 @@ public class SettingsDialog extends JDialog { panel.add(graphicsModeBox, gbc); // Fill remaining space - gbc.gridy = 3; + gbc.gridy = 4; gbc.weighty = 1.0; panel.add(Box.createGlue(), gbc); @@ -429,6 +321,7 @@ public class SettingsDialog extends JDialog { JPanel resetPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); JButton btnResetColors = new JButton("Reset to Defaults"); + ThemeManager.styleButton(btnResetColors, ThemeManager.ButtonVariant.DEFAULT); btnResetColors.addActionListener(e -> { for (int i=0; i<16; i++) { tempHostColors[i] = TerminalPanel.DEFAULT_HOST_COLORS[i]; @@ -436,8 +329,6 @@ public class SettingsDialog extends JDialog { for (int i=0; i { int row = table.getSelectedRow(); if (row < 0) return; @@ -545,6 +437,7 @@ public class SettingsDialog extends JDialog { // Add Binding — appends an additional key to the existing binding(s) JButton btnAdd = new JButton("Add Binding"); + ThemeManager.styleButton(btnAdd, ThemeManager.ButtonVariant.DEFAULT); btnAdd.addActionListener(e -> { int row = table.getSelectedRow(); if (row < 0) return; @@ -565,6 +458,7 @@ public class SettingsDialog extends JDialog { // Remove Last — removes the last comma-separated binding entry JButton btnRemoveLast = new JButton("Remove Last"); + ThemeManager.styleButton(btnRemoveLast, ThemeManager.ButtonVariant.DEFAULT); btnRemoveLast.addActionListener(e -> { int row = table.getSelectedRow(); if (row < 0) return; @@ -585,6 +479,7 @@ public class SettingsDialog extends JDialog { // Unbind All — clears all bindings for the action JButton btnUnbind = new JButton("Unbind All"); + ThemeManager.styleButton(btnUnbind, ThemeManager.ButtonVariant.DEFAULT); btnUnbind.addActionListener(e -> { int row = table.getSelectedRow(); if (row < 0) return; @@ -596,6 +491,7 @@ public class SettingsDialog extends JDialog { // Reset Keymaps — restore all defaults JButton btnReset = new JButton("Reset All"); + ThemeManager.styleButton(btnReset, ThemeManager.ButtonVariant.DEFAULT); btnReset.addActionListener(e -> { for (int row = 0; row < keymapModel.getRowCount(); row++) { String action = (String) keymapModel.getValueAt(row, 0); @@ -628,6 +524,12 @@ public class SettingsDialog extends JDialog { private boolean applySettings() { try { // Apply Appearance + UITheme selectedTheme = (UITheme) uiThemeBox.getSelectedItem(); + if (selectedTheme != null) { + Settings.setJavaUiTheme(selectedTheme); + ThemeManager.setTheme(selectedTheme); + } + String fontFam = (String) fontBox.getSelectedItem(); if (fontFam != null) { Settings.setFontFamily(fontFam); @@ -668,6 +570,7 @@ public class SettingsDialog extends JDialog { } parentApp.getTerminalPanel().reloadSettings(); + ThemeManager.applyThemeToWindow(this); return true; } catch (Exception e) { diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java b/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java index 3329a2e..dba72ca 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java @@ -26,29 +26,22 @@ public class StatusBar extends JPanel { private Telnet3270Client client; private TerminalPanel terminalPanel; - // OIA colors - private static final Color OIA_BG = new Color(20, 20, 20); - private static final Color OIA_FG = new Color(50, 205, 50); - private static final Color OIA_DIM = new Color(80, 80, 80); - private static final Color OIA_ALERT = new Color(255, 80, 80); - private static final Color OIA_WARN = new Color(255, 200, 80); - public StatusBar() { setLayout(new BoxLayout(this, BoxLayout.X_AXIS)); - setBackground(OIA_BG); - setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(40, 40, 40))); + setBackground(ThemeManager.getStatusBarBg()); + setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, ThemeManager.getStatusBarBorder())); setPreferredSize(new Dimension(800, 22)); Font oiaFont = new Font(Font.MONOSPACED, Font.PLAIN, 12); - connectionStatus = createLabel("Not Connected", oiaFont, OIA_DIM); - tlsStatus = createLabel("", oiaFont, OIA_FG); - luName = createLabel("", oiaFont, OIA_FG); - lockStatus = createLabel("", oiaFont, OIA_ALERT); - fieldTypeStatus = createLabel("", oiaFont, OIA_DIM); - codePageInfo = createLabel("", oiaFont, OIA_DIM); - modelInfo = createLabel("", oiaFont, OIA_DIM); - cursorPosition = createLabel("001/001 [0000]", oiaFont, OIA_FG); + connectionStatus = createLabel("Not Connected", oiaFont, ThemeManager.getOiaFgDim()); + tlsStatus = createLabel("", oiaFont, ThemeManager.getOiaFgNormal()); + luName = createLabel("", oiaFont, ThemeManager.getOiaFgNormal()); + lockStatus = createLabel("", oiaFont, ThemeManager.getOiaFgAlert()); + fieldTypeStatus = createLabel("", oiaFont, ThemeManager.getOiaFgDim()); + codePageInfo = createLabel("", oiaFont, ThemeManager.getOiaFgDim()); + modelInfo = createLabel("", oiaFont, ThemeManager.getOiaFgDim()); + cursorPosition = createLabel("001/001 [0000]", oiaFont, ThemeManager.getOiaFgNormal()); add(Box.createHorizontalStrut(6)); add(connectionStatus); @@ -82,10 +75,17 @@ public class StatusBar extends JPanel { updateStatus(); } + public void applyTheme(UITheme theme) { + setBackground(ThemeManager.getStatusBarBg(theme)); + setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, ThemeManager.getStatusBarBorder(theme))); + updateStatus(); + } + public void updateStatus() { + UITheme theme = ThemeManager.getTheme(); if (client == null) { connectionStatus.setText("Not Connected"); - connectionStatus.setForeground(OIA_DIM); + connectionStatus.setForeground(ThemeManager.getOiaFgDim(theme)); tlsStatus.setText(""); luName.setText(""); lockStatus.setText(""); @@ -101,38 +101,38 @@ public class StatusBar extends JPanel { switch (state) { case NOT_CONNECTED: connectionStatus.setText("Not Connected"); - connectionStatus.setForeground(OIA_DIM); + connectionStatus.setForeground(ThemeManager.getOiaFgDim(theme)); break; case TCP_PENDING: case TELNET_PENDING: connectionStatus.setText("Connecting..."); - connectionStatus.setForeground(OIA_ALERT); + connectionStatus.setForeground(ThemeManager.getOiaFgAlert(theme)); break; case CONNECTED_3270: connectionStatus.setText("TN3270"); - connectionStatus.setForeground(OIA_FG); + connectionStatus.setForeground(ThemeManager.getOiaFgNormal(theme)); break; case CONNECTED_TN3270E: connectionStatus.setText("TN3270E"); - connectionStatus.setForeground(OIA_FG); + connectionStatus.setForeground(ThemeManager.getOiaFgNormal(theme)); break; case CONNECTED_SSCP: connectionStatus.setText("SSCP-LU"); - connectionStatus.setForeground(OIA_FG); + connectionStatus.setForeground(ThemeManager.getOiaFgNormal(theme)); break; case CONNECTED_NVT: case CONNECTED_NVT_CHAR: case CONNECTED_E_NVT: connectionStatus.setText("NVT"); - connectionStatus.setForeground(OIA_FG); + connectionStatus.setForeground(ThemeManager.getOiaFgNormal(theme)); break; case CONNECTED_UNBOUND: connectionStatus.setText("Unbound"); - connectionStatus.setForeground(OIA_WARN); + connectionStatus.setForeground(ThemeManager.getOiaFgWarn(theme)); break; default: connectionStatus.setText(state.name()); - connectionStatus.setForeground(OIA_DIM); + connectionStatus.setForeground(ThemeManager.getOiaFgDim(theme)); break; } @@ -144,11 +144,11 @@ public class StatusBar extends JPanel { String protocol = session != null ? session.getProtocol() : "TLS"; if (verified) { tlsStatus.setText("🔒 TLS"); - tlsStatus.setForeground(OIA_FG); + tlsStatus.setForeground(ThemeManager.getOiaFgNormal(theme)); tlsStatus.setToolTipText(protocol + " / " + cipher + " (Verified)"); } else { tlsStatus.setText("🔓 TLS (Unverified)"); - tlsStatus.setForeground(new Color(255, 180, 80)); + tlsStatus.setForeground(ThemeManager.getOiaFgWarn(theme)); tlsStatus.setToolTipText(protocol + " / " + cipher + " (Verification Bypassed)"); } } else { @@ -164,6 +164,7 @@ public class StatusBar extends JPanel { lu = "LU:" + client.getConfig().getLuName(); } luName.setText(lu); + luName.setForeground(ThemeManager.getOiaFgNormal(theme)); // Lock / Inhibit status int inhibit = client.getOIA().getInputInhibited(); @@ -191,10 +192,10 @@ public class StatusBar extends JPanel { lockStatus.setText("X LOCKED"); break; } - lockStatus.setForeground(OIA_ALERT); + lockStatus.setForeground(ThemeManager.getOiaFgAlert(theme)); } else if (client.getInputProcessor().isInsertMode()) { lockStatus.setText("INSERT"); - lockStatus.setForeground(OIA_FG); + lockStatus.setForeground(ThemeManager.getOiaFgNormal(theme)); } else { lockStatus.setText(""); } @@ -203,10 +204,10 @@ public class StatusBar extends JPanel { if (state.isFullSession() && client.getScreenBuffer().isFormatted()) { if (client.getOIA().isNumeric()) { fieldTypeStatus.setText("NUM"); - fieldTypeStatus.setForeground(OIA_WARN); + fieldTypeStatus.setForeground(ThemeManager.getOiaFgWarn(theme)); } else { fieldTypeStatus.setText("ALPHA"); - fieldTypeStatus.setForeground(OIA_DIM); + fieldTypeStatus.setForeground(ThemeManager.getOiaFgDim(theme)); } } else { fieldTypeStatus.setText(""); @@ -215,6 +216,7 @@ public class StatusBar extends JPanel { // Active Code Page String cp = client.getCodePage(); codePageInfo.setText(cp != null ? "CP" + cp : ""); + codePageInfo.setForeground(ThemeManager.getOiaFgDim(theme)); codePageInfo.setToolTipText("Active EBCDIC Code Page: CP" + cp); // Model & Dimensions info @@ -222,11 +224,13 @@ public class StatusBar extends JPanel { int rows = sb.getDisplayRows(); int cols = sb.getDisplayCols(); modelInfo.setText(client.getConfig().getModel().name().replace('_', '-') + " [" + rows + "x" + cols + "]"); + modelInfo.setForeground(ThemeManager.getOiaFgDim(theme)); // Cursor position and buffer address int curAddr = sb.getCursorAddress(); int row = sb.getCursorRow() + 1; int col = sb.getCursorCol() + 1; cursorPosition.setText(String.format("%03d/%03d [%04d]", row, col, curAddr)); + cursorPosition.setForeground(ThemeManager.getOiaFgNormal(theme)); } } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java index 1ffa1fa..0a01b8a 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -434,6 +434,16 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { public void pasteClipboard() { if (client == null || !client.getConnectionState().isFullSession()) return; + if (client.getConnectionState().isNvt()) { + try { + String text = (String) Toolkit.getDefaultToolkit().getSystemClipboard() + .getData(DataFlavor.stringFlavor); + if (text != null && !text.isEmpty()) { + client.sendNVTString(text); + } + } catch (Exception ignored) {} + return; + } try { String text = (String) Toolkit.getDefaultToolkit().getSystemClipboard() .getData(DataFlavor.stringFlavor); @@ -481,24 +491,29 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void showContextMenu(MouseEvent e) { JPopupMenu popup = new JPopupMenu(); + ThemeManager.stylePopupMenu(popup); JMenuItem copyItem = new JMenuItem("Copy"); + ThemeManager.styleMenuItem(copyItem); copyItem.setEnabled(hasSelection()); copyItem.addActionListener(ev -> copySelection()); popup.add(copyItem); JMenuItem pasteItem = new JMenuItem("Paste"); + ThemeManager.styleMenuItem(pasteItem); pasteItem.setEnabled(client != null && client.getConnectionState().isFullSession()); pasteItem.addActionListener(ev -> pasteClipboard()); popup.add(pasteItem); JMenuItem selectAllItem = new JMenuItem("Select All"); + ThemeManager.styleMenuItem(selectAllItem); selectAllItem.addActionListener(ev -> selectAll()); popup.add(selectAllItem); popup.addSeparator(); JCheckBoxMenuItem blockModeItem = new JCheckBoxMenuItem("Block Select Mode", blockSelectMode); + ThemeManager.styleMenuItem(blockModeItem); blockModeItem.addActionListener(ev -> { blockSelectMode = blockModeItem.isSelected(); haus.nightmare.j3270.config.Settings.setBlockSelectMode(blockSelectMode); @@ -693,19 +708,63 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { @Override protected void processKeyEvent(KeyEvent e) { - if (e.getID() == KeyEvent.KEY_TYPED) { - char ch = e.getKeyChar(); - if (ch >= 0x20 && ch != 0x7F && ch != KeyEvent.CHAR_UNDEFINED - && !e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) { - if (client != null) { - ConnectionState state = client.getConnectionState(); - if (state == ConnectionState.CONNECTED_NVT || state == ConnectionState.CONNECTED_NVT_CHAR || state == ConnectionState.CONNECTED_E_NVT) { + if (client != null) { + ConnectionState state = client.getConnectionState(); + if (state.isNvt()) { + if (e.getID() == KeyEvent.KEY_TYPED) { + char ch = e.getKeyChar(); + if (ch >= 0x20 && ch != 0x7F && ch != KeyEvent.CHAR_UNDEFINED + && !e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) { try { client.sendNVTChar(ch); } catch (Exception ignored) {} e.consume(); return; - } else if (state.isFullSession()) { + } else if (e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) { + if (ch > 0 && ch < 0x20) { + try { + client.sendNVTChar(ch); + } catch (Exception ignored) {} + e.consume(); + return; + } + } + } else if (e.getID() == KeyEvent.KEY_PRESSED) { + if (e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) { + int code = e.getKeyCode(); + if (code >= KeyEvent.VK_A && code <= KeyEvent.VK_Z) { + char ctrlChar = (char) (code - KeyEvent.VK_A + 1); + try { + client.sendNVTChar(ctrlChar); + } catch (Exception ignored) {} + e.consume(); + return; + } else if (code == KeyEvent.VK_OPEN_BRACKET) { // Ctrl+[ = ESC + try { + client.sendNVTChar('\u001B'); + } catch (Exception ignored) {} + e.consume(); + return; + } else if (code == KeyEvent.VK_BACK_SLASH) { // Ctrl+\ = FS + try { + client.sendNVTChar('\u001C'); + } catch (Exception ignored) {} + e.consume(); + return; + } else if (code == KeyEvent.VK_CLOSE_BRACKET) { // Ctrl+] = GS + try { + client.sendNVTChar('\u001D'); + } catch (Exception ignored) {} + e.consume(); + return; + } + } + } + } else if (e.getID() == KeyEvent.KEY_TYPED) { + char ch = e.getKeyChar(); + if (ch >= 0x20 && ch != 0x7F && ch != KeyEvent.CHAR_UNDEFINED + && !e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) { + if (state.isFullSession()) { client.typeCharacter(ch); refreshScreen(); e.consume(); @@ -731,7 +790,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleEnter() { if (client != null) { ConnectionState state = client.getConnectionState(); - if (state == ConnectionState.CONNECTED_NVT || state == ConnectionState.CONNECTED_NVT_CHAR || state == ConnectionState.CONNECTED_E_NVT) { + if (state.isNvt()) { try { client.sendNVTString("\r\n"); } catch (Exception ignored) {} @@ -744,6 +803,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleReset() { if (client != null) { + if (client.getConnectionState().isNvt()) { + try { + client.sendNVTChar('\u001B'); + } catch (Exception ignored) {} + return; + } clearSearchHighlight(); clearSelection(); client.reset(); @@ -753,6 +818,16 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleTab(boolean shift) { if (client != null && client.getConnectionState().isFullSession()) { + if (client.getConnectionState().isNvt()) { + try { + if (shift) { + client.sendNVTString("\u001B[Z"); + } else { + client.sendNVTChar('\t'); + } + } catch (Exception ignored) {} + return; + } if (shift) client.backTab(); else client.tab(); refreshScreen(); @@ -761,6 +836,18 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleCursor(String dir) { if (client != null && client.getConnectionState().isFullSession()) { + if (client.getConnectionState().isNvt()) { + try { + switch (dir) { + case "up": client.sendNVTString("\u001B[A"); break; + case "down": client.sendNVTString("\u001B[B"); break; + case "left": client.sendNVTString("\u001B[D"); break; + case "right": client.sendNVTString("\u001B[C"); break; + case "home": client.sendNVTString("\u001B[H"); break; + } + } catch (Exception ignored) {} + return; + } switch (dir) { case "up": client.cursorUp(); break; case "down": client.cursorDown(); break; @@ -774,11 +861,35 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handlePF(int n) { if (client != null && client.getConnectionState().isFullSession()) { + if (client.getConnectionState().isNvt()) { + try { + client.sendNVTString(getNvtFunctionKeySequence(n)); + } catch (Exception ignored) {} + return; + } client.sendPF(n); refreshScreen(); } } + private String getNvtFunctionKeySequence(int n) { + switch (n) { + case 1: return "\u001BOP"; + case 2: return "\u001BOQ"; + case 3: return "\u001BOR"; + case 4: return "\u001BOS"; + case 5: return "\u001B[15~"; + case 6: return "\u001B[17~"; + case 7: return "\u001B[18~"; + case 8: return "\u001B[19~"; + case 9: return "\u001B[20~"; + case 10: return "\u001B[21~"; + case 11: return "\u001B[23~"; + case 12: return "\u001B[24~"; + default: return ""; + } + } + private void handlePA(int n) { if (client != null && client.getConnectionState().isFullSession()) { client.sendPA(n); @@ -788,6 +899,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleEraseEOF() { if (client != null && client.getConnectionState().isFullSession()) { + if (client.getConnectionState().isNvt()) { + try { + client.sendNVTString("\u001B[F"); + } catch (Exception ignored) {} + return; + } client.getInputProcessor().eraseEof(); refreshScreen(); } @@ -795,6 +912,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleDelete() { if (client != null && client.getConnectionState().isFullSession()) { + if (client.getConnectionState().isNvt()) { + try { + client.sendNVTString("\u001B[3~"); + } catch (Exception ignored) {} + return; + } client.getInputProcessor().deleteChar(); refreshScreen(); } @@ -803,7 +926,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleBackspace() { if (client != null) { ConnectionState state = client.getConnectionState(); - if (state == ConnectionState.CONNECTED_NVT || state == ConnectionState.CONNECTED_NVT_CHAR || state == ConnectionState.CONNECTED_E_NVT) { + if (state.isNvt()) { try { client.sendNVTChar('\b'); } catch (Exception ignored) {} @@ -824,6 +947,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private void handleClear() { if (client != null && client.getConnectionState().isFullSession()) { + if (client.getConnectionState().isNvt()) { + try { + client.sendNVTChar('\u000C'); + } catch (Exception ignored) {} + return; + } client.sendClear(); refreshScreen(); } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java new file mode 100644 index 0000000..9bcad98 --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java @@ -0,0 +1,955 @@ +package haus.nightmare.j3270.ui; + +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import javax.swing.border.TitledBorder; +import javax.swing.plaf.basic.BasicButtonUI; +import javax.swing.plaf.basic.BasicMenuBarUI; +import javax.swing.plaf.basic.BasicMenuItemUI; +import javax.swing.plaf.basic.BasicMenuUI; +import javax.swing.plaf.basic.BasicPopupMenuUI; +import javax.swing.plaf.basic.BasicTabbedPaneUI; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.JTableHeader; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Centralized theme manager for j3270 Java desktop UI. + * Provides high-contrast, platform-independent Light and Dark theme palettes, + * custom Swing UI delegates, and recursive styling helpers for all dialogs and menus. + */ +public final class ThemeManager { + + private static UITheme currentTheme = UITheme.DARK; + private static final List> themeChangeListeners = new ArrayList<>(); + + private ThemeManager() {} + + public static UITheme getTheme() { + return currentTheme; + } + + public static boolean isDark() { + return currentTheme == UITheme.DARK; + } + + public static boolean isLight() { + return currentTheme == UITheme.LIGHT; + } + + public static void setTheme(UITheme theme) { + if (theme == null) theme = UITheme.DARK; + if (currentTheme != theme) { + currentTheme = theme; + applyUIManagerDefaults(theme); + for (Consumer listener : new ArrayList<>(themeChangeListeners)) { + try { + listener.accept(theme); + } catch (Exception ignored) {} + } + } + } + + public static void addThemeChangeListener(Consumer listener) { + if (listener != null && !themeChangeListeners.contains(listener)) { + themeChangeListeners.add(listener); + } + } + + public static void removeThemeChangeListener(Consumer listener) { + themeChangeListeners.remove(listener); + } + + // ========================================================================= + // Color Palette Tokens + // ========================================================================= + + public static Color getBgMain() { return getBgMain(currentTheme); } + public static Color getBgMain(UITheme t) { + return t == UITheme.DARK ? new Color(28, 28, 28) : new Color(242, 242, 244); + } + + public static Color getBgPanel() { return getBgPanel(currentTheme); } + public static Color getBgPanel(UITheme t) { + return t == UITheme.DARK ? new Color(36, 36, 36) : new Color(250, 250, 252); + } + + public static Color getBgComponent() { return getBgComponent(currentTheme); } + public static Color getBgComponent(UITheme t) { + return t == UITheme.DARK ? new Color(46, 46, 46) : Color.WHITE; + } + + public static Color getBgComponentLighter() { return getBgComponentLighter(currentTheme); } + public static Color getBgComponentLighter(UITheme t) { + return t == UITheme.DARK ? new Color(56, 56, 56) : new Color(236, 236, 238); + } + + public static Color getFgMain() { return getFgMain(currentTheme); } + public static Color getFgMain(UITheme t) { + return t == UITheme.DARK ? new Color(238, 238, 238) : new Color(24, 24, 24); + } + + public static Color getFgMuted() { return getFgMuted(currentTheme); } + public static Color getFgMuted(UITheme t) { + return t == UITheme.DARK ? new Color(175, 175, 175) : new Color(90, 90, 90); + } + + public static Color getFgDisabled() { return getFgDisabled(currentTheme); } + public static Color getFgDisabled(UITheme t) { + return t == UITheme.DARK ? new Color(110, 110, 110) : new Color(160, 160, 160); + } + + public static Color getBorder() { return getBorder(currentTheme); } + public static Color getBorder(UITheme t) { + return t == UITheme.DARK ? new Color(65, 65, 65) : new Color(205, 205, 205); + } + public static Color getBorderColor() { return getBorder(currentTheme); } + public static Color getBorderColor(UITheme t) { return getBorder(t); } + + public static Color getBorderFocused() { return getBorderFocused(currentTheme); } + public static Color getBorderFocused(UITheme t) { + return t == UITheme.DARK ? new Color(80, 140, 230) : new Color(50, 115, 220); + } + + public static Color getSelectionBg() { return getSelectionBg(currentTheme); } + public static Color getSelectionBg(UITheme t) { + return t == UITheme.DARK ? new Color(55, 105, 180) : new Color(60, 125, 215); + } + + public static Color getSelectionFg() { return getSelectionFg(currentTheme); } + public static Color getSelectionFg(UITheme t) { + return Color.WHITE; + } + + public static Color getMenuBarBg() { return getMenuBarBg(currentTheme); } + public static Color getMenuBarBg(UITheme t) { + return t == UITheme.DARK ? new Color(26, 26, 26) : new Color(240, 240, 242); + } + + public static Color getMenuBarFg() { return getMenuBarFg(currentTheme); } + public static Color getMenuBarFg(UITheme t) { + return t == UITheme.DARK ? new Color(235, 235, 235) : new Color(25, 25, 25); + } + + public static Color getMenuPopupBg() { return getMenuPopupBg(currentTheme); } + public static Color getMenuPopupBg(UITheme t) { + return t == UITheme.DARK ? new Color(38, 38, 38) : Color.WHITE; + } + + public static Color getMenuItemFg() { return getMenuItemFg(currentTheme); } + public static Color getMenuItemFg(UITheme t) { + return t == UITheme.DARK ? new Color(230, 230, 230) : new Color(30, 30, 30); + } + + public static Color getMenuItemHoverBg() { return getMenuItemHoverBg(currentTheme); } + public static Color getMenuItemHoverBg(UITheme t) { + return t == UITheme.DARK ? new Color(55, 95, 160) : new Color(220, 232, 250); + } + + public static Color getMenuItemHoverFg() { return getMenuItemHoverFg(currentTheme); } + public static Color getMenuItemHoverFg(UITheme t) { + return t == UITheme.DARK ? Color.WHITE : new Color(15, 15, 15); + } + + public static Color getTabBg() { return getTabBg(currentTheme); } + public static Color getTabBg(UITheme t) { + return t == UITheme.DARK ? new Color(34, 34, 34) : new Color(230, 230, 232); + } + + public static Color getTabFg() { return getTabFg(currentTheme); } + public static Color getTabFg(UITheme t) { + return t == UITheme.DARK ? new Color(185, 185, 185) : new Color(75, 75, 75); + } + + public static Color getTabSelectedBg() { return getTabSelectedBg(currentTheme); } + public static Color getTabSelectedBg(UITheme t) { + return t == UITheme.DARK ? new Color(50, 50, 50) : Color.WHITE; + } + + public static Color getTabSelectedFg() { return getTabSelectedFg(currentTheme); } + public static Color getTabSelectedFg(UITheme t) { + return t == UITheme.DARK ? Color.WHITE : new Color(20, 20, 20); + } + + public static Color getTableHeaderBg() { return getTableHeaderBg(currentTheme); } + public static Color getTableHeaderBg(UITheme t) { + return t == UITheme.DARK ? new Color(46, 46, 46) : new Color(232, 232, 236); + } + + public static Color getTableHeaderFg() { return getTableHeaderFg(currentTheme); } + public static Color getTableHeaderFg(UITheme t) { + return t == UITheme.DARK ? new Color(235, 235, 235) : new Color(30, 30, 30); + } + + public static Color getTableRowEven() { return getTableRowEven(currentTheme); } + public static Color getTableRowEven(UITheme t) { + return t == UITheme.DARK ? new Color(38, 38, 38) : Color.WHITE; + } + + public static Color getTableRowOdd() { return getTableRowOdd(currentTheme); } + public static Color getTableRowOdd(UITheme t) { + return t == UITheme.DARK ? new Color(33, 33, 33) : new Color(246, 247, 249); + } + + public static Color getTableGrid() { return getTableGrid(currentTheme); } + public static Color getTableGrid(UITheme t) { + return t == UITheme.DARK ? new Color(55, 55, 55) : new Color(225, 225, 225); + } + + public static Color getCodeAreaBg() { return getCodeAreaBg(currentTheme); } + public static Color getCodeAreaBg(UITheme t) { + return t == UITheme.DARK ? new Color(22, 22, 22) : new Color(252, 252, 252); + } + + public static Color getCodeAreaFg() { return getCodeAreaFg(currentTheme); } + public static Color getCodeAreaFg(UITheme t) { + return t == UITheme.DARK ? new Color(80, 230, 80) : new Color(0, 120, 0); + } + + public static Color getStatusBarBg() { return getStatusBarBg(currentTheme); } + public static Color getStatusBarBg(UITheme t) { + return t == UITheme.DARK ? new Color(20, 20, 20) : new Color(235, 235, 238); + } + + public static Color getStatusBarBorder() { return getStatusBarBorder(currentTheme); } + public static Color getStatusBarBorder(UITheme t) { + return t == UITheme.DARK ? new Color(42, 42, 42) : new Color(210, 210, 215); + } + + public static Color getOiaFgNormal() { return getOiaFgNormal(currentTheme); } + public static Color getOiaFgNormal(UITheme t) { + return t == UITheme.DARK ? new Color(50, 205, 50) : new Color(0, 130, 0); + } + + public static Color getOiaFgDim() { return getOiaFgDim(currentTheme); } + public static Color getOiaFgDim(UITheme t) { + return t == UITheme.DARK ? new Color(110, 110, 110) : new Color(90, 90, 90); + } + + public static Color getOiaFgAlert() { return getOiaFgAlert(currentTheme); } + public static Color getOiaFgAlert(UITheme t) { + return t == UITheme.DARK ? new Color(255, 80, 80) : new Color(190, 20, 20); + } + + public static Color getOiaFgWarn() { return getOiaFgWarn(currentTheme); } + public static Color getOiaFgWarn(UITheme t) { + return t == UITheme.DARK ? new Color(255, 190, 70) : new Color(180, 100, 0); + } + + // ========================================================================= + // Button Variants & Color helpers + // ========================================================================= + + public enum ButtonVariant { + DEFAULT, + PRIMARY, + CANCEL, + DANGER, + ACCENT + } + + public static Color getButtonBg(ButtonVariant variant) { + return getButtonBg(variant, currentTheme, false, false); + } + + public static Color getButtonBg(ButtonVariant variant, UITheme t) { + return getButtonBg(variant, t, false, false); + } + + public static Color getButtonBg(ButtonVariant variant, UITheme t, boolean hover, boolean pressed) { + switch (variant) { + case PRIMARY: + if (pressed) return new Color(25, 105, 35); + if (hover) return new Color(38, 145, 48); + return new Color(32, 128, 42); + + case DANGER: + if (pressed) return new Color(125, 30, 30); + if (hover) return new Color(165, 45, 45); + return new Color(145, 38, 38); + + case ACCENT: + if (pressed) return new Color(35, 80, 145); + if (hover) return new Color(55, 115, 195); + return new Color(45, 100, 175); + + case CANCEL: + case DEFAULT: + default: + if (t == UITheme.DARK) { + if (pressed) return new Color(42, 42, 42); + if (hover) return new Color(68, 68, 68); + return new Color(56, 56, 56); + } else { + if (pressed) return new Color(210, 210, 212); + if (hover) return new Color(228, 228, 232); + return new Color(238, 238, 240); + } + } + } + + public static Color getButtonFg(ButtonVariant variant, UITheme t) { + switch (variant) { + case PRIMARY: + case DANGER: + case ACCENT: + return Color.WHITE; + case CANCEL: + case DEFAULT: + default: + return t == UITheme.DARK ? new Color(235, 235, 235) : new Color(30, 30, 30); + } + } + + // ========================================================================= + // UIManager Defaults Configuration + // ========================================================================= + + public static void applyUIManagerDefaults(UITheme theme) { + Color bgMain = getBgMain(theme); + Color bgPanel = getBgPanel(theme); + Color bgComp = getBgComponent(theme); + Color fgMain = getFgMain(theme); + Color fgMuted = getFgMuted(theme); + Color border = getBorder(theme); + Color selBg = getSelectionBg(theme); + Color selFg = getSelectionFg(theme); + + UIManager.put("Panel.background", bgPanel); + UIManager.put("Panel.foreground", fgMain); + UIManager.put("Label.foreground", fgMain); + + UIManager.put("TextField.background", bgComp); + UIManager.put("TextField.foreground", fgMain); + UIManager.put("TextField.caretForeground", fgMain); + UIManager.put("TextField.selectionBackground", selBg); + UIManager.put("TextField.selectionForeground", selFg); + + UIManager.put("TextArea.background", bgComp); + UIManager.put("TextArea.foreground", fgMain); + UIManager.put("TextArea.caretForeground", fgMain); + UIManager.put("TextArea.selectionBackground", selBg); + UIManager.put("TextArea.selectionForeground", selFg); + + UIManager.put("Button.background", getBgComponentLighter(theme)); + UIManager.put("Button.foreground", fgMain); + UIManager.put("Button.select", selBg); + + UIManager.put("ComboBox.background", bgComp); + UIManager.put("ComboBox.foreground", fgMain); + UIManager.put("ComboBox.selectionBackground", selBg); + UIManager.put("ComboBox.selectionForeground", selFg); + + UIManager.put("Table.background", bgComp); + UIManager.put("Table.foreground", fgMain); + UIManager.put("Table.selectionBackground", selBg); + UIManager.put("Table.selectionForeground", selFg); + UIManager.put("Table.gridColor", getTableGrid(theme)); + + UIManager.put("TableHeader.background", getTableHeaderBg(theme)); + UIManager.put("TableHeader.foreground", getTableHeaderFg(theme)); + + UIManager.put("ScrollPane.background", bgPanel); + UIManager.put("Viewport.background", bgComp); + + UIManager.put("MenuBar.background", getMenuBarBg(theme)); + UIManager.put("MenuBar.foreground", getMenuBarFg(theme)); + UIManager.put("Menu.background", getMenuBarBg(theme)); + UIManager.put("Menu.foreground", getMenuBarFg(theme)); + UIManager.put("Menu.selectionBackground", selBg); + UIManager.put("Menu.selectionForeground", selFg); + + UIManager.put("PopupMenu.background", getMenuPopupBg(theme)); + UIManager.put("PopupMenu.foreground", getMenuItemFg(theme)); + + UIManager.put("MenuItem.background", getMenuPopupBg(theme)); + UIManager.put("MenuItem.foreground", getMenuItemFg(theme)); + UIManager.put("MenuItem.selectionBackground", selBg); + UIManager.put("MenuItem.selectionForeground", selFg); + + UIManager.put("CheckBoxMenuItem.background", getMenuPopupBg(theme)); + UIManager.put("CheckBoxMenuItem.foreground", getMenuItemFg(theme)); + UIManager.put("CheckBoxMenuItem.selectionBackground", selBg); + UIManager.put("CheckBoxMenuItem.selectionForeground", selFg); + + UIManager.put("RadioButtonMenuItem.background", getMenuPopupBg(theme)); + UIManager.put("RadioButtonMenuItem.foreground", getMenuItemFg(theme)); + UIManager.put("RadioButtonMenuItem.selectionBackground", selBg); + UIManager.put("RadioButtonMenuItem.selectionForeground", selFg); + + UIManager.put("Separator.background", border); + UIManager.put("Separator.foreground", border); + + UIManager.put("TabbedPane.background", getTabBg(theme)); + UIManager.put("TabbedPane.foreground", getTabFg(theme)); + UIManager.put("TabbedPane.selected", getTabSelectedBg(theme)); + UIManager.put("TabbedPane.selectHighlight", getTabSelectedBg(theme)); + + UIManager.put("CheckBox.background", bgPanel); + UIManager.put("CheckBox.foreground", fgMain); + UIManager.put("RadioButton.background", bgPanel); + UIManager.put("RadioButton.foreground", fgMain); + + UIManager.put("TitledBorder.titleColor", fgMain); + UIManager.put("OptionPane.background", bgPanel); + UIManager.put("OptionPane.messageForeground", fgMain); + } + + // ========================================================================= + // Component Styling & Custom UI Delegates + // ========================================================================= + + /** + * Styles a button with custom rendering, ensuring consistent high contrast + * and proper background color across Windows, macOS, and Linux. + */ + public static JButton styleButton(JButton button, ButtonVariant variant) { + if (button == null) return null; + button.setUI(new StyledButtonUI(variant)); + button.setFocusPainted(false); + button.setOpaque(false); + button.setContentAreaFilled(false); + button.setBorder(BorderFactory.createEmptyBorder(6, 14, 6, 14)); + button.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + return button; + } + + public static JButton createStyledButton(String text, ButtonVariant variant, Runnable action) { + JButton btn = new JButton(text); + styleButton(btn, variant); + if (action != null) { + btn.addActionListener(e -> action.run()); + } + return btn; + } + + public static JTextField styleTextField(JTextField field) { + if (field == null) return null; + field.setBackground(getBgComponent()); + field.setForeground(getFgMain()); + field.setCaretColor(getFgMain()); + field.setSelectionColor(getSelectionBg()); + field.setSelectedTextColor(getSelectionFg()); + field.setBorder(BorderFactory.createCompoundBorder( + new LineBorder(getBorder(), 1, true), + new EmptyBorder(4, 8, 4, 8))); + return field; + } + + public static JTextArea styleTextArea(JTextArea area) { + if (area == null) return null; + area.setBackground(getCodeAreaBg()); + area.setForeground(getCodeAreaFg()); + area.setCaretColor(getFgMain()); + area.setSelectionColor(getSelectionBg()); + area.setSelectedTextColor(getSelectionFg()); + return area; + } + + public static JComboBox styleComboBox(JComboBox box) { + if (box == null) return null; + box.setBackground(getBgComponent()); + box.setForeground(getFgMain()); + box.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + setBorder(new EmptyBorder(4, 8, 4, 8)); + if (isSelected) { + setBackground(getSelectionBg()); + setForeground(getSelectionFg()); + } else { + setBackground(getBgComponent()); + setForeground(getFgMain()); + } + return this; + } + }); + return box; + } + + public static JTable styleTable(JTable table) { + if (table == null) return null; + table.setBackground(getBgComponent()); + table.setForeground(getFgMain()); + table.setSelectionBackground(getSelectionBg()); + table.setSelectionForeground(getSelectionFg()); + table.setGridColor(getTableGrid()); + table.setRowHeight(24); + + table.setDefaultRenderer(Object.class, new DefaultTableCellRenderer() { + @Override + public Component getTableCellRendererComponent(JTable tbl, Object val, boolean isSel, boolean hasFocus, int row, int col) { + super.getTableCellRendererComponent(tbl, val, isSel, hasFocus, row, col); + setBorder(new EmptyBorder(2, 6, 2, 6)); + if (isSel) { + setBackground(getSelectionBg()); + setForeground(getSelectionFg()); + } else { + setBackground(row % 2 == 0 ? getTableRowEven() : getTableRowOdd()); + setForeground(getFgMain()); + } + return this; + } + }); + + JTableHeader header = table.getTableHeader(); + if (header != null) { + header.setBackground(getTableHeaderBg()); + header.setForeground(getTableHeaderFg()); + header.setFont(header.getFont().deriveFont(Font.BOLD)); + header.setDefaultRenderer(new DefaultTableCellRenderer() { + @Override + public Component getTableCellRendererComponent(JTable tbl, Object val, boolean isSel, boolean hasFocus, int row, int col) { + super.getTableCellRendererComponent(tbl, val, isSel, hasFocus, row, col); + setBackground(getTableHeaderBg()); + setForeground(getTableHeaderFg()); + setFont(getFont().deriveFont(Font.BOLD)); + setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 1, getBorderColor()), + new EmptyBorder(4, 6, 4, 6))); + return this; + } + }); + } + return table; + } + + public static JTabbedPane styleTabbedPane(JTabbedPane tp) { + if (tp == null) return null; + tp.setUI(new StyledTabbedPaneUI()); + tp.setBackground(getBgMain()); + tp.setForeground(getFgMain()); + return tp; + } + + public static JMenuBar styleMenuBar(JMenuBar bar) { + if (bar == null) return null; + bar.setUI(new StyledMenuBarUI()); + bar.setBackground(getMenuBarBg()); + bar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, getBorder())); + return bar; + } + + public static JMenu styleMenu(JMenu menu) { + if (menu == null) return null; + menu.setUI(new StyledMenuUI()); + menu.setForeground(getMenuBarFg()); + menu.setBackground(getMenuBarBg()); + menu.setOpaque(false); + return menu; + } + + public static JMenuItem styleMenuItem(JMenuItem item) { + if (item == null) return null; + item.setUI(new StyledMenuItemUI()); + item.setBackground(getMenuPopupBg()); + item.setForeground(getMenuItemFg()); + return item; + } + + public static JPopupMenu stylePopupMenu(JPopupMenu popup) { + if (popup == null) return null; + popup.setUI(new StyledPopupMenuUI()); + popup.setBackground(getMenuPopupBg()); + popup.setBorder(BorderFactory.createCompoundBorder( + new LineBorder(getBorder(), 1), + new EmptyBorder(4, 0, 4, 0))); + return popup; + } + + public static JScrollPane styleScrollPane(JScrollPane sp) { + if (sp == null) return null; + sp.setBackground(getBgPanel()); + if (sp.getViewport() != null) { + sp.getViewport().setBackground(getBgComponent()); + } + sp.setBorder(new LineBorder(getBorder(), 1)); + return sp; + } + + public static JSpinner styleSpinner(JSpinner sp) { + if (sp == null) return null; + sp.setBackground(getBgComponent()); + sp.setForeground(getFgMain()); + sp.setBorder(new LineBorder(getBorder(), 1)); + JComponent editor = sp.getEditor(); + if (editor instanceof JSpinner.DefaultEditor) { + JTextField tf = ((JSpinner.DefaultEditor) editor).getTextField(); + tf.setBackground(getBgComponent()); + tf.setForeground(getFgMain()); + tf.setCaretColor(getFgMain()); + tf.setBorder(new EmptyBorder(2, 4, 2, 4)); + } + return sp; + } + + public static JCheckBox styleCheckBox(JCheckBox cb) { + if (cb == null) return null; + cb.setOpaque(false); + cb.setForeground(getFgMain()); + cb.setFocusPainted(false); + return cb; + } + + public static JRadioButton styleRadioButton(JRadioButton rb) { + if (rb == null) return null; + rb.setOpaque(false); + rb.setForeground(getFgMain()); + rb.setFocusPainted(false); + return rb; + } + + public static Border createTitledBorder(String title) { + return BorderFactory.createTitledBorder( + new LineBorder(getBorder(), 1), + title, + TitledBorder.DEFAULT_JUSTIFICATION, + TitledBorder.DEFAULT_POSITION, + new Font(Font.SANS_SERIF, Font.BOLD, 12), + getFgMain()); + } + + // ========================================================================= + // Recursive Component Styling + // ========================================================================= + + public static void applyTheme(Component comp) { + applyTheme(comp, currentTheme); + } + + public static void applyTheme(Component comp, UITheme theme) { + if (comp == null) return; + + // Skip swatch preview panels whose background is the user-configured color + if ("colorSwatch".equals(comp.getName())) { + return; + } + + if (comp instanceof JMenuBar) { + styleMenuBar((JMenuBar) comp); + for (int i = 0; i < ((JMenuBar) comp).getMenuCount(); i++) { + applyTheme(((JMenuBar) comp).getMenu(i), theme); + } + return; + } + + if (comp instanceof JMenu) { + styleMenu((JMenu) comp); + JMenu menu = (JMenu) comp; + for (int i = 0; i < menu.getItemCount(); i++) { + JMenuItem item = menu.getItem(i); + if (item != null) applyTheme(item, theme); + } + return; + } + + if (comp instanceof JMenuItem) { + styleMenuItem((JMenuItem) comp); + return; + } + + if (comp instanceof JPopupMenu) { + stylePopupMenu((JPopupMenu) comp); + for (Component child : ((JPopupMenu) comp).getComponents()) { + applyTheme(child, theme); + } + return; + } + + if (comp instanceof JTabbedPane) { + styleTabbedPane((JTabbedPane) comp); + JTabbedPane tp = (JTabbedPane) comp; + for (int i = 0; i < tp.getTabCount(); i++) { + applyTheme(tp.getComponentAt(i), theme); + } + return; + } + + if (comp instanceof JTable) { + styleTable((JTable) comp); + return; + } + + if (comp instanceof JScrollPane) { + styleScrollPane((JScrollPane) comp); + JScrollPane sp = (JScrollPane) comp; + if (sp.getViewport() != null) { + applyTheme(sp.getViewport().getView(), theme); + } + return; + } + + if (comp instanceof JButton) { + JButton btn = (JButton) comp; + if (!(btn.getUI() instanceof StyledButtonUI)) { + styleButton(btn, ButtonVariant.DEFAULT); + } + return; + } + + if (comp instanceof JComboBox) { + styleComboBox((JComboBox) comp); + return; + } + + if (comp instanceof JSpinner) { + styleSpinner((JSpinner) comp); + return; + } + + if (comp instanceof JTextArea) { + styleTextArea((JTextArea) comp); + return; + } + + if (comp instanceof JTextField) { + styleTextField((JTextField) comp); + return; + } + + if (comp instanceof JCheckBox) { + styleCheckBox((JCheckBox) comp); + return; + } + + if (comp instanceof JRadioButton) { + styleRadioButton((JRadioButton) comp); + return; + } + + if (comp instanceof JLabel) { + comp.setForeground(getFgMain(theme)); + return; + } + + if (comp instanceof JPanel) { + JPanel panel = (JPanel) comp; + if (panel.isOpaque()) { + panel.setBackground(getBgPanel(theme)); + } + panel.setForeground(getFgMain(theme)); + Border b = panel.getBorder(); + if (b instanceof TitledBorder) { + TitledBorder tb = (TitledBorder) b; + tb.setTitleColor(getFgMain(theme)); + tb.setBorder(new LineBorder(getBorder(theme), 1)); + } + } else if (comp instanceof Container) { + comp.setBackground(getBgMain(theme)); + comp.setForeground(getFgMain(theme)); + } + + if (comp instanceof Container) { + for (Component child : ((Container) comp).getComponents()) { + applyTheme(child, theme); + } + } + } + + public static void applyThemeToWindow(Window window) { + if (window == null) return; + window.setBackground(getBgMain()); + if (window instanceof RootPaneContainer) { + RootPaneContainer rpc = (RootPaneContainer) window; + if (rpc.getContentPane() != null) { + rpc.getContentPane().setBackground(getBgMain()); + applyTheme(rpc.getContentPane()); + } + if (rpc.getRootPane() != null && rpc.getRootPane().getJMenuBar() != null) { + applyTheme(rpc.getRootPane().getJMenuBar()); + } + } + window.repaint(); + } + + // ========================================================================= + // Custom UI Implementations + // ========================================================================= + + public static class StyledButtonUI extends BasicButtonUI { + private final ButtonVariant variant; + private boolean hover = false; + + public StyledButtonUI(ButtonVariant variant) { + this.variant = variant != null ? variant : ButtonVariant.DEFAULT; + } + + @Override + public void installUI(JComponent c) { + super.installUI(c); + AbstractButton b = (AbstractButton) c; + b.addMouseListener(new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + hover = true; + b.repaint(); + } + @Override + public void mouseExited(MouseEvent e) { + hover = false; + b.repaint(); + } + }); + } + + @Override + public void paint(Graphics g, JComponent c) { + AbstractButton b = (AbstractButton) c; + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = c.getWidth(); + int h = c.getHeight(); + + ButtonModel model = b.getModel(); + boolean pressed = model.isArmed() && model.isPressed(); + boolean enabled = b.isEnabled(); + + UITheme t = ThemeManager.getTheme(); + Color bg = enabled ? ThemeManager.getButtonBg(variant, t, hover, pressed) : (t == UITheme.DARK ? new Color(40, 40, 40) : new Color(220, 220, 220)); + Color fg = enabled ? ThemeManager.getButtonFg(variant, t) : ThemeManager.getFgDisabled(t); + + // Background + g2.setColor(bg); + g2.fillRoundRect(0, 0, w, h, 6, 6); + + // Border + if (variant == ButtonVariant.DEFAULT || variant == ButtonVariant.CANCEL) { + g2.setColor(ThemeManager.getBorder(t)); + g2.drawRoundRect(0, 0, w - 1, h - 1, 6, 6); + } + + // Text / Icon + FontMetrics fm = g2.getFontMetrics(b.getFont()); + String text = b.getText(); + if (text != null && !text.isEmpty()) { + g2.setColor(fg); + g2.setFont(b.getFont()); + int tx = (w - fm.stringWidth(text)) / 2; + int ty = (h - fm.getHeight()) / 2 + fm.getAscent(); + g2.drawString(text, tx, ty); + } + + g2.dispose(); + } + } + + public static class StyledMenuBarUI extends BasicMenuBarUI { + @Override + public void paint(Graphics g, JComponent c) { + g.setColor(ThemeManager.getMenuBarBg()); + g.fillRect(0, 0, c.getWidth(), c.getHeight()); + } + } + + public static class StyledMenuUI extends BasicMenuUI { + @Override + protected void paintBackground(Graphics g, JMenuItem menuItem, Color bgColor) { + ButtonModel model = menuItem.getModel(); + if (model.isArmed() || model.isSelected()) { + g.setColor(ThemeManager.getMenuItemHoverBg()); + g.fillRect(0, 0, menuItem.getWidth(), menuItem.getHeight()); + } else { + g.setColor(ThemeManager.getMenuBarBg()); + g.fillRect(0, 0, menuItem.getWidth(), menuItem.getHeight()); + } + } + + @Override + protected void paintText(Graphics g, JMenuItem menuItem, Rectangle textRect, String text) { + ButtonModel model = menuItem.getModel(); + if (model.isArmed() || model.isSelected()) { + g.setColor(ThemeManager.getMenuItemHoverFg()); + } else { + g.setColor(ThemeManager.getMenuBarFg()); + } + super.paintText(g, menuItem, textRect, text); + } + } + + public static class StyledMenuItemUI extends BasicMenuItemUI { + @Override + protected void paintBackground(Graphics g, JMenuItem menuItem, Color bgColor) { + ButtonModel model = menuItem.getModel(); + if (model.isArmed() || model.isSelected()) { + g.setColor(ThemeManager.getSelectionBg()); + g.fillRect(0, 0, menuItem.getWidth(), menuItem.getHeight()); + } else { + g.setColor(ThemeManager.getMenuPopupBg()); + g.fillRect(0, 0, menuItem.getWidth(), menuItem.getHeight()); + } + } + + @Override + protected void paintText(Graphics g, JMenuItem menuItem, Rectangle textRect, String text) { + ButtonModel model = menuItem.getModel(); + if (model.isArmed() || model.isSelected()) { + g.setColor(ThemeManager.getSelectionFg()); + } else { + g.setColor(ThemeManager.getMenuItemFg()); + } + super.paintText(g, menuItem, textRect, text); + } + } + + public static class StyledPopupMenuUI extends BasicPopupMenuUI { + @Override + public void paint(Graphics g, JComponent c) { + g.setColor(ThemeManager.getMenuPopupBg()); + g.fillRect(0, 0, c.getWidth(), c.getHeight()); + } + } + + public static class StyledTabbedPaneUI extends BasicTabbedPaneUI { + @Override + protected void installDefaults() { + super.installDefaults(); + tabInsets = new Insets(6, 16, 6, 16); + selectedTabPadInsets = new Insets(2, 2, 2, 2); + tabAreaInsets = new Insets(4, 4, 0, 4); + } + + @Override + protected void paintTabBackground(Graphics g, int tabPlacement, int tabIndex, int x, int y, int w, int h, boolean isSelected) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(isSelected ? ThemeManager.getTabSelectedBg() : ThemeManager.getTabBg()); + g2.fillRoundRect(x, y, w, h + 4, 6, 6); + g2.setColor(ThemeManager.getBorder()); + g2.drawRoundRect(x, y, w - 1, h + 4, 6, 6); + g2.dispose(); + } + + @Override + protected void paintText(Graphics g, int tabPlacement, Font font, FontMetrics metrics, int tabIndex, String title, Rectangle textRect, boolean isSelected) { + g.setFont(font); + g.setColor(isSelected ? ThemeManager.getTabSelectedFg() : ThemeManager.getTabFg()); + g.drawString(title, textRect.x, textRect.y + metrics.getAscent()); + } + + @Override + protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) { + int width = tabPane.getWidth(); + int height = tabPane.getHeight(); + Insets insets = tabPane.getInsets(); + int x = insets.left; + int y = insets.top + calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight); + int w = width - insets.right - insets.left; + int h = height - insets.top - insets.bottom - calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight); + + g.setColor(ThemeManager.getTabSelectedBg()); + g.fillRect(x, y, w, h); + g.setColor(ThemeManager.getBorder()); + g.drawRect(x, y, w - 1, h - 1); + } + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/UITheme.java b/j3270/src/main/java/haus/nightmare/j3270/ui/UITheme.java new file mode 100644 index 0000000..7afb1ed --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/UITheme.java @@ -0,0 +1,33 @@ +package haus.nightmare.j3270.ui; + +/** + * Supported UI themes for the j3270 Java desktop interface. + */ +public enum UITheme { + DARK("Dark Mode"), + LIGHT("Light Mode"); + + private final String displayName; + + UITheme(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + @Override + public String toString() { + return displayName; + } + + public static UITheme fromString(String name) { + if (name == null) return DARK; + String s = name.trim().toUpperCase(); + if ("LIGHT".equals(s) || "LIGHT MODE".equals(s) || "LIGHT_MODE".equals(s)) { + return LIGHT; + } + return DARK; + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/UntrustedCertificateDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/UntrustedCertificateDialog.java index baea7ee..5f909ca 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/UntrustedCertificateDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/UntrustedCertificateDialog.java @@ -26,25 +26,24 @@ public class UntrustedCertificateDialog extends JDialog { private void buildUI(String host, int port, X509Certificate[] chain, CertificateException exception) { JPanel mainPanel = new JPanel(new BorderLayout(12, 12)); mainPanel.setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); - mainPanel.setBackground(new Color(30, 30, 30)); // Header JPanel headerPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); - headerPanel.setBackground(new Color(30, 30, 30)); + headerPanel.setOpaque(false); JLabel iconLabel = new JLabel("⚠️"); iconLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 28)); headerPanel.add(iconLabel); JPanel titleBox = new JPanel(new GridLayout(2, 1, 0, 2)); - titleBox.setBackground(new Color(30, 30, 30)); + titleBox.setOpaque(false); JLabel titleLabel = new JLabel("Untrusted SSL Certificate"); titleLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16)); - titleLabel.setForeground(new Color(255, 180, 80)); + titleLabel.setForeground(ThemeManager.getOiaFgWarn()); titleBox.add(titleLabel); JLabel subtitleLabel = new JLabel("The server certificate for " + host + ":" + port + " could not be verified."); subtitleLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); - subtitleLabel.setForeground(new Color(180, 180, 180)); + subtitleLabel.setForeground(ThemeManager.getFgMuted()); titleBox.add(subtitleLabel); headerPanel.add(titleBox); mainPanel.add(headerPanel, BorderLayout.NORTH); @@ -71,30 +70,27 @@ public class UntrustedCertificateDialog extends JDialog { JTextArea detailsArea = new JTextArea(sb.toString()); detailsArea.setEditable(false); detailsArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); - detailsArea.setBackground(new Color(20, 20, 20)); - detailsArea.setForeground(new Color(210, 210, 210)); + ThemeManager.styleTextArea(detailsArea); detailsArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); JScrollPane scrollPane = new JScrollPane(detailsArea); scrollPane.setPreferredSize(new Dimension(520, 260)); - scrollPane.setBorder(BorderFactory.createLineBorder(new Color(60, 60, 60))); + ThemeManager.styleScrollPane(scrollPane); mainPanel.add(scrollPane, BorderLayout.CENTER); // Buttons JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); - buttonPanel.setBackground(new Color(30, 30, 30)); + buttonPanel.setOpaque(false); JButton cancelBtn = new JButton("Cancel Connection"); - cancelBtn.setBackground(new Color(60, 60, 60)); - cancelBtn.setForeground(new Color(220, 220, 220)); + ThemeManager.styleButton(cancelBtn, ThemeManager.ButtonVariant.CANCEL); cancelBtn.addActionListener(e -> { accepted = false; dispose(); }); JButton trustBtn = new JButton("Connect Anyway"); - trustBtn.setBackground(new Color(180, 100, 40)); - trustBtn.setForeground(Color.WHITE); + ThemeManager.styleButton(trustBtn, ThemeManager.ButtonVariant.DANGER); trustBtn.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 13)); trustBtn.addActionListener(e -> { accepted = true; @@ -106,6 +102,7 @@ public class UntrustedCertificateDialog extends JDialog { mainPanel.add(buttonPanel, BorderLayout.SOUTH); setContentPane(mainPanel); + ThemeManager.applyThemeToWindow(this); getRootPane().setDefaultButton(trustBtn); } diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java new file mode 100644 index 0000000..97e9137 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java @@ -0,0 +1,213 @@ +package haus.nightmare.j3270.ui; + +import haus.nightmare.j3270.config.Settings; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.io.File; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +public class ThemeManagerTest { + + @BeforeEach + public void setup() { + ThemeManager.setTheme(UITheme.DARK); + } + + @Test + public void testUIThemeEnum() { + assertEquals("Dark Mode", UITheme.DARK.getDisplayName()); + assertEquals("Light Mode", UITheme.LIGHT.getDisplayName()); + + assertEquals(UITheme.DARK, UITheme.fromString("DARK")); + assertEquals(UITheme.DARK, UITheme.fromString("dark")); + assertEquals(UITheme.DARK, UITheme.fromString("Dark Mode")); + assertEquals(UITheme.LIGHT, UITheme.fromString("LIGHT")); + assertEquals(UITheme.LIGHT, UITheme.fromString("light")); + assertEquals(UITheme.LIGHT, UITheme.fromString("Light Mode")); + assertEquals(UITheme.DARK, UITheme.fromString("invalid_value")); + } + + @Test + public void testColorTokensContrast() { + for (UITheme theme : UITheme.values()) { + Color bg = ThemeManager.getBgMain(theme); + Color fg = ThemeManager.getFgMain(theme); + assertNotNull(bg); + assertNotNull(fg); + + // Compute luminance contrast + double bgLum = (0.299 * bg.getRed() + 0.587 * bg.getGreen() + 0.114 * bg.getBlue()); + double fgLum = (0.299 * fg.getRed() + 0.587 * fg.getGreen() + 0.114 * fg.getBlue()); + double lumDiff = Math.abs(bgLum - fgLum); + + // Minimum luminance difference to guarantee readability + assertTrue(lumDiff > 120, "Theme " + theme + " text contrast luminance difference " + lumDiff + " must be > 120"); + + // Verify Menu Bar colors + Color menuBg = ThemeManager.getMenuBarBg(theme); + Color menuFg = ThemeManager.getMenuItemFg(theme); + double menuBgLum = (0.299 * menuBg.getRed() + 0.587 * menuBg.getGreen() + 0.114 * menuBg.getBlue()); + double menuFgLum = (0.299 * menuFg.getRed() + 0.587 * menuFg.getGreen() + 0.114 * menuFg.getBlue()); + assertTrue(Math.abs(menuBgLum - menuFgLum) > 100, "Menu contrast in " + theme + " must be high"); + + // Verify Status Bar colors + Color sbBg = ThemeManager.getStatusBarBg(theme); + Color sbNormal = ThemeManager.getOiaFgNormal(theme); + assertNotNull(sbBg); + assertNotNull(sbNormal); + + // Verify Button colors + Color btnDefBg = ThemeManager.getButtonBg(ThemeManager.ButtonVariant.DEFAULT, theme); + Color btnDefFg = ThemeManager.getButtonFg(ThemeManager.ButtonVariant.DEFAULT, theme); + assertNotNull(btnDefBg); + assertNotNull(btnDefFg); + + Color btnPriBg = ThemeManager.getButtonBg(ThemeManager.ButtonVariant.PRIMARY, theme); + Color btnPriFg = ThemeManager.getButtonFg(ThemeManager.ButtonVariant.PRIMARY, theme); + assertNotNull(btnPriBg); + assertNotNull(btnPriFg); + } + } + + @Test + public void testThemeChangeListeners() { + AtomicReference notifiedTheme = new AtomicReference<>(null); + ThemeManager.addThemeChangeListener(notifiedTheme::set); + + ThemeManager.setTheme(UITheme.LIGHT); + assertEquals(UITheme.LIGHT, ThemeManager.getTheme()); + assertEquals(UITheme.LIGHT, notifiedTheme.get()); + assertTrue(ThemeManager.isLight()); + assertFalse(ThemeManager.isDark()); + + ThemeManager.setTheme(UITheme.DARK); + assertEquals(UITheme.DARK, ThemeManager.getTheme()); + assertEquals(UITheme.DARK, notifiedTheme.get()); + assertTrue(ThemeManager.isDark()); + assertFalse(ThemeManager.isLight()); + } + + @Test + public void testComponentStyling() { + JButton btn = new JButton("Test"); + ThemeManager.styleButton(btn, ThemeManager.ButtonVariant.PRIMARY); + assertNotNull(btn.getUI()); + assertTrue(btn.getUI() instanceof ThemeManager.StyledButtonUI); + + JTextField tf = new JTextField("Test"); + ThemeManager.styleTextField(tf); + assertNotNull(tf.getCaretColor()); + + JTextArea ta = new JTextArea("Test area"); + ThemeManager.styleTextArea(ta); + assertNotNull(ta.getCaretColor()); + + JComboBox cb = new JComboBox<>(new String[]{"A", "B"}); + ThemeManager.styleComboBox(cb); + assertNotNull(cb.getUI()); + + JTable table = new JTable(new DefaultTableModel(new Object[]{"Col1"}, 1)); + ThemeManager.styleTable(table); + assertNotNull(table.getSelectionBackground()); + + JTabbedPane tp = new JTabbedPane(); + tp.addTab("Tab1", new JPanel()); + ThemeManager.styleTabbedPane(tp); + assertNotNull(tp.getUI()); + + JMenuBar mb = new JMenuBar(); + ThemeManager.styleMenuBar(mb); + + JMenu menu = new JMenu("File"); + ThemeManager.styleMenu(menu); + + JMenuItem mi = new JMenuItem("Open"); + ThemeManager.styleMenuItem(mi); + + JPopupMenu popup = new JPopupMenu(); + ThemeManager.stylePopupMenu(popup); + + JCheckBox chk = new JCheckBox("Check"); + ThemeManager.styleCheckBox(chk); + + JRadioButton rb = new JRadioButton("Radio"); + ThemeManager.styleRadioButton(rb); + + Border tb = ThemeManager.createTitledBorder("Title"); + assertNotNull(tb); + } + + @Test + public void testRecursiveApplyTheme() { + JPanel root = new JPanel(new BorderLayout()); + JButton b = new JButton("OK"); + JTextField f = new JTextField("Data"); + JTabbedPane tp = new JTabbedPane(); + JPanel tabContent = new JPanel(); + JLabel lbl = new JLabel("Label"); + tabContent.add(lbl); + tp.addTab("T1", tabContent); + + root.add(b, BorderLayout.NORTH); + root.add(f, BorderLayout.CENTER); + root.add(tp, BorderLayout.SOUTH); + + ThemeManager.setTheme(UITheme.LIGHT); + ThemeManager.applyTheme(root); + + assertEquals(ThemeManager.getBgPanel(UITheme.LIGHT), root.getBackground()); + assertEquals(ThemeManager.getFgMain(UITheme.LIGHT), lbl.getForeground()); + + ThemeManager.setTheme(UITheme.DARK); + ThemeManager.applyTheme(root); + + assertEquals(ThemeManager.getBgPanel(UITheme.DARK), root.getBackground()); + assertEquals(ThemeManager.getFgMain(UITheme.DARK), lbl.getForeground()); + } + + @Test + public void testSettingsPersistence() throws Exception { + Settings.setJavaUiTheme(UITheme.LIGHT); + assertEquals(UITheme.LIGHT, Settings.getJavaUiTheme()); + + File tmpIni = File.createTempFile("j3270_test_config", ".ini"); + tmpIni.deleteOnExit(); + + try { + // Write INI file with javaUiTheme = dark + try (PrintWriter pw = new PrintWriter(tmpIni)) { + pw.println("[j3270]"); + pw.println("javaUiTheme = dark"); + pw.println("fontFamily = Monospaced"); + pw.println("fontSize = 16"); + } + + Settings.loadFromIniFile(tmpIni.getAbsolutePath()); + assertEquals(UITheme.DARK, Settings.getJavaUiTheme()); + + // Switch to light and export + Settings.setJavaUiTheme(UITheme.LIGHT); + File exportFile = File.createTempFile("j3270_export_config", ".ini"); + exportFile.deleteOnExit(); + + Settings.exportToIniFile(exportFile.getAbsolutePath()); + String exportedContent = new String(Files.readAllBytes(exportFile.toPath())); + assertTrue(exportedContent.toLowerCase().contains("javauitheme = light")); + + // Reload exported file + Settings.loadFromIniFile(exportFile.getAbsolutePath()); + assertEquals(UITheme.LIGHT, Settings.getJavaUiTheme()); + } finally { + tmpIni.delete(); + } + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java index b898db1..237f1eb 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java @@ -5,6 +5,10 @@ package haus.nightmare.lib3270j; */ public class ConnectionConfig { + public enum ProxyType { + NONE, HTTP, SOCKS4, SOCKS5 + } + private String host; private int port = 23; private TerminalModel model = TerminalModel.IBM_3279_4; @@ -28,6 +32,21 @@ public class ConnectionConfig { private haus.nightmare.lib3270j.graphics.GraphicsMode graphicsMode = haus.nightmare.lib3270j.graphics.GraphicsMode.BOTH; private String codePage = "037"; private String associatedPrinterLu = null; + private boolean nvtLocalEcho = false; + + // Proxy configuration + private ProxyType proxyType = ProxyType.NONE; + private String proxyHost = null; + private int proxyPort = 0; + private String proxyUsername = null; + private String proxyPassword = null; + + // STARTTLS (Telnet Option 46) dynamic socket elevation + private boolean startTlsEnabled = true; + + // RFC 1572 / RFC 2877 Environment variables (Express Logon) + private java.util.Map environmentVariables = new java.util.LinkedHashMap<>(); + private java.util.Map userVariables = new java.util.LinkedHashMap<>(); public ConnectionConfig() {} @@ -101,6 +120,9 @@ public class ConnectionConfig { this.codePage = (codePage != null && !codePage.trim().isEmpty()) ? codePage.trim() : "037"; } + public boolean isNvtLocalEcho() { return nvtLocalEcho; } + public void setNvtLocalEcho(boolean nvtLocalEcho) { this.nvtLocalEcho = nvtLocalEcho; } + public String getTerminalName() { return terminalName; } public void setTerminalName(String name) { this.terminalName = name; } @@ -134,9 +156,58 @@ public class ConnectionConfig { this.dynamicCols = cols; } + public ProxyType getProxyType() { return proxyType; } + public void setProxyType(ProxyType proxyType) { this.proxyType = proxyType != null ? proxyType : ProxyType.NONE; } + + public String getProxyHost() { return proxyHost; } + public void setProxyHost(String proxyHost) { this.proxyHost = proxyHost; } + + public int getProxyPort() { return proxyPort; } + public void setProxyPort(int proxyPort) { this.proxyPort = proxyPort; } + + public String getProxyUsername() { return proxyUsername; } + public void setProxyUsername(String proxyUsername) { this.proxyUsername = proxyUsername; } + + public String getProxyPassword() { return proxyPassword; } + public void setProxyPassword(String proxyPassword) { this.proxyPassword = proxyPassword; } + + public void setProxy(ProxyType type, String host, int port, String username, String password) { + this.proxyType = type != null ? type : ProxyType.NONE; + this.proxyHost = host; + this.proxyPort = port; + this.proxyUsername = username; + this.proxyPassword = password; + } + + public boolean isStartTlsEnabled() { return startTlsEnabled; } + public void setStartTlsEnabled(boolean enabled) { this.startTlsEnabled = enabled; } + + public java.util.Map getEnvironmentVariables() { return environmentVariables; } + public void setEnvironmentVariables(java.util.Map vars) { + this.environmentVariables = (vars != null) ? new java.util.LinkedHashMap<>(vars) : new java.util.LinkedHashMap<>(); + } + public void setEnvironmentVariable(String name, String value) { + if (name != null) { + if (value != null) this.environmentVariables.put(name, value); + else this.environmentVariables.remove(name); + } + } + + public java.util.Map getUserVariables() { return userVariables; } + public void setUserVariables(java.util.Map vars) { + this.userVariables = (vars != null) ? new java.util.LinkedHashMap<>(vars) : new java.util.LinkedHashMap<>(); + } + public void setUserVariable(String name, String value) { + if (name != null) { + if (value != null) this.userVariables.put(name, value); + else this.userVariables.remove(name); + } + } + /** * Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"), - * plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), or standard "host:port" formats. + * plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), proxy flags (e.g. "--proxy=http://proxy:8080 host:23"), + * or standard "host:port" formats. */ public static ConnectionConfig parseHostString(String hostStr, int defaultPort, TerminalModel defaultModel) { if (hostStr == null || hostStr.trim().isEmpty()) { @@ -146,6 +217,50 @@ public class ConnectionConfig { boolean tls = false; boolean tn3270e = true; + // Parse --proxy= or -proxy= flags + ProxyType pType = ProxyType.NONE; + String pHost = null; + int pPort = 0; + String pUser = null; + String pPass = null; + + String[] tokens = s.split("\\s+"); + StringBuilder remaining = new StringBuilder(); + for (String tok : tokens) { + if (tok.startsWith("--proxy=") || tok.startsWith("-proxy=")) { + String proxyUrl = tok.substring(tok.indexOf('=') + 1).trim(); + try { + java.net.URI uri = new java.net.URI(proxyUrl); + String scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase() : "http"; + if (scheme.equals("http") || scheme.equals("https")) { + pType = ProxyType.HTTP; + pPort = uri.getPort() > 0 ? uri.getPort() : 8080; + } else if (scheme.equals("socks4") || scheme.equals("socks4a")) { + pType = ProxyType.SOCKS4; + pPort = uri.getPort() > 0 ? uri.getPort() : 1080; + } else if (scheme.equals("socks5") || scheme.equals("socks")) { + pType = ProxyType.SOCKS5; + pPort = uri.getPort() > 0 ? uri.getPort() : 1080; + } + pHost = uri.getHost(); + String userInfo = uri.getUserInfo(); + if (userInfo != null) { + int colon = userInfo.indexOf(':'); + if (colon >= 0) { + pUser = userInfo.substring(0, colon); + pPass = userInfo.substring(colon + 1); + } else { + pUser = userInfo; + } + } + } catch (Exception ignored) {} + } else { + if (remaining.length() > 0) remaining.append(" "); + remaining.append(tok); + } + } + s = remaining.toString().trim(); + // Parse chained x3270-style prefixes (e.g. "L:P:host:port" or "P:host:port") boolean prefixFound = true; while (prefixFound) { @@ -200,6 +315,9 @@ public class ConnectionConfig { ConnectionConfig config = new ConnectionConfig(host, port, defaultModel != null ? defaultModel : TerminalModel.IBM_3279_4); config.setUseTls(tls); config.setTn3270eEnabled(tn3270e); + if (pType != ProxyType.NONE && pHost != null) { + config.setProxy(pType, pHost, pPort, pUser, pPass); + } return config; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index 22fb045..2046584 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -118,6 +118,14 @@ public class Telnet3270Client { dsProcessor.addScreenUpdateListener(l); } + public void addSCSInboundListener(haus.nightmare.lib3270j.listener.SCSInboundListener l) { + fsm.addSCSInboundListener(l); + } + + public void removeSCSInboundListener(haus.nightmare.lib3270j.listener.SCSInboundListener l) { + fsm.removeSCSInboundListener(l); + } + // ========== Screen access ========== /** Get the screen buffer for rendering. */ diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java index 0352ec5..aa1ea8a 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -144,6 +144,9 @@ public class DataStreamProcessor { int oldCols = screen.getCols(); screen.erase(false); graphicsPlane.clear(); + if (gocaDecoder != null) { + gocaDecoder.setGraphicsCursorActive(false); + } processWrite(data, offset, length, true); if (oldRows != screen.getRows() || oldCols != screen.getCols()) { notifyScreenSizeChanged(); @@ -161,6 +164,9 @@ public class DataStreamProcessor { int oldCols = screen.getCols(); screen.erase(true); graphicsPlane.clear(); + if (gocaDecoder != null) { + gocaDecoder.setGraphicsCursorActive(false); + } processWrite(data, offset, length, true); if (oldRows != screen.getRows() || oldCols != screen.getCols()) { notifyScreenSizeChanged(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java index 2c43944..f6e5107 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java @@ -33,6 +33,19 @@ public class ECLPS implements ECLConstants { return fieldList; } + private boolean nvtMode = false; + + public boolean isNVTmode() { + if (inputProcessor != null && inputProcessor.isNvtMode()) { + return true; + } + return nvtMode; + } + + public void setNVTmode(boolean nvt) { + this.nvtMode = nvt; + } + public int getSize() { return screen.getRows() * screen.getCols(); } public int getRows() { return screen.getRows(); } public int getCols() { return screen.getCols(); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java index 4c82018..bec4f98 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -8,6 +8,7 @@ import haus.nightmare.lib3270j.protocol.TelnetConstants; import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.logging.Logger; /** @@ -31,6 +32,14 @@ public class InputProcessor { this.fsm = fsm; } + public TelnetFSM getFsm() { + return fsm; + } + + public boolean isNvtMode() { + return fsm != null && fsm.getConnectionState() != null && fsm.getConnectionState().isNvt(); + } + private haus.nightmare.lib3270j.graphics.GraphicsPlane graphicsPlane; private haus.nightmare.lib3270j.graphics.GocaDecoder gocaDecoder; @@ -95,6 +104,15 @@ public class InputProcessor { public void typeCharacter(char ch) { if (keyboardLocked) return; + if (isNvtMode()) { + try { + fsm.sendNVTChar(ch); + } catch (IOException e) { + log.warning("Failed to send NVT character: " + e.getMessage()); + } + return; + } + int size = screen.getRows() * screen.getCols(); if (size <= 0) return; int baddr = screen.getCursorAddress(); @@ -237,86 +255,6 @@ public class InputProcessor { return; } - if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) { - int gx = gocaDecoder.getGraphicCursorX(); - int gy = gocaDecoder.getGraphicCursorY(); - int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80; - int numRows = (screen != null && screen.getRows() > 0) ? screen.getRows() : 24; - int cursorAddr = screen != null ? screen.getCursorAddress() : 0; - int row = cursorAddr / cols; - int col = cursorAddr % cols; - - byte[] sf = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( - gx, gy, row, col, aidCode, false, false, false - ); - - StringBuilder sfHex = new StringBuilder(); - for (byte b : sf) { - sfHex.append(String.format("%02X ", b & 0xFF)); - } - log.info(String.format( - "sendAid (graphic): goca=(%d, %d) row=%d col=%d cursorAddr=%d aid=0x%02X SF_HEX=[%s]", - gx, gy, row, col, cursorAddr, aidCode, sfHex.toString().trim() - )); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - // Structured Field AID (0x88) + 56-byte Graphic Input SF - out.write(AID_SF); - try { - out.write(sf); - } catch (java.io.IOException ignored) {} - - // Trailing AID + cursor address - out.write(aidCode); - byte[] caddr = encodeAddress(cursorAddr, numRows, cols); - out.write(caddr[0] & 0xFF); - out.write(caddr[1] & 0xFF); - - if (aidCode == AID_PA1 || aidCode == AID_PA2 || aidCode == AID_PA3) { - sendAidResponse(out.toByteArray()); - return; - } - - if (screen.isFormatted()) { - int size = screen.getRows() * screen.getCols(); - for (int i = 0; i < size; i++) { - ExtendedAttribute ea = screen.getCell(i); - if (ea.isFieldAttribute() && faIsModified(ea.fa & 0xFF)) { - int fieldStart = (i + 1) % size; - - // Always send SBA and address of first character in field - out.write(ORDER_SBA); - byte[] addr = encodeAddress(fieldStart, screen.getRows(), screen.getCols()); - out.write(addr[0] & 0xFF); - out.write(addr[1] & 0xFF); - - // Send all non-null characters in field (suppressing 0x00) - int pos = fieldStart; - while (!screen.getCell(pos).isFieldAttribute()) { - int b = screen.getCell(pos).ec & 0xFF; - if (b != 0x00) { - out.write(b); - } - pos = (pos + 1) % size; - if (pos == fieldStart) break; - } - } - } - } else { - int size = screen.getRows() * screen.getCols(); - for (int i = 0; i < size; i++) { - int b = screen.getCell(i).ec & 0xFF; - if (b != 0x00) { - out.write(b); - } - } - } - - sendAidResponse(out.toByteArray()); - return; - } - if (aidCode == AID_PA1 || aidCode == AID_PA2 || aidCode == AID_PA3) { // PA keys: send AID + optional PID + cursor address only (no modified data) ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -863,6 +801,16 @@ public class InputProcessor { * Equivalent to emulate_input() in x3270. */ public void emulateInput(String text) { + if (text == null) return; + if (isNvtMode()) { + try { + fsm.sendNVTString(text); + } catch (IOException e) { + log.warning("Failed to send NVT input: " + e.getMessage()); + } + return; + } + // Type each character for (int i = 0; i < text.length(); i++) { char ch = text.charAt(i); @@ -967,6 +915,93 @@ public class InputProcessor { } private void executeMnemonicToken(String token) { + if (isNvtMode()) { + try { + switch (token) { + case "enter": + case "return": + fsm.sendNVTString("\r\n"); + break; + case "clear": + fsm.sendNVTChar('\u000C'); + break; + case "tab": + fsm.sendNVTChar('\t'); + break; + case "backtab": + case "btab": + fsm.sendNVTString("\u001B[Z"); + break; + case "newline": + case "nl": + fsm.sendNVTString("\r\n"); + break; + case "home": + fsm.sendNVTString("\u001B[H"); + break; + case "end": + fsm.sendNVTString("\u001B[F"); + break; + case "up": + case "curup": + fsm.sendNVTString("\u001B[A"); + break; + case "down": + case "curdown": + fsm.sendNVTString("\u001B[B"); + break; + case "left": + case "curleft": + fsm.sendNVTString("\u001B[D"); + break; + case "right": + case "curright": + fsm.sendNVTString("\u001B[C"); + break; + case "pageup": + case "pgup": + fsm.sendNVTString("\u001B[5~"); + break; + case "pagedown": + case "pgdn": + fsm.sendNVTString("\u001B[6~"); + break; + case "delete": + case "del": + fsm.sendNVTString("\u001B[3~"); + break; + case "backspace": + case "bs": + fsm.sendNVTChar('\b'); + break; + case "attn": + case "break": + fsm.sendNVTChar('\u0003'); + break; + case "sysreq": + case "escape": + case "esc": + fsm.sendNVTChar('\u001B'); + break; + case "reset": + setKeyboardLocked(false); + break; + default: + if (token.startsWith("pf") || token.startsWith("f")) { + try { + String numStr = token.startsWith("pf") ? token.substring(2) : token.substring(1); + int fn = Integer.parseInt(numStr); + sendNvtFunctionKey(fn); + } catch (NumberFormatException ignored) {} + } + break; + } + } catch (IOException e) { + log.warning("Failed to send NVT mnemonic token: " + e.getMessage()); + } + return; + } + switch (token) { case "enter": case "return": @@ -1068,6 +1103,24 @@ public class InputProcessor { } } + private void sendNvtFunctionKey(int fn) throws IOException { + switch (fn) { + case 1: fsm.sendNVTString("\u001BOP"); break; + case 2: fsm.sendNVTString("\u001BOQ"); break; + case 3: fsm.sendNVTString("\u001BOR"); break; + case 4: fsm.sendNVTString("\u001BOS"); break; + case 5: fsm.sendNVTString("\u001B[15~"); break; + case 6: fsm.sendNVTString("\u001B[17~"); break; + case 7: fsm.sendNVTString("\u001B[18~"); break; + case 8: fsm.sendNVTString("\u001B[19~"); break; + case 9: fsm.sendNVTString("\u001B[20~"); break; + case 10: fsm.sendNVTString("\u001B[21~"); break; + case 11: fsm.sendNVTString("\u001B[23~"); break; + case 12: fsm.sendNVTString("\u001B[24~"); break; + default: break; + } + } + /** * Jump cursor to next or previous word boundary. */ diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/SCSInboundListener.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/SCSInboundListener.java new file mode 100644 index 0000000..07cc255 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/SCSInboundListener.java @@ -0,0 +1,17 @@ +package haus.nightmare.lib3270j.listener; + +/** + * Listener interface for receiving inbound SCS (SNA Character String) data streams + * transmitted by the host in TN3270E mode (Data Type DT_SCS_DATA = 0x01). + */ +public interface SCSInboundListener { + + /** + * Called when an inbound SCS record is received from the host. + * + * @param data Raw SCS record payload bytes + * @param offset Start offset within buffer + * @param length Number of bytes in record + */ + void onSCSDataReceived(byte[] data, int offset, int length); +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java index 1dad215..c0465cd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java @@ -9,7 +9,7 @@ import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Logger; @@ -17,7 +17,7 @@ import java.util.logging.Logger; /** * Network Virtual Terminal (NVT) processor. * Handles ASCII / ANSI VT100 character stream processing, cursor positioning, - * escape sequence decoding, and NVT character/string transmission. + * escape sequence decoding, terminal capability reports, and NVT transmission. */ public class NvtProcessor { @@ -28,9 +28,10 @@ public class NvtProcessor { private final List screenListeners = new CopyOnWriteArrayList<>(); // Escape sequence parser states - private static final int STATE_NORMAL = 0; - private static final int STATE_ESC = 1; - private static final int STATE_CSI = 2; + private static final int STATE_NORMAL = 0; + private static final int STATE_ESC = 1; + private static final int STATE_CSI = 2; + private static final int STATE_CHARSET = 3; private int parseState = STATE_NORMAL; private final ByteArrayOutputStream escBuffer = new ByteArrayOutputStream(); @@ -47,6 +48,21 @@ public class NvtProcessor { private int savedCursorRow = 0; private int savedCursorCol = 0; + // Scrolling margins (0-indexed, inclusive) + private int scrollTop = 0; + private int scrollBottom = -1; // -1 means default (rows - 1) + + // Tab stops + private boolean[] tabStops; + + // Cursor visibility + private boolean cursorVisible = true; + + // Line drawing mode + private boolean lineDrawingG0 = false; + private boolean lineDrawingG1 = false; + private boolean activeCharsetG1 = false; + @FunctionalInterface public interface OutputSender { void sendRaw(byte[] data) throws IOException; @@ -55,6 +71,15 @@ public class NvtProcessor { public NvtProcessor(ScreenBuffer screenBuffer, EbcdicTranslator translator) { this.screenBuffer = screenBuffer; this.translator = translator; + initTabStops(); + } + + private void initTabStops() { + int cols = screenBuffer.getCols(); + tabStops = new boolean[cols]; + for (int i = 0; i < cols; i++) { + tabStops[i] = (i % 8 == 0); + } } public void setOutputSender(OutputSender outputSender) { @@ -65,6 +90,14 @@ public class NvtProcessor { screenListeners.add(l); } + public void removeScreenUpdateListener(ScreenUpdateListener l) { + screenListeners.remove(l); + } + + public boolean isCursorVisible() { + return cursorVisible; + } + /** * Process incoming ASCII NVT data bytes. */ @@ -73,6 +106,12 @@ public class NvtProcessor { int rows = screenBuffer.getRows(); int cols = screenBuffer.getCols(); + if (tabStops == null || tabStops.length != cols) { + initTabStops(); + } + int effectiveScrollBottom = (scrollBottom >= 0 && scrollBottom < rows) ? scrollBottom : rows - 1; + int effectiveScrollTop = Math.max(0, Math.min(scrollTop, effectiveScrollBottom)); + int size = rows * cols; int curAddr = screenBuffer.getCursorAddress(); @@ -90,27 +129,60 @@ public class NvtProcessor { } else if (b == 0x0A) { // LF int r = curAddr / cols; int c = curAddr % cols; - r++; - if (r >= rows) { - scrollUp(); - r = rows - 1; + if (r == effectiveScrollBottom) { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + } else if (r < rows - 1) { + r++; } curAddr = r * cols + c; - } else if (b == 0x08 || b == 0x7F) { // BS or DEL + } else if (b == 0x08) { // BS int c = curAddr % cols; if (c > 0) { curAddr--; } + } else if (b == 0x7F) { // DEL + // Ignore or backspace per NVT convention } else if (b == 0x09) { // TAB + int r = curAddr / cols; int c = curAddr % cols; - int nextTab = ((c / 8) + 1) * 8; - if (nextTab >= cols) nextTab = cols - 1; - curAddr = (curAddr / cols) * cols + nextTab; + int nextTab = cols - 1; + for (int tc = c + 1; tc < cols; tc++) { + if (tc < tabStops.length && tabStops[tc]) { + nextTab = tc; + break; + } + } + curAddr = r * cols + nextTab; } else if (b == 0x0C) { // FF screenBuffer.clear(); curAddr = 0; - } else if (b >= 0x20 && b < 0xFF) { // Printable ASCII + } else if (b == 0x07) { // BEL + for (ScreenUpdateListener l : screenListeners) { + l.onSoundAlarm(); + } + } else if (b == 0x0E) { // SO (Select G1 charset) + activeCharsetG1 = true; + } else if (b == 0x0F) { // SI (Select G0 charset) + activeCharsetG1 = false; + } else if (b >= 0x20 && b <= 0xFF) { // Printable character char ch = (char) b; + if (activeCharsetG1 ? lineDrawingG1 : lineDrawingG0) { + ch = mapVt100SpecialGraphics(ch); + } + + int r = curAddr / cols; + int c = curAddr % cols; + + if (c >= cols) { + c = 0; + if (r == effectiveScrollBottom) { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + } else if (r < rows - 1) { + r++; + } + curAddr = r * cols + c; + } + int ebc = translator.unicodeToEbcdic(ch); ExtendedAttribute cell = screenBuffer.getCell(curAddr); cell.clear(); @@ -120,37 +192,98 @@ public class NvtProcessor { cell.bg = currentBg; cell.gr = currentGr; - curAddr++; - if (curAddr >= size) { - scrollUp(); - curAddr = (rows - 1) * cols; + c++; + if (c >= cols) { + if (r < rows - 1) { + if (r == effectiveScrollBottom) { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + c = 0; + } else { + r++; + c = 0; + } + } else { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + c = 0; + } } + curAddr = r * cols + c; } } else if (parseState == STATE_ESC) { escBuffer.write(b); if (b == '[') { parseState = STATE_CSI; - } else if (b == '7') { // Save cursor + } else if (b == '(' || b == ')') { + parseState = STATE_CHARSET; + } else if (b == '7') { // DECSC - Save cursor savedCursorRow = curAddr / cols; savedCursorCol = curAddr % cols; parseState = STATE_NORMAL; - } else if (b == '8') { // Restore cursor + } else if (b == '8') { // DECRC - Restore cursor curAddr = Math.min(rows - 1, savedCursorRow) * cols + Math.min(cols - 1, savedCursorCol); parseState = STATE_NORMAL; + } else if (b == 'D') { // IND - Index (down 1 line) + int r = curAddr / cols; + int c = curAddr % cols; + if (r == effectiveScrollBottom) { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + } else if (r < rows - 1) { + r++; + } + curAddr = r * cols + c; + parseState = STATE_NORMAL; + } else if (b == 'M') { // RI - Reverse Index (up 1 line) + int r = curAddr / cols; + int c = curAddr % cols; + if (r == effectiveScrollTop) { + scrollDownRegion(effectiveScrollTop, effectiveScrollBottom); + } else if (r > 0) { + r--; + } + curAddr = r * cols + c; + parseState = STATE_NORMAL; + } else if (b == 'E') { // NEL - Next Line (CR + LF) + int r = curAddr / cols; + if (r == effectiveScrollBottom) { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + } else if (r < rows - 1) { + r++; + } + curAddr = r * cols; + parseState = STATE_NORMAL; + } else if (b == 'H') { // HTS - Horizontal Tab Set + int c = curAddr % cols; + if (c < tabStops.length) { + tabStops[c] = true; + } + parseState = STATE_NORMAL; } else if (b == 'c') { // RIS - Reset to Initial State screenBuffer.clear(); curAddr = 0; currentFg = 0; currentBg = 0; currentGr = 0; + scrollTop = 0; + scrollBottom = rows - 1; + cursorVisible = true; + initTabStops(); parseState = STATE_NORMAL; } else { - // Unknown 2-byte escape, finish + // Unknown 2-byte escape, return to normal parseState = STATE_NORMAL; } + } else if (parseState == STATE_CHARSET) { + byte[] seq = escBuffer.toByteArray(); + if (seq.length >= 2) { + boolean isG1 = (seq[1] == ')'); + boolean isLineDraw = (b == '0'); + if (isG1) lineDrawingG1 = isLineDraw; + else lineDrawingG0 = isLineDraw; + } + parseState = STATE_NORMAL; } else if (parseState == STATE_CSI) { escBuffer.write(b); - // CSI parameter/intermediate bytes: 0x20..0x3F, final bytes: 0x40..0x7E + // CSI final bytes are in the range 0x40..0x7E if (b >= 0x40 && b <= 0x7E) { byte[] seq = escBuffer.toByteArray(); curAddr = processAnsiEscapeSequence(seq, curAddr, rows, cols); @@ -159,7 +292,7 @@ public class NvtProcessor { } } - screenBuffer.setCursorAddress(curAddr); + screenBuffer.setCursorAddress(Math.max(0, Math.min(size - 1, curAddr))); screenBuffer.markAllChanged(); screenBuffer.updateDisplaySnapshot(); notifyScreenUpdated(); @@ -175,6 +308,9 @@ public class NvtProcessor { String paramStr = new String(seq, 2, seq.length - 3, StandardCharsets.US_ASCII); String[] params = paramStr.split(";"); + int effectiveScrollBottom = (scrollBottom >= 0 && scrollBottom < rows) ? scrollBottom : rows - 1; + int effectiveScrollTop = Math.max(0, Math.min(scrollTop, effectiveScrollBottom)); + int r = curAddr / cols; int c = curAddr % cols; @@ -212,6 +348,31 @@ public class NvtProcessor { c = Math.max(0, c - count); return r * cols + c; } + case 'E': // CNL - Cursor Next Line + { + int count = parseParam(params, 0, 1); + r = Math.min(rows - 1, r + count); + return r * cols; // column 0 + } + case 'F': // CPL - Cursor Previous Line + { + int count = parseParam(params, 0, 1); + r = Math.max(0, r - count); + return r * cols; // column 0 + } + case 'G': // CHA - Cursor Horizontal Absolute + case '`': // HPA - Horizontal Position Absolute + { + int p = parseParam(params, 0, 1) - 1; + c = Math.max(0, Math.min(cols - 1, p)); + return r * cols + c; + } + case 'd': // VPA - Vertical Position Absolute + { + int p = parseParam(params, 0, 1) - 1; + r = Math.max(0, Math.min(rows - 1, p)); + return r * cols + c; + } case 'J': // ED - Erase in Display { int mode = parseParam(params, 0, 0); @@ -239,6 +400,84 @@ public class NvtProcessor { } return curAddr; } + case 'L': // IL - Insert Line + { + int count = parseParam(params, 0, 1); + for (int n = 0; n < count; n++) { + scrollDownRegion(r, effectiveScrollBottom); + } + return r * cols; + } + case 'M': // DL - Delete Line + { + int count = parseParam(params, 0, 1); + for (int n = 0; n < count; n++) { + scrollUpRegion(r, effectiveScrollBottom); + } + return r * cols; + } + case '@': // ICH - Insert Character + { + int count = parseParam(params, 0, 1); + int lineStart = r * cols; + for (int col = cols - 1; col >= c + count; col--) { + screenBuffer.getCell(lineStart + col).copyFrom(screenBuffer.getCell(lineStart + col - count)); + } + for (int col = c; col < Math.min(cols, c + count); col++) { + clearCell(lineStart + col); + } + return curAddr; + } + case 'P': // DCH - Delete Character + { + int count = parseParam(params, 0, 1); + int lineStart = r * cols; + for (int col = c; col < cols - count; col++) { + screenBuffer.getCell(lineStart + col).copyFrom(screenBuffer.getCell(lineStart + col + count)); + } + for (int col = cols - count; col < cols; col++) { + clearCell(lineStart + col); + } + return curAddr; + } + case 'X': // ECH - Erase Character + { + int count = parseParam(params, 0, 1); + int end = Math.min((r + 1) * cols, curAddr + count); + for (int i = curAddr; i < end; i++) { + clearCell(i); + } + return curAddr; + } + case 'S': // SU - Scroll Up + { + int count = parseParam(params, 0, 1); + for (int n = 0; n < count; n++) { + scrollUpRegion(effectiveScrollTop, effectiveScrollBottom); + } + return curAddr; + } + case 'T': // SD - Scroll Down + { + int count = parseParam(params, 0, 1); + for (int n = 0; n < count; n++) { + scrollDownRegion(effectiveScrollTop, effectiveScrollBottom); + } + return curAddr; + } + case 'r': // DECSTBM - Set Top and Bottom Margins (Scrolling Region) + { + int top = parseParam(params, 0, 1) - 1; + int bottom = parseParam(params, 1, rows) - 1; + if (top >= 0 && bottom < rows && top < bottom) { + scrollTop = top; + scrollBottom = bottom; + } else { + scrollTop = 0; + scrollBottom = rows - 1; + } + return 0; // Move cursor to home + } case 'm': // SGR - Select Graphic Rendition { if (params.length == 0 || (params.length == 1 && params[0].isEmpty())) { @@ -256,6 +495,57 @@ public class NvtProcessor { } return curAddr; } + case 'n': // DSR - Device Status Report + { + int code = parseParam(params, 0, 0); + if (code == 6) { // Cursor position request + // Reply: ESC [ ; R (1-indexed) + String response = String.format("\u001B[%d;%dR", r + 1, c + 1); + sendResponseString(response); + } else if (code == 5) { // Status report request + sendResponseString("\u001B[0n"); // OK + } + return curAddr; + } + case 'c': // DA - Device Attributes + { + int code = parseParam(params, 0, 0); + if (code == 0) { + // Identify as standard VT100 with Advanced Video Option + sendResponseString("\u001B[?1;2c"); + } + return curAddr; + } + case 'g': // TBC - Tab Clear + { + int mode = parseParam(params, 0, 0); + if (mode == 0) { + if (c < tabStops.length) tabStops[c] = false; + } else if (mode == 3) { + Arrays.fill(tabStops, false); + } + return curAddr; + } + case 'h': // Set Mode / Private Mode + { + if (paramStr.startsWith("?")) { + String sub = paramStr.substring(1).trim(); + if ("25".equals(sub)) { + cursorVisible = true; + } + } + return curAddr; + } + case 'l': // Reset Mode / Private Mode + { + if (paramStr.startsWith("?")) { + String sub = paramStr.substring(1).trim(); + if ("25".equals(sub)) { + cursorVisible = false; + } + } + return curAddr; + } case 's': // Save cursor savedCursorRow = r; savedCursorCol = c; @@ -269,10 +559,22 @@ public class NvtProcessor { } } + private void sendResponseString(String s) { + if (outputSender != null) { + try { + outputSender.sendRaw(s.getBytes(StandardCharsets.US_ASCII)); + } catch (IOException e) { + log.warning("Failed to send ANSI response: " + e.getMessage()); + } + } + } + private int parseParam(String[] params, int idx, int defaultVal) { if (params != null && idx < params.length && !params[idx].trim().isEmpty()) { try { - return Integer.parseInt(params[idx].trim()); + String val = params[idx].trim(); + if (val.startsWith("?")) val = val.substring(1); + return Integer.parseInt(val); } catch (NumberFormatException ignored) {} } return defaultVal; @@ -281,27 +583,73 @@ public class NvtProcessor { private void clearCell(int addr) { ExtendedAttribute cell = screenBuffer.getCell(addr); cell.clear(); - cell.ec = 0; + cell.ec = (byte) 0x40; // EBCDIC space cell.ucs4 = ' '; + cell.fg = 0; + cell.bg = 0; + cell.gr = 0; } - private void scrollUp() { + private void scrollUpRegion(int top, int bottom) { int rows = screenBuffer.getRows(); int cols = screenBuffer.getCols(); - for (int r = 0; r < rows - 1; r++) { + top = Math.max(0, Math.min(rows - 1, top)); + bottom = Math.max(top, Math.min(rows - 1, bottom)); + + for (int r = top; r < bottom; r++) { for (int c = 0; c < cols; c++) { int dst = r * cols + c; int src = (r + 1) * cols + c; screenBuffer.getCell(dst).copyFrom(screenBuffer.getCell(src)); } } - // Clear last line - int lastRowStart = (rows - 1) * cols; + int lastRowStart = bottom * cols; for (int c = 0; c < cols; c++) { clearCell(lastRowStart + c); } } + private void scrollDownRegion(int top, int bottom) { + int rows = screenBuffer.getRows(); + int cols = screenBuffer.getCols(); + top = Math.max(0, Math.min(rows - 1, top)); + bottom = Math.max(top, Math.min(rows - 1, bottom)); + + for (int r = bottom; r > top; r--) { + for (int c = 0; c < cols; c++) { + int dst = r * cols + c; + int src = (r - 1) * cols + c; + screenBuffer.getCell(dst).copyFrom(screenBuffer.getCell(src)); + } + } + int topRowStart = top * cols; + for (int c = 0; c < cols; c++) { + clearCell(topRowStart + c); + } + } + + private char mapVt100SpecialGraphics(char c) { + switch (c) { + case 'j': return '┘'; + case 'k': return '┐'; + case 'l': return '┌'; + case 'm': return '└'; + case 'n': return '┼'; + case 'q': return '─'; + case 't': return '├'; + case 'u': return '┤'; + case 'v': return '┴'; + case 'w': return '┬'; + case 'x': return '│'; + case '`': return '◆'; + case 'a': return '▒'; + case 'f': return '°'; + case 'g': return '±'; + case '~': return '•'; + default: return c; + } + } + private void applySgr(int code) { switch (code) { case 0: // Reset diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TelnetConstants.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TelnetConstants.java index 3c1f896..6f7828d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TelnetConstants.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TelnetConstants.java @@ -115,4 +115,23 @@ public final class TelnetConstants { default: return "CMD-" + cmd; } } + public static String qualifierName(int qual) { + switch (qual) { + case TELQUAL_IS: return "IS"; + case TELQUAL_SEND: return "SEND"; + case TELQUAL_INFO: return "INFO"; + default: return "QUAL-" + qual; + } + } + + /** NEW-ENVIRON object name lookup. */ + public static String environObjectName(int obj) { + switch (obj) { + case TELOBJ_VAR: return "VAR"; + case TELOBJ_VALUE: return "VALUE"; + case TELOBJ_ESC: return "ESC"; + case TELOBJ_USERVAR: return "USERVAR"; + default: return "OBJ-" + obj; + } + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java index b43f56d..faa2e8e 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java @@ -40,44 +40,71 @@ public class TelnetConnection { } /** - * Connect to the host. Blocks until connection is established or fails. + * Connect to the host (optionally through a proxy). Blocks until connection is established or fails. */ public void connect() throws IOException { + ConnectionConfig.ProxyType proxyType = config.getProxyType(); + boolean hasProxy = proxyType != null && proxyType != ConnectionConfig.ProxyType.NONE && + config.getProxyHost() != null && !config.getProxyHost().trim().isEmpty(); + + String connectHost = hasProxy ? config.getProxyHost().trim() : config.getHost(); + int connectPort = hasProxy ? (config.getProxyPort() > 0 ? config.getProxyPort() : (proxyType == ConnectionConfig.ProxyType.HTTP ? 8080 : 1080)) : config.getPort(); + + log.info("Connecting TCP socket to " + connectHost + ":" + connectPort + + (hasProxy ? " (via " + proxyType + " proxy for " + config.getHost() + ":" + config.getPort() + ")" : "") + + (config.isUseTls() ? " with TLS" : "")); + + Socket rawSocket = new Socket(); + rawSocket.setKeepAlive(config.isSoKeepAlive()); + rawSocket.setOOBInline(true); + rawSocket.setTcpNoDelay(config.isTcpNoDelay()); + if (config.getSoTimeoutMs() > 0) { + rawSocket.setSoTimeout(config.getSoTimeoutMs()); + } + rawSocket.connect(new InetSocketAddress(connectHost, connectPort), config.getConnectTimeoutMs()); + + // Perform proxy handshake if configured + if (hasProxy) { + switch (proxyType) { + case HTTP: + establishHttpProxy(rawSocket, config.getHost(), config.getPort(), config.getProxyUsername(), config.getProxyPassword()); + break; + case SOCKS4: + establishSocks4Proxy(rawSocket, config.getHost(), config.getPort(), config.getProxyUsername()); + break; + case SOCKS5: + establishSocks5Proxy(rawSocket, config.getHost(), config.getPort(), config.getProxyUsername(), config.getProxyPassword()); + break; + default: + break; + } + } + if (config.isUseTls()) { - log.info("Connecting with TLS to " + config.getHost() + ":" + config.getPort() + + log.info("Performing TLS handshake with " + config.getHost() + ":" + config.getPort() + " (verifyCert=" + config.isTlsVerifyCert() + ")"); try { javax.net.ssl.SSLContext sslContext = haus.nightmare.lib3270j.tls.TlsTrustManager.createSSLContext(config); javax.net.ssl.SSLSocketFactory ssf = sslContext.getSocketFactory(); - javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket(); + javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket( + rawSocket, config.getHost(), config.getPort(), true); sslSocket.setKeepAlive(config.isSoKeepAlive()); sslSocket.setTcpNoDelay(config.isTcpNoDelay()); if (config.getSoTimeoutMs() > 0) { sslSocket.setSoTimeout(config.getSoTimeoutMs()); } - sslSocket.connect(new InetSocketAddress(config.getHost(), config.getPort()), - config.getConnectTimeoutMs()); sslSocket.startHandshake(); socket = sslSocket; sslSession = sslSocket.getSession(); log.info("TLS session active: protocol=" + sslSession.getProtocol() + - " cipher=" + sslSession.getCipherSuite()); + " cipher=" + sslSession.getCipherSuite()); } catch (IOException e) { throw e; } catch (Exception e) { throw new IOException("TLS setup failure: " + e.getMessage(), e); } } else { - log.info("Connecting to " + config.getHost() + ":" + config.getPort()); - socket = new Socket(); - socket.setKeepAlive(config.isSoKeepAlive()); - socket.setOOBInline(true); - socket.setTcpNoDelay(config.isTcpNoDelay()); - if (config.getSoTimeoutMs() > 0) { - socket.setSoTimeout(config.getSoTimeoutMs()); - } - socket.connect(new InetSocketAddress(config.getHost(), config.getPort()), - config.getConnectTimeoutMs()); + socket = rawSocket; } inputStream = new BufferedInputStream(socket.getInputStream(), READ_BUFFER_SIZE); @@ -91,6 +118,235 @@ public class TelnetConnection { readerThread.start(); } + /** + * Dynamically elevate active socket to TLS in-band (STARTTLS / Option 46). + */ + public synchronized void upgradeToTls() throws IOException { + if (socket == null || !socket.isConnected() || socket.isClosed()) { + throw new IOException("Cannot upgrade disconnected socket to TLS"); + } + log.info("Elevating active connection to TLS via STARTTLS"); + try { + javax.net.ssl.SSLContext sslContext = haus.nightmare.lib3270j.tls.TlsTrustManager.createSSLContext(config); + javax.net.ssl.SSLSocketFactory ssf = sslContext.getSocketFactory(); + javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket( + socket, config.getHost(), config.getPort(), true); + sslSocket.setKeepAlive(config.isSoKeepAlive()); + sslSocket.setTcpNoDelay(config.isTcpNoDelay()); + if (config.getSoTimeoutMs() > 0) { + sslSocket.setSoTimeout(config.getSoTimeoutMs()); + } + sslSocket.startHandshake(); + this.socket = sslSocket; + this.sslSession = sslSocket.getSession(); + this.inputStream = new BufferedInputStream(sslSocket.getInputStream(), READ_BUFFER_SIZE); + this.outputStream = new BufferedOutputStream(sslSocket.getOutputStream()); + log.info("STARTTLS session active: protocol=" + sslSession.getProtocol() + + " cipher=" + sslSession.getCipherSuite()); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("STARTTLS setup failure: " + e.getMessage(), e); + } + } + + private void establishHttpProxy(Socket s, String targetHost, int targetPort, String user, String pass) throws IOException { + OutputStream out = s.getOutputStream(); + InputStream in = s.getInputStream(); + + StringBuilder req = new StringBuilder(); + req.append("CONNECT ").append(targetHost).append(":").append(targetPort).append(" HTTP/1.1\r\n"); + req.append("Host: ").append(targetHost).append(":").append(targetPort).append("\r\n"); + if (user != null && !user.isEmpty()) { + String auth = user + ":" + (pass != null ? pass : ""); + String encoded = java.util.Base64.getEncoder().encodeToString(auth.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + req.append("Proxy-Authorization: Basic ").append(encoded).append("\r\n"); + } + req.append("Proxy-Connection: Keep-Alive\r\n\r\n"); + out.write(req.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + out.flush(); + + // Read HTTP status line + ByteArrayOutputStream lineBuf = new ByteArrayOutputStream(); + int b; + while ((b = in.read()) != -1) { + if (b == '\n') break; + if (b != '\r') lineBuf.write(b); + } + String statusLine = new String(lineBuf.toByteArray(), java.nio.charset.StandardCharsets.US_ASCII); + if (!statusLine.contains(" 200")) { + throw new IOException("HTTP proxy connection failed: " + statusLine); + } + + // Consume remaining response headers until empty line + while (true) { + lineBuf.reset(); + while ((b = in.read()) != -1) { + if (b == '\n') break; + if (b != '\r') lineBuf.write(b); + } + if (lineBuf.size() == 0) break; // empty line terminates headers + } + log.info("HTTP proxy tunnel established to " + targetHost + ":" + targetPort); + } + + private void establishSocks4Proxy(Socket s, String targetHost, int targetPort, String user) throws IOException { + OutputStream out = s.getOutputStream(); + InputStream in = s.getInputStream(); + + byte[] ip = new byte[4]; + boolean isSocks4a = false; + try { + InetAddress addr = InetAddress.getByName(targetHost); + if (addr instanceof Inet4Address) { + ip = addr.getAddress(); + } else { + isSocks4a = true; + ip = new byte[] { 0, 0, 0, 1 }; + } + } catch (Exception e) { + isSocks4a = true; + ip = new byte[] { 0, 0, 0, 1 }; + } + + ByteArrayOutputStream req = new ByteArrayOutputStream(); + req.write(0x04); // SOCKS version 4 + req.write(0x01); // CONNECT command + req.write((targetPort >> 8) & 0xFF); + req.write(targetPort & 0xFF); + req.write(ip); + if (user != null && !user.isEmpty()) { + req.write(user.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + } + req.write(0x00); // Null terminator for userid + if (isSocks4a) { + req.write(targetHost.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + req.write(0x00); // Null terminator for domain name + } + + out.write(req.toByteArray()); + out.flush(); + + byte[] resp = new byte[8]; + int read = 0; + while (read < 8) { + int n = in.read(resp, read, 8 - read); + if (n < 0) throw new IOException("Unexpected EOF reading SOCKS4 response"); + read += n; + } + + int status = resp[1] & 0xFF; + if (status != 0x5A) { + throw new IOException("SOCKS4 proxy request rejected, status=0x" + Integer.toHexString(status)); + } + log.info("SOCKS4 proxy tunnel established to " + targetHost + ":" + targetPort); + } + + private void establishSocks5Proxy(Socket s, String targetHost, int targetPort, String user, String pass) throws IOException { + OutputStream out = s.getOutputStream(); + InputStream in = s.getInputStream(); + + boolean hasAuth = user != null && !user.isEmpty(); + if (hasAuth) { + out.write(new byte[] { 0x05, 0x02, 0x00, 0x02 }); // SOCKS5, 2 methods: NO_AUTH(0x00), USER_PASS(0x02) + } else { + out.write(new byte[] { 0x05, 0x01, 0x00 }); // SOCKS5, 1 method: NO_AUTH(0x00) + } + out.flush(); + + byte[] methodResp = new byte[2]; + readFully(in, methodResp); + if ((methodResp[0] & 0xFF) != 0x05) { + throw new IOException("Invalid SOCKS5 version response: " + (methodResp[0] & 0xFF)); + } + + int authMethod = methodResp[1] & 0xFF; + if (authMethod == 0x02) { + // RFC 1929 Username/Password Authentication + byte[] uBytes = user.getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] pBytes = (pass != null ? pass : "").getBytes(java.nio.charset.StandardCharsets.UTF_8); + ByteArrayOutputStream authReq = new ByteArrayOutputStream(); + authReq.write(0x01); // Auth subnegotiation version + authReq.write(uBytes.length); + authReq.write(uBytes); + authReq.write(pBytes.length); + authReq.write(pBytes); + out.write(authReq.toByteArray()); + out.flush(); + + byte[] authResp = new byte[2]; + readFully(in, authResp); + if (authResp[1] != 0x00) { + throw new IOException("SOCKS5 username/password authentication failed"); + } + } else if (authMethod != 0x00) { + throw new IOException("SOCKS5 proxy authentication method rejected: 0x" + Integer.toHexString(authMethod)); + } + + // Send CONNECT request + ByteArrayOutputStream connReq = new ByteArrayOutputStream(); + connReq.write(0x05); // SOCKS5 + connReq.write(0x01); // CONNECT + connReq.write(0x00); // Reserved + + try { + InetAddress addr = InetAddress.getByName(targetHost); + if (addr instanceof Inet4Address) { + connReq.write(0x01); // ATYP IPv4 + connReq.write(addr.getAddress()); + } else if (addr instanceof Inet6Address) { + connReq.write(0x04); // ATYP IPv6 + connReq.write(addr.getAddress()); + } else { + byte[] dBytes = targetHost.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + connReq.write(0x03); // ATYP Domain + connReq.write(dBytes.length); + connReq.write(dBytes); + } + } catch (Exception e) { + byte[] dBytes = targetHost.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + connReq.write(0x03); // ATYP Domain + connReq.write(dBytes.length); + connReq.write(dBytes); + } + + connReq.write((targetPort >> 8) & 0xFF); + connReq.write(targetPort & 0xFF); + out.write(connReq.toByteArray()); + out.flush(); + + byte[] connResp = new byte[4]; + readFully(in, connResp); + int rep = connResp[1] & 0xFF; + if (rep != 0x00) { + throw new IOException("SOCKS5 connect command failed, rep=0x" + Integer.toHexString(rep)); + } + + int atyp = connResp[3] & 0xFF; + if (atyp == 0x01) { + byte[] bnd = new byte[4 + 2]; // IPv4 + Port + readFully(in, bnd); + } else if (atyp == 0x03) { + int len = in.read(); + if (len < 0) throw new IOException("Unexpected EOF in SOCKS5 domain response"); + byte[] bnd = new byte[len + 2]; // Domain + Port + readFully(in, bnd); + } else if (atyp == 0x04) { + byte[] bnd = new byte[16 + 2]; // IPv6 + Port + readFully(in, bnd); + } + log.info("SOCKS5 proxy tunnel established to " + targetHost + ":" + targetPort); + } + + private static void readFully(InputStream in, byte[] buf) throws IOException { + int read = 0; + while (read < buf.length) { + int n = in.read(buf, read, buf.length - read); + if (n < 0) throw new IOException("Unexpected EOF reading proxy response"); + read += n; + } + } + /** * Send raw bytes to the host. */ diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java index 95d3f9d..13823dd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -107,19 +107,20 @@ public class TelnetFSM { // Listeners private final List connectionListeners = new CopyOnWriteArrayList<>(); private final List screenListeners = new CopyOnWriteArrayList<>(); + private final List scsListeners = new CopyOnWriteArrayList<>(); // Connected LU info private String connectedLu; private String connectedType; - enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP } + public enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP } public TelnetFSM(ConnectionConfig config, ScreenBuffer screenBuffer, DataStreamProcessor dsProcessor) { this.config = config; this.screenBuffer = screenBuffer; this.dsProcessor = dsProcessor; this.nvtProcessor = new haus.nightmare.lib3270j.nvt.NvtProcessor(screenBuffer, new haus.nightmare.lib3270j.charset.EbcdicTranslator()); - this.nvtProcessor.setOutputSender(this::sendBytes); + this.nvtProcessor.setOutputSender(this::sendNvtData); } public haus.nightmare.lib3270j.nvt.NvtProcessor getNvtProcessor() { @@ -131,10 +132,25 @@ public class TelnetFSM { } public void addConnectionListener(ConnectionListener l) { connectionListeners.add(l); } + public void removeConnectionListener(ConnectionListener l) { connectionListeners.remove(l); } + public void addScreenUpdateListener(ScreenUpdateListener l) { screenListeners.add(l); nvtProcessor.addScreenUpdateListener(l); } + public void removeScreenUpdateListener(ScreenUpdateListener l) { + screenListeners.remove(l); + nvtProcessor.removeScreenUpdateListener(l); + } + + public void addSCSInboundListener(SCSInboundListener l) { + if (l != null && !scsListeners.contains(l)) { + scsListeners.add(l); + } + } + public void removeSCSInboundListener(SCSInboundListener l) { + scsListeners.remove(l); + } public ConnectionState getConnectionState() { return connectionState; } public boolean[] getMyOpts() { return myOpts; } @@ -189,9 +205,11 @@ public class TelnetFSM { if (connectionState == ConnectionState.TELNET_PENDING) { changeState(ConnectionState.CONNECTED_NVT); } - if (connectionState.isNvt()) { + boolean isPlainNvt = (connectionState == ConnectionState.CONNECTED_NVT || connectionState == ConnectionState.CONNECTED_NVT_CHAR) + && !(hisOpts[TELOPT_BINARY] && hisOpts[TELOPT_EOR]); + if (isPlainNvt) { nvtProcessor.processNVTData(buf, start, i - start); - } else if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) { + } else if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING || hisOpts[TELOPT_BINARY] || hisOpts[TELOPT_EOR]) { ibuf.write(buf, start, i - start); } } @@ -260,13 +278,15 @@ public class TelnetFSM { changeState(ConnectionState.CONNECTED_NVT); } - if (connectionState.isNvt()) { + boolean isPlainNvt = (connectionState == ConnectionState.CONNECTED_NVT || connectionState == ConnectionState.CONNECTED_NVT_CHAR) + && !(hisOpts[TELOPT_BINARY] && hisOpts[TELOPT_EOR]); + if (isPlainNvt) { nvtProcessor.processNVTData(new byte[] { (byte) c }, 0, 1); return; } - // Accumulate data for 3270, TN3270E (including SSCP-LU and unbound states, and pending) - if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) { + // Accumulate data for 3270, TN3270E (including CONNECTED_E_NVT, SSCP-LU and unbound states, and pending) + if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING || hisOpts[TELOPT_BINARY] || hisOpts[TELOPT_EOR]) { ibuf.write(c); } } @@ -336,6 +356,13 @@ public class TelnetFSM { sendCommand(DO, opt); break; + case TELOPT_STARTTLS: + if (config.isStartTlsEnabled() && !hisOpts[opt]) { + hisOpts[opt] = true; + sendCommand(DO, opt); + } + break; + case TELOPT_TN3270E: if (!config.isTn3270eEnabled()) { sendCommand(DONT, opt); @@ -384,6 +411,13 @@ public class TelnetFSM { sendCommand(WILL, opt); break; + case TELOPT_STARTTLS: + if (config.isStartTlsEnabled() && !myOpts[opt]) { + myOpts[opt] = true; + sendCommand(WILL, opt); + } + break; + case TELOPT_TTYPE: if (!myOpts[opt]) { myOpts[opt] = true; @@ -489,12 +523,37 @@ public class TelnetFSM { case TELOPT_NEW_ENVIRON: handleNewEnvironSB(data); break; + case TELOPT_STARTTLS: + handleStartTlsSB(data); + break; default: log.info("Ignoring SB for option " + opt); break; } } + // ========== STARTTLS sub-negotiation ========== + + private void handleStartTlsSB(byte[] data) { + if (data.length >= 2 && (data[1] & 0xFF) == TLS_FOLLOWS) { + log.info("RCVD SB STARTTLS FOLLOWS (1) - Initiating TLS elevation"); + processStartTls(); + } + } + + public void processStartTls() { + log.info("Elevating active connection to TLS via STARTTLS (Option 46)"); + try { + if (connection != null) { + connection.upgradeToTls(); + statusDisplay(STATUS_SECURITY, "TLS socket elevated via STARTTLS"); + } + } catch (IOException e) { + log.log(Level.SEVERE, "Failed to elevate socket to TLS via STARTTLS", e); + onError("STARTTLS elevation failed: " + e.getMessage()); + } + } + // ========== TTYPE sub-negotiation ========== private void handleTTypeSB(byte[] data) { @@ -522,15 +581,117 @@ public class TelnetFSM { } } - // ========== NEW_ENVIRON sub-negotiation ========== + // ========== NEW_ENVIRON sub-negotiation (RFC 1572 / RFC 2877) ========== private void handleNewEnvironSB(byte[] data) { - if (data.length >= 2 && data[1] == TELQUAL_SEND) { - log.info("RCVD SB NEW-ENVIRON SEND - Responding with empty IS"); - byte[] response = { (byte) IAC, (byte) SB, (byte) TELOPT_NEW_ENVIRON, - (byte) TELQUAL_IS, (byte) IAC, (byte) SE }; - sendBytes(response); - log.info("SENT SB NEW-ENVIRON IS SE"); + if (data.length < 2) return; + int qual = data[1] & 0xFF; + if (qual == TELQUAL_SEND) { + log.info("RCVD SB NEW-ENVIRON SEND (" + (data.length - 2) + " bytes)"); + if (data.length == 2) { + // Empty SEND: send all configured variables + sendNewEnvironmentVariables(config.getEnvironmentVariables(), config.getUserVariables()); + return; + } + + // Parse requested variable names + java.util.Map respVars = new java.util.LinkedHashMap<>(); + java.util.Map respUserVars = new java.util.LinkedHashMap<>(); + + int idx = 2; + while (idx < data.length) { + int objType = data[idx++] & 0xFF; + ByteArrayOutputStream nameBuf = new ByteArrayOutputStream(); + boolean escaped = false; + while (idx < data.length) { + int b = data[idx] & 0xFF; + if (!escaped && (b == TELOBJ_VAR || b == TELOBJ_USERVAR)) { + break; + } + idx++; + if (!escaped && b == TELOBJ_ESC) { + escaped = true; + continue; + } + nameBuf.write(b); + escaped = false; + } + + String varName = new String(nameBuf.toByteArray(), java.nio.charset.StandardCharsets.US_ASCII); + if (objType == TELOBJ_VAR) { + if (varName.isEmpty()) { + respVars.putAll(config.getEnvironmentVariables()); + } else if (config.getEnvironmentVariables().containsKey(varName)) { + respVars.put(varName, config.getEnvironmentVariables().get(varName)); + } + } else if (objType == TELOBJ_USERVAR) { + if (varName.isEmpty()) { + respUserVars.putAll(config.getUserVariables()); + } else if (config.getUserVariables().containsKey(varName)) { + respUserVars.put(varName, config.getUserVariables().get(varName)); + } + } + } + + sendNewEnvironmentVariables(respVars, respUserVars); + } + } + + public void sendNewEnvironmentVariables(java.util.Properties props) { + java.util.Map vars = new java.util.LinkedHashMap<>(); + java.util.Map uVars = new java.util.LinkedHashMap<>(); + if (props != null) { + for (String k : props.stringPropertyNames()) { + if (k.startsWith("USERVAR_") || k.startsWith("USER_")) { + uVars.put(k, props.getProperty(k)); + } else { + vars.put(k, props.getProperty(k)); + } + } + } + sendNewEnvironmentVariables(vars, uVars); + } + + public void sendNewEnvironmentVariables(java.util.Map vars, java.util.Map userVars) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(IAC); + out.write(SB); + out.write(TELOPT_NEW_ENVIRON); + out.write(TELQUAL_IS); + + if (vars != null) { + for (java.util.Map.Entry e : vars.entrySet()) { + out.write(TELOBJ_VAR); + writeEscapedEnvironString(out, e.getKey()); + out.write(TELOBJ_VALUE); + writeEscapedEnvironString(out, e.getValue()); + } + } + if (userVars != null) { + for (java.util.Map.Entry e : userVars.entrySet()) { + out.write(TELOBJ_USERVAR); + writeEscapedEnvironString(out, e.getKey()); + out.write(TELOBJ_VALUE); + writeEscapedEnvironString(out, e.getValue()); + } + } + + out.write(IAC); + out.write(SE); + sendBytes(out.toByteArray()); + log.info("SENT SB NEW-ENVIRON IS (" + ((vars != null ? vars.size() : 0) + (userVars != null ? userVars.size() : 0)) + " vars) SE"); + } + + private void writeEscapedEnvironString(ByteArrayOutputStream out, String s) { + if (s == null) return; + for (byte b : s.getBytes(java.nio.charset.StandardCharsets.US_ASCII)) { + int ub = b & 0xFF; + if (ub == TELOBJ_VAR || ub == TELOBJ_VALUE || ub == TELOBJ_ESC || ub == TELOBJ_USERVAR) { + out.write(TELOBJ_ESC); + } else if (ub == IAC) { + out.write(IAC); + } + out.write(ub); } } @@ -816,8 +977,10 @@ public class TelnetFSM { ibuf.reset(); if (data.length == 0) return; - if (connectionState == ConnectionState.TELNET_PENDING && !tn3270eNegotiated) { - log.info("Received EOR during TELNET_PENDING - transitioning to plain TN3270 mode"); + if ((connectionState == ConnectionState.TELNET_PENDING || + connectionState == ConnectionState.CONNECTED_NVT || + connectionState == ConnectionState.CONNECTED_NVT_CHAR) && !tn3270eNegotiated) { + log.info("Received EOR during NVT/pending - transitioning to plain TN3270 mode"); changeState(ConnectionState.CONNECTED_3270); } @@ -831,6 +994,10 @@ public class TelnetFSM { } } + public void processTn3270eHeader(byte[] data) { + processTN3270ERecord(data); + } + private void processTN3270ERecord(byte[] data) { if (data.length < EH_SIZE) { log.warning("TN3270E record too short: " + data.length); @@ -850,11 +1017,10 @@ public class TelnetFSM { switch (dataType) { case DT_3270_DATA: if (data.length > EH_SIZE) { - // Transition to 3270 mode - if (connectionState == ConnectionState.CONNECTED_UNBOUND || - connectionState == ConnectionState.CONNECTED_SSCP) { - // Clear screen on transition to 3270 mode from unbound/SSCP - // This ensures old SSCP-LU data or stale content doesn't persist + // Transition to 3270 mode from any non-3270 state (E_NVT, UNBOUND, SSCP) + if (connectionState != ConnectionState.CONNECTED_TN3270E) { + // Clear screen on transition to 3270 mode from unbound/SSCP/NVT + // This ensures old SSCP-LU or NVT data doesn't persist screenBuffer.erase(false); changeState(ConnectionState.CONNECTED_TN3270E); tn3270eSubmode = TN3270ESubmode.E_3270; @@ -879,9 +1045,30 @@ public class TelnetFSM { } break; + case DT_SCS_DATA: + if (data.length > EH_SIZE) { + try { + processSCSInbound(data, EH_SIZE, data.length - EH_SIZE); + if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { + sendTN3270EPositiveResponse(seqNumber); + } + } catch (Exception e) { + log.log(Level.WARNING, "Error processing SCS record", e); + if (eFuncs[FUNC_RESPONSES] && (responseFlag == RSF_ALWAYS_RESPONSE || responseFlag == RSF_ERROR_RESPONSE)) { + sendTN3270ENegativeResponse(seqNumber, NEG_OPERATION_CHECK); + } + } + } else { + if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { + sendTN3270EPositiveResponse(seqNumber); + } + } + break; + case DT_SSCP_LU_DATA: if (connectionState != ConnectionState.CONNECTED_SSCP) { - if (connectionState == ConnectionState.CONNECTED_UNBOUND) { + if (connectionState == ConnectionState.CONNECTED_UNBOUND || + connectionState == ConnectionState.CONNECTED_E_NVT) { // Clear screen on first SSCP-LU transition to remove stale data screenBuffer.clear(); } @@ -919,8 +1106,13 @@ public class TelnetFSM { case DT_NVT_DATA: // NVT data in TN3270E mode - changeState(ConnectionState.CONNECTED_E_NVT); - tn3270eSubmode = TN3270ESubmode.E_NVT; + if (connectionState != ConnectionState.CONNECTED_E_NVT) { + changeState(ConnectionState.CONNECTED_E_NVT); + tn3270eSubmode = TN3270ESubmode.E_NVT; + } + if (dsProcessor != null && dsProcessor.getInputProcessor() != null) { + dsProcessor.getInputProcessor().setKeyboardLocked(false); + } if (data.length > EH_SIZE) { try { processNVTData(data, EH_SIZE, data.length - EH_SIZE); @@ -938,6 +1130,7 @@ public class TelnetFSM { sendTN3270EPositiveResponse(seqNumber); } } + notifyScreenUpdate(); break; case DT_REQUEST: @@ -957,6 +1150,16 @@ public class TelnetFSM { } break; + case DT_BID: + process_BID(responseFlag, seqNumber); + break; + + case DT_PRINT_EOJ: + if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { + sendTN3270EPositiveResponse(seqNumber); + } + break; + case DT_RESPONSE: lastRcvSeq = seqNumber; log.fine("Received response, seq=" + seqNumber); @@ -966,8 +1169,8 @@ public class TelnetFSM { // Check if the host sent a raw 3270 data stream (e.g. 0xF5 EraseWrite, 0x7E EW Alternate, 0xF1 Write, etc.) // This happens when the server ignores or drops TN3270E framing and speaks plain 3270 data stream. if (dataType == 0xF5 || dataType == 0x7E || dataType == 0xF1 || dataType == 0x6F || - dataType == 0x6E || dataType == 0xF2 || dataType == 0xF6 || dataType == 0x05 || - dataType == 0x0D || dataType == 0x01 || (data.length >= 2 && (data[0] & 0xFF) == 0x11)) { + dataType == 0x6E || dataType == 0xF2 || dataType == 0xF6 || + dataType == 0x0D || (data.length >= 2 && (data[0] & 0xFF) == 0x11)) { log.warning("Received plain 3270 command (0x" + Integer.toHexString(dataType) + ") in TN3270E mode — automatically switching to plain TN3270 mode"); tn3270eNegotiated = false; @@ -981,28 +1184,56 @@ public class TelnetFSM { } } - public void sendTN3270EPositiveResponse(int seqNumber) { + public void processSCSInbound(byte[] data) { + if (data == null) return; + processSCSInbound(data, 0, data.length); + } + + public void processSCSInbound(byte[] data, int offset, int length) { + log.fine("Processing SCS inbound data (" + length + " bytes)"); + for (SCSInboundListener l : scsListeners) { + try { + l.onSCSDataReceived(data, offset, length); + } catch (Exception e) { + log.log(Level.WARNING, "Error in SCS inbound listener", e); + } + } + } + + /** + * Send 5-byte/6-byte TN3270E response packet (DT_RESPONSE = 0x02). + */ + public void sendTn3270eResponse(byte responseFlag, byte responseData, int seq) { byte[] resp = new byte[EH_SIZE + 1]; resp[0] = (byte) DT_RESPONSE; resp[1] = 0; - resp[2] = (byte) RSF_POSITIVE_RESPONSE; - resp[3] = (byte) ((seqNumber >> 8) & 0xFF); - resp[4] = (byte) (seqNumber & 0xFF); - resp[5] = (byte) POS_DEVICE_END; + resp[2] = responseFlag; + resp[3] = (byte) ((seq >> 8) & 0xFF); + resp[4] = (byte) (seq & 0xFF); + resp[5] = responseData; sendRecord(resp); } - public void sendTN3270ENegativeResponse(int seqNumber, int negCode) { - byte[] resp = new byte[EH_SIZE + 1]; - resp[0] = (byte) DT_RESPONSE; - resp[1] = 0; - resp[2] = (byte) RSF_NEGATIVE_RESPONSE; - resp[3] = (byte) ((seqNumber >> 8) & 0xFF); - resp[4] = (byte) (seqNumber & 0xFF); - resp[5] = (byte) (negCode & 0xFF); + /** + * HoD 5-byte send_response compatible signature (com.ibm.eNetwork.ECL.tn3270.Telnet3270E). + */ + public void send_response(short s, short s2, int n) { + byte[] byArray = new byte[5]; + byArray[0] = (byte) DT_RESPONSE; + byArray[1] = (byte) s; + byArray[2] = (byte) s2; + byArray[3] = (byte) ((n >> 8) & 0xFF); + byArray[4] = (byte) (n & 0xFF); + sendRecord(byArray); + } - sendRecord(resp); + public void sendTN3270EPositiveResponse(int seqNumber) { + sendTn3270eResponse((byte) RSF_POSITIVE_RESPONSE, (byte) POS_DEVICE_END, seqNumber); + } + + public void sendTN3270ENegativeResponse(int seqNumber, int negCode) { + sendTn3270eResponse((byte) RSF_NEGATIVE_RESPONSE, (byte) (negCode & 0xFF), seqNumber); } public void sendTN3270ESnaSenseResponse(int seqNumber, int sense1, int sense2) { @@ -1021,7 +1252,11 @@ public class TelnetFSM { // ========== Check if we should transition to 3270 mode ========== private void checkIn3270() { - if (connectionState != ConnectionState.TELNET_PENDING) return; + if (connectionState != ConnectionState.TELNET_PENDING && + connectionState != ConnectionState.CONNECTED_NVT && + connectionState != ConnectionState.CONNECTED_NVT_CHAR) { + return; + } // For TN3270E, we wait for TN3270E negotiation to complete if (config.isTn3270eEnabled() && myOpts[TELOPT_TN3270E] && hisOpts[TELOPT_TN3270E]) { @@ -1032,7 +1267,11 @@ public class TelnetFSM { if (myOpts[TELOPT_BINARY] && hisOpts[TELOPT_BINARY] && myOpts[TELOPT_EOR] && hisOpts[TELOPT_EOR]) { log.info("Transitioning to plain TN3270 mode"); - changeState(ConnectionState.CONNECTED_3270); + if (connectionState != ConnectionState.CONNECTED_3270) { + screenBuffer.erase(false); + changeState(ConnectionState.CONNECTED_3270); + notifyScreenUpdate(); + } } } @@ -1130,6 +1369,32 @@ public class TelnetFSM { } } + /** + * Send NVT data record (with TN3270E header and EOR framing if in TN3270E mode, or raw bytes). + */ + public void sendNvtData(byte[] data) { + if (data == null || data.length == 0) return; + + if (tn3270eNegotiated && (connectionState == ConnectionState.CONNECTED_E_NVT || tn3270eSubmode == TN3270ESubmode.E_NVT)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE); + out.write(DT_NVT_DATA); + out.write(0); + out.write(0); + out.write((eXmitSeq >> 8) & 0xFF); + out.write(eXmitSeq & 0xFF); + eXmitSeq = (eXmitSeq + 1) & 0xFFFF; + for (byte b : data) { + out.write(b & 0xFF); + } + sendRecord(out.toByteArray()); + } else { + sendBytes(data); + if (config != null && config.isNvtLocalEcho()) { + nvtProcessor.processNVTData(data, 0, data.length); + } + } + } + private void sendBytes(byte[] data) { try { connection.sendRaw(data); @@ -1170,6 +1435,7 @@ public class TelnetFSM { public boolean isTn3270eNegotiated() { return tn3270eNegotiated; } public String getConnectedLu() { return connectedLu; } public String getConnectedType() { return connectedType; } + public TN3270ESubmode getTn3270eSubmode() { return tn3270eSubmode; } // ========== SNA BIND, UNBIND, BID, DFC Handlers (Phase 2) ========== @@ -1434,6 +1700,10 @@ public class TelnetFSM { return tn3270eBound; } + public void processSysReq() { + handleSysReq(); + } + public void handleSysReq() { if (tn3270eNegotiated) { byte[] ao = new byte[] { (byte) IAC, (byte) AO }; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java index fb12a35..a7d0395 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java @@ -114,4 +114,14 @@ public class ECLPSTest implements ECLConstants { int count = ps.pasteLineWrap("ABC\nDEF", 1, 80, false); assertEquals(6, count); } + + @Test + public void testNVTModeAndSendKeys() { + assertFalse(ps.isNVTmode()); + ps.setNVTmode(true); + assertTrue(ps.isNVTmode()); + + ps.setNVTmode(false); + assertFalse(ps.isNVTmode()); + } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java index ab9b850..d900941 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java @@ -453,17 +453,15 @@ public class InputProcessorTest { } @Test - public void testSendAidWhenGraphicsCursorActiveFraming() { + public void testSendAidAlwaysSendsStandard3270StreamEvenIfGraphicCursorActive() { screen.erase(false); screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_MODIFY)); screen.getCell(1).ec = (byte) 0xC1; // 'A' - screen.setCellFA(5, (byte) (FA_PRINTABLE | FA_PROTECT)); screen.setCursorAddress(2); haus.nightmare.lib3270j.graphics.GraphicsPlane plane = new haus.nightmare.lib3270j.graphics.GraphicsPlane(800, 600); haus.nightmare.lib3270j.graphics.GocaDecoder goca = new haus.nightmare.lib3270j.graphics.GocaDecoder(plane); goca.setGraphicsCursorActive(true); - goca.setGraphicCursorPosition(150, -80); java.util.concurrent.atomic.AtomicReference sent = new java.util.concurrent.atomic.AtomicReference<>(); InputProcessor input = new InputProcessor(screen, translator, null) { @@ -478,64 +476,10 @@ public class InputProcessorTest { byte[] result = sent.get(); assertNotNull(result); - // Total expected length: - // 1 (AID_SF 0x88) + 56 (SF) + 1 (AID_ENTER 0x7D) + 2 (Cursor Addr) + 1 (SBA) + 2 (Field Addr) + 1 (Data 'A') = 64 bytes - assertEquals(64, result.length); - assertEquals((byte) AID_SF, result[0]); - // SF length = 52 (0x00 0x34) per IBM HOD / GOCA specification - assertEquals(0x00, result[1]); - assertEquals(0x34, result[2]); - // SF ID = 0x0F0F - assertEquals(0x0F, result[3]); - assertEquals(0x0F, result[4]); - // Coordinates in SF at index 1 + 24 = 25 - int gx = (result[25] << 8) | (result[26] & 0xFF); - int gy = (result[27] << 8) | (result[28] & 0xFF); - assertEquals(150, (short) gx); - assertEquals(-80, (short) gy); - // Keyboard constants at index 1 + 31 = 32 and 1 + 33 = 34 - assertEquals(0x07, result[32]); - assertEquals(0x07, result[34]); - assertEquals((byte) 0xFF, result[35]); - assertEquals((byte) AID_ENTER, result[36]); - - // Trailing AID at index 57 - assertEquals((byte) AID_ENTER, result[57]); - // Trailing SBA at 60 - assertEquals((byte) ORDER_SBA, result[60]); - // Trailing field content 'A' at 63 - assertEquals((byte) 0xC1, result[63]); - } - - @Test - public void testSendAidPAWhenGraphicsCursorActiveFraming() { - screen.erase(false); - screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_MODIFY)); - screen.getCell(1).ec = (byte) 0xC1; - screen.setCursorAddress(2); - - haus.nightmare.lib3270j.graphics.GraphicsPlane plane = new haus.nightmare.lib3270j.graphics.GraphicsPlane(800, 600); - haus.nightmare.lib3270j.graphics.GocaDecoder goca = new haus.nightmare.lib3270j.graphics.GocaDecoder(plane); - goca.setGraphicsCursorActive(true); - goca.setGraphicCursorPosition(100, 200); - - java.util.concurrent.atomic.AtomicReference sent = new java.util.concurrent.atomic.AtomicReference<>(); - InputProcessor input = new InputProcessor(screen, translator, null) { - @Override - protected void sendAidResponse(byte[] data) { - sent.set(data); - } - }; - input.setGocaDecoder(goca); - - input.sendAid(AID_PA1); - - byte[] result = sent.get(); - assertNotNull(result); - // 1 (AID_SF 0x88) + 56 (SF) + 1 (AID_PA1 0x6C) + 2 (Cursor Addr) = 60 bytes (no modified field data) - assertEquals(60, result.length); - assertEquals((byte) AID_SF, result[0]); - assertEquals((byte) AID_PA1, result[36]); // Keyboard AID in SF - assertEquals((byte) AID_PA1, result[57]); // Trailing AID + // Standard 3270 stream: AID_ENTER (1) + Cursor Addr (2) + SBA (1) + Field Addr (2) + Data 'A' (1) = 7 bytes + assertEquals(7, result.length); + assertEquals((byte) AID_ENTER, result[0]); + assertEquals((byte) ORDER_SBA, result[3]); + assertEquals((byte) 0xC1, result[6]); } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java index f906150..71385ed 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java @@ -138,4 +138,125 @@ public class NvtProcessorTest { assertEquals('\r', (char) sent[5]); assertEquals('\n', (char) sent[6]); } + + @Test + public void testCursorPositionReportDSR() { + // Move to row 12, col 34 (0-indexed: row 11, col 33) + byte[] move = "\u001B[12;34H".getBytes(); + processor.processNVTData(move, 0, move.length); + output.reset(); + + // Send DSR Cursor Position Request: ESC [ 6 n + byte[] dsr = "\u001B[6n".getBytes(); + processor.processNVTData(dsr, 0, dsr.length); + + assertEquals("\u001B[12;34R", output.toString(java.nio.charset.StandardCharsets.US_ASCII)); + } + + @Test + public void testDeviceAttributesDA() { + output.reset(); + // Send DA Request: ESC [ c + byte[] da1 = "\u001B[c".getBytes(); + processor.processNVTData(da1, 0, da1.length); + assertEquals("\u001B[?1;2c", output.toString(java.nio.charset.StandardCharsets.US_ASCII)); + + output.reset(); + // Send DA Request: ESC [ 0 c + byte[] da2 = "\u001B[0c".getBytes(); + processor.processNVTData(da2, 0, da2.length); + assertEquals("\u001B[?1;2c", output.toString(java.nio.charset.StandardCharsets.US_ASCII)); + } + + @Test + public void testExtendedAnsiSequences() { + // CHA: Cursor Horizontal Absolute -> ESC [ 20 G (moves to col 20 -> 0-indexed col 19) + byte[] cha = "\u001B[20G".getBytes(); + processor.processNVTData(cha, 0, cha.length); + assertEquals(19, screen.getCursorAddress()); + + // CNL: Cursor Next Line -> ESC [ 2 E (down 2 rows, col 0) + byte[] cnl = "\u001B[2E".getBytes(); + processor.processNVTData(cnl, 0, cnl.length); + assertEquals(2 * 80, screen.getCursorAddress()); + + // CPL: Cursor Previous Line -> ESC [ 1 F (up 1 row, col 0) + byte[] cpl = "\u001B[1F".getBytes(); + processor.processNVTData(cpl, 0, cpl.length); + assertEquals(1 * 80, screen.getCursorAddress()); + + // Write line "ABCDE" + byte[] text = "\u001B[1;1HABCDE".getBytes(); + processor.processNVTData(text, 0, text.length); + assertEquals('A', screen.getCell(0).ucs4); + assertEquals('E', screen.getCell(4).ucs4); + + // Move to pos 2 ('C') and ECH (Erase Character): ESC [ 2 X + byte[] ech = "\u001B[1;3H\u001B[2X".getBytes(); + processor.processNVTData(ech, 0, ech.length); + assertEquals('A', screen.getCell(0).ucs4); + assertEquals('B', screen.getCell(1).ucs4); + assertEquals(' ', screen.getCell(2).ucs4); + assertEquals(' ', screen.getCell(3).ucs4); + assertEquals('E', screen.getCell(4).ucs4); + + // DCH: Delete Character at pos 0 -> ESC [ 1;1H ESC [ 1 P + byte[] dch = "\u001B[1;1H\u001B[1P".getBytes(); + processor.processNVTData(dch, 0, dch.length); + assertEquals('B', screen.getCell(0).ucs4); + + // ICH: Insert Character at pos 0 -> ESC [ 1;1H ESC [ 1 @ + byte[] ich = "\u001B[1;1H\u001B[1@".getBytes(); + processor.processNVTData(ich, 0, ich.length); + assertEquals(' ', screen.getCell(0).ucs4); + assertEquals('B', screen.getCell(1).ucs4); + } + + @Test + public void testScrollingRegionAndSpecialMovement() { + // Set scrolling region lines 2..4 (1-indexed): ESC [ 2 ; 4 r + byte[] decstbm = "\u001B[2;4r".getBytes(); + processor.processNVTData(decstbm, 0, decstbm.length); + assertEquals(0, screen.getCursorAddress()); + + // Move to row 4 (bottom of region), col 1 + byte[] line4 = "\u001B[4;1HLine4".getBytes(); + processor.processNVTData(line4, 0, line4.length); + assertEquals('L', screen.getCell(3 * 80).ucs4); + + // Index (line feed down at bottom of region): ESC D -> scrolls region up + byte[] ind = "\u001BD".getBytes(); + processor.processNVTData(ind, 0, ind.length); + assertEquals('L', screen.getCell(2 * 80).ucs4); // Moved to row 3 + + // Reverse Index (line feed up at top of region): ESC [ 2;1H ESC M -> scrolls region down + byte[] ri = "\u001B[2;1H\u001BM".getBytes(); + processor.processNVTData(ri, 0, ri.length); + assertEquals('L', screen.getCell(3 * 80).ucs4); // Moved back down to row 4 + } + + @Test + public void testCursorVisibilityAndVt100Graphics() { + assertTrue(processor.isCursorVisible()); + + // Hide cursor: ESC [ ? 25 l + byte[] hide = "\u001B[?25l".getBytes(); + processor.processNVTData(hide, 0, hide.length); + assertFalse(processor.isCursorVisible()); + + // Show cursor: ESC [ ? 25 h + byte[] show = "\u001B[?25h".getBytes(); + processor.processNVTData(show, 0, show.length); + assertTrue(processor.isCursorVisible()); + + // Enable VT100 line drawing on G0: ESC ( 0 + byte[] g0 = "\u001B(0q".getBytes(); + processor.processNVTData(g0, 0, g0.length); + assertEquals('─', screen.getCell(0).ucs4); + + // Reset G0 to ASCII: ESC ( B + byte[] g0Ascii = "\u001B(Bq".getBytes(); + processor.processNVTData(g0Ascii, 0, g0Ascii.length); + assertEquals('q', screen.getCell(1).ucs4); + } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/ProxyConnectionTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/ProxyConnectionTest.java new file mode 100644 index 0000000..058d972 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/ProxyConnectionTest.java @@ -0,0 +1,244 @@ +package haus.nightmare.lib3270j.telnet; + +import org.junit.jupiter.api.Test; +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.TerminalModel; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class ProxyConnectionTest { + + @Test + public void testParseHostStringWithProxyFlags() { + // HTTP proxy without auth + ConnectionConfig c1 = ConnectionConfig.parseHostString("--proxy=http://proxy.corp.com:8080 mainframe.net:23", 23, TerminalModel.IBM_3279_4); + assertEquals(ConnectionConfig.ProxyType.HTTP, c1.getProxyType()); + assertEquals("proxy.corp.com", c1.getProxyHost()); + assertEquals(8080, c1.getProxyPort()); + assertNull(c1.getProxyUsername()); + assertEquals("mainframe.net", c1.getHost()); + assertEquals(23, c1.getPort()); + + // HTTP proxy with auth + ConnectionConfig c2 = ConnectionConfig.parseHostString("--proxy=http://alice:secret123@10.0.0.1:3128 zos.ibm.com:2323", 23, TerminalModel.IBM_3279_4); + assertEquals(ConnectionConfig.ProxyType.HTTP, c2.getProxyType()); + assertEquals("10.0.0.1", c2.getProxyHost()); + assertEquals(3128, c2.getProxyPort()); + assertEquals("alice", c2.getProxyUsername()); + assertEquals("secret123", c2.getProxyPassword()); + assertEquals("zos.ibm.com", c2.getHost()); + assertEquals(2323, c2.getPort()); + + // SOCKS4 proxy + ConnectionConfig c3 = ConnectionConfig.parseHostString("--proxy=socks4://socks.local:1080 L:secure.mvs.com:992", 23, TerminalModel.IBM_3279_4); + assertEquals(ConnectionConfig.ProxyType.SOCKS4, c3.getProxyType()); + assertEquals("socks.local", c3.getProxyHost()); + assertEquals(1080, c3.getProxyPort()); + assertTrue(c3.isUseTls()); + assertEquals("secure.mvs.com", c3.getHost()); + assertEquals(992, c3.getPort()); + + // SOCKS5 proxy with auth + ConnectionConfig c4 = ConnectionConfig.parseHostString("--proxy=socks5://bob:pass55@127.0.0.1:9050 P:vm.host:23", 23, TerminalModel.IBM_3279_4); + assertEquals(ConnectionConfig.ProxyType.SOCKS5, c4.getProxyType()); + assertEquals("127.0.0.1", c4.getProxyHost()); + assertEquals(9050, c4.getProxyPort()); + assertEquals("bob", c4.getProxyUsername()); + assertEquals("pass55", c4.getProxyPassword()); + assertFalse(c4.isTn3270eEnabled()); + assertEquals("vm.host", c4.getHost()); + } + + @Test + public void testHttpProxyConnectHandshake() throws Exception { + try (ServerSocket proxyServer = new ServerSocket(0)) { + int proxyPort = proxyServer.getLocalPort(); + + CountDownLatch serverHandshakeDone = new CountDownLatch(1); + CompletableFuture receivedRequest = new CompletableFuture<>(); + + Thread serverThread = new Thread(() -> { + try (Socket clientSock = proxyServer.accept()) { + BufferedReader reader = new BufferedReader(new InputStreamReader(clientSock.getInputStream(), StandardCharsets.US_ASCII)); + StringBuilder req = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) break; + req.append(line).append("\n"); + } + receivedRequest.complete(req.toString()); + + // Respond 200 Connection established + OutputStream out = clientSock.getOutputStream(); + out.write("HTTP/1.1 200 Connection established\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + out.flush(); + serverHandshakeDone.countDown(); + + // Echo back test data + int b; + while ((b = clientSock.getInputStream().read()) != -1) { + out.write(b); + out.flush(); + } + } catch (Exception e) { + receivedRequest.completeExceptionally(e); + } + }); + serverThread.setDaemon(true); + serverThread.start(); + + ConnectionConfig config = new ConnectionConfig("target.mainframe.org", 23); + config.setProxy(ConnectionConfig.ProxyType.HTTP, "127.0.0.1", proxyPort, "testuser", "testpass"); + config.setConnectTimeoutMs(5000); + + TelnetFSM fsm = new TelnetFSM(config, new haus.nightmare.lib3270j.screen.ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator()), null); + TelnetConnection connection = new TelnetConnection(config, fsm); + + connection.connect(); + assertTrue(connection.isConnected()); + + String req = receivedRequest.get(3, TimeUnit.SECONDS); + assertTrue(req.startsWith("CONNECT target.mainframe.org:23 HTTP/1.1"), "Expected CONNECT request"); + assertTrue(req.contains("Proxy-Authorization: Basic "), "Expected Basic auth in proxy request"); + + connection.disconnect(); + } + } + + @Test + public void testSocks4ProxyHandshake() throws Exception { + try (ServerSocket proxyServer = new ServerSocket(0)) { + int proxyPort = proxyServer.getLocalPort(); + CountDownLatch handshakeDone = new CountDownLatch(1); + + Thread serverThread = new Thread(() -> { + try (Socket clientSock = proxyServer.accept()) { + InputStream in = clientSock.getInputStream(); + OutputStream out = clientSock.getOutputStream(); + + // Read SOCKS4 request header + byte[] req = new byte[8]; + in.read(req); + assertEquals(0x04, req[0]); // SOCKS4 + assertEquals(0x01, req[1]); // CONNECT + + // Read null-terminated username + ByteArrayOutputStream userBuf = new ByteArrayOutputStream(); + int b; + while ((b = in.read()) != 0 && b != -1) { + userBuf.write(b); + } + assertEquals("user4", new String(userBuf.toByteArray())); + + // Reply 0x00 0x5A (Request granted) + byte[] resp = new byte[] { 0x00, 0x5A, 0x00, 0x17, 127, 0, 0, 1 }; + out.write(resp); + out.flush(); + handshakeDone.countDown(); + + while (in.read() != -1) {} + } catch (Exception ignored) {} + }); + serverThread.setDaemon(true); + serverThread.start(); + + ConnectionConfig config = new ConnectionConfig("127.0.0.1", 23); + config.setProxy(ConnectionConfig.ProxyType.SOCKS4, "127.0.0.1", proxyPort, "user4", null); + config.setConnectTimeoutMs(5000); + + TelnetFSM fsm = new TelnetFSM(config, new haus.nightmare.lib3270j.screen.ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator()), null); + TelnetConnection connection = new TelnetConnection(config, fsm); + + connection.connect(); + assertTrue(connection.isConnected()); + assertTrue(handshakeDone.await(3, TimeUnit.SECONDS)); + + connection.disconnect(); + } + } + + @Test + public void testSocks5ProxyHandshakeWithAuth() throws Exception { + try (ServerSocket proxyServer = new ServerSocket(0)) { + int proxyPort = proxyServer.getLocalPort(); + CountDownLatch handshakeDone = new CountDownLatch(1); + + Thread serverThread = new Thread(() -> { + try (Socket clientSock = proxyServer.accept()) { + InputStream in = clientSock.getInputStream(); + OutputStream out = clientSock.getOutputStream(); + + // 1. Read method selection + int ver = in.read(); + int nmethods = in.read(); + byte[] methods = new byte[nmethods]; + in.read(methods); + assertEquals(0x05, ver); + + // Select USER_PASS (0x02) + out.write(new byte[] { 0x05, 0x02 }); + out.flush(); + + // 2. Read auth request (RFC 1929) + int authVer = in.read(); + int ulen = in.read(); + byte[] u = new byte[ulen]; + in.read(u); + int plen = in.read(); + byte[] p = new byte[plen]; + in.read(p); + + assertEquals(1, authVer); + assertEquals("admin", new String(u)); + assertEquals("pass123", new String(p)); + + // Auth success: 0x01 0x00 + out.write(new byte[] { 0x01, 0x00 }); + out.flush(); + + // 3. Read connect command + byte[] cmd = new byte[4]; + in.read(cmd); + assertEquals(0x05, cmd[0]); + assertEquals(0x01, cmd[1]); // CONNECT + int atyp = cmd[3]; + if (atyp == 0x03) { // Domain name + int dlen = in.read(); + byte[] d = new byte[dlen]; + in.read(d); + } else if (atyp == 0x01) { // IPv4 + in.read(new byte[4]); + } + in.read(new byte[2]); // Port + + // Reply success: 0x05 0x00 0x00 0x01 127.0.0.1:port + out.write(new byte[] { 0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 23 }); + out.flush(); + handshakeDone.countDown(); + + while (in.read() != -1) {} + } catch (Exception ignored) {} + }); + serverThread.setDaemon(true); + serverThread.start(); + + ConnectionConfig config = new ConnectionConfig("mvs.corp.local", 23); + config.setProxy(ConnectionConfig.ProxyType.SOCKS5, "127.0.0.1", proxyPort, "admin", "pass123"); + config.setConnectTimeoutMs(5000); + + TelnetFSM fsm = new TelnetFSM(config, new haus.nightmare.lib3270j.screen.ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator()), null); + TelnetConnection connection = new TelnetConnection(config, fsm); + + connection.connect(); + assertTrue(connection.isConnected()); + assertTrue(handshakeDone.await(3, TimeUnit.SECONDS)); + + connection.disconnect(); + } + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMPhase1FullTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMPhase1FullTest.java new file mode 100644 index 0000000..e6f50d0 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMPhase1FullTest.java @@ -0,0 +1,346 @@ +package haus.nightmare.lib3270j.telnet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.datastream.DataStreamProcessor; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.listener.SCSInboundListener; +import haus.nightmare.lib3270j.protocol.TelnetConstants; +import haus.nightmare.lib3270j.protocol.TN3270EConstants; +import haus.nightmare.lib3270j.screen.ScreenBuffer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; +import static haus.nightmare.lib3270j.protocol.TelnetConstants.*; +import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*; + +public class TelnetFSMPhase1FullTest { + + private ConnectionConfig config; + private ScreenBuffer screenBuffer; + private DataStreamProcessor dsProcessor; + private InputProcessor inputProcessor; + private TelnetFSM fsm; + private MockConnection connection; + private EbcdicTranslator translator; + + private static class MockConnection extends TelnetConnection { + final List sentData = new ArrayList<>(); + boolean tlsUpgraded = false; + + MockConnection(ConnectionConfig config, TelnetFSM fsm) { + super(config, fsm); + } + + @Override + public synchronized void sendRaw(byte[] data) { + sentData.add(data.clone()); + } + + @Override + public synchronized void sendRaw(byte[] data, int offset, int length) { + byte[] b = new byte[length]; + System.arraycopy(data, offset, b, 0, length); + sentData.add(b); + } + + @Override + public synchronized void upgradeToTls() throws IOException { + tlsUpgraded = true; + } + } + + @BeforeEach + public void setup() { + translator = new EbcdicTranslator(); + config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + dsProcessor = new DataStreamProcessor(screenBuffer, translator); + fsm = new TelnetFSM(config, screenBuffer, dsProcessor); + inputProcessor = new InputProcessor(screenBuffer, translator, fsm); + dsProcessor.setInputProcessor(inputProcessor); + connection = new MockConnection(config, fsm); + fsm.setConnection(connection); + } + + private void feedBytes(int... bytes) { + for (int b : bytes) { + fsm.feedByte(b & 0xFF); + } + } + + @Test + public void testSendTn3270eResponseAndHoD5ByteSignature() { + connection.sentData.clear(); + + // 1. Standard 6-byte RFC 2355 response + fsm.sendTn3270eResponse((byte) RSF_POSITIVE_RESPONSE, (byte) POS_DEVICE_END, 0x1234); + assertEquals(1, connection.sentData.size()); + byte[] resp6 = connection.sentData.get(0); + assertEquals(6 + 2, resp6.length); // 6 bytes header/trailer + IAC EOR + assertEquals(DT_RESPONSE, resp6[0] & 0xFF); + assertEquals(0, resp6[1] & 0xFF); + assertEquals(RSF_POSITIVE_RESPONSE, resp6[2] & 0xFF); + assertEquals(0x12, resp6[3] & 0xFF); + assertEquals(0x34, resp6[4] & 0xFF); + assertEquals(POS_DEVICE_END, resp6[5] & 0xFF); + assertEquals(IAC, resp6[6] & 0xFF); + assertEquals(EOR, resp6[7] & 0xFF); + + // 2. HoD 5-byte send_response + connection.sentData.clear(); + fsm.send_response((short) 0, (short) RSF_POSITIVE_RESPONSE, 0x5678); + assertEquals(1, connection.sentData.size()); + byte[] resp5 = connection.sentData.get(0); + assertEquals(5 + 2, resp5.length); // 5 bytes + IAC EOR + assertEquals(DT_RESPONSE, resp5[0] & 0xFF); + assertEquals(0, resp5[1] & 0xFF); + assertEquals(RSF_POSITIVE_RESPONSE, resp5[2] & 0xFF); + assertEquals(0x56, resp5[3] & 0xFF); + assertEquals(0x78, resp5[4] & 0xFF); + } + + @Test + public void testStartTlsOption46NegotiationAndElevation() { + config.setStartTlsEnabled(true); + fsm.onConnected(); + connection.sentData.clear(); + + // Server sends DO STARTTLS (Option 46) + feedBytes(IAC, DO, TELOPT_STARTTLS); + + // Client must reply WILL STARTTLS + assertEquals(1, connection.sentData.size()); + assertArrayEquals(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_STARTTLS }, connection.sentData.get(0)); + assertTrue(fsm.getMyOpts()[TELOPT_STARTTLS]); + connection.sentData.clear(); + + // Server sends SB STARTTLS 1 (TLS_FOLLOWS) SE + feedBytes(IAC, SB, TELOPT_STARTTLS, TLS_FOLLOWS, IAC, SE); + + // Connection must have been upgraded to TLS + assertTrue(connection.tlsUpgraded, "Expected upgradeToTls() to be invoked on STARTTLS_FOLLOWS"); + } + + @Test + public void testRfc1572NewEnvironEmptySendReturnsAllVariables() { + config.setEnvironmentVariable("USER", "MAINFRAME_USER"); + config.setEnvironmentVariable("SYSTEMTYPE", "MVS"); + config.setUserVariable("IBM_EXPRESS_LOGON", "CERT_AUTH_ENABLED"); + + fsm.onConnected(); + connection.sentData.clear(); + + // Server sends DO NEW_ENVIRON + feedBytes(IAC, DO, TELOPT_NEW_ENVIRON); + assertEquals(1, connection.sentData.size()); + assertArrayEquals(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_NEW_ENVIRON }, connection.sentData.get(0)); + connection.sentData.clear(); + + // Server sends SB NEW-ENVIRON SEND SE (empty query -> all variables) + feedBytes(IAC, SB, TELOPT_NEW_ENVIRON, TELQUAL_SEND, IAC, SE); + + assertEquals(1, connection.sentData.size()); + byte[] pkt = connection.sentData.get(0); + assertTrue(pkt.length >= 6); + assertEquals(IAC, pkt[0] & 0xFF); + assertEquals(SB, pkt[1] & 0xFF); + assertEquals(TELOPT_NEW_ENVIRON, pkt[2] & 0xFF); + assertEquals(TELQUAL_IS, pkt[3] & 0xFF); + + String pktStr = new String(pkt); + assertTrue(pktStr.contains("USER")); + assertTrue(pktStr.contains("MAINFRAME_USER")); + assertTrue(pktStr.contains("SYSTEMTYPE")); + assertTrue(pktStr.contains("MVS")); + assertTrue(pktStr.contains("IBM_EXPRESS_LOGON")); + assertTrue(pktStr.contains("CERT_AUTH_ENABLED")); + } + + @Test + public void testRfc1572NewEnvironSelectiveSend() throws Exception { + config.setEnvironmentVariable("USER", "ALICE"); + config.setEnvironmentVariable("JOB", "DAILY_REPORT"); + config.setUserVariable("SEC_TOKEN", "XYZ123"); + + fsm.onConnected(); + connection.sentData.clear(); + + // Server sends SB NEW-ENVIRON SEND VAR "USER" USERVAR "SEC_TOKEN" SE + ByteArrayOutputStream query = new ByteArrayOutputStream(); + query.write(IAC); + query.write(SB); + query.write(TELOPT_NEW_ENVIRON); + query.write(TELQUAL_SEND); + query.write(TELOBJ_VAR); + query.write("USER".getBytes()); + query.write(TELOBJ_USERVAR); + query.write("SEC_TOKEN".getBytes()); + query.write(IAC); + query.write(SE); + + for (byte b : query.toByteArray()) { + fsm.feedByte(b & 0xFF); + } + + assertEquals(1, connection.sentData.size()); + String respStr = new String(connection.sentData.get(0)); + assertTrue(respStr.contains("USER")); + assertTrue(respStr.contains("ALICE")); + assertTrue(respStr.contains("SEC_TOKEN")); + assertTrue(respStr.contains("XYZ123")); + assertFalse(respStr.contains("JOB"), "Unrequested variable JOB should not be sent"); + } + + @Test + public void testSendNewEnvironmentVariablesFromProperties() { + Properties props = new Properties(); + props.setProperty("VAR_TEST", "VAL1"); + props.setProperty("USERVAR_TOKEN", "VAL2"); + + connection.sentData.clear(); + fsm.sendNewEnvironmentVariables(props); + + assertEquals(1, connection.sentData.size()); + byte[] pkt = connection.sentData.get(0); + assertEquals(IAC, pkt[0] & 0xFF); + assertEquals(SB, pkt[1] & 0xFF); + assertEquals(TELOPT_NEW_ENVIRON, pkt[2] & 0xFF); + assertEquals(TELQUAL_IS, pkt[3] & 0xFF); + + String pktStr = new String(pkt); + assertTrue(pktStr.contains("VAR_TEST")); + assertTrue(pktStr.contains("VAL1")); + assertTrue(pktStr.contains("USERVAR_TOKEN")); + assertTrue(pktStr.contains("VAL2")); + } + + @Test + public void testInboundSCSDataHandlingAndAutoPositiveResponse() throws Exception { + config.setTn3270eEnabled(true); + fsm.onConnected(); + + // Negotiate TN3270E with RESPONSES and SCS_CTL_CODES + feedBytes(IAC, DO, TELOPT_TN3270E); + feedBytes(IAC, SB, TELOPT_TN3270E, OP_SEND, OP_DEVICE_TYPE, IAC, SE); + byte[] devTypeIs = new byte[]{ + (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, + 0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '8', '7', '-', '1', + (byte) IAC, (byte) SE + }; + for (byte b : devTypeIs) fsm.feedByte(b & 0xFF); + + byte[] funcsReq = new byte[]{ + (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, + (byte) OP_FUNCTIONS, (byte) OP_REQUEST, + (byte) FUNC_BIND_IMAGE, (byte) FUNC_RESPONSES, (byte) FUNC_SCS_CTL_CODES, + (byte) IAC, (byte) SE + }; + for (byte b : funcsReq) fsm.feedByte(b & 0xFF); + + List scsReceived = new ArrayList<>(); + fsm.addSCSInboundListener(new SCSInboundListener() { + @Override + public void onSCSDataReceived(byte[] data, int offset, int length) { + byte[] copy = new byte[length]; + System.arraycopy(data, offset, copy, 0, length); + scsReceived.add(copy); + } + }); + + connection.sentData.clear(); + + // Send DT_SCS_DATA (0x01) record with ALWAYS_RESPONSE + int seq = 0x00A1; + byte[] scsPayload = new byte[] { 0x15, 0x2B, (byte) 0xD2, 0x04, 0x15 }; // SCS orders + ByteArrayOutputStream scsRecord = new ByteArrayOutputStream(); + scsRecord.write(DT_SCS_DATA); + scsRecord.write(0x00); // Request flag + scsRecord.write(RSF_ALWAYS_RESPONSE); + scsRecord.write((seq >> 8) & 0xFF); + scsRecord.write(seq & 0xFF); + scsRecord.write(scsPayload); + scsRecord.write(IAC); + scsRecord.write(EOR); + + for (byte b : scsRecord.toByteArray()) { + fsm.feedByte(b & 0xFF); + } + + // Verify SCS listener was notified with exact payload + assertEquals(1, scsReceived.size()); + assertArrayEquals(scsPayload, scsReceived.get(0)); + + // Verify positive response was sent + boolean foundPositive = false; + for (byte[] pkt : connection.sentData) { + if (pkt.length >= 8 && + (pkt[0] & 0xFF) == DT_RESPONSE && + (pkt[2] & 0xFF) == RSF_POSITIVE_RESPONSE && + ((((pkt[3] & 0xFF) << 8) | (pkt[4] & 0xFF)) == seq) && + (pkt[5] & 0xFF) == POS_DEVICE_END) { + foundPositive = true; + } + } + assertTrue(foundPositive, "Expected positive response for DT_SCS_DATA with RSF_ALWAYS_RESPONSE"); + } + + @Test + public void testProcessSysReqMethod() { + config.setTn3270eEnabled(true); + fsm.onConnected(); + + // Negotiate TN3270E + feedBytes(IAC, DO, TELOPT_TN3270E); + feedBytes(IAC, SB, TELOPT_TN3270E, OP_SEND, OP_DEVICE_TYPE, IAC, SE); + byte[] devTypeIs = new byte[]{ + (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, + 0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E', + (byte) IAC, (byte) SE + }; + for (byte b : devTypeIs) fsm.feedByte(b & 0xFF); + byte[] funcsReq = new byte[]{ + (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, + (byte) OP_FUNCTIONS, (byte) OP_REQUEST, + (byte) FUNC_BIND_IMAGE, (byte) FUNC_RESPONSES, (byte) FUNC_SYSREQ, + (byte) IAC, (byte) SE + }; + for (byte b : funcsReq) fsm.feedByte(b & 0xFF); + + // Bind session + byte[] bindPacket = new byte[EH_SIZE + 35]; + bindPacket[0] = DT_BIND_IMAGE; + bindPacket[1] = 0; bindPacket[2] = 0; bindPacket[3] = 0; bindPacket[4] = 1; + bindPacket[EH_SIZE + 24] = 0x02; + ByteArrayOutputStream bStream = new ByteArrayOutputStream(); + bStream.write(bindPacket, 0, bindPacket.length); + bStream.write(IAC); + bStream.write(EOR); + for (byte b : bStream.toByteArray()) fsm.feedByte(b & 0xFF); + + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState()); + connection.sentData.clear(); + + // Call processSysReq() + fsm.processSysReq(); + + // State changes to CONNECTED_SSCP and IAC AO sent + assertEquals(ConnectionState.CONNECTED_SSCP, fsm.getConnectionState()); + boolean foundAo = false; + for (byte[] pkt : connection.sentData) { + if (pkt.length == 2 && (pkt[0] & 0xFF) == IAC && (pkt[1] & 0xFF) == AO) { + foundAo = true; + } + } + assertTrue(foundAo, "Expected IAC AO out-of-band telnet abort"); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMTest.java index d5ed696..9038d14 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMTest.java @@ -50,6 +50,9 @@ public class TelnetFSMTest { screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, new EbcdicTranslator()); dsProcessor = new DataStreamProcessor(screenBuffer, new EbcdicTranslator()); fsm = new TelnetFSM(config, screenBuffer, dsProcessor); + haus.nightmare.lib3270j.input.InputProcessor inputProcessor = + new haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, new EbcdicTranslator(), fsm); + dsProcessor.setInputProcessor(inputProcessor); connection = new MockConnection(config, fsm); fsm.setConnection(connection); } @@ -294,4 +297,212 @@ public class TelnetFSMTest { String resp3 = new String(lastPkt, 4, lastPkt.length - 6); assertEquals("IBM-3278-4-E", resp3); } + + @Test + public void testTn3270eNvtInboundAndOutbound() throws Exception { + fsm.onConnected(); + connection.sentData.clear(); + + // Negotiate TN3270E without BIND-IMAGE + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E); + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TN3270E); + + // Host sends DEVICE-TYPE SEND + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E, + 0x08, 0x01, TelnetConstants.IAC, TelnetConstants.SE); + + // Host sends DEVICE-TYPE IS IBM-3279-4-E + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E, + 0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E', + TelnetConstants.IAC, TelnetConstants.SE); + + // Host sends FUNCTIONS IS (no BIND-IMAGE) + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E, + 0x03, 0x04, TelnetConstants.IAC, TelnetConstants.SE); + + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState()); + + // Host sends DT_NVT_DATA (0x05) record with text "PROMPT: " + byte[] nvtRecord = { + 0x05, // DT_NVT_DATA + 0x00, // request flag + 0x00, // response flag + 0x00, 0x01, // seq 1 + 'P', 'R', 'O', 'M', 'P', 'T', ':', ' ', + (byte) TelnetConstants.IAC, (byte) TelnetConstants.EOR + }; + for (byte b : nvtRecord) { + fsm.feedByte(b & 0xFF); + } + + // FSM must transition to CONNECTED_E_NVT + assertEquals(ConnectionState.CONNECTED_E_NVT, fsm.getConnectionState()); + assertTrue(fsm.getConnectionState().isNvt()); + + // Verify screen buffer contains the NVT text + assertEquals('P', screenBuffer.getCell(0).ucs4); + assertEquals('R', screenBuffer.getCell(1).ucs4); + assertEquals('O', screenBuffer.getCell(2).ucs4); + assertEquals('M', screenBuffer.getCell(3).ucs4); + assertEquals('P', screenBuffer.getCell(4).ucs4); + assertEquals('T', screenBuffer.getCell(5).ucs4); + assertEquals(':', screenBuffer.getCell(6).ucs4); + assertEquals(' ', screenBuffer.getCell(7).ucs4); + assertEquals(8, screenBuffer.getCursorAddress()); + + // Test Outbound NVT sending in TN3270E mode + connection.sentData.clear(); + fsm.sendNVTString("OK\n"); + + assertFalse(connection.sentData.isEmpty()); + byte[] sentPkt = connection.sentData.get(connection.sentData.size() - 1); + + // Sent packet should be TN3270E record: 5-byte header + "OK\r\n" + IAC EOR + assertEquals(5 + 4 + 2, sentPkt.length); + assertEquals(0x05, sentPkt[0]); // DT_NVT_DATA + assertEquals('O', sentPkt[5]); + assertEquals('K', sentPkt[6]); + assertEquals('\r', sentPkt[7]); + assertEquals('\n', sentPkt[8]); + assertEquals((byte) TelnetConstants.IAC, sentPkt[9]); + assertEquals((byte) TelnetConstants.EOR, sentPkt[10]); + } + + @Test + public void testPlainTelnetNvtStreamingAndLocalEcho() throws Exception { + fsm.onConnected(); + config.setNvtLocalEcho(true); + + // Receiving raw bytes in TELNET_PENDING automatically transitions to CONNECTED_NVT + feedBytes('L', 'O', 'G', 'I', 'N', '>'); + assertEquals(ConnectionState.CONNECTED_NVT, fsm.getConnectionState()); + assertEquals('L', screenBuffer.getCell(0).ucs4); + assertEquals('>', screenBuffer.getCell(5).ucs4); + + // Sending NVT data with local echo enabled + connection.sentData.clear(); + fsm.sendNVTString("TEST"); + + // Raw bytes sent over TCP + assertEquals(1, connection.sentData.size()); + byte[] sent = connection.sentData.get(0); + assertArrayEquals("TEST".getBytes(), sent); + + // Local echo placed "TEST" on screen starting at address 6 + assertEquals('T', screenBuffer.getCell(6).ucs4); + assertEquals('E', screenBuffer.getCell(7).ucs4); + assertEquals('S', screenBuffer.getCell(8).ucs4); + assertEquals('T', screenBuffer.getCell(9).ucs4); + assertEquals(10, screenBuffer.getCursorAddress()); + } + + @Test + public void testVmConmode3270TransitionTn3270e() throws Exception { + fsm.onConnected(); + connection.sentData.clear(); + + // 1. Negotiate TN3270E + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E); + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TN3270E); + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E, + 0x08, 0x01, TelnetConstants.IAC, TelnetConstants.SE); + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E, + 0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E', + TelnetConstants.IAC, TelnetConstants.SE); + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E, + 0x03, 0x04, TelnetConstants.IAC, TelnetConstants.SE); + + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState()); + + // 2. VM console in line-mode sends DT_NVT_DATA (e.g. CP prompt) + byte[] nvtRecord = { + 0x05, 0x00, 0x00, 0x00, 0x01, + 'C', 'P', ' ', 'R', 'E', 'A', 'D', 'Y', '\r', '\n', + (byte) TelnetConstants.IAC, (byte) TelnetConstants.EOR + }; + for (byte b : nvtRecord) fsm.feedByte(b & 0xFF); + + assertEquals(ConnectionState.CONNECTED_E_NVT, fsm.getConnectionState()); + assertEquals(TelnetFSM.TN3270ESubmode.E_NVT, fsm.getTn3270eSubmode()); + + // 3. VM executes "cp term conmode 3270" and sends 3270 full screen (DT_3270_DATA) + EbcdicTranslator trans = new EbcdicTranslator(); + String banner = "z/VM 3270 FULLSCREEN"; + byte[] stream3270 = new byte[5 + 5 + banner.length() + 2]; + // 5-byte TN3270E header + stream3270[0] = 0x00; // DT_3270_DATA + stream3270[1] = 0x00; + stream3270[2] = 0x00; + stream3270[3] = 0x00; stream3270[4] = 0x02; // seq 2 + // 3270 Orders + stream3270[5] = (byte) 0xF5; // EraseWrite + stream3270[6] = (byte) 0xC3; // WCC + stream3270[7] = 0x11; // SBA + stream3270[8] = 0x40; stream3270[9] = 0x40; // pos 0 + for (int i = 0; i < banner.length(); i++) { + stream3270[10 + i] = (byte) trans.unicodeToEbcdic(banner.charAt(i)); + } + stream3270[stream3270.length - 2] = (byte) TelnetConstants.IAC; + stream3270[stream3270.length - 1] = (byte) TelnetConstants.EOR; + + for (byte b : stream3270) fsm.feedByte(b & 0xFF); + + // Verify transition from E_NVT to E_3270 (CONNECTED_TN3270E) + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState()); + assertEquals(TelnetFSM.TN3270ESubmode.E_3270, fsm.getTn3270eSubmode()); + assertFalse(fsm.getConnectionState().isNvt()); + + // Verify screen displays 3270 content + assertEquals('z', trans.ebcdicToUnicode(screenBuffer.getCellEC(0))); + assertEquals('/', trans.ebcdicToUnicode(screenBuffer.getCellEC(1))); + assertEquals('V', trans.ebcdicToUnicode(screenBuffer.getCellEC(2))); + assertEquals('M', trans.ebcdicToUnicode(screenBuffer.getCellEC(3))); + + // 4. Outbound 3270 AID transmission should now use DT_3270_DATA + connection.sentData.clear(); + dsProcessor.getInputProcessor().sendAid(0x7D); + + assertFalse(connection.sentData.isEmpty()); + byte[] sentAid = connection.sentData.get(connection.sentData.size() - 1); + assertEquals(0x00, sentAid[0]); // DT_3270_DATA header + assertEquals((byte) 0x7D, sentAid[5]); // Enter AID code + } + + @Test + public void testVmConmode3270TransitionPlainTelnet() throws Exception { + fsm.onConnected(); + config.setTn3270eEnabled(false); + + // 1. Connected in line-mode (NVT) + feedBytes('C', 'P', '>'); + assertEquals(ConnectionState.CONNECTED_NVT, fsm.getConnectionState()); + + // 2. VM sends BINARY and EOR options upon CONMODE 3270 + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_BINARY); + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_BINARY); + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_EOR); + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_EOR); + + // Verify transition to CONNECTED_3270 + assertEquals(ConnectionState.CONNECTED_3270, fsm.getConnectionState()); + + // 3. VM sends 3270 data stream + EbcdicTranslator trans = new EbcdicTranslator(); + String msg = "VM 3270"; + byte[] rawStream = new byte[5 + msg.length() + 2]; + rawStream[0] = (byte) 0xF5; // EraseWrite + rawStream[1] = (byte) 0xC3; // WCC + rawStream[2] = 0x11; // SBA + rawStream[3] = 0x40; rawStream[4] = 0x40; + for (int i = 0; i < msg.length(); i++) { + rawStream[5 + i] = (byte) trans.unicodeToEbcdic(msg.charAt(i)); + } + rawStream[rawStream.length - 2] = (byte) TelnetConstants.IAC; + rawStream[rawStream.length - 1] = (byte) TelnetConstants.EOR; + + for (byte b : rawStream) fsm.feedByte(b & 0xFF); + + assertEquals('V', trans.ebcdicToUnicode(screenBuffer.getCellEC(0))); + assertEquals('M', trans.ebcdicToUnicode(screenBuffer.getCellEC(1))); + } }