diff --git a/build/j3270/org/pubvm/j3270/J3270App$2.class b/build/j3270/org/pubvm/j3270/J3270App$2.class index c31a9ed..de1d926 100644 Binary files a/build/j3270/org/pubvm/j3270/J3270App$2.class and b/build/j3270/org/pubvm/j3270/J3270App$2.class differ diff --git a/build/j3270/org/pubvm/j3270/J3270App.class b/build/j3270/org/pubvm/j3270/J3270App.class index daefe95..3f7ee53 100644 Binary files a/build/j3270/org/pubvm/j3270/J3270App.class and b/build/j3270/org/pubvm/j3270/J3270App.class differ diff --git a/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class b/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class index cd3bfd2..6839a76 100644 Binary files a/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class and b/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class differ diff --git a/build/j3270/org/pubvm/j3270/config/Settings.class b/build/j3270/org/pubvm/j3270/config/Settings.class index 7c1bc03..0614591 100644 Binary files a/build/j3270/org/pubvm/j3270/config/Settings.class and b/build/j3270/org/pubvm/j3270/config/Settings.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class index 5609b4a..145519e 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class index d4c041d..7e3d3e9 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class index f85b833..f5efe87 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class index 7480083..3d9b553 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class index 2720300..d925b0d 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class index 27b7e2d..c0bbf60 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class and b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class index 2f3341d..c26fa2c 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class and b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class b/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class index cc1a8cf..a8bfd05 100644 Binary files a/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class and b/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class differ diff --git a/j3270/src/main/java/org/pubvm/j3270/J3270App.java b/j3270/src/main/java/org/pubvm/j3270/J3270App.java index 618c6ce..84f255a 100644 --- a/j3270/src/main/java/org/pubvm/j3270/J3270App.java +++ b/j3270/src/main/java/org/pubvm/j3270/J3270App.java @@ -109,7 +109,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate viewMenu.add(createMenuItem("Font Size -", KeyEvent.VK_MINUS, () -> adjustFontSize(-2))); viewMenu.add(createMenuItem("Reset Font", KeyEvent.VK_0, () -> { terminalPanel.setFontSize(16); - pack(); + terminalPanel.guardedPack(); })); menuBar.add(viewMenu); @@ -200,8 +200,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate setTitle("j3270 — " + config.getHost() + ":" + config.getPort()); // Resize window to match model - terminalPanel.revalidate(); - pack(); + terminalPanel.guardedPack(); new Thread(() -> { try { @@ -233,9 +232,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate private void adjustFontSize(int delta) { int current = terminalPanel.getFontSize(); - int newSize = Math.max(8, Math.min(36, current + delta)); + int newSize = Math.max(8, Math.min(72, current + delta)); terminalPanel.setFontSize(newSize); - pack(); + // Pack after font change — setFontSize sets the resize guard + // to prevent the componentResized from re-triggering autoFitFont + terminalPanel.guardedPack(); } // ========== ConnectionListener ========== @@ -249,8 +250,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate // Auto-resize and focus on first full session if (newState.isFullSession() && !oldState.isFullSession()) { - terminalPanel.revalidate(); - pack(); + terminalPanel.guardedPack(); terminalPanel.requestFocusInWindow(); } }); @@ -293,8 +293,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate @Override public void onScreenSizeChanged(int rows, int cols) { SwingUtilities.invokeLater(() -> { - terminalPanel.revalidate(); - pack(); + terminalPanel.guardedPack(); }); } diff --git a/j3270/src/main/java/org/pubvm/j3270/config/Settings.java b/j3270/src/main/java/org/pubvm/j3270/config/Settings.java index 4bf43bd..28b7cac 100644 --- a/j3270/src/main/java/org/pubvm/j3270/config/Settings.java +++ b/j3270/src/main/java/org/pubvm/j3270/config/Settings.java @@ -3,6 +3,7 @@ package org.pubvm.j3270.config; import java.util.prefs.Preferences; import java.awt.Color; import java.io.*; +import java.io.PrintWriter; import java.util.logging.Logger; import java.util.logging.Level; @@ -171,6 +172,16 @@ public class Settings { log.info("Configuration loaded successfully."); } + // ========== Block select mode ========== + + public static boolean getBlockSelectMode() { + return prefs.getBoolean("blockSelectMode", false); + } + + public static void setBlockSelectMode(boolean block) { + prefs.putBoolean("blockSelectMode", block); + } + private static void applyConfigEntry(String section, String key, String value) { switch (section) { case "appearance": @@ -189,6 +200,7 @@ public class Settings { break; case "autoConnectHost": setAutoConnectHost(value); break; case "autoConnectPort": setAutoConnectPort(Integer.parseInt(value)); break; + case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break; default: log.warning("Unknown behavior key: " + key); } @@ -217,4 +229,82 @@ public class Settings { break; } } + + // ========== Export to INI ========== + + /** + * Export the current configuration to an INI-style file. + * Writes all settings including appearance, behavior, colors, and keybindings. + */ + public static void exportToIniFile(String path) throws IOException { + try (PrintWriter w = new PrintWriter(new BufferedWriter(new FileWriter(path)))) { + w.println("; j3270 Configuration File"); + w.println("; Exported on " + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date())); + w.println("; Load with: java -jar j3270.jar -c " + new File(path).getName()); + w.println(); + + // [appearance] + w.println("[appearance]"); + w.println("fontFamily = " + getFontFamily()); + w.println("fontSize = " + getFontSize()); + w.println(); + + // [behavior] + w.println("[behavior]"); + w.println("startupBehavior = " + getStartupBehavior().name()); + String acHost = getAutoConnectHost(); + if (acHost != null && !acHost.isEmpty()) { + w.println("autoConnectHost = " + acHost); + w.println("autoConnectPort = " + getAutoConnectPort()); + } + w.println("blockSelectMode = " + getBlockSelectMode()); + w.println(); + + // [colors] + w.println("[colors]"); + // Host colors 0-15 + for (int i = 0; i < 16; i++) { + String hex = prefs.get("color_" + i, null); + if (hex != null) { + w.println("color_" + i + " = " + hex); + } + } + // Mono colors + String[] monoKeys = {"NORMAL", "INTENSIFY", "PROTECTED", "PROTECTED_HIGH", "BACKGROUND"}; + for (String mk : monoKeys) { + String hex = prefs.get("mono_" + mk, null); + if (hex != null) { + w.println("mono_" + mk + " = " + hex); + } + } + w.println(); + + // [keybindings] + w.println("[keybindings]"); + // Navigation keys + String[] navActions = {"ENTER", "TAB", "shift TAB", "UP", "DOWN", "LEFT", "RIGHT", + "HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR"}; + for (String act : navActions) { + String val = prefs.get("key_" + act, null); + if (val != null) { + w.println(act + " = " + val); + } + } + // PF keys + for (int i = 1; i <= 24; i++) { + String val = prefs.get("key_PF" + i, null); + if (val != null) { + w.println("PF" + i + " = " + val); + } + } + // PA keys + for (int i = 1; i <= 3; i++) { + String val = prefs.get("key_PA" + i, null); + if (val != null) { + w.println("PA" + i + " = " + val); + } + } + } + log.info("Configuration exported to: " + path); + } } diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java b/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java index 00be7ae..7ef2eee 100644 --- a/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java @@ -10,12 +10,24 @@ import java.awt.event.*; import java.util.HashMap; import java.util.Map; import javax.swing.table.DefaultTableModel; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.JTableHeader; import javax.swing.border.LineBorder; +import javax.swing.border.TitledBorder; 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 fontBox; private JSpinner fontSizeSpinner; @@ -25,6 +37,7 @@ public class SettingsDialog extends JDialog { private JPanel autoConnectPanel; private JTextField hostField; private JTextField portField; + private JCheckBox blockSelectCheck; // Advanced tab state tracking private final Color[] tempHostColors = new Color[16]; @@ -49,10 +62,15 @@ public class SettingsDialog extends JDialog { tabbedPane.addTab("Advanced", createAdvancedPanel()); JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton btnExport = new JButton("Export Config..."); JButton btnOk = new JButton("OK"); JButton btnApply = new JButton("Apply"); JButton btnCancel = new JButton("Cancel"); + btnExport.addActionListener((ActionEvent e) -> { + exportConfig(); + }); + btnOk.addActionListener((ActionEvent e) -> { boolean success = applySettings(); if (success) { @@ -68,14 +86,145 @@ public class SettingsDialog extends JDialog { dispose(); }); + buttonPanel.add(btnExport); + buttonPanel.add(Box.createHorizontalStrut(20)); buttonPanel.add(btnApply); buttonPanel.add(btnCancel); buttonPanel.add(btnOk); 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); } + // ========== 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); + } + } + } + + // ========== Tab Panels ========== + private JPanel createAppearancePanel() { JPanel panel = new JPanel(new GridBagLayout()); GridBagConstraints gbc = new GridBagConstraints(); @@ -174,8 +323,14 @@ public class SettingsDialog extends JDialog { }); autoConnectPanel.setVisible(Settings.getStartupBehavior() == Settings.StartupBehavior.AUTO_CONNECT); - // Placeholder for potentially more behavior options below + // Block select mode checkbox gbc.gridy = 2; + gbc.gridwidth = 2; + blockSelectCheck = new JCheckBox("Block selection mode (rectangular select)", Settings.getBlockSelectMode()); + panel.add(blockSelectCheck, gbc); + + // Placeholder for potentially more behavior options below + gbc.gridy = 3; gbc.weighty = 1.0; panel.add(Box.createGlue(), gbc); @@ -204,6 +359,7 @@ public class SettingsDialog extends JDialog { Color init = org.pubvm.j3270.config.Settings.getColorOverride(i, TerminalPanel.DEFAULT_HOST_COLORS[i]); tempHostColors[i] = init; JPanel cb = new JPanel(); + cb.setName("colorSwatch"); cb.setBackground(init); cb.setPreferredSize(new Dimension(30, 30)); cb.setBorder(new LineBorder(Color.DARK_GRAY)); @@ -238,6 +394,7 @@ public class SettingsDialog extends JDialog { JPanel p = new JPanel(new BorderLayout()); p.add(new JLabel(mk, SwingConstants.CENTER), BorderLayout.NORTH); JPanel cb = new JPanel(); + cb.setName("colorSwatch"); cb.setBackground(init); cb.setPreferredSize(new Dimension(40, 40)); cb.setBorder(new LineBorder(Color.DARK_GRAY)); @@ -331,6 +488,8 @@ public class SettingsDialog extends JDialog { captureDialog.setSize(320, 100); captureDialog.setLocationRelativeTo(this); JLabel lbl = new JLabel("Press any key combination now...", SwingConstants.CENTER); + lbl.setForeground(DARK_FG); + captureDialog.getContentPane().setBackground(DARK_BG); captureDialog.add(lbl); captureDialog.addKeyListener(new KeyAdapter() { @Override @@ -442,6 +601,8 @@ public class SettingsDialog extends JDialog { return action; // nav keys default to their own name } + // ========== Apply Settings ========== + private boolean applySettings() { try { // Apply Appearance @@ -464,6 +625,9 @@ public class SettingsDialog extends JDialog { } } + // Block select mode + Settings.setBlockSelectMode(blockSelectCheck.isSelected()); + // Propagate visual changes to the app // Save Colors for (int i=0; i<16; i++) { @@ -487,4 +651,43 @@ public class SettingsDialog extends JDialog { return false; } } + + // ========== Export Config ========== + + private void exportConfig() { + JFileChooser fc = new JFileChooser(); + fc.setDialogTitle("Export Configuration"); + fc.setSelectedFile(new java.io.File("j3270.ini")); + fc.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("INI Files (*.ini)", "ini")); + + int result = fc.showSaveDialog(this); + if (result == JFileChooser.APPROVE_OPTION) { + java.io.File file = fc.getSelectedFile(); + // Ensure .ini extension + if (!file.getName().toLowerCase().endsWith(".ini")) { + file = new java.io.File(file.getAbsolutePath() + ".ini"); + } + + // Confirm overwrite + if (file.exists()) { + int confirm = JOptionPane.showConfirmDialog(this, + "File already exists. Overwrite?", "Confirm Overwrite", + JOptionPane.YES_NO_OPTION); + if (confirm != JOptionPane.YES_OPTION) return; + } + + try { + // Apply current dialog state first so the export reflects latest changes + applySettings(); + Settings.exportToIniFile(file.getAbsolutePath()); + JOptionPane.showMessageDialog(this, + "Configuration exported to:\n" + file.getAbsolutePath(), + "Export Successful", JOptionPane.INFORMATION_MESSAGE); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, + "Export failed: " + ex.getMessage(), + "Export Error", JOptionPane.ERROR_MESSAGE); + } + } + } } diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java index 3163aab..81394bf 100644 --- a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java @@ -6,6 +6,7 @@ import org.lib3270j.screen.ScreenBuffer; import javax.swing.*; import java.awt.*; +import java.awt.datatransfer.*; import java.awt.event.*; import static org.lib3270j.protocol.DS3270Constants.*; @@ -44,6 +45,39 @@ public class TerminalPanel extends JPanel { // Background private Color bgColor; + // ========== Selection / Copy-Paste state ========== + private boolean blockSelectMode = false; + private int selectionStartRow = -1, selectionStartCol = -1; + private int selectionEndRow = -1, selectionEndCol = -1; + private boolean isDragging = false; + private static final Color SELECTION_COLOR = new Color(75, 110, 175, 100); + + // ========== Resize guard ========== + private boolean resizeGuard = false; + + /** + * Compute the horizontal render offset to center the grid within the panel. + * Any leftover pixels (from integer font sizing) are split evenly. + */ + private int getRenderOffsetX() { + int termCols = 80; + if (client != null) termCols = client.getScreenBuffer().getCols(); + int gridW = termCols * cellWidth; + int extra = getWidth() - gridW; + return Math.max(padding, extra / 2); + } + + /** + * Compute the vertical render offset to center the grid within the panel. + */ + private int getRenderOffsetY() { + int termRows = 24; + if (client != null) termRows = client.getScreenBuffer().getRows(); + int gridH = termRows * cellHeight; + int extra = getHeight() - gridH; + return Math.max(padding, extra / 2); + } + // Default Host color mapping public static final Color[] DEFAULT_HOST_COLORS = { new Color(0, 0, 0), // 0: Neutral Black @@ -87,22 +121,313 @@ public class TerminalPanel extends JPanel { setupFont(); setupCursorBlink(); - // Handle mouse clicks to position cursor and grab focus - addMouseListener(new MouseAdapter() { + blockSelectMode = org.pubvm.j3270.config.Settings.getBlockSelectMode(); + + // Handle mouse clicks to position cursor, selection, and grab focus + MouseAdapter mouseHandler = new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { requestFocusInWindow(); + + // Right-click context menu + if (SwingUtilities.isRightMouseButton(e)) { + showContextMenu(e); + return; + } + if (client != null && client.getConnectionState().isFullSession()) { - int col = (e.getX() - padding) / cellWidth; - int row = (e.getY() - padding) / cellHeight; ScreenBuffer sb = client.getScreenBuffer(); - if (row >= 0 && row < sb.getRows() && col >= 0 && col < sb.getCols()) { - sb.setCursorAddress(row * sb.getCols() + col); - repaint(); + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + int col = (e.getX() - ox) / cellWidth; + int row = (e.getY() - oy) / cellHeight; + col = Math.max(0, Math.min(col, sb.getCols() - 1)); + row = Math.max(0, Math.min(row, sb.getRows() - 1)); + + // Start selection + selectionStartRow = row; + selectionStartCol = col; + selectionEndRow = row; + selectionEndCol = col; + isDragging = true; + repaint(); + } + } + + @Override + public void mouseDragged(MouseEvent e) { + if (isDragging && client != null && client.getConnectionState().isFullSession()) { + ScreenBuffer sb = client.getScreenBuffer(); + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + int col = (e.getX() - ox) / cellWidth; + int row = (e.getY() - oy) / cellHeight; + col = Math.max(0, Math.min(col, sb.getCols() - 1)); + row = Math.max(0, Math.min(row, sb.getRows() - 1)); + + selectionEndRow = row; + selectionEndCol = col; + repaint(); + } + } + + @Override + public void mouseReleased(MouseEvent e) { + if (isDragging) { + isDragging = false; + // If start == end, treat as a click (position cursor, clear selection) + if (selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol) { + if (client != null && client.getConnectionState().isFullSession()) { + ScreenBuffer sb = client.getScreenBuffer(); + sb.setCursorAddress(selectionStartRow * sb.getCols() + selectionStartCol); + clearSelection(); + } + } + repaint(); + } + } + }; + addMouseListener(mouseHandler); + addMouseMotionListener(mouseHandler); + + // Handle resize: auto-fit font to window size + addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + if (resizeGuard) return; + autoFitFont(); + } + }); + } + + // ========== Selection / Copy-Paste ========== + + private void clearSelection() { + selectionStartRow = -1; + selectionStartCol = -1; + selectionEndRow = -1; + selectionEndCol = -1; + } + + private boolean hasSelection() { + return selectionStartRow >= 0 && selectionEndRow >= 0 && + !(selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol); + } + + /** + * Get the selected text from the screen buffer. + * In block mode: rectangular selection with newlines between rows. + * In line mode: stream selection, flowing left-to-right, top-to-bottom. + */ + public String getSelectedText() { + if (!hasSelection() || client == null) return ""; + ScreenBuffer sb = client.getScreenBuffer(); + int cols = sb.getCols(); + + int r1 = Math.min(selectionStartRow, selectionEndRow); + int r2 = Math.max(selectionStartRow, selectionEndRow); + int c1, c2; + + if (blockSelectMode) { + // Block mode: rectangular selection + c1 = Math.min(selectionStartCol, selectionEndCol); + c2 = Math.max(selectionStartCol, selectionEndCol); + + StringBuilder result = new StringBuilder(); + for (int r = r1; r <= r2; r++) { + if (r > r1) result.append('\n'); + for (int c = c1; c <= c2; c++) { + int baddr = r * cols + c; + ExtendedAttribute ea = sb.getCell(baddr); + char ch = ea.ucs4; + if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) { + result.append(' '); + } else { + result.append(ch); } } } + return result.toString(); + } else { + // Line/stream mode: flow from start to end + int startAddr, endAddr; + if (selectionStartRow < selectionEndRow || + (selectionStartRow == selectionEndRow && selectionStartCol <= selectionEndCol)) { + startAddr = selectionStartRow * cols + selectionStartCol; + endAddr = selectionEndRow * cols + selectionEndCol; + } else { + startAddr = selectionEndRow * cols + selectionEndCol; + endAddr = selectionStartRow * cols + selectionStartCol; + } + + StringBuilder result = new StringBuilder(); + int prevRow = startAddr / cols; + for (int addr = startAddr; addr <= endAddr; addr++) { + int curRow = addr / cols; + if (curRow != prevRow) { + result.append('\n'); + prevRow = curRow; + } + ExtendedAttribute ea = sb.getCell(addr); + char ch = ea.ucs4; + if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) { + result.append(' '); + } else { + result.append(ch); + } + } + return result.toString(); + } + } + + private void copySelection() { + String text = getSelectedText(); + if (!text.isEmpty()) { + StringSelection ss = new StringSelection(text); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(ss, null); + } + } + + private void pasteClipboard() { + if (client == null || !client.getConnectionState().isFullSession()) return; + try { + String text = (String) Toolkit.getDefaultToolkit().getSystemClipboard() + .getData(DataFlavor.stringFlavor); + if (text != null) { + for (char ch : text.toCharArray()) { + if (ch == '\n' || ch == '\r') { + // Skip newlines in paste — user may paste multi-line text + // but 3270 fields don't wrap the same way + continue; + } + if (ch >= 0x20 && ch != 0x7F) { + client.typeCharacter(ch); + } + } + refreshScreen(); + } + } catch (Exception ex) { + // Clipboard not available or wrong type — silently ignore + } + } + + private void showContextMenu(MouseEvent e) { + JPopupMenu popup = new JPopupMenu(); + + JMenuItem copyItem = new JMenuItem("Copy"); + copyItem.setEnabled(hasSelection()); + copyItem.addActionListener(ev -> copySelection()); + popup.add(copyItem); + + JMenuItem pasteItem = new JMenuItem("Paste"); + pasteItem.setEnabled(client != null && client.getConnectionState().isFullSession()); + pasteItem.addActionListener(ev -> pasteClipboard()); + popup.add(pasteItem); + + popup.addSeparator(); + + JCheckBoxMenuItem blockModeItem = new JCheckBoxMenuItem("Block Select Mode", blockSelectMode); + blockModeItem.addActionListener(ev -> { + blockSelectMode = blockModeItem.isSelected(); + org.pubvm.j3270.config.Settings.setBlockSelectMode(blockSelectMode); }); + popup.add(blockModeItem); + + popup.show(this, e.getX(), e.getY()); + } + + private boolean isCellSelected(int row, int col) { + if (!hasSelection()) return false; + + int r1 = Math.min(selectionStartRow, selectionEndRow); + int r2 = Math.max(selectionStartRow, selectionEndRow); + + if (blockSelectMode) { + int c1 = Math.min(selectionStartCol, selectionEndCol); + int c2 = Math.max(selectionStartCol, selectionEndCol); + return row >= r1 && row <= r2 && col >= c1 && col <= c2; + } else { + // Stream mode + int cols = 80; + if (client != null) cols = client.getScreenBuffer().getCols(); + int addr = row * cols + col; + int startAddr, endAddr; + if (selectionStartRow < selectionEndRow || + (selectionStartRow == selectionEndRow && selectionStartCol <= selectionEndCol)) { + startAddr = selectionStartRow * cols + selectionStartCol; + endAddr = selectionEndRow * cols + selectionEndCol; + } else { + startAddr = selectionEndRow * cols + selectionEndCol; + endAddr = selectionStartRow * cols + selectionStartCol; + } + return addr >= startAddr && addr <= endAddr; + } + } + + // ========== Font auto-resize ========== + + /** + * Auto-fit the font size to fill the current panel dimensions + * while respecting the terminal model's character grid. + * Any leftover pixels are handled by centering the grid + * (see getRenderOffsetX/Y). + */ + private void autoFitFont() { + int panelW = getWidth(); + int panelH = getHeight(); + if (panelW <= 0 || panelH <= 0) return; + + int termCols = 80; + int termRows = 24; + if (client != null) { + ScreenBuffer sb = client.getScreenBuffer(); + termCols = sb.getCols(); + termRows = sb.getRows(); + } + + // Use minimal padding for the fit calculation + int availW = panelW - 2 * padding; + int availH = panelH - 2 * padding; + if (availW <= 0 || availH <= 0) return; + + // Find the largest font size where the grid fits + int bestSize = 8; + for (int testSize = 8; testSize <= 72; testSize++) { + Font testFont = new Font(terminalFont.getFamily(), Font.PLAIN, testSize); + FontMetrics fm = getFontMetrics(testFont); + int testCellW = fm.charWidth('M'); + int testCellH = fm.getHeight(); + + if (testCellW * termCols <= availW && testCellH * termRows <= availH) { + bestSize = testSize; + } else { + break; + } + } + + if (bestSize != currentFontSize) { + currentFontSize = bestSize; + org.pubvm.j3270.config.Settings.setFontSize(bestSize); + terminalFont = new Font(terminalFont.getFamily(), Font.PLAIN, bestSize); + updateCellSize(); + } + // No window snap — any leftover pixels are centered via getRenderOffsetX/Y + repaint(); + } + + /** + * Called by J3270App when it needs to pack the frame (screen size changed, + * connection established, etc.). Sets the resize guard to prevent + * autoFitFont from firing during the pack. + */ + public void guardedPack() { + resizeGuard = true; + revalidate(); + Container top = getTopLevelAncestor(); + if (top instanceof java.awt.Window) { + ((java.awt.Window) top).pack(); + } + SwingUtilities.invokeLater(() -> resizeGuard = false); } /** @@ -167,6 +492,11 @@ public class TerminalPanel extends JPanel { // Clear bindKeyToMap(im, "CLEAR", org.pubvm.j3270.config.Settings.getKeyBinding("CLEAR", "alt C")); + // Copy/Paste bindings — Cmd+C / Cmd+V (macOS) or Ctrl+C / Ctrl+V (others) + int shortcutMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, shortcutMask), "j3270-COPY"); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, shortcutMask), "j3270-PASTE"); + // Create actions for all bound keys am.put("j3270-ENTER", createAction(this::handleEnter)); am.put("j3270-ESCAPE", createAction(this::handleReset)); @@ -185,6 +515,10 @@ public class TerminalPanel extends JPanel { am.put("j3270-INSERT", createAction(this::handleInsert)); am.put("j3270-CLEAR", createAction(this::handleClear)); + // Copy/Paste actions + am.put("j3270-COPY", createAction(this::copySelection)); + am.put("j3270-PASTE", createAction(this::pasteClipboard)); + for (int i = 1; i <= 24; i++) { final int pf = i; am.put("j3270-PF" + i, createAction(() -> handlePF(pf))); @@ -365,6 +699,7 @@ public class TerminalPanel extends JPanel { setupColors(); setupKeyBindings(); setupFont(); + blockSelectMode = org.pubvm.j3270.config.Settings.getBlockSelectMode(); revalidate(); repaint(); } @@ -374,6 +709,7 @@ public class TerminalPanel extends JPanel { org.pubvm.j3270.config.Settings.setFontSize(size); terminalFont = terminalFont.deriveFont((float) size); updateCellSize(); + resizeGuard = true; revalidate(); repaint(); } @@ -406,10 +742,11 @@ public class TerminalPanel extends JPanel { public Dimension getPreferredSize() { if (client != null) { ScreenBuffer sb = client.getScreenBuffer(); - // Always size to the MAXIMUM (alternate) screen dimensions, - // matching how x3270 works — the window shows the full model size - int displayCols = sb.getMaxCols(); - int displayRows = sb.getMaxRows(); + // Use the CURRENT screen dimensions (not max) to eliminate extra space. + // When the host switches to alternate screen, onScreenSizeChanged fires + // and the frame re-packs. + int displayCols = sb.getCols(); + int displayRows = sb.getRows(); return new Dimension(displayCols * cellWidth + padding * 2, displayRows * cellHeight + padding * 2); } @@ -427,11 +764,14 @@ public class TerminalPanel extends JPanel { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); - // Manually clear the entire graphics bounds to ensure no ghosting on - // resize/clip + // Clear entire panel with background color g2.setColor(bgColor); g2.fillRect(0, 0, getWidth(), getHeight()); + // Compute centered offsets for the grid + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + ScreenBuffer sb = client.getScreenBuffer(); int rows = sb.getRows(); int cols = sb.getCols(); @@ -446,8 +786,8 @@ public class TerminalPanel extends JPanel { int baddr = row * cols + col; ExtendedAttribute ea = sb.getCell(baddr); - int x = padding + col * cellWidth; - int y = padding + row * cellHeight; + int x = ox + col * cellWidth; + int y = oy + row * cellHeight; // Determine colors and attributes Color fgColor; @@ -462,6 +802,11 @@ public class TerminalPanel extends JPanel { // Field attributes display as blanks g2.setColor(this.bgColor); g2.fillRect(x, y, cellWidth, cellHeight); + // Selection highlight on field attribute cells too + if (isCellSelected(row, col)) { + g2.setColor(SELECTION_COLOR); + g2.fillRect(x, y, cellWidth, cellHeight); + } continue; } @@ -493,6 +838,10 @@ public class TerminalPanel extends JPanel { if (faIsZero(currentFA & 0xFF)) { g2.setColor(this.bgColor); g2.fillRect(x, y, cellWidth, cellHeight); + if (isCellSelected(row, col)) { + g2.setColor(SELECTION_COLOR); + g2.fillRect(x, y, cellWidth, cellHeight); + } continue; } @@ -522,6 +871,12 @@ public class TerminalPanel extends JPanel { g2.drawLine(x, y + cellHeight - fontDescent, x + cellWidth - 1, y + cellHeight - fontDescent); } + + // Draw selection highlight + if (isCellSelected(row, col)) { + g2.setColor(SELECTION_COLOR); + g2.fillRect(x, y, cellWidth, cellHeight); + } } } @@ -530,8 +885,8 @@ public class TerminalPanel extends JPanel { int curAddr = sb.getCursorAddress(); int curRow = curAddr / cols; int curCol = curAddr % cols; - int cx = padding + curCol * cellWidth; - int cy = padding + curRow * cellHeight; + int cx = ox + curCol * cellWidth; + int cy = oy + curRow * cellHeight; g2.setColor(new Color(255, 255, 255, 180)); g2.setXORMode(bgColor); diff --git a/run.sh b/run.sh index e877d9f..36a7539 100755 --- a/run.sh +++ b/run.sh @@ -39,6 +39,7 @@ echo "Executable JAR created at: $BUILD_DIR/j3270.jar" # --------------------------------------------------------- # Run the newly packaged JAR # --------------------------------------------------------- -exec java -Dapple.laf.useScreenMenuBar=true \ - -Dapple.awt.application.name=j3270 \ - -jar "$BUILD_DIR/j3270.jar" "$@" \ No newline at end of file +#exec java -Dapple.laf.useScreenMenuBar=true \ +# -Dapple.awt.application.name=j3270 \ +# -jar "$BUILD_DIR/j3270.jar" "$@" +nohup java -jar build/j3270.jar &