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 bcfef04..894db5e 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java @@ -453,7 +453,8 @@ public class SettingsDialog extends JDialog { JPanel main = new JPanel(new BorderLayout()); String[] actions = {"ENTER", "TAB", "shift TAB", "UP", "DOWN", "LEFT", "RIGHT", - "HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR"}; + "HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR", + "ERASE_INPUT", "NEWLINE", "DUP", "FIELD_MARK", "ATTN", "SYSREQ", "CURSEL"}; keymapModel = new DefaultTableModel(new Object[]{"Action", "Key Binding"}, 0) { @Override @@ -465,7 +466,14 @@ public class SettingsDialog extends JDialog { // Populate table from Settings or Defaults for(String act : actions) { String def = act; - if(def.equals("PAGE_UP")) def = "PAGE_UP"; // fallback example + if (def.equals("ERASE_INPUT")) def = "alt E"; + else if (def.equals("NEWLINE")) def = "shift ENTER"; + else if (def.equals("DUP")) def = "alt D"; + else if (def.equals("FIELD_MARK")) def = "alt M"; + else if (def.equals("ATTN")) def = "alt A"; + else if (def.equals("SYSREQ")) def = "alt S"; + else if (def.equals("CURSEL")) def = "alt Q"; + else if (def.equals("CLEAR")) def = "alt C"; String current = haus.nightmare.j3270.config.Settings.getKeyBinding(act, def); tempKeyBindings.put(act, current); keymapModel.addRow(new Object[]{act, current}); 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 2b04f4d..1331bf2 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -259,10 +259,12 @@ public class TerminalPanel extends JPanel { int gx = client.getGocaDecoder().getGraphicCursorX(); int gy = client.getGocaDecoder().getGraphicCursorY(); + int hitSeg = client.getGocaDecoder().findPickedSegment(gx, gy); + int hitTag = client.getGocaDecoder().getSegmentTag(hitSeg); int button = javax.swing.SwingUtilities.isRightMouseButton(e) ? 2 : 1; System.err.println(String.format( - "TerminalPanel.mouseReleased: mouse=(%d, %d) offset=(%d, %d) grid=(%dx%d) px=(%d, %d) goca=(%d, %d) btn=%d", - e.getX(), e.getY(), ox, oy, gridW, gridH, px, py, gx, gy, button + "TerminalPanel.mouseReleased: mouse=(%d, %d) offset=(%d, %d) grid=(%dx%d) px=(%d, %d) goca=(%d, %d) hitSeg=%d hitTag=%d btn=%d", + e.getX(), e.getY(), ox, oy, gridW, gridH, px, py, gx, gy, hitSeg, hitTag, button )); client.getInputProcessor().sendGraphicMouseAid( @@ -278,10 +280,12 @@ public class TerminalPanel extends JPanel { if (lightPenMode) { clearSelection(); sb.setCursorAddress(clickAddr); - boolean result = client.getInputProcessor().lightPenSelect(clickAddr); + boolean result = client.lightPenSelect(clickAddr); if (result) { refreshScreen(); return; + } else { + Toolkit.getDefaultToolkit().beep(); } } @@ -608,6 +612,7 @@ public class TerminalPanel extends JPanel { bindKeyToMap(im, "FIELD_MARK", haus.nightmare.j3270.config.Settings.getKeyBinding("FIELD_MARK", "alt M")); bindKeyToMap(im, "ATTN", haus.nightmare.j3270.config.Settings.getKeyBinding("ATTN", "alt A")); bindKeyToMap(im, "SYSREQ", haus.nightmare.j3270.config.Settings.getKeyBinding("SYSREQ", "alt S")); + bindKeyToMap(im, "CURSEL", haus.nightmare.j3270.config.Settings.getKeyBinding("CURSEL", "alt Q")); // Copy/Paste bindings — Cmd+C / Cmd+V (macOS) or Ctrl+C / Ctrl+V (others) int shortcutMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); @@ -638,6 +643,7 @@ public class TerminalPanel extends JPanel { am.put("j3270-FIELD_MARK", createAction(this::handleFieldMark)); am.put("j3270-ATTN", createAction(this::handleAttn)); am.put("j3270-SYSREQ", createAction(this::handleSysReq)); + am.put("j3270-CURSEL", createAction(this::handleCursorSelect)); // Copy/Paste actions am.put("j3270-COPY", createAction(this::copySelection)); @@ -824,6 +830,17 @@ public class TerminalPanel extends JPanel { } } + private void handleCursorSelect() { + if (client != null && client.getConnectionState().isFullSession()) { + boolean selected = client.cursorSelect(); + if (selected) { + refreshScreen(); + } else { + Toolkit.getDefaultToolkit().beep(); + } + } + } + private void refreshScreen() { if (client != null) { client.getScreenBuffer().updateDisplaySnapshot(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index e35cbcd..c375cd1 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -223,6 +223,10 @@ public class Telnet3270Client { public void sysReq() { inputProcessor.sysReq(); } /** Reset (unlock keyboard). */ public void reset() { inputProcessor.reset(); } + /** Trigger 3270 CURSR SEL (Cursor Select) key at current cursor position. */ + public boolean cursorSelect() { return inputProcessor.cursorSelect(); } + /** Trigger Light Pen selection at the specified screen address. */ + public boolean lightPenSelect(int address) { return inputProcessor.lightPenSelect(address); } public haus.nightmare.lib3270j.graphics.ProgramSymbolManager getProgramSymbolManager() { return dsProcessor.getProgramSymbolManager(); 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 b270835..7ab5b84 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -832,9 +832,15 @@ public class DataStreamProcessor { programSymbolManager.loadps(psData); } break; + case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJDATA_SUB: { // 0x0F: Object Control / Data Unit + // Per IBM HOD processDataunit(), 0x0F activates graphic cursor and initializes data unit + log.info(String.format("SF 0x0F sub=0x0F (Data Unit Object Control len=%d): activating graphics cursor", fieldLen)); + gocaDecoder.setGraphicsCursorActive(true); + notifyScreenUpdated(); + break; + } case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB: // 0x11: Object Control (Procedure orders) - case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJPICT_SUB: // 0x10: Object Picture (Picture segments) - case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJDATA_SUB: { // 0x0F: Object Data (GOCA draw orders) + case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJPICT_SUB: { // 0x10: Object Picture (Picture segments) int flags = (fieldLen >= 6) ? (data[pos + 5] & 0xC0) : 0xC0; int orderOffset = (fieldLen >= 7) ? (pos + 7) : (pos + 4); int orderLen = Math.max(0, fieldLen - (orderOffset - pos)); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java new file mode 100644 index 0000000..487e26f --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java @@ -0,0 +1,206 @@ +package haus.nightmare.lib3270j.graphics; + +import java.awt.Point; + +/** + * Dedicated scaling layer for IBM 3179G / GDDM GOCA graphics presentation space. + * Matches IBM Host On-Demand (HODTransform.java, PS3179G.java, HODInput.java). + * + *

Coordinate System Architecture: + *

+ * + *

Why simple fixed pixel scaling or hardcoded multipliers failed in past iterations: + *

+ */ +public class GddmCoordinateTransform { + + public static final int DEFAULT_CHAR_WIDTH = 9; // defsx: 3179G standard character cell width + public static final int DEFAULT_CHAR_HEIGHT_16 = 16; // defsy: 3179G standard character cell height + public static final int DEFAULT_CHAR_HEIGHT_12 = 12; // defsy: 3279-2 alternate character cell height + + private int defaultCharWidth = DEFAULT_CHAR_WIDTH; + private int defaultCharHeight = DEFAULT_CHAR_HEIGHT_16; + + private int screenCols = 80; + private int screenRows = 24; + + private double transformX = 1.0; + private double transformY = 1.0; + + public GddmCoordinateTransform() { + this(80, 24, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16); + } + + public GddmCoordinateTransform(int screenCols, int screenRows) { + this(screenCols, screenRows, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16); + } + + public GddmCoordinateTransform(int screenCols, int screenRows, int defCharWidth, int defCharHeight) { + this.screenCols = screenCols > 0 ? screenCols : 80; + this.screenRows = screenRows > 0 ? screenRows : 24; + this.defaultCharWidth = defCharWidth > 0 ? defCharWidth : DEFAULT_CHAR_WIDTH; + this.defaultCharHeight = defCharHeight > 0 ? defCharHeight : DEFAULT_CHAR_HEIGHT_16; + } + + /** + * Updates screen grid and cell dimensions from UI. + * + * @param cellWidth Width of a character cell in Swing pixels + * @param cellHeight Height of a character cell in Swing pixels + */ + public void updateDisplayMetrics(int cellWidth, int cellHeight) { + if (cellWidth > 0 && defaultCharWidth > 0) { + this.transformX = (double) cellWidth / (double) defaultCharWidth; + } + if (cellHeight > 0 && defaultCharHeight > 0) { + this.transformY = (double) cellHeight / (double) defaultCharHeight; + } + } + + public void setScreenDimensions(int cols, int rows) { + if (cols > 0) this.screenCols = cols; + if (rows > 0) this.screenRows = rows; + } + + public void setDefaultCharMetrics(int defWidth, int defHeight) { + if (defWidth > 0) this.defaultCharWidth = defWidth; + if (defHeight > 0) this.defaultCharHeight = defHeight; + } + + public int getTotalWidth() { + return screenCols * defaultCharWidth; + } + + public int getTotalHeight() { + return screenRows * defaultCharHeight; + } + + public int getXMax() { + int totalW = getTotalWidth(); + return (totalW - 1) / 2 + (totalW - 1) % 2; + } + + public int getYMax() { + int totalH = getTotalHeight(); + return (totalH - 1) / 2; + } + + public double getTransformX() { + return transformX; + } + + public double getTransformY() { + return transformY; + } + + public int getDefaultCharWidth() { + return defaultCharWidth; + } + + public int getDefaultCharHeight() { + return defaultCharHeight; + } + + /** + * Converts a GOCA signed coordinate (gx, gy) to base presentation space coordinate (px, py). + */ + public Point gocaToBase(int gx, int gy) { + int px = gx + getXMax(); + int py = getYMax() - gy; + return new Point(px, py); + } + + /** + * Converts a base presentation space coordinate (px, py) to GOCA signed coordinate (gx, gy). + */ + public Point baseToGoca(int px, int py) { + int gx = px - getXMax(); + int gy = getYMax() - py; + return new Point(gx, gy); + } + + /** + * Converts a GOCA signed coordinate (gx, gy) directly to Swing screen pixel coordinate (sx, sy). + */ + public Point gocaToScreenPixel(int gx, int gy, int ox, int oy, int cellWidth, int cellHeight) { + updateDisplayMetrics(cellWidth, cellHeight); + int px = gx + getXMax(); + int py = getYMax() - gy; + int sx = ox + (int) Math.round(px * transformX); + int sy = oy + (int) Math.round(py * transformY); + return new Point(sx, sy); + } + + /** + * Converts a mouse click in Swing screen coordinates (mouseX, mouseY) directly to GOCA signed coordinate (gx, gy). + * Matches HODInput.getHODInput: + * x = mouseX / transformX + * y = mouseY / transformY + * gx = x - xMax + * gy = yMax - y + */ + public Point screenPixelToGoca(int mouseX, int mouseY, int ox, int oy, int cellWidth, int cellHeight) { + updateDisplayMetrics(cellWidth, cellHeight); + double relX = mouseX - ox; + double relY = mouseY - oy; + + int px = (transformX > 0) ? (int) Math.round(relX / transformX) : (int) relX; + int py = (transformY > 0) ? (int) Math.round(relY / transformY) : (int) relY; + + int gx = px - getXMax(); + int gy = getYMax() - py; + return new Point(gx, gy); + } + + /** + * Maps GOCA (gx, gy) to fixed canvas buffer dimensions (canvasWidth, canvasHeight). + */ + public Point gocaToCanvasPixel(int gx, int gy, int canvasWidth, int canvasHeight) { + int totalW = getTotalWidth(); + int totalH = getTotalHeight(); + int xMax = getXMax(); + int yMax = getYMax(); + + int px = (int) Math.round((double) (gx + xMax) * canvasWidth / (totalW > 0 ? totalW : 1)); + int py = (int) Math.round((double) (yMax - gy) * canvasHeight / (totalH > 0 ? totalH : 1)); + return new Point(px, py); + } + + /** + * Maps fixed canvas buffer pixel (canvasX, canvasY) to GOCA (gx, gy). + */ + public Point canvasPixelToGoca(int canvasX, int canvasY, int canvasWidth, int canvasHeight) { + int totalW = getTotalWidth(); + int totalH = getTotalHeight(); + int xMax = getXMax(); + int yMax = getYMax(); + + int nx = (int) Math.round((double) canvasX * totalW / (canvasWidth > 0 ? canvasWidth : 1)); + int ny = (int) Math.round((double) canvasY * totalH / (canvasHeight > 0 ? canvasHeight : 1)); + return new Point(nx - xMax, yMax - ny); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java index 4d0ea91..44f9519 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java @@ -164,7 +164,7 @@ public class GocaDecoder { for (int i = activeSegmentsInOrder.size() - 1; i >= 0; i--) { SegmentBounds sb = activeSegmentsInOrder.get(i); if (sb.contains(gx, gy, 25)) { - System.err.println(String.format( + logger.info(String.format( "findPickedSegment: goca=(%d, %d) HIT segId=%d bounds=[%d..%d, %d..%d] tag=%d", gx, gy, sb.segId, sb.minX, sb.maxX, sb.minY, sb.maxY, sb.tag )); @@ -173,14 +173,14 @@ public class GocaDecoder { } for (SegmentBounds sb : segmentBoundsMap.values()) { if (sb.contains(gx, gy, 25)) { - System.err.println(String.format( + logger.info(String.format( "findPickedSegment (fallback): goca=(%d, %d) HIT segId=%d bounds=[%d..%d, %d..%d] tag=%d", gx, gy, sb.segId, sb.minX, sb.maxX, sb.minY, sb.maxY, sb.tag )); return sb.segId; } } - System.err.println(String.format("findPickedSegment: goca=(%d, %d) NO HIT (defaulting to 0/canvas)", gx, gy)); + logger.info(String.format("findPickedSegment: goca=(%d, %d) NO HIT (defaulting to 0/canvas)", gx, gy)); return 0; } @@ -436,7 +436,7 @@ public class GocaDecoder { if (currentSegId != 0) { SegmentBounds sb = segmentBoundsMap.get(currentSegId); if (sb != null) { - System.err.println(String.format( + logger.info(String.format( "GocaDecoder: ENDSEGM segId=%d bounds=[%d..%d, %d..%d] tag=%d", sb.segId, sb.minX, sb.maxX, sb.minY, sb.maxY, sb.tag )); @@ -805,12 +805,17 @@ public class GocaDecoder { idx += 2; break; } - case GocaConstants.P_ERASE: { // 0x0A: Erase Graphics Presentation Space + case GocaConstants.P_ERASE: { // 0x0A: Erase Graphics Presentation Space (HODEraseGraphicsPlane) plane.clear(); resetAttributes(); curX = 0; curY = 0; activeSegmentsInOrder.clear(); + segmentBoundsMap.clear(); + segmentStore.clear(); + segmentOrderList.clear(); + chainedTargets.clear(); + logger.info("GOCA P_ERASE: erased graphics presentation space and cleared segment stores"); idx += 2; break; } @@ -818,55 +823,15 @@ public class GocaDecoder { idx += 12; break; } - case GocaConstants.P_SCUDEF: { // 0x21: Drawing Process Control / Segment Execute - if (idx + 5 < end) { - int pLen = data[idx + 1] & 0xFF; - if (pLen >= 6) { - int flags0 = data[idx + 2] & 0xFF; - int flags1 = data[idx + 3] & 0xFF; - int startSeg = ((data[idx + 4] & 0xFF) << 8) | (data[idx + 5] & 0xFF); - int endSeg = (idx + 7 < end) ? (((data[idx + 6] & 0xFF) << 8) | (data[idx + 7] & 0xFF)) : startSeg; - logger.info(String.format("GOCA P_SCUDEF: flags0=0x%02x flags1=0x%02x startSeg=%d endSeg=%d", - flags0, flags1, startSeg, endSeg)); - // Redraw all stored base segments not in dynamic range [startSeg..endSeg] - for (int segId : segmentOrderList) { - if (segId < startSeg || segId > endSeg) { - if (!chainedTargets.contains(segId)) { - byte[] segBytes = segmentStore.get(segId); - if (segBytes != null) { - int savedSegId = currentSegId; - currentSegId = segId; - SegmentBounds sb = segmentBoundsMap.computeIfAbsent(segId, SegmentBounds::new); - activeSegmentsInOrder.remove(sb); - activeSegmentsInOrder.add(sb); - callDepth++; - decodeStreamDirect(segBytes, 0, segBytes.length); - callDepth--; - currentSegId = savedSegId; - } - } - } - } - - for (int s = startSeg; s <= endSeg; s++) { - byte[] segBytes = segmentStore.get(s); - if (segBytes != null) { - int savedSegId = currentSegId; - currentSegId = s; - SegmentBounds sb = segmentBoundsMap.computeIfAbsent(s, SegmentBounds::new); - activeSegmentsInOrder.remove(sb); - activeSegmentsInOrder.add(sb); - callDepth++; - decodeStreamDirect(segBytes, 0, segBytes.length); - callDepth--; - currentSegId = savedSegId; - } - } - } - } + case GocaConstants.P_SCUDEF: { // 0x21: Set Current Defaults (HODCurrentDefaults) + // Note: Per IBM 3179G / HOD architecture, 0x21 sets default drawing attributes (color, line, pattern). + // It is NOT an executive segment redraw order. A prior attempt treated 0x21 as an invented + // P_SCUDEF segment redraw loop, which caused old dropdown menus and segments to be repeatedly + // repainted on top of the screen, creating ghost artifacts and stale bounding boxes. if (idx + 1 < end) { - int len = (data[idx + 1] & 0xFF) + 2; - idx += len; + int pLen = data[idx + 1] & 0xFF; + logger.fine(String.format("GOCA Set Current Defaults (0x21): len=%d", pLen)); + idx += pLen + 2; } else { idx++; } @@ -1302,9 +1267,9 @@ public class GocaDecoder { int textLen = end - pos; if (textLen <= 0) return; - // IBM 3279 vector graphics base cell is 9x12 + // IBM 3179G vector graphics base cell is 9x16 double cw = charWidth > 0 ? ((double) charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10.0; - double ch = charHeight > 0 ? ((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 12.0)) : 14.0; + double ch = charHeight > 0 ? ((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 16.0)) : 14.0; int totalW = textLen * (charWidth > 0 ? charWidth : 9); int totalH = (charHeight > 0 ? charHeight : 14); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java index ffaa7c3..31cea87 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java @@ -11,8 +11,8 @@ public class GraphicInputBuilder { 0x00, 0x38, 0x0F, 0x0F, 0x00, (byte) 0xC0, 0x00, 0x40, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x23, 0x00, 0x00, 0x00, 0x1F, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, + 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, (byte) 0x80, 0x00 }; @@ -30,16 +30,17 @@ public class GraphicInputBuilder { */ public static byte[] buildGraphicInput(int gocaX, int gocaY, int buttonOrAidCode, boolean isMouseAction, boolean isShift, boolean isCtrl) { - return buildGraphicInput(gocaX, gocaY, buttonOrAidCode, isMouseAction, isShift, isCtrl, 0, 0); + return buildGraphicInput(gocaX, gocaY, 0, 0, buttonOrAidCode, isMouseAction, isShift, isCtrl); } /** - * Builds the 56-byte Graphic Input Structured Field with picked segment ID and correlation tag. + * Builds the 56-byte Graphic Input Structured Field with picked segment ID and correlation tag (legacy overload). + * Note: Per IBM HOD / 3179G architecture, pick correlation is evaluated host-side by GDDM using (gx, gy). */ public static byte[] buildGraphicInput(int gocaX, int gocaY, int buttonOrAidCode, boolean isMouseAction, boolean isShift, boolean isCtrl, int pickedSegId, int pickTag) { - return buildGraphicInput(gocaX, gocaY, 0, 0, buttonOrAidCode, isMouseAction, isShift, isCtrl, pickedSegId, pickTag); + return buildGraphicInput(gocaX, gocaY, 0, 0, buttonOrAidCode, isMouseAction, isShift, isCtrl); } /** @@ -48,6 +49,14 @@ public class GraphicInputBuilder { public static byte[] buildGraphicInput(int gocaX, int gocaY, int row, int col, int buttonOrAidCode, boolean isMouseAction, boolean isShift, boolean isCtrl, int pickedSegId, int pickTag) { + return buildGraphicInput(gocaX, gocaY, row, col, buttonOrAidCode, isMouseAction, isShift, isCtrl); + } + + /** + * Builds the 56-byte Graphic Input Structured Field matching IBM Host On-Demand (HODInput.java). + */ + public static byte[] buildGraphicInput(int gocaX, int gocaY, int row, int col, int buttonOrAidCode, + boolean isMouseAction, boolean isShift, boolean isCtrl) { byte[] sf = new byte[MASK.length]; System.arraycopy(MASK, 0, sf, 0, MASK.length); @@ -55,13 +64,8 @@ public class GraphicInputBuilder { sf[0] = (byte) ((MASK.length >> 8) & 0xFF); sf[1] = (byte) (MASK.length & 0xFF); - // Bytes 16-19: Cursor Row & Column - if (row > 0 || col > 0) { - sf[16] = (byte) (row & 0xFF); - sf[17] = (byte) (col & 0xFF); - sf[18] = (byte) (row & 0xFF); - sf[19] = (byte) (col & 0xFF); - } + // Bytes 16-19: Device correlation class descriptor (0x23, 0x00, 0x23, 0x00) + // Note: Preserved from MASK per IBM Host On-Demand (HODInput.java); do not overwrite with row/col. // Byte 24-25: GOCA X coordinate (signed 16-bit big-endian) sf[24] = (byte) ((gocaX >> 8) & 0xFF); @@ -72,15 +76,15 @@ public class GraphicInputBuilder { sf[27] = (byte) (gocaY & 0xFF); if (isMouseAction) { - // Byte 28-31: Picked Segment ID (4 bytes big-endian) - sf[28] = (byte) ((pickedSegId >> 24) & 0xFF); - sf[29] = (byte) ((pickedSegId >> 16) & 0xFF); - sf[30] = (byte) ((pickedSegId >> 8) & 0xFF); - sf[31] = (byte) (pickedSegId & 0xFF); + // Byte 28-31: Fixed mouse trigger class constant (0x00000004 per IBM HODInput.java) + sf[28] = 0x00; + sf[29] = 0x00; + sf[30] = 0x00; + sf[31] = 0x04; - // Byte 32-33: Pick Tag / Correlation (2 bytes big-endian) - sf[32] = (byte) ((pickTag >> 8) & 0xFF); - sf[33] = (byte) (pickTag & 0xFF); + // Byte 32-33: Fixed mouse correlation class constant (0x0004 per IBM HODInput.java) + sf[32] = 0x00; + sf[33] = 0x04; sf[34] = isShift ? (byte) 0x80 : (isCtrl ? (byte) 0x40 : 0x00); sf[35] = (byte) (buttonOrAidCode == 2 ? 0x02 : 0x01); // Button 1 = Pick, Button 2 = Action diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java index f39618b..2d2d74f 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java @@ -115,6 +115,7 @@ public class GraphicsPlane { public synchronized void setScreenDimensions(int cols, int rows) { this.screenCols = cols > 0 ? cols : 80; this.screenRows = rows > 0 ? rows : 24; + this.transform.setScreenDimensions(this.screenCols, this.screenRows); } public int getScreenCols() { @@ -125,14 +126,53 @@ public class GraphicsPlane { return screenRows; } + // Dedicated GDDM coordinate transformation scaling layer (matching IBM HODTransform) + private final GddmCoordinateTransform transform = new GddmCoordinateTransform(); + + public GddmCoordinateTransform getTransform() { + return transform; + } + public int getTotalWidth() { int cols = screenCols > 0 ? screenCols : 80; return cols * 9; } + /** + * Total vertical presentation space units. + * + *

Historical / Architecture Note for Future Iterations: + *

+ */ public int getTotalHeight() { int rows = screenRows > 0 ? screenRows : 24; - return rows * 12; + return rows * 16; } public int getXMax() { @@ -158,7 +198,7 @@ public class GraphicsPlane { /** * Maps a 3179G / GOCA signed coordinate (centered at screen midpoint, bottom-up) to canvas pixel Y (top-down) as a double. - * IBM 3179G / HOD presentation space coordinate range is [-yMax .. +yMax] (height = rows * 12). + * IBM 3179G / HOD presentation space coordinate range is [-yMax .. +yMax] (height = rows * 16). */ public double mapYDouble(double gocaY) { int totalH = getTotalHeight(); @@ -555,8 +595,20 @@ public class GraphicsPlane { int fill = (fillColorArgb != 0) ? fillColorArgb : GocaConstants.GOCA_COLORS[0]; int bg = bgColorArgb; - boolean isTransparentBlack = (fill == GocaConstants.GOCA_COLORS[8] || (fill & 0x00FFFFFF) == 0) && (bgMix != GocaConstants.MIX_OVER); - if (pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary) && !isTransparentBlack) { + // Note / Logic for Future Iterations: + // In IBM 3179G / GDDM architecture, solid black fills (fillColor = GOCA_COLORS[8] / 0xFF000000, + // pattern = PT_SOLID / 16) are the standard mechanism used by host applications to ERASE + // dropdown menus, dialog boxes, and dynamic regions from the screen. + // + // A prior failed attempt added an 'isTransparentBlack' guard: + // boolean isTransparentBlack = (fill == GOCA_COLORS[8] || (fill & 0x00FFFFFF) == 0) && (bgMix != MIX_OVER); + // if (... && !isTransparentBlack) + // This caused GDDM's menu erasure rectangles (which use fillColor = Black and bgMix = 5) to be + // silently skipped and discarded. As a result, closed dropdown menus were never erased, causing + // multiple menus (FILE, DRAW, TRANSFORM) to linger and stack on top of each other permanently. + // + // Solid black fills (0xFF000000) must always be rasterized to overwrite and erase previously drawn pixels. + if (pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary)) { // Find polygon vertical bounds across all points int minY = py[0]; int maxY = py[0]; 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 f6a78ff..155d264 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -298,7 +298,7 @@ public class InputProcessor { sendAidResponse(out.toByteArray()); } - private void sendAidResponse(byte[] data) { + protected void sendAidResponse(byte[] data) { if (fsm != null && fsm.getConnectionState() != null && fsm.getConnectionState().isSscp()) { fsm.sendSscpLuData(data); } else if (fsm != null) { @@ -307,10 +307,10 @@ public class InputProcessor { } /** - * Transmit a mouse / light-pen graphic input AID matching IBM Host On-Demand PS3179G. + * Transmit a mouse / light-pen graphic input AID matching IBM Host On-Demand PS3179G and DS3270. */ public void sendGraphicMouseAid(int aidCode, int button, boolean isShift, boolean isCtrl) { - if (fsm == null || !fsm.getConnectionState().isFullSession()) { + if (fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isFullSession()) { return; } if (isKeyboardLocked()) { @@ -322,8 +322,6 @@ public class InputProcessor { if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) { int gx = gocaDecoder.getGraphicCursorX(); int gy = gocaDecoder.getGraphicCursorY(); - int pickedSeg = gocaDecoder.findPickedSegment(gx, gy); - int pickTag = gocaDecoder.getSegmentTag(pickedSeg); int cursorAddr = screen != null ? screen.getCursorAddress() : 0; int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80; int row = cursorAddr / cols; @@ -340,16 +338,53 @@ public class InputProcessor { } byte[] sf = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( - gx, gy, row, col, button, true, isShift, isCtrl, pickedSeg, pickTag + gx, gy, row, col, button, true, isShift, isCtrl ); - System.err.println(String.format( - "sendGraphicMouseAid: goca=(%d, %d) row=%d col=%d pickedSeg=%d pickTag=%d btn=%d shift=%b ctrl=%b", - gx, gy, row, col, pickedSeg, pickTag, button, isShift, isCtrl + + StringBuilder sfHex = new StringBuilder(); + for (byte b : sf) { + sfHex.append(String.format("%02X ", b & 0xFF)); + } + log.info(String.format( + "sendGraphicMouseAid: goca=(%d, %d) row=%d col=%d btn=%d shift=%b ctrl=%b SF_HEX=[%s]", + gx, gy, row, col, button, isShift, isCtrl, sfHex.toString().trim() )); + + // 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 + modified fields matching HOD DS3270.sendMouseAid + out.write(aidCode); + byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); + out.write(caddr[0] & 0xFF); + out.write(caddr[1] & 0xFF); + + 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; + out.write(ORDER_SBA); + byte[] addr = encodeAddress(fieldStart, screen.getRows(), screen.getCols()); + out.write(addr[0] & 0xFF); + out.write(addr[1] & 0xFF); + + 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 { out.write(aidCode); byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); @@ -363,11 +398,11 @@ public class InputProcessor { // ========== Cursor movement ========== /** - * Simulate a Text Light Pen selection. + * Simulate a Text Light Pen selection at the specified screen address. + * Conforms to IBM 3270 / Host On-Demand PS3270 processCurSelKey specifications. */ public boolean lightPenSelect(int address) { if (screen == null || !screen.isFormatted()) { - System.out.println("LP: screen null or unformatted"); return false; } int size = screen.getRows() * screen.getCols(); @@ -375,15 +410,13 @@ public class InputProcessor { address = ((address % size) + size) % size; int faPos = screen.findFieldAttribute(address); if (faPos < 0) { - System.out.println("LP: no FA found for addr=" + address); return false; } ExtendedAttribute faCell = screen.getCell(faPos); int fa = faCell.fa & 0xFF; - System.out.println("LP: addr=" + address + " faPos=" + faPos + " fa=0x" + String.format("%02X", fa) - + " selectable=" + haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa)); - if (!haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa)) { + if (!haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa) || + haus.nightmare.lib3270j.protocol.DS3270Constants.faIsZero(fa)) { return false; } @@ -392,33 +425,52 @@ public class InputProcessor { int ebcdic = desCell.ec & 0xFF; char ascii = (char) desCell.ucs4; - screen.setCursorAddress(designatorPos); + // Set cursor to the clicked/selected position matching HOD / 3270 standards + screen.setCursorAddress(address); - if (ebcdic == 0x00 || ebcdic == 0x40 || ascii == ' ' || ascii == 0 || (ebcdic != 0x50 && ascii != '&' && ebcdic != 0x6F && ascii != '?' && ebcdic != 0x6E && ascii != '>')) { - faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; - sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT); - return true; - } else if (ebcdic == 0x50 || ascii == '&') { - faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; - sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER); - return true; - } else if (ebcdic == 0x6F || ascii == '?') { - desCell.ec = (byte) 0x6E; - desCell.ucs4 = '>'; - faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; - screen.updateDisplaySnapshot(); - return true; - } else if (ebcdic == 0x6E || ascii == '>') { - desCell.ec = (byte) 0x6F; - desCell.ucs4 = '?'; - faCell.fa &= ~haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; - screen.updateDisplaySnapshot(); - return true; - } else { + // Immediate Selection (Space or Null) -> AID_SELECT (0x7E) + if (ebcdic == 0x00 || ebcdic == 0x40 || ascii == ' ' || ascii == 0 || ascii == '\u3000') { faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT); return true; } + // Enter Immediate (&) -> AID_ENTER (0x7D) + else if (ebcdic == 0x50 || ascii == '&' || ascii == '\uFF06') { + faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; + sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER); + return true; + } + // Deferred Selection (? -> >) + else if (ebcdic == 0x6F || ascii == '?' || ascii == '\uFF1F') { + desCell.ec = (byte) 0x6E; + desCell.ucs4 = '>'; + faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; + screen.markAllChanged(); + screen.updateDisplaySnapshot(); + return true; + } + // Deferred Deselection (> -> ?) + else if (ebcdic == 0x6E || ascii == '>' || ascii == '\uFF1E') { + desCell.ec = (byte) 0x6F; + desCell.ucs4 = '?'; + faCell.fa &= ~haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; + screen.markAllChanged(); + screen.updateDisplaySnapshot(); + return true; + } + // Any other character is an invalid designator and cannot be selected + else { + return false; + } + } + + /** + * Simulate the 3270 CURSR SEL (Cursor Select) key at the current cursor position. + * Equivalent to processCurSelKey in IBM Host On-Demand PS3270. + */ + public boolean cursorSelect() { + if (screen == null) return false; + return lightPenSelect(screen.getCursorAddress()); } public void cursorUp() { diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTDftTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTDftTest.java new file mode 100644 index 0000000..6d4b164 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTDftTest.java @@ -0,0 +1,190 @@ +package haus.nightmare.lib3270j.ft; + +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.TerminalModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +public class FTDftTest { + + private EbcdicTranslator translator; + private ScreenBuffer screen; + private InputProcessor input; + private TestListener listener; + private FTDft dft; + private FTConfig config; + + private static class TestListener implements FTDft.FTDftListener { + boolean dftRunningCalled = false; + boolean completeCalled = false; + boolean abortCalled = false; + String completeMsg = null; + String abortMsg = null; + long bytesTransferred = 0; + FTConstants.FTState state = FTConstants.FTState.RUNNING; + FTConfig config; + + @Override + public void onDftRunning() { + dftRunningCalled = true; + } + + @Override + public void onTransferComplete(String errorMessage) { + completeCalled = true; + completeMsg = errorMessage; + } + + @Override + public void onTransferAborted(String errorMessage) { + abortCalled = true; + abortMsg = errorMessage; + } + + @Override + public void onBytesTransferred(long bytes) { + bytesTransferred = bytes; + } + + @Override + public FTConstants.FTState getCurrentState() { + return state; + } + + @Override + public void setState(FTConstants.FTState state) { + this.state = state; + } + + @Override + public FTConfig getConfig() { + return config; + } + + @Override + public File getLocalFile() { + return null; + } + } + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + input = new InputProcessor(screen, translator, null); + listener = new TestListener(); + config = new FTConfig(); + config.setDirection(FTConfig.Direction.RECEIVE); + config.setTransferMode(FTConfig.TransferMode.BINARY); + listener.config = config; + + dft = new FTDft(input, translator, listener); + } + + @Test + public void testOpenMessageStreamAsciiAndHandleTrans14Error() { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + dft.initTransfer(null, outStream); + + // 1. Simulate TR_OPEN_REQ with ASCII "FT:MSG" matching j3270.log: + // SF length = 0x0023, SF type = 0xD0, Request code = 0x0012, stream name "FT:MSG " at offset 28 + byte[] openReq = new byte[] { + 0x00, 0x23, (byte) 0xD0, 0x00, 0x12, 0x01, 0x06, 0x01, 0x01, 0x04, + 0x03, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x11, 0x01, 0x01, + 0x00, 0x50, 0x05, 0x52, 0x03, (byte) 0xF0, 0x03, 0x09, + 0x46, 0x54, 0x3A, 0x4D, 0x53, 0x47, 0x20 // "FT:MSG " + }; + + dft.processStructuredField(openReq, 0, openReq.length); + + // Should NOT trigger onDftRunning because it's a message stream + assertFalse(listener.dftRunningCalled); + + // 2. Simulate TR_DATA_INSERT on the message stream with "TRANS14 Error reading file from host: ..." + String errorMsg = "TRANS14 Error reading file from host: file format invalid"; + byte[] msgBytes = errorMsg.getBytes(StandardCharsets.ISO_8859_1); + int totalPayloadLen = msgBytes.length + 5; + + ByteArrayOutputStream sfOut = new ByteArrayOutputStream(); + sfOut.write(0); sfOut.write(0); // placeholder length + sfOut.write(0xD0); // SF_TRANSFER_DATA + sfOut.write((FTConstants.TR_DATA_INSERT >> 8) & 0xFF); + sfOut.write(FTConstants.TR_DATA_INSERT & 0xFF); + sfOut.write((FTConstants.TR_NOT_COMPRESSED >> 8) & 0xFF); + sfOut.write(FTConstants.TR_NOT_COMPRESSED & 0xFF); + sfOut.write(FTConstants.TR_BEGIN_DATA); + sfOut.write((totalPayloadLen >> 8) & 0xFF); + sfOut.write(totalPayloadLen & 0xFF); + sfOut.write(msgBytes, 0, msgBytes.length); + + byte[] insertData = sfOut.toByteArray(); + int sfLen = insertData.length; + insertData[0] = (byte) ((sfLen >> 8) & 0xFF); + insertData[1] = (byte) (sfLen & 0xFF); + + dft.processStructuredField(insertData, 0, insertData.length); + + // Verify that onTransferAborted was called with the error message + assertTrue(listener.abortCalled, "onTransferAborted should be called for TRANS14 error"); + assertNotNull(listener.abortMsg); + assertTrue(listener.abortMsg.contains("TRANS14")); + assertFalse(listener.completeCalled, "onTransferComplete should NOT be called for an error"); + + // Verify that the error message was NOT written to the destination file stream! + assertEquals(0, outStream.size(), "Error message should not be written to destination file"); + } + + @Test + public void testOpenDataStreamAndWriteData() { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + dft.initTransfer(null, outStream); + + // 1. Simulate TR_OPEN_REQ with ASCII "FT:DATA" + byte[] openReq = new byte[] { + 0x00, 0x23, (byte) 0xD0, 0x00, 0x12, 0x01, 0x06, 0x01, 0x01, 0x04, + 0x03, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x11, 0x01, 0x01, + 0x00, 0x50, 0x05, 0x52, 0x03, (byte) 0xF0, 0x03, 0x09, + 0x46, 0x54, 0x3A, 0x44, 0x41, 0x54, 0x41 // "FT:DATA" + }; + + dft.processStructuredField(openReq, 0, openReq.length); + assertTrue(listener.dftRunningCalled, "onDftRunning should be called for file data stream"); + + // 2. Simulate TR_DATA_INSERT with binary file payload + byte[] fileData = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; // "%PDF-1.4" + int totalPayloadLen = fileData.length + 5; + + ByteArrayOutputStream sfOut = new ByteArrayOutputStream(); + sfOut.write(0); sfOut.write(0); + sfOut.write(0xD0); + sfOut.write((FTConstants.TR_DATA_INSERT >> 8) & 0xFF); + sfOut.write(FTConstants.TR_DATA_INSERT & 0xFF); + sfOut.write((FTConstants.TR_NOT_COMPRESSED >> 8) & 0xFF); + sfOut.write(FTConstants.TR_NOT_COMPRESSED & 0xFF); + sfOut.write(FTConstants.TR_BEGIN_DATA); + sfOut.write((totalPayloadLen >> 8) & 0xFF); + sfOut.write(totalPayloadLen & 0xFF); + sfOut.write(fileData, 0, fileData.length); + + byte[] insertData = sfOut.toByteArray(); + int sfLen = insertData.length; + insertData[0] = (byte) ((sfLen >> 8) & 0xFF); + insertData[1] = (byte) (sfLen & 0xFF); + + dft.processStructuredField(insertData, 0, insertData.length); + + // Data must be written to file stream + assertArrayEquals(fileData, outStream.toByteArray()); + assertEquals(8, listener.bytesTransferred); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java new file mode 100644 index 0000000..0d63f99 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java @@ -0,0 +1,43 @@ +package haus.nightmare.lib3270j.graphics; + +import org.junit.jupiter.api.Test; +import java.awt.Point; + +import static org.junit.jupiter.api.Assertions.*; + +public class GddmCoordinateTransformTest { + + @Test + public void testModel4PresentationSpaceMetrics() { + GddmCoordinateTransform transform = new GddmCoordinateTransform(80, 43, 9, 16); + + assertEquals(720, transform.getTotalWidth()); + assertEquals(688, transform.getTotalHeight()); + assertEquals(360, transform.getXMax()); + assertEquals(343, transform.getYMax()); + + // Screen center (0, 0) + Point baseCenter = transform.gocaToBase(0, 0); + assertEquals(360, baseCenter.x); + assertEquals(343, baseCenter.y); + assertEquals(new Point(0, 0), transform.baseToGoca(360, 343)); + + // Bidirectional screen pixel transformation + // Cell width = 10, Cell height = 20, Offset = (100, 50) + int ox = 100, oy = 50, cw = 10, ch = 20; + Point screenPt = transform.gocaToScreenPixel(0, 0, ox, oy, cw, ch); + Point roundtripGoca = transform.screenPixelToGoca(screenPt.x, screenPt.y, ox, oy, cw, ch); + assertEquals(0, roundtripGoca.x); + assertEquals(0, roundtripGoca.y); + } + + @Test + public void testModel2PresentationSpaceMetrics() { + GddmCoordinateTransform transform = new GddmCoordinateTransform(80, 24, 9, 16); + + assertEquals(720, transform.getTotalWidth()); + assertEquals(384, transform.getTotalHeight()); + assertEquals(360, transform.getXMax()); + assertEquals(191, transform.getYMax()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java index 519ef70..09237f7 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java @@ -221,6 +221,12 @@ public class GocaDecoderTest { GraphicsPlane plane = new GraphicsPlane(1000, 750); plane.setScreenDimensions(80, 43); + // Standard IBM 3179G 43-row presentation space (80*9 = 720 wide, 43*16 = 688 high) + assertEquals(720, plane.getTotalWidth()); + assertEquals(688, plane.getTotalHeight()); + assertEquals(360, plane.getXMax()); + assertEquals(343, plane.getYMax()); + // Screen center (0, 0) should map to canvas center assertEquals(500, plane.mapX(0)); assertEquals(374, plane.mapY(0)); @@ -240,6 +246,13 @@ public class GocaDecoderTest { assertEquals(0, plane.unmapY(plane.mapY(0))); assertEquals(100, plane.unmapY(plane.mapY(100))); assertEquals(-200, plane.unmapY(plane.mapY(-200))); + + // Standard IBM 3179G Model 2 (24-row) presentation space (24*16 = 384 high, yMax = 191) + plane.setScreenDimensions(80, 24); + assertEquals(720, plane.getTotalWidth()); + assertEquals(384, plane.getTotalHeight()); + assertEquals(360, plane.getXMax()); + assertEquals(191, plane.getYMax()); } @Test @@ -735,8 +748,9 @@ public class GocaDecoderTest { out.write(0x00); out.write(20); out.write(0x00); out.write(40); out.write(GocaConstants.G_GEAR); - // 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, GSCOL White inside GBAR) + // 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, Pattern 15/Empty, GSCOL White inside GBAR) out.write(GocaConstants.G_GSCOL); out.write(0x08); // Black (background) + out.write(GocaConstants.G_GSPT); out.write(0x0F); // Empty pattern (transparent interior) out.write(GocaConstants.G_GBAR); out.write(0x80); out.write(GocaConstants.G_GSCOL); out.write(0x07); // White line color out.write(GocaConstants.G_GSLT); out.write(GocaConstants.LT_DOT); // Dotted line 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 75624ec..5a7b082 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java @@ -154,38 +154,170 @@ public class InputProcessorTest { assertEquals((byte) 0xFF, sf[34]); assertEquals((byte) AID_ENTER, sf[35]); - // Mouse Button 1 with picked segment 2 and tag 5 + // Mouse Button 1 matching HODInput.java (0x04 trigger constants and 0x23 correlation descriptor) byte[] sfMouse = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( - 294, 192, 1, true, false, false, 2, 5 + 294, 192, 1, true, false, false ); assertEquals(56, sfMouse.length); assertEquals(0x00, sfMouse[0]); assertEquals(0x38, sfMouse[1]); + assertEquals(0x23, sfMouse[16]); + assertEquals(0x00, sfMouse[17]); + assertEquals(0x23, sfMouse[18]); + assertEquals(0x00, sfMouse[19]); int mx = (sfMouse[24] << 8) | (sfMouse[25] & 0xFF); int my = (sfMouse[26] << 8) | (sfMouse[27] & 0xFF); assertEquals(294, (short) mx); assertEquals(192, (short) my); - int segId = ((sfMouse[28] & 0xFF) << 24) | ((sfMouse[29] & 0xFF) << 16) | - ((sfMouse[30] & 0xFF) << 8) | (sfMouse[31] & 0xFF); - assertEquals(2, segId); - int tag = ((sfMouse[32] & 0xFF) << 8) | (sfMouse[33] & 0xFF); - assertEquals(5, tag); + assertEquals(0x00, sfMouse[28]); + assertEquals(0x00, sfMouse[29]); + assertEquals(0x00, sfMouse[30]); + assertEquals(0x04, sfMouse[31]); // Mouse trigger class constant (0x04) + assertEquals(0x00, sfMouse[32]); + assertEquals(0x04, sfMouse[33]); // Mouse correlation class constant (0x04) assertEquals(0x00, sfMouse[34]); assertEquals(0x01, sfMouse[35]); // Button 1 - // Mouse Button 2 (Action) with no segment (0) + // Mouse Button 2 (Action) with Shift modifier byte[] sfMouse2 = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( - 0, 0, 2, true, true, false, 0, 0 + 0, 0, 2, true, true, false ); - int segId2 = ((sfMouse2[28] & 0xFF) << 24) | ((sfMouse2[29] & 0xFF) << 16) | - ((sfMouse2[30] & 0xFF) << 8) | (sfMouse2[31] & 0xFF); - assertEquals(0, segId2); - int tag2 = ((sfMouse2[32] & 0xFF) << 8) | (sfMouse2[33] & 0xFF); - assertEquals(0, tag2); + assertEquals(0x23, sfMouse2[16]); + assertEquals(0x00, sfMouse2[17]); + assertEquals(0x23, sfMouse2[18]); + assertEquals(0x00, sfMouse2[19]); + assertEquals(0x04, sfMouse2[31]); + assertEquals(0x04, sfMouse2[33]); assertEquals((byte) 0x80, sfMouse2[34]); // Shift modifier assertEquals(0x02, sfMouse2[35]); // Button 2 } + @Test + public void testLightPenSelectImmediateSpace() { + screen.erase(false); + // Field attribute at pos 0: unprotected, intensified selectable (0x08) + screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_HIGH_SEL)); + // Designator character at pos 1: space (immediate select) + screen.getCell(1).ec = 0x40; + screen.getCell(1).ucs4 = ' '; + screen.getCell(2).ec = (byte) 0xC1; + screen.getCell(2).ucs4 = 'A'; + + java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1); + InputProcessor input = new InputProcessor(screen, translator, null) { + @Override + public void sendAid(int aidCode) { + sentAid.set(aidCode); + } + }; + + boolean result = input.lightPenSelect(2); + assertTrue(result); + assertEquals(AID_SELECT, sentAid.get()); + assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0); + assertEquals(2, screen.getCursorAddress()); + } + + @Test + public void testLightPenSelectEnterImmediateAmpersand() { + screen.erase(false); + // Field attribute at pos 0: normal detectable (0x04) + screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL)); + // Designator character at pos 1: '&' (enter immediate) + screen.getCell(1).ec = 0x50; + screen.getCell(1).ucs4 = '&'; + + java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1); + InputProcessor input = new InputProcessor(screen, translator, null) { + @Override + public void sendAid(int aidCode) { + sentAid.set(aidCode); + } + }; + + boolean result = input.lightPenSelect(1); + assertTrue(result); + assertEquals(AID_ENTER, sentAid.get()); + assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0); + } + + @Test + public void testLightPenSelectDeferredToggle() { + screen.erase(false); + screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL)); + // Designator at pos 1: '?' (deferred select) + screen.getCell(1).ec = 0x6F; + screen.getCell(1).ucs4 = '?'; + + java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1); + InputProcessor input = new InputProcessor(screen, translator, null) { + @Override + public void sendAid(int aidCode) { + sentAid.set(aidCode); + } + }; + + // First click: ? -> > and MDT=1, no transmission + boolean r1 = input.lightPenSelect(1); + assertTrue(r1); + assertEquals(-1, sentAid.get()); + assertEquals('>', screen.getCell(1).ucs4); + assertEquals((byte) 0x6E, screen.getCell(1).ec); + assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0); + + // Second click: > -> ? and MDT=0, no transmission + boolean r2 = input.lightPenSelect(1); + assertTrue(r2); + assertEquals(-1, sentAid.get()); + assertEquals('?', screen.getCell(1).ucs4); + assertEquals((byte) 0x6F, screen.getCell(1).ec); + assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0); + } + + @Test + public void testLightPenSelectInvalidDesignatorRejected() { + screen.erase(false); + screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL)); + // Designator at pos 1: 'A' (invalid designator) + screen.getCell(1).ec = (byte) 0xC1; + screen.getCell(1).ucs4 = 'A'; + + java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1); + InputProcessor input = new InputProcessor(screen, translator, null) { + @Override + public void sendAid(int aidCode) { + sentAid.set(aidCode); + } + }; + + boolean result = input.lightPenSelect(1); + assertFalse(result); + assertEquals(-1, sentAid.get()); + assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0); + } + + @Test + public void testCursorSelectKey() { + screen.erase(false); + screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL)); + screen.getCell(1).ec = 0x50; + screen.getCell(1).ucs4 = '&'; + screen.setCursorAddress(5); + + java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1); + InputProcessor input = new InputProcessor(screen, translator, null) { + @Override + public void sendAid(int aidCode) { + sentAid.set(aidCode); + } + }; + + boolean result = input.cursorSelect(); + assertTrue(result); + assertEquals(AID_ENTER, sentAid.get()); + assertEquals(5, screen.getCursorAddress()); + } + @Test public void testSendAidSuppressesNullsInModifiedField() { java.util.concurrent.atomic.AtomicReference sent = new java.util.concurrent.atomic.AtomicReference<>(); @@ -231,4 +363,59 @@ public class InputProcessorTest { assertEquals((byte) 0xC9, result[8]); // 'I' assertEquals((byte) 0xE3, result[9]); // 'T' } + + @Test + public void testSendGraphicMouseAidFraming() { + 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) { + @Override + protected void sendAidResponse(byte[] data) { + sent.set(data); + } + }; + input.setGocaDecoder(goca); + + input.sendGraphicMouseAid(AID_ENTER, 1, false, false); + + 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 = 56 (0x00 0x38) + assertEquals(0x00, result[1]); + assertEquals(0x38, 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); + // Mouse constant at index 1 + 31 = 32 + assertEquals(0x04, result[32]); + assertEquals(0x04, result[34]); + // Button 1 at index 1 + 35 = 36 + assertEquals(0x01, 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]); + } }