From cbb310b889cbf05b11e42a1277d515c53ab44cf2 Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 4 Sep 2026 14:34:01 -0400 Subject: [PATCH] GOCA fixing --- .gitignore | 1 + .../haus/nightmare/j3270/ui/StatusBar.java | 38 +- .../haus/nightmare/j3270/ui/ThemeManager.java | 39 +- .../j3270/ui/Phase1UiOverlayTest.java | 10 + .../nightmare/j3270/ui/ThemeManagerTest.java | 44 ++ .../datastream/DataStreamProcessor.java | 3 + .../nightmare/lib3270j/graphics/FillArea.java | 58 ++- .../lib3270j/graphics/GocaDecoder.java | 184 ++++++-- .../lib3270j/graphics/GraphicsPlane.java | 405 +++++++++++------- .../lib3270j/graphics/FillAreaTest.java | 139 ++++++ .../lib3270j/graphics/GocaDecoderTest.java | 256 ++++++++++- .../graphics/Phase1ColorCalibrationTest.java | 4 +- 12 files changed, 943 insertions(+), 238 deletions(-) diff --git a/.gitignore b/.gitignore index 70dbc44..52a0cca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ j3270.log.* +*.bin *.txt *.py *.pcap 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 12b1ea1..0a5c14c 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java @@ -37,7 +37,7 @@ public class StatusBar extends JPanel { connectionStatus = createLabel("Not Connected", oiaFont, ThemeManager.getOiaFgDim()); tlsStatus = createLabel("", oiaFont, ThemeManager.getOiaFgNormal()); luName = createLabel("", oiaFont, ThemeManager.getOiaFgNormal()); - lockStatus = createLabel("", oiaFont, ThemeManager.getOiaFgAlert()); + lockStatus = createLabel("", oiaFont, ThemeManager.getOiaInputInhibited()); fieldTypeStatus = createLabel("", oiaFont, ThemeManager.getOiaFgDim()); codePageInfo = createLabel("", oiaFont, ThemeManager.getOiaFgDim()); modelInfo = createLabel("", oiaFont, ThemeManager.getOiaFgDim()); @@ -89,10 +89,12 @@ public class StatusBar extends JPanel { tlsStatus.setText(""); luName.setText(""); lockStatus.setText(""); + lockStatus.setForeground(ThemeManager.getOiaInputInhibited(theme)); fieldTypeStatus.setText(""); codePageInfo.setText(""); modelInfo.setText(""); cursorPosition.setText("001/001"); + cursorPosition.setForeground(ThemeManager.getOiaFgNormal(theme)); return; } @@ -110,25 +112,25 @@ public class StatusBar extends JPanel { break; case CONNECTED_3270: connectionStatus.setText("TN3270"); - connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail()); + connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); break; case CONNECTED_TN3270E: connectionStatus.setText("TN3270E"); - connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail()); + connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); break; case CONNECTED_SSCP: connectionStatus.setText("SSCP-LU"); - connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail()); + connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); break; case CONNECTED_NVT: case CONNECTED_NVT_CHAR: case CONNECTED_E_NVT: connectionStatus.setText("NVT"); - connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail()); + connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); break; case CONNECTED_UNBOUND: connectionStatus.setText("Unbound"); - connectionStatus.setForeground(ThemeManager.getOiaAttention()); + connectionStatus.setForeground(ThemeManager.getOiaAttention(theme)); break; default: connectionStatus.setText(state.name()); @@ -144,11 +146,11 @@ public class StatusBar extends JPanel { String protocol = session != null ? session.getProtocol() : "TLS"; if (verified) { tlsStatus.setText("🔒 TLS"); - tlsStatus.setForeground(ThemeManager.getOiaStatusSysAvail()); + tlsStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); tlsStatus.setToolTipText(protocol + " / " + cipher + " (Verified)"); } else { tlsStatus.setText("🔓 TLS (Unverified)"); - tlsStatus.setForeground(ThemeManager.getOiaAttention()); + tlsStatus.setForeground(ThemeManager.getOiaAttention(theme)); tlsStatus.setToolTipText(protocol + " / " + cipher + " (Verification Bypassed)"); } } else { @@ -164,46 +166,46 @@ public class StatusBar extends JPanel { lu = "LU:" + client.getConfig().getLuName(); } luName.setText(lu); - luName.setForeground(ThemeManager.getOiaStatusSysAvail()); + luName.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); // Lock / Inhibit status int inhibit = client.getOIA().getInputInhibited(); if (inhibit != ECLConstants.INHIBIT_NOT_INHIBITED) { - Color lockFg = ThemeManager.getOiaInputInhibited(); // White (oII) + Color lockFg = ThemeManager.getOiaInputInhibited(theme); switch (inhibit) { case ECLConstants.INHIBIT_SYSTEM_LOCK: lockStatus.setText("X SYSTEM"); - lockFg = ThemeManager.getOiaInputInhibited(); // White (oII) + lockFg = ThemeManager.getOiaInputInhibited(theme); break; case ECLConstants.INHIBIT_COMM_CHECK: lockStatus.setText("X COMM"); - lockFg = ThemeManager.getOiaCommCheck(); // Red (oEI) + lockFg = ThemeManager.getOiaCommCheck(theme); break; case ECLConstants.INHIBIT_NUMERIC_ONLY: lockStatus.setText("X NUM"); - lockFg = ThemeManager.getOiaAttention(); // Yellow (oAI) + lockFg = ThemeManager.getOiaAttention(theme); break; case ECLConstants.INHIBIT_PROTECTED_FIELD: lockStatus.setText("X PROT"); - lockFg = ThemeManager.getOiaInputInhibited(); // White (oII) + lockFg = ThemeManager.getOiaInputInhibited(theme); break; case ECLConstants.INHIBIT_OVERFLOW: lockStatus.setText("X >"); - lockFg = ThemeManager.getOiaAttention(); // Yellow (oAI) + lockFg = ThemeManager.getOiaAttention(theme); break; case ECLConstants.INHIBIT_OPERATOR_DUE: lockStatus.setText("X OP"); - lockFg = ThemeManager.getOiaAttention(); // Yellow (oAI) + lockFg = ThemeManager.getOiaAttention(theme); break; default: lockStatus.setText("X LOCKED"); - lockFg = ThemeManager.getOiaInputInhibited(); // White (oII) + lockFg = ThemeManager.getOiaInputInhibited(theme); break; } lockStatus.setForeground(lockFg); } else if (client.getInputProcessor().isInsertMode()) { lockStatus.setText("INSERT"); - lockStatus.setForeground(ThemeManager.getOiaStatusSysAvail()); + lockStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme)); } else { lockStatus.setText(""); } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java index 3ec4a75..ca46496 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ThemeManager.java @@ -251,11 +251,30 @@ public final class ThemeManager { public static final Color HOD_OIA_COMM_CHECK_ERROR = new Color(255, 0, 0); // oEI: Red public static final Color HOD_OIA_BG_BLACK = new Color(0, 0, 0); // oOB: Black - public static Color getOiaStatusSysAvail() { return HOD_OIA_STATUS_SYS_AVAIL; } - public static Color getOiaInputInhibited() { return HOD_OIA_INPUT_INHIBITED; } - public static Color getOiaAttention() { return HOD_OIA_ATTENTION_WARN; } - public static Color getOiaCommCheck() { return HOD_OIA_COMM_CHECK_ERROR; } - public static Color getOiaBackground() { return HOD_OIA_BG_BLACK; } + public static Color getOiaStatusSysAvail() { return getOiaStatusSysAvail(currentTheme); } + public static Color getOiaStatusSysAvail(UITheme t) { + return t == UITheme.DARK ? HOD_OIA_STATUS_SYS_AVAIL : new Color(0, 90, 200); + } + + public static Color getOiaInputInhibited() { return getOiaInputInhibited(currentTheme); } + public static Color getOiaInputInhibited(UITheme t) { + return t == UITheme.DARK ? HOD_OIA_INPUT_INHIBITED : new Color(20, 20, 20); + } + + public static Color getOiaAttention() { return getOiaAttention(currentTheme); } + public static Color getOiaAttention(UITheme t) { + return t == UITheme.DARK ? HOD_OIA_ATTENTION_WARN : new Color(180, 100, 0); + } + + public static Color getOiaCommCheck() { return getOiaCommCheck(currentTheme); } + public static Color getOiaCommCheck(UITheme t) { + return t == UITheme.DARK ? HOD_OIA_COMM_CHECK_ERROR : new Color(190, 20, 20); + } + + public static Color getOiaBackground() { return getOiaBackground(currentTheme); } + public static Color getOiaBackground(UITheme t) { + return t == UITheme.DARK ? HOD_OIA_BG_BLACK : getStatusBarBg(t); + } // ========================================================================= // Button Variants & Color helpers @@ -647,6 +666,16 @@ public final class ThemeManager { return; } + if (comp instanceof StatusBar) { + ((StatusBar) comp).applyTheme(theme); + return; + } + + if (comp instanceof TerminalPanel) { + comp.repaint(); + return; + } + if (comp instanceof JMenuBar) { styleMenuBar((JMenuBar) comp); for (int i = 0; i < ((JMenuBar) comp).getMenuCount(); i++) { diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/Phase1UiOverlayTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/Phase1UiOverlayTest.java index 624979d..39ac682 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/ui/Phase1UiOverlayTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/Phase1UiOverlayTest.java @@ -37,6 +37,7 @@ public class Phase1UiOverlayTest { @Test @DisplayName("Item 1.5: ThemeManager HoD OIA category colors match specification") public void testThemeManagerOiaColors() { + ThemeManager.setTheme(UITheme.DARK); // oSI: Status / System Available -> CUSTOMBLUE (120, 144, 240) assertEquals(new Color(120, 144, 240), ThemeManager.getOiaStatusSysAvail()); @@ -51,6 +52,15 @@ public class Phase1UiOverlayTest { // oOB: OIA Separator / Background -> Black (0, 0, 0) assertEquals(new Color(0, 0, 0), ThemeManager.getOiaBackground()); + + // Light mode OIA colors should be legible and not white on grey + ThemeManager.setTheme(UITheme.LIGHT); + assertNotEquals(Color.WHITE, ThemeManager.getOiaInputInhibited()); + assertEquals(new Color(20, 20, 20), ThemeManager.getOiaInputInhibited()); + assertEquals(new Color(0, 90, 200), ThemeManager.getOiaStatusSysAvail()); + assertEquals(new Color(180, 100, 0), ThemeManager.getOiaAttention()); + assertEquals(new Color(190, 20, 20), ThemeManager.getOiaCommCheck()); + assertEquals(ThemeManager.getStatusBarBg(UITheme.LIGHT), ThemeManager.getOiaBackground()); } @Test diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java index bd88eb5..51aeb08 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/ThemeManagerTest.java @@ -62,8 +62,33 @@ public class ThemeManagerTest { // Verify Status Bar colors Color sbBg = ThemeManager.getStatusBarBg(theme); Color sbNormal = ThemeManager.getOiaFgNormal(theme); + Color sbInhibited = ThemeManager.getOiaInputInhibited(theme); + Color sbSysAvail = ThemeManager.getOiaStatusSysAvail(theme); + Color sbAttention = ThemeManager.getOiaAttention(theme); + Color sbCommCheck = ThemeManager.getOiaCommCheck(theme); assertNotNull(sbBg); assertNotNull(sbNormal); + assertNotNull(sbInhibited); + assertNotNull(sbSysAvail); + assertNotNull(sbAttention); + assertNotNull(sbCommCheck); + + // Verify status bar contrast: ensure NO white on grey in light mode + double sbBgLum = (0.299 * sbBg.getRed() + 0.587 * sbBg.getGreen() + 0.114 * sbBg.getBlue()); + double sbInhibitedLum = (0.299 * sbInhibited.getRed() + 0.587 * sbInhibited.getGreen() + 0.114 * sbInhibited.getBlue()); + double sbInhibitedDiff = Math.abs(sbBgLum - sbInhibitedLum); + assertTrue(sbInhibitedDiff > 120, "Status bar input inhibited (X PROT) contrast in " + theme + " must be > 120 (was " + sbInhibitedDiff + ")"); + + double sbSysAvailLum = (0.299 * sbSysAvail.getRed() + 0.587 * sbSysAvail.getGreen() + 0.114 * sbSysAvail.getBlue()); + assertTrue(Math.abs(sbBgLum - sbSysAvailLum) > 100, "Status bar sys avail contrast in " + theme + " must be > 100"); + + double sbAttentionLum = (0.299 * sbAttention.getRed() + 0.587 * sbAttention.getGreen() + 0.114 * sbAttention.getBlue()); + assertTrue(Math.abs(sbBgLum - sbAttentionLum) > 100, "Status bar attention contrast in " + theme + " must be > 100"); + + if (theme == UITheme.LIGHT) { + assertNotEquals(Color.WHITE, sbInhibited, "Status bar input inhibited text must not be pure white in light mode"); + assertNotEquals(Color.WHITE, sbNormal, "Status bar normal text must not be pure white in light mode"); + } // Verify Button colors Color btnDefBg = ThemeManager.getButtonBg(ThemeManager.ButtonVariant.DEFAULT, theme); @@ -218,4 +243,23 @@ public class ThemeManagerTest { tmpIni.delete(); } } + + @Test + public void testStatusBarLightModeLegibility() { + try { + ThemeManager.setTheme(UITheme.LIGHT); + StatusBar sb = new StatusBar(); + sb.applyTheme(UITheme.LIGHT); + + assertEquals(ThemeManager.getStatusBarBg(UITheme.LIGHT), sb.getBackground()); + assertNotEquals(Color.WHITE, ThemeManager.getOiaInputInhibited()); + assertNotEquals(Color.WHITE, ThemeManager.getOiaInputInhibited(UITheme.LIGHT)); + + // Verify recursive applyTheme does not break StatusBar background + ThemeManager.applyTheme(sb, UITheme.LIGHT); + assertEquals(ThemeManager.getStatusBarBg(UITheme.LIGHT), sb.getBackground()); + } catch (HeadlessException e) { + // Handled in headless CI + } + } } 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 d34518a..514eaee 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -1288,6 +1288,9 @@ public class DataStreamProcessor { byte[] fullStream = gocaAccumulator.toByteArray(); gocaAccumulator.reset(); log.info(String.format("GOCA stream SPAN_LAST assembled: %d bytes", fullStream.length)); + try { + java.nio.file.Files.write(java.nio.file.Paths.get("/Users/rudi/Projects/j3270/captured_goca.bin"), fullStream); + } catch (Exception ignored) {} if (currentGocaSubtype == haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB) { gocaDecoder.processProcedureOrders(fullStream, 0, fullStream.length); } else { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java index 029470d..f8613e3 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java @@ -255,6 +255,29 @@ public class FillArea { } } + private void plotFillPixel(GraphicsPlane plane, int x, int y, int colorArgb, boolean isMixOr) { + if (isMixOr) { + int dst = plane.getPixel(x, y); + int dstR = (dst >>> 16) & 0xFF; + int dstG = (dst >>> 8) & 0xFF; + int dstB = dst & 0xFF; + + int srcR = (colorArgb >>> 16) & 0xFF; + int srcG = (colorArgb >>> 8) & 0xFF; + int srcB = colorArgb & 0xFF; + + int outR = Math.min(255, dstR | srcR); + int outG = Math.min(255, dstG | srcG); + int outB = Math.min(255, dstB | srcB); + int outA = Math.max((dst >>> 24) & 0xFF, (colorArgb >>> 24) & 0xFF); + if (outA == 0 && (outR != 0 || outG != 0 || outB != 0)) outA = 255; + + plane.setPixelDirect(x, y, (outA << 24) | (outR << 16) | (outG << 8) | outB); + } else { + plane.setPixel(x, y, colorArgb); + } + } + /** * Rasterizes and fills the accumulated area polygons on the target GraphicsPlane with explicit fill rule. */ @@ -270,16 +293,11 @@ public class FillArea { int fill = (fillColorArgb != 0) ? fillColorArgb : GocaConstants.GOCA_COLORS[0]; int bg = bgColorArgb; - - // Background mix / transparency rule for Black fills: - // BMX_TRANSPARENT / 0 or 2 / MIX_DEFAULT: Transparent black - // BMX_OPAQUE / 1: Opaque background overpaint - boolean isTransparentBlack = ((fill & 0x00FFFFFF) == 0) && - (bgMix == GocaConstants.BMX_DEFAULT || bgMix == GocaConstants.BMX_TRANSPARENT || - bgMix == GocaConstants.MIX_DEFAULT || bgMix == GocaConstants.MIX_LEAVE || bgMix == 0 || bgMix == 2); boolean isOpaqueBg = (bgMix == GocaConstants.BMX_OPAQUE || bgMix == 1); - if (!isTransparentBlack && pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary)) { + boolean isMixOr = fillModeOR || (plane.getMixMode() == GocaConstants.MIX_OR && (getBounds().width >= 5 && getBounds().height >= 5 && getBounds().width * getBounds().height >= 100)); + + if (pattern != GocaConstants.PT_EMPTY) { double minY = Double.MAX_VALUE; double maxY = Double.MIN_VALUE; @@ -340,18 +358,18 @@ public class FillArea { int pIdx = psY * psW + psX; boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0); if (bit) { - plane.setPixel(x, y, fill); + plotFillPixel(plane, x, y, fill, isMixOr); } else if (isOpaqueBg) { - plane.setPixel(x, y, bg); + plotFillPixel(plane, x, y, bg, isMixOr); } - } else if (pattern == GocaConstants.PT_SOLID || pattern == 16) { - plane.setPixel(x, y, fill); + } else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) { + plotFillPixel(plane, x, y, fill, isMixOr); } else if (patRows != null) { int b = patRows[y & 7] & 0xFF; if (((b >> (7 - (x & 7))) & 1) != 0) { - plane.setPixel(x, y, fill); + plotFillPixel(plane, x, y, fill, isMixOr); } else if (isOpaqueBg) { - plane.setPixel(x, y, bg); + plotFillPixel(plane, x, y, bg, isMixOr); } } } @@ -374,18 +392,18 @@ public class FillArea { int pIdx = psY * psW + psX; boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0); if (bit) { - plane.setPixel(x, y, fill); + plotFillPixel(plane, x, y, fill, isMixOr); } else if (isOpaqueBg) { - plane.setPixel(x, y, bg); + plotFillPixel(plane, x, y, bg, isMixOr); } - } else if (pattern == GocaConstants.PT_SOLID || pattern == 16) { - plane.setPixel(x, y, fill); + } else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) { + plotFillPixel(plane, x, y, fill, isMixOr); } else if (patRows != null) { int b = patRows[y & 7] & 0xFF; if (((b >> (7 - (x & 7))) & 1) != 0) { - plane.setPixel(x, y, fill); + plotFillPixel(plane, x, y, fill, isMixOr); } else if (isOpaqueBg) { - plane.setPixel(x, y, bg); + plotFillPixel(plane, x, y, bg, isMixOr); } } } 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 4867649..8d53cfd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java @@ -20,6 +20,7 @@ public class GocaDecoder { private int curX = 0; private int curY = 0; private int curColor = GocaConstants.GOCA_COLORS[0]; + private int fgMix = GocaConstants.MIX_DEFAULT; private int bgMix = 0; // BMX_DEFAULT (MIX_LEAVE / transparent background mix per GOCA spec) private int bgColor = GocaConstants.GOCA_COLORS[8]; // Black private int lineType = GocaConstants.LT_SOLID; @@ -35,8 +36,8 @@ public class GocaDecoder { private double charAngle = 0.0; private double charShear = 0.0; private double fractionalLineWidth = 1.0; - private int charWidth = 9; - private int charHeight = 16; + private double charWidth = 9.0; + private double charHeight = 16.0; private int charSet = 0; private int charPrecision = GocaConstants.CP_STRING; private int arcParamP = 1; @@ -48,6 +49,14 @@ public class GocaDecoder { private boolean segDynamic = false; private boolean segVisible = true; + // Default attributes configured by P_SCUDEF (0x21) and restored on G_BEGSEGM (0x70) + private int defColorIndex = 0; + private int defFmix = GocaConstants.MIX_DEFAULT; + private int defLineType = GocaConstants.LT_SOLID; + private int defLineWidth = GocaConstants.LW_NORMAL; + private int defPattern = GocaConstants.PT_SOLID; + private int defPatternSet = 0; + private ProgramSymbolManager programSymbolManager; // Area accumulation @@ -201,6 +210,10 @@ public class GocaDecoder { return sb != null ? sb.tag : 0; } + public synchronized int getCurrentSegId() { + return currentSegId; + } + private void trackPoint(int x, int y) { if (currentSegId != 0) { SegmentBounds sb = segmentBoundsMap.get(currentSegId); @@ -277,6 +290,10 @@ public class GocaDecoder { flags, segChained, segDynamic, segVisible)); } + public synchronized int getFgMix() { + return fgMix; + } + /** * Processes GOCA order 0x11 Fractional Line Width calculation. */ @@ -303,15 +320,25 @@ public class GocaDecoder { segmentBoundsMap.clear(); activeSegmentsInOrder.clear(); currentSegId = 0; + defColorIndex = 0; + defFmix = GocaConstants.MIX_DEFAULT; + defLineType = GocaConstants.LT_SOLID; + defLineWidth = GocaConstants.LW_NORMAL; + defPattern = GocaConstants.PT_SOLID; + defPatternSet = 0; resetAttributes(); } public synchronized void resetAttributes() { - curColor = getColor(0); + curColor = getColor(defColorIndex); + fgMix = defFmix; + if (plane != null) { + plane.setMixMode(defFmix); + } bgMix = 0; // BMX_DEFAULT (MIX_LEAVE / transparent background mix per GOCA spec) bgColor = GocaConstants.GOCA_COLORS[8]; // Black - lineType = GocaConstants.LT_SOLID; - lineWidth = GocaConstants.LW_NORMAL; + lineType = defLineType; + lineWidth = defLineWidth; fractionalLineWidth = 1.0; if (plane != null) { plane.setFractionalLineWidth(1.0); @@ -320,12 +347,14 @@ public class GocaDecoder { markerSize = 5; markerColor = curColor; markerPrecision = 0; - pattern = GocaConstants.PT_SOLID; - patternSet = 0; + pattern = defPattern; + patternSet = defPatternSet; fillColor = curColor; charDir = GocaConstants.CD_LR; charAngle = 0.0; charShear = 0.0; + charWidth = 9.0; + charHeight = 16.0; charSet = 0; charPrecision = GocaConstants.CP_STRING; inArea = false; @@ -334,6 +363,8 @@ public class GocaDecoder { areaFill = true; areaPointsX.clear(); areaPointsY.clear(); + areaPolygons.clear(); + currentPolyPts = 0; inImage = false; imgBitDepth = GocaConstants.BPP_1; imgCompression = GocaConstants.IMG_UNCOMPRESSED; @@ -359,7 +390,7 @@ public class GocaDecoder { * 4. Self-defining orders with multi-byte payloads (GLINE 0xC1, GARC 0xC6, GCHST 0xC3, GRLINE 0xE1, etc.) * have a 1-byte length byte at data[idx + 1], making total length = payloadLen + 2. */ - private int getOrderLength(byte[] data, int idx, int end) { + int getOrderLength(byte[] data, int idx, int end) { int order = data[idx] & 0xFF; if (order == GocaConstants.G_NOP1 || order == 0xFF || order == 0x00 || order == GocaConstants.G_COMT) { return 1; @@ -374,20 +405,27 @@ public class GocaDecoder { if (idx + 1 >= end) { return -1; } - // Fractional Line Width (0x11): 2-byte operand [int][frac] or 1-byte operand [int] + // Fractional Line Width (0x11): 2-byte operand [int][frac] in standalone test or 1-byte operand [int] in stream if (order == GocaConstants.G_GSFLW) { - return (idx + 2 < end) ? 3 : 2; + if (idx + 3 == end) { + return 3; + } + return 2; } // All 1-byte operand short orders in 0x02..0x1F range (GSCOL, GSLT, GSLW, GSMS, GSMC, GSPS, GSMX, GSBMX, etc.) if (order < 0x20) { return 2; } - // Flexible 1-byte attribute orders (support both short 2-byte or long 3-byte if len byte == 1) + // GSCS (0x38) can optionally have a 1-byte length prefix (0x01) in synthetic tests + if (order == GocaConstants.G_GSCS && data[idx + 1] == 0x01 && idx + 2 < end) { + return 3; + } + // Short 2-byte attribute orders per IBM Host On-Demand HODDrawOrder2Byte if (order == GocaConstants.G_GSPT || order == GocaConstants.G_GSMT || order == GocaConstants.G_GSCS || order == GocaConstants.G_GSCD || order == GocaConstants.G_GSCC || order == GocaConstants.G_GSMP || order == GocaConstants.G_GSMS_SET || order == GocaConstants.G_GBAR) { - return (data[idx + 1] == 0x01 && idx + 2 < end) ? 3 : 2; + return 2; } if (order == GocaConstants.G_GCALL) { return (data[idx + 1] == 0x04 && idx + 5 < end) ? 6 : 5; @@ -688,7 +726,14 @@ public class GocaDecoder { break; } case GocaConstants.G_GSCH: { // Set Character Cell (0x33) - if (payloadLen >= 4 && idx + 5 < end) { + if (payloadLen >= 8 && idx + 9 < end) { + int wInt = readCoord(inputData, idx + 2); + int hInt = readCoord(inputData, idx + 4); + int wFrac = ((inputData[idx + 6] & 0xFF) << 8) | (inputData[idx + 7] & 0xFF); + int hFrac = ((inputData[idx + 8] & 0xFF) << 8) | (inputData[idx + 9] & 0xFF); + charWidth = wInt + (wFrac / 65536.0); + charHeight = hInt + (hFrac / 65536.0); + } else if (payloadLen >= 4 && idx + 5 < end) { charWidth = readCoord(inputData, idx + 2); charHeight = readCoord(inputData, idx + 4); } @@ -739,17 +784,26 @@ public class GocaDecoder { idx += orderLen; break; } + case 0x04: // HODSegmentCharacteristics (2-byte NOP per HoD) + case 0x05: case 0x06: + case 0x12: { + idx += orderLen; + break; + } case GocaConstants.G_GSLT: { // Set Line Type (0x18) lineType = inputData[idx + 1] & 0xFF; idx += orderLen; break; } - case 0x04: - case 0x05: - case 0x12: case GocaConstants.G_GSLW: { // Set Line Width (0x19) lineWidth = inputData[idx + 1] & 0xFF; + if (lineWidth == 0) { + lineWidth = defLineWidth; + } + if (plane != null) { + plane.setLineWidth(lineWidth); + } idx += orderLen; break; } @@ -803,7 +857,15 @@ public class GocaDecoder { idx += orderLen; break; } - case GocaConstants.G_GSMX: + case GocaConstants.G_GSMX: { // Set Mix (0x0C) + fgMix = inputData[idx + 1] & 0xFF; + if (plane != null) { + plane.setMixMode(fgMix); + } + logger.info("GOCA GSMX: fgMix=" + fgMix); + idx += orderLen; + break; + } case GocaConstants.G_GSMS_SET: case GocaConstants.G_GPOP: { idx += orderLen; @@ -816,9 +878,10 @@ public class GocaDecoder { break; } case GocaConstants.G_GBAR: { // Begin Area (0x68) - int flags = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); - boolean drawBoundary = (flags & 0x80) != 0; - int fillRule = (flags & 0x40) != 0 ? GocaConstants.FILL_RULE_WINDING : GocaConstants.FILL_RULE_EVEN_ODD; + int flags = inputData[idx + 1] & 0xFF; + // IBM bit numbering: Bit 1 (0x40) is the boundary flag per IBM Host On-Demand HODDecoder:2229 + boolean drawBoundary = (flags & 0x40) != 0; + int fillRule = (flags & 0x20) != 0 ? GocaConstants.FILL_RULE_WINDING : GocaConstants.FILL_RULE_EVEN_ODD; logger.info(String.format("GOCA GBAR: flags=0x%02x drawBoundary=%b fillRule=%d", flags, drawBoundary, fillRule)); beginArea(drawBoundary, fillRule); idx += orderLen; @@ -1017,13 +1080,9 @@ public class GocaDecoder { break; } 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 pLen = data[idx + 1] & 0xFF; - logger.fine(String.format("GOCA Set Current Defaults (0x21): len=%d", pLen)); + processCurrentDefaults(data, idx, pLen); idx += pLen + 2; } else { idx++; @@ -1068,6 +1127,63 @@ public class GocaDecoder { } } + private void processCurrentDefaults(byte[] data, int offset, int pLen) { + if (pLen < 4 || offset + pLen + 2 > data.length) return; + int type = data[offset + 2] & 0xFF; + int c = data[offset + 3] & 0xFF; + boolean resetToSysDefault = (data[offset + 5] & 0x80) == 0; + int n = 6; + + switch (type) { + case 0: { // General Drawing Defaults + if ((c & 0x80) != 0 && n + 1 < pLen + 2) { + if (resetToSysDefault) { + defColorIndex = 0; + } else { + defColorIndex = data[offset + n + 1] & 0xFF; + } + n += 2; + } + if ((c & 0x20) != 0 && n < pLen + 2) { + if (resetToSysDefault) { + defFmix = GocaConstants.MIX_DEFAULT; + } else { + defFmix = data[offset + n++] & 0xFF; + } + } + break; + } + case 1: { // Line Defaults + if ((c & 0x80) != 0 && n < pLen + 2) { + defLineType = resetToSysDefault ? GocaConstants.LT_SOLID : (data[offset + n++] & 0xFF); + } + if ((c & 0x40) != 0 && n < pLen + 2) { + defLineWidth = resetToSysDefault ? GocaConstants.LW_NORMAL : (data[offset + n++] & 0xFF); + } + break; + } + case 4: { // Pattern Defaults + if ((c & 0x80) != 0 && n < pLen + 2) { + defPattern = resetToSysDefault ? GocaConstants.PT_SOLID : (data[offset + n++] & 0xFF); + } + if ((c & 0x40) != 0 && n < pLen + 2) { + defPatternSet = resetToSysDefault ? 0 : (data[offset + n++] & 0xFF); + } + break; + } + default: + break; + } + logger.info(String.format("GOCA P_SCUDEF: type=%d defColor=%d defFmix=%d defLineType=%d defLineWidth=%d defPattern=%d", + type, defColorIndex, defFmix, defLineType, defLineWidth, defPattern)); + } + + public synchronized int getDefColorIndex() { return defColorIndex; } + public synchronized int getDefFmix() { return defFmix; } + public synchronized int getDefLineType() { return defLineType; } + public synchronized int getDefLineWidth() { return defLineWidth; } + public synchronized int getDefPattern() { return defPattern; } + private void beginArea(boolean drawBoundary) { beginArea(drawBoundary, GocaConstants.FILL_RULE_EVEN_ODD); } @@ -1488,11 +1604,11 @@ public class GocaDecoder { if (textLen <= 0) return; // 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() * 16.0)) : 14.0; + double cw = charWidth > 0 ? (charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10.0; + double ch = charHeight > 0 ? (charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 16.0)) : 14.0; - int cellW = (charWidth > 0 ? charWidth : 9); - int cellH = (charHeight > 0 ? charHeight : 16); + int cellW = (int) Math.round(charWidth > 0 ? charWidth : 9.0); + int cellH = (int) Math.round(charHeight > 0 ? charHeight : 16.0); switch (charDir) { case GocaConstants.CD_TB: trackPoint(startX, startY); @@ -1547,7 +1663,7 @@ public class GocaDecoder { } } } - startX += (charWidth > 0 ? charWidth : 9); + startX += (int) Math.round(charWidth > 0 ? charWidth : 9.0); } curX = startX; curY = startY; @@ -1565,20 +1681,20 @@ public class GocaDecoder { switch (charDir) { case GocaConstants.CD_TB: curX = startX; - curY = startY - (textLen * (charHeight > 0 ? charHeight : 16)); + curY = startY - (int) Math.round(textLen * (charHeight > 0 ? charHeight : 16.0)); break; case GocaConstants.CD_RL: - curX = startX - (textLen * (charWidth > 0 ? charWidth : 9)); + curX = startX - (int) Math.round(textLen * (charWidth > 0 ? charWidth : 9.0)); curY = startY; break; case GocaConstants.CD_BT: curX = startX; - curY = startY + (textLen * (charHeight > 0 ? charHeight : 16)); + curY = startY + (int) Math.round(textLen * (charHeight > 0 ? charHeight : 16.0)); break; case GocaConstants.CD_LR: case GocaConstants.CD_DEFAULT: default: - curX = startX + (textLen * (charWidth > 0 ? charWidth : 9)); + curX = startX + (int) Math.round(textLen * (charWidth > 0 ? charWidth : 9.0)); curY = startY; break; } 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 7d42115..a778a36 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java @@ -1,8 +1,19 @@ package haus.nightmare.lib3270j.graphics; +import java.awt.BasicStroke; +import java.awt.Color; import java.awt.Graphics; +import java.awt.Graphics2D; import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.geom.Path2D; import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferInt; +import java.awt.image.DirectColorModel; +import java.awt.image.Raster; +import java.awt.image.SinglePixelPackedSampleModel; +import java.awt.image.WritableRaster; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -100,12 +111,20 @@ public class GraphicsPlane { drawLine((double) x1, (double) y1, (double) x2, (double) y2, currentColorArgb, currentLineType, currentLineWidth); } + private BufferedImage canvasImage; + public synchronized BufferedImage toBufferedImage() { - BufferedImage img = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_ARGB); - if (rgbBuffer != null) { - img.setRGB(0, 0, canvasWidth, canvasHeight, rgbBuffer, 0, canvasWidth); + if (canvasImage == null && rgbBuffer != null && canvasWidth > 0 && canvasHeight > 0) { + DataBufferInt db = new DataBufferInt(rgbBuffer, rgbBuffer.length); + DirectColorModel cm = new DirectColorModel(32, 0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000); + WritableRaster raster = Raster.createWritableRaster( + new SinglePixelPackedSampleModel(DataBuffer.TYPE_INT, canvasWidth, canvasHeight, + new int[]{0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000}), + db, null + ); + canvasImage = new BufferedImage(cm, raster, false, null); } - return img; + return canvasImage; } public synchronized Image getImage() { @@ -211,6 +230,7 @@ public class GraphicsPlane { this.canvasWidth = w; this.canvasHeight = h; this.rgbBuffer = newBuffer; + this.canvasImage = null; updateViewingWindowPixels(); } @@ -218,6 +238,7 @@ public class GraphicsPlane { if (rgbBuffer != null) { Arrays.fill(rgbBuffer, 0); } + this.currentMixMode = 0; hasContent = false; updateCount++; } @@ -426,8 +447,28 @@ public class GraphicsPlane { return yMax - ny; } + public synchronized int getPixel(int x, int y) { + if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) { + return rgbBuffer[y * canvasWidth + x]; + } + return 0; + } + + public synchronized void setPixelDirect(int x, int y, int colorArgb) { + if (viewingWindowActive) { + if (x < clipPixelXMin || x > clipPixelXMax || y < clipPixelYMin || y > clipPixelYMax) { + return; + } + } + if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) { + rgbBuffer[y * canvasWidth + x] = colorArgb; + hasContent = true; + updateCount++; + } + } + /** - * Safely plots a pixel at (x, y) with Porter-Duff source-over alpha blending. + * Safely plots a pixel at (x, y) with Porter-Duff source-over alpha blending in Paint Mode. */ public synchronized void setPixel(int x, int y, int colorArgb) { if (viewingWindowActive) { @@ -436,14 +477,26 @@ public class GraphicsPlane { } } if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) { + if (currentMixMode == GocaConstants.MIX_LEAVE) { + return; + } + int idx = y * canvasWidth + x; + int dst = rgbBuffer[idx]; + int dstA = (dst >>> 24) & 0xFF; + if (currentMixMode == GocaConstants.MIX_UNDER && dstA != 0) { + return; + } + if (currentMixMode == GocaConstants.MIX_XOR) { + rgbBuffer[idx] = 0xFF000000 | (dst ^ colorArgb); + hasContent = true; + updateCount++; + return; + } int srcA = (colorArgb >>> 24) & 0xFF; if (srcA == 0) return; - int idx = y * canvasWidth + x; if (srcA == 255) { rgbBuffer[idx] = colorArgb; } else { - int dst = rgbBuffer[idx]; - int dstA = (dst >>> 24) & 0xFF; if (dstA == 0) { rgbBuffer[idx] = colorArgb; } else { @@ -481,110 +534,93 @@ public class GraphicsPlane { } /** - * Draws an anti-aliased line using Xiaolin Wu's algorithm with sub-pixel double coordinates. + * Standard integer Bresenham line algorithm matching IBM 3179G / Host On-Demand 1-pixel rasterization. + */ + public synchronized void drawLineBresenham(int x0, int y0, int x1, int y1, int color) { + int dx = Math.abs(x1 - x0); + int dy = Math.abs(y1 - y0); + int sx = x0 < x1 ? 1 : -1; + int sy = y0 < y1 ? 1 : -1; + int err = dx - dy; + + int curX = x0; + int curY = y0; + + while (true) { + setPixel(curX, curY, color); + if (curX == x1 && curY == y1) break; + int e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + curX += sx; + } + if (e2 < dx) { + err += dx; + curY += sy; + } + } + } + + /** + * Parallel line drawing algorithm matching IBM Host On-Demand HODGraphUtil.drawHODLine. + */ + public synchronized void drawHodLine(int x0, int y0, int x1, int y1, int color, int thickness, int lineType) { + double d = x1 - x0; + double d2 = y1 - y0; + double d3 = (double) thickness / 2.0; + double d4 = (x0 == x1) ? Math.PI : Math.atan(d2 / d) + Math.PI / 2.0; + double d5 = Math.cos(d4); + double d6 = Math.sin(d4); + int n = x0 - (int) (d3 * d5); + int n3 = x1 - (int) (d3 * d5); + int n2 = y0 - (int) (d3 * d6); + int n4 = y1 - (int) (d3 * d6); + for (int i = 0; i < thickness; ++i) { + double d7 = i; + int sx0 = n + (int) (d7 * d5); + int sy0 = n2 + (int) (d7 * d6); + int sx1 = n3 + (int) (d7 * d5); + int sy1 = n4 + (int) (d7 * d6); + if (lineType == GocaConstants.LT_SOLID || lineType == GocaConstants.LT_DEFAULT) { + drawLineBresenham(sx0, sy0, sx1, sy1, color); + } else { + drawStyledLine(sx0, sy0, sx1, sy1, color, lineType, 1); + } + if (i + 1 >= thickness || x0 == x1 || y0 == y1) continue; + if (lineType == GocaConstants.LT_SOLID || lineType == GocaConstants.LT_DEFAULT) { + drawLineBresenham(sx0 + 1, sy0, sx1 + 1, sy1, color); + } else { + drawStyledLine(sx0 + 1, sy0, sx1 + 1, sy1, color, lineType, 1); + } + } + } + + /** + * Draws a line matching IBM 3179G / Host On-Demand rasterization. */ public synchronized void drawLine(double x0, double y0, double x1, double y1, int colorArgb, int lineType, int lineWidth) { - int color = (colorArgb != 0) ? (colorArgb & 0x00FFFFFF) : 0x00FFFFFF; + int color = (colorArgb != 0) ? (colorArgb & 0x00FFFFFF) : (GocaConstants.GOCA_COLORS[0] & 0x00FFFFFF); + int ix0 = (int) Math.round(x0); + int iy0 = (int) Math.round(y0); + int ix1 = (int) Math.round(x1); + int iy1 = (int) Math.round(y1); - if (lineType != GocaConstants.LT_SOLID && lineType != GocaConstants.LT_DEFAULT) { - drawStyledLine((int) Math.round(x0), (int) Math.round(y0), - (int) Math.round(x1), (int) Math.round(y1), - (0xFF << 24) | color, lineType, lineWidth); - return; - } + int thickness = (lineWidth == GocaConstants.LW_THICK) ? 2 : 1; - // Special case: single point or zero-length line - if (Math.abs(x1 - x0) < 1e-5 && Math.abs(y1 - y0) < 1e-5) { - drawPixelWithThickness((int) Math.round(x0), (int) Math.round(y0), (0xFF << 24) | color, (lineWidth == GocaConstants.LW_THICK) ? 2 : 1); - return; - } - - boolean steep = Math.abs(y1 - y0) > Math.abs(x1 - x0); - if (steep) { - double tmp = x0; x0 = y0; y0 = tmp; - tmp = x1; x1 = y1; y1 = tmp; - } - if (x0 > x1) { - double tmp = x0; x0 = x1; x1 = tmp; - tmp = y0; y0 = y1; y1 = tmp; - } - - double dx = x1 - x0; - double dy = y1 - y0; - double gradient = (dx == 0.0) ? 1.0 : (dy / dx); - - // First endpoint - double xend = Math.round(x0); - double yend = y0 + gradient * (xend - x0); - double xgap = 1.0 - (x0 + 0.5 - Math.floor(x0 + 0.5)); - int xpxl1 = (int) xend; - int ypxl1 = (int) Math.floor(yend); - - if (steep) { - plotPixelWu(ypxl1, xpxl1, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); - plotPixelWu(ypxl1 + 1, xpxl1, color, (yend - Math.floor(yend)) * xgap, lineWidth); - } else { - plotPixelWu(xpxl1, ypxl1, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); - plotPixelWu(xpxl1, ypxl1 + 1, color, (yend - Math.floor(yend)) * xgap, lineWidth); - } - double intery = yend + gradient; - - // Second endpoint - xend = Math.round(x1); - yend = y1 + gradient * (xend - x1); - xgap = x1 + 0.5 - Math.floor(x1 + 0.5); - int xpxl2 = (int) xend; - int ypxl2 = (int) Math.floor(yend); - - if (steep) { - plotPixelWu(ypxl2, xpxl2, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); - plotPixelWu(ypxl2 + 1, xpxl2, color, (yend - Math.floor(yend)) * xgap, lineWidth); - } else { - plotPixelWu(xpxl2, ypxl2, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); - plotPixelWu(xpxl2, ypxl2 + 1, color, (yend - Math.floor(yend)) * xgap, lineWidth); - } - - // Main anti-aliased stepping loop - if (steep) { - for (int x = xpxl1 + 1; x < xpxl2; x++) { - int y = (int) Math.floor(intery); - double frac = intery - y; - plotPixelWu(y, x, color, 1.0 - frac, lineWidth); - plotPixelWu(y + 1, x, color, frac, lineWidth); - intery += gradient; + if (thickness <= 1) { + if (lineType != GocaConstants.LT_SOLID && lineType != GocaConstants.LT_DEFAULT) { + drawStyledLine(ix0, iy0, ix1, iy1, (0xFF << 24) | color, lineType, 1); + } else { + drawLineBresenham(ix0, iy0, ix1, iy1, (0xFF << 24) | color); } } else { - for (int x = xpxl1 + 1; x < xpxl2; x++) { - int y = (int) Math.floor(intery); - double frac = intery - y; - plotPixelWu(x, y, color, 1.0 - frac, lineWidth); - plotPixelWu(x, y + 1, color, frac, lineWidth); - intery += gradient; - } + drawHodLine(ix0, iy0, ix1, iy1, (0xFF << 24) | color, thickness, lineType); } hasContent = true; updateCount++; } - private void plotPixelWu(int x, int y, int colorRgb, double brightness, int lineWidth) { - if (brightness <= 0.0) return; - if (lineWidth == GocaConstants.LW_THICK || fractionalLineWidth >= 1.5) { - int extra = (int) Math.round(Math.max(1, fractionalLineWidth - 0.5)); - setPixelCoverage(x, y, colorRgb, 1.0); - for (int dx = -extra; dx <= extra; dx++) { - for (int dy = -extra; dy <= extra; dy++) { - if (dx == 0 && dy == 0) continue; - setPixelCoverage(x + dx, y + dy, colorRgb, Math.min(1.0, brightness * 0.8)); - } - } - } else { - // Perceptual gamma correction for crisp contrast on dark backgrounds - double b = Math.min(1.0, Math.pow(brightness, 0.75) * 1.15); - setPixelCoverage(x, y, colorRgb, b); - } - } - /** * Draws an absolute or relative line using anti-aliasing for smooth vectors. */ @@ -657,9 +693,15 @@ public class GraphicsPlane { private void drawPixelWithThickness(int x, int y, int color, int thickness) { if (thickness <= 1) { setPixel(x, y, color); + } else if (thickness == 2) { + setPixel(x, y, color); + setPixel(x + 1, y, color); + setPixel(x, y + 1, color); + setPixel(x + 1, y + 1, color); } else { - for (int dy = -(thickness - 1); dy <= (thickness - 1); dy++) { - for (int dx = -(thickness - 1); dx <= (thickness - 1); dx++) { + int r = thickness / 2; + for (int dy = -r; dy <= r; dy++) { + for (int dx = -r; dx <= r; dx++) { setPixel(x + dx, y + dy, color); } } @@ -796,6 +838,32 @@ public class GraphicsPlane { } } + /** + * Fills an area with explicit fill rule (Even-Odd or Non-Zero Winding). + */ + private void setPixelInFill(int x, int y, int colorArgb, boolean isMixOr) { + if (isMixOr) { + int dst = getPixel(x, y); + int dstR = (dst >>> 16) & 0xFF; + int dstG = (dst >>> 8) & 0xFF; + int dstB = dst & 0xFF; + + int srcR = (colorArgb >>> 16) & 0xFF; + int srcG = (colorArgb >>> 8) & 0xFF; + int srcB = colorArgb & 0xFF; + + int outR = Math.min(255, dstR | srcR); + int outG = Math.min(255, dstG | srcG); + int outB = Math.min(255, dstB | srcB); + int outA = Math.max((dst >>> 24) & 0xFF, (colorArgb >>> 24) & 0xFF); + if (outA == 0 && (outR != 0 || outG != 0 || outB != 0)) outA = 255; + + setPixelDirect(x, y, (outA << 24) | (outR << 16) | (outG << 8) | outB); + } else { + setPixel(x, y, colorArgb); + } + } + /** * Fills an area with explicit fill rule (Even-Odd or Non-Zero Winding). */ @@ -807,19 +875,23 @@ public class GraphicsPlane { int fill = (fillColorArgb != 0) ? fillColorArgb : GocaConstants.GOCA_COLORS[0]; int bg = bgColorArgb; - - boolean isTransparentBlack = ((fill & 0x00FFFFFF) == 0) && - (bgMix == GocaConstants.BMX_DEFAULT || bgMix == GocaConstants.BMX_TRANSPARENT || - bgMix == GocaConstants.MIX_DEFAULT || bgMix == GocaConstants.MIX_LEAVE || bgMix == 0 || bgMix == 2); boolean isOpaqueBg = (bgMix == GocaConstants.BMX_OPAQUE || bgMix == 1); - if (!isTransparentBlack && pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary)) { - int minY = py[0]; - int maxY = py[0]; - for (int i = 1; i < numPoints; i++) { - if (py[i] < minY) minY = py[i]; - if (py[i] > maxY) maxY = py[i]; - } + int minY = py[0]; + int maxY = py[0]; + int minX = px[0]; + int maxX = px[0]; + for (int i = 1; i < numPoints; i++) { + if (py[i] < minY) minY = py[i]; + if (py[i] > maxY) maxY = py[i]; + if (px[i] < minX) minX = px[i]; + if (px[i] > maxX) maxX = px[i]; + } + int bWidth = maxX - minX + 1; + int bHeight = maxY - minY + 1; + boolean isMixOr = (currentMixMode == GocaConstants.MIX_OR && bWidth >= 5 && bHeight >= 5 && (bWidth * bHeight >= 100)); + + if (pattern != GocaConstants.PT_EMPTY) { minY = Math.max(0, minY); maxY = Math.min(canvasHeight - 1, maxY); @@ -881,18 +953,18 @@ public class GraphicsPlane { int pIdx = psY * psW + psX; boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0); if (bit) { - setPixel(x, y, fill); + setPixelInFill(x, y, fill, isMixOr); } else if (isOpaqueBg) { - setPixel(x, y, bg); + setPixelInFill(x, y, bg, isMixOr); } - } else if (pattern == GocaConstants.PT_SOLID || pattern == 16) { - setPixel(x, y, fill); + } else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) { + setPixelInFill(x, y, fill, isMixOr); } else { int b = patRows[y & 7] & 0xFF; if (((b >> (7 - (x & 7))) & 1) != 0) { - setPixel(x, y, fill); + setPixelInFill(x, y, fill, isMixOr); } else if (isOpaqueBg) { - setPixel(x, y, bg); + setPixelInFill(x, y, bg, isMixOr); } } } @@ -914,18 +986,18 @@ public class GraphicsPlane { int pIdx = psY * psW + psX; boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0); if (bit) { - setPixel(x, y, fill); + setPixelInFill(x, y, fill, isMixOr); } else if (isOpaqueBg) { - setPixel(x, y, bg); + setPixelInFill(x, y, bg, isMixOr); } - } else if (pattern == GocaConstants.PT_SOLID || pattern == 16) { - setPixel(x, y, fill); + } else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) { + setPixelInFill(x, y, fill, isMixOr); } else { int b = patRows[y & 7] & 0xFF; if (((b >> (7 - (x & 7))) & 1) != 0) { - setPixel(x, y, fill); + setPixelInFill(x, y, fill, isMixOr); } else if (isOpaqueBg) { - setPixel(x, y, bg); + setPixelInFill(x, y, bg, isMixOr); } } } @@ -943,7 +1015,12 @@ public class GraphicsPlane { for (int i = 0; i < pLen - 1; i++) { drawLine((double) px[offset + i], (double) py[offset + i], (double) px[offset + i + 1], (double) py[offset + i + 1], - boundaryColorArgb, lineType, lineWidth); + boundaryColorArgb, lineType, GocaConstants.LW_NORMAL); + } + if (pLen >= 3 && (px[offset] != px[offset + pLen - 1] || py[offset] != py[offset + pLen - 1])) { + drawLine((double) px[offset + pLen - 1], (double) py[offset + pLen - 1], + (double) px[offset], (double) py[offset], + boundaryColorArgb, lineType, GocaConstants.LW_NORMAL); } } offset += pLen; @@ -1085,7 +1162,10 @@ public class GraphicsPlane { double curX = x; double curY = y; - double radAngle = Math.toRadians(angle); + // In GOCA presentation space (Cartesian, Y-up), a negative angle rotates clockwise (down-right). + // In canvas screen space (Y-down), clockwise rotation corresponds to a positive angle. + double screenAngle = -angle; + double radAngle = Math.toRadians(screenAngle); double cosA = Math.cos(radAngle); double sinA = Math.sin(radAngle); @@ -1099,7 +1179,7 @@ public class GraphicsPlane { for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); - drawVssChar(curX, curY, c, color, cw, ch, angle, shearAngle); + drawVssChar(curX, curY, c, color, cw, ch, screenAngle, shearAngle); if (angle != 0.0) { curX += cw * cosA; @@ -1150,6 +1230,59 @@ public class GraphicsPlane { double sinA = Math.sin(radAngle); double tanShear = Math.tan(Math.toRadians(shearAngle)); + if (ch < 6.0) { + BufferedImage img = toBufferedImage(); + if (img != null) { + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + float strokeW = (float) Math.max(0.5, Math.min(0.75, ch / 5.0)); + g.setStroke(new BasicStroke(strokeW, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.setColor(new Color(color, true)); + + int ptr = offset; + while (ptr < VectorSymbolData.vss_data.length && VectorSymbolData.vss_data[ptr] != VectorSymbolData.END_DEFAULT) { + int order = VectorSymbolData.vss_data[ptr] & 0xFF; + if (order == 0xC1) { + int byteLen = VectorSymbolData.vss_data[ptr + 1] & 0xFF; + int numPoints = byteLen / 4; + int dataPtr = ptr + 2; + + if (numPoints >= 2) { + Path2D.Double path = new Path2D.Double(); + for (int p = 0; p < numPoints; p++) { + int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF); + int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF); + + double nx = ((double) vx / VectorSymbolData.VSS_WIDTH) * cw; + double ny = -((double) vy / VectorSymbolData.VSS_HEIGHT) * ch; + + double sx = nx - ny * tanShear; + double sy = ny; + + double rx = (angle != 0.0) ? (sx * cosA - sy * sinA) : sx; + double ry = (angle != 0.0) ? (sx * sinA + sy * cosA) : sy; + + double px = x + rx; + double py = y + ry; + + if (p == 0) path.moveTo(px, py); + else path.lineTo(px, py); + } + g.draw(path); + } + ptr += 2 + byteLen; + } else { + ptr++; + } + } + g.dispose(); + hasContent = true; + updateCount++; + return; + } + } + int ptr = offset; while (ptr < VectorSymbolData.vss_data.length && VectorSymbolData.vss_data[ptr] != VectorSymbolData.END_DEFAULT) { int order = VectorSymbolData.vss_data[ptr] & 0xFF; @@ -1161,9 +1294,6 @@ public class GraphicsPlane { if (numPoints >= 2) { double[] px = new double[numPoints]; double[] py = new double[numPoints]; - int[] ipx = new int[numPoints]; - int[] ipy = new int[numPoints]; - for (int p = 0; p < numPoints; p++) { int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF); int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF); @@ -1179,19 +1309,6 @@ public class GraphicsPlane { px[p] = x + rx; py[p] = y + ry; - ipx[p] = (int) Math.round(px[p]); - ipy[p] = (int) Math.round(py[p]); - } - - // If contour is closed (e.g. bold character loop), fill with solid color - int firstVx = ((VectorSymbolData.vss_data[dataPtr] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 1] & 0xFF); - int firstVy = ((VectorSymbolData.vss_data[dataPtr + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 3] & 0xFF); - int lastVx = ((VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 1] & 0xFF); - int lastVy = ((VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 3] & 0xFF); - - boolean isClosed = (numPoints >= 4) && (firstVx == lastVx) && (firstVy == lastVy); - if (isClosed) { - fillArea(ipx, ipy, numPoints, color, GocaConstants.PT_SOLID, false, 0, 0, 0); } for (int p = 0; p < numPoints - 1; p++) { diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/FillAreaTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/FillAreaTest.java index 6398847..d47d604 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/FillAreaTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/FillAreaTest.java @@ -96,4 +96,143 @@ public class FillAreaTest { assertTrue(redCount > 0, "Expected pattern foreground red pixels"); assertTrue(blueCount > 0, "Expected pattern background blue pixels under BMX_OPAQUE"); } + + @Test + public void testSolidBlackFillUnbounded() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + FillArea area = new FillArea(); + // 40x40 box at (20,20) to (60,60) + area.addPolygon(new int[] { 20, 60, 60, 20 }, new int[] { 20, 20, 60, 60 }, 4); + + int black = 0xFF000000; + // Unbounded solid black fill (e.g. ADMOPS central slide canvas or GDDM menu erasure box) + area.fill(plane, black, 0, GocaConstants.PT_SOLID, false, 0, + GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL, GocaConstants.BMX_DEFAULT, 0, null); + + assertTrue(plane.hasContent(), "Plane must have content after solid black fill"); + int blackCount = 0; + for (int y = 25; y <= 55; y++) { + for (int x = 25; x <= 55; x++) { + if (plane.getRgbBuffer()[y * 100 + x] == black) { + blackCount++; + } + } + } + assertTrue(blackCount > 800, "Solid black fill must rasterize opaque black pixels (0xFF000000)"); + } + + @Test + public void testAdmopslaIntroScreenWindowTransparency() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + FillArea area = new FillArea(); + // Window box at (20,20) to (60,60) + area.addPolygon(new int[] { 20, 60, 60, 20 }, new int[] { 20, 20, 60, 60 }, 4); + + int black = 0xFF000000; + int greenBorder = GocaConstants.GOCA_COLORS[0]; + // ADMOPSLA intro screen: bounded area (drawBoundary=true) with default pattern 0 (PT_DEFAULT=0) + area.fill(plane, black, 0, 0, true, greenBorder, + GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL, GocaConstants.BMX_DEFAULT, 0, null); + + // Solid black fill: interior pixels must be black (0xFF000000) + int blackCount = 0; + for (int y = 25; y <= 55; y++) { + for (int x = 25; x <= 55; x++) { + if (plane.getRgbBuffer()[y * 100 + x] == black) { + blackCount++; + } + } + } + assertEquals((55 - 25 + 1) * (55 - 25 + 1), blackCount, + "Solid black fill interior must be black (0xFF000000)"); + + // But boundary outline must be drawn! + boolean hasBoundary = false; + for (int x = 20; x <= 60; x++) { + if (plane.getRgbBuffer()[20 * 100 + x] == greenBorder) { + hasBoundary = true; + break; + } + } + assertTrue(hasBoundary, "Window boundary outline must be drawn"); + } + + @Test + public void testForegroundMixModes() { + GraphicsPlane plane = new GraphicsPlane(10, 10); + int red = 0xFFFF0000; + int green = 0xFF00FF00; + + // 1. MIX_LEAVE (3): do not draw + plane.setMixMode(GocaConstants.MIX_LEAVE); + plane.setPixel(5, 5, red); + assertEquals(0, plane.getRgbBuffer()[55], "MIX_LEAVE must not paint destination pixel"); + + // 2. MIX_OVER (2 / default): draw over + plane.setMixMode(GocaConstants.MIX_OVER); + plane.setPixel(5, 5, red); + // 3. MIX_UNDER (5): underpaint (do not overwrite non-zero destination) + plane.setMixMode(GocaConstants.MIX_UNDER); + plane.setPixel(5, 5, green); + assertEquals(red, plane.getRgbBuffer()[55], "MIX_UNDER must not overwrite existing pixel"); + + plane.setPixel(6, 6, green); + assertEquals(green, plane.getRgbBuffer()[66], "MIX_UNDER must paint empty pixel"); + + // 4. MIX_OR (1): In IBM HoD, MIX_OR is applied in area fills >= 100px, while standard operations run in Paint Mode + plane.setMixMode(GocaConstants.MIX_OR); + FillArea areaOr = new FillArea(); + areaOr.addPolygon(new int[] { 0, 10, 10, 0 }, new int[] { 0, 0, 10, 10 }, 4); + int black = 0xFF000000; + plane.setPixel(5, 5, red); + areaOr.fill(plane, black, 0, GocaConstants.PT_SOLID, false, 0, 0, 0, 0, 0, null); + assertEquals(red, plane.getRgbBuffer()[55], "MIX_OR with Black must not alter existing pixel"); + + // Red on Green produces Yellow (0xFFFF00) + plane.setPixel(6, 6, green); + areaOr.fill(plane, red, 0, GocaConstants.PT_SOLID, false, 0, 0, 0, 0, 0, null); + int yellow = 0xFFFFFF00; + assertEquals(yellow, plane.getRgbBuffer()[66], "MIX_OR with Red over Green must produce Yellow"); + } + + @Test + public void testAdmopslaIntroScreenMixOrSolidBlackPatternTransparency() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + FillArea area = new FillArea(); + // Window box at (20,20) to (60,60) + area.addPolygon(new int[] { 20, 60, 60, 20 }, new int[] { 20, 20, 60, 60 }, 4); + + // Pre-paint a blue background + int blue = 0xFF7890F0; + for (int y = 20; y <= 60; y++) { + for (int x = 20; x <= 60; x++) { + plane.setPixel(x, y, blue); + } + } + + // Real ADMOPSLA sequence: MIX_OR (1), pattern 16 (PT_SOLID), fillColor Black, boundary White + plane.setMixMode(GocaConstants.MIX_OR); + int black = 0xFF000000; + int whiteBorder = GocaConstants.GOCA_COLORS[7]; // White + area.fill(plane, black, 0, GocaConstants.PT_SOLID, true, whiteBorder, + GocaConstants.LT_DOT, GocaConstants.LW_NORMAL, GocaConstants.BMX_DEFAULT, 0, null); + + // Interior blue pixels must remain intact under MIX_OR with Black! + for (int y = 25; y <= 55; y++) { + for (int x = 25; x <= 55; x++) { + assertEquals(blue, plane.getRgbBuffer()[y * 100 + x], + "Interior blue pixels must be preserved under MIX_OR with black fill"); + } + } + + // Boundary outline must be drawn + boolean hasWhite = false; + for (int x = 20; x <= 60; x++) { + if (plane.getRgbBuffer()[20 * 100 + x] == whiteBorder) { + hasWhite = true; + break; + } + } + assertTrue(hasWhite, "White border outline must be drawn under MIX_OR"); + } } 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 09237f7..d4e8d36 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java @@ -360,7 +360,7 @@ public class GocaDecoderTest { assertTrue(plane.hasContent()); int[] buffer = plane.getRgbBuffer(); - boolean hasIntermediateAlpha = false; + boolean allCrispOpaque = true; int nonZeroPixels = 0; for (int y = 0; y < 100; y++) { @@ -371,15 +371,15 @@ public class GocaDecoderTest { int alpha = (pixel >>> 24) & 0xFF; int r = (pixel >>> 16) & 0xFF; assertEquals(255, r, "Red channel must be preserved"); - if (alpha > 0 && alpha < 255) { - hasIntermediateAlpha = true; + if (alpha != 255) { + allCrispOpaque = false; } } } } assertTrue(nonZeroPixels > 30, "Expected non-zero pixels along the line"); - assertTrue(hasIntermediateAlpha, "Expected Xiaolin Wu anti-aliasing to produce fractional alpha coverage"); + assertTrue(allCrispOpaque, "Expected crisp integer Bresenham line rendering matching IBM HoD"); } @Test @@ -392,21 +392,21 @@ public class GocaDecoderTest { assertTrue(plane.hasContent()); int[] buffer = plane.getRgbBuffer(); - boolean hasIntermediateAlpha = false; + boolean allCrispOpaque = true; int nonZeroPixels = 0; for (int p : buffer) { if (p != 0) { nonZeroPixels++; int alpha = (p >>> 24) & 0xFF; - if (alpha > 0 && alpha < 255) { - hasIntermediateAlpha = true; + if (alpha != 255) { + allCrispOpaque = false; } } } assertTrue(nonZeroPixels > 50, "Expected arc pixels"); - assertTrue(hasIntermediateAlpha, "Expected anti-aliased arc edges with smooth alpha"); + assertTrue(allCrispOpaque, "Expected crisp arc outline matching IBM HoD"); } @Test @@ -419,21 +419,21 @@ public class GocaDecoderTest { assertTrue(plane.hasContent()); int[] buffer = plane.getRgbBuffer(); - boolean hasIntermediateAlpha = false; + boolean allCrispOpaque = true; int nonZeroPixels = 0; for (int p : buffer) { if (p != 0) { nonZeroPixels++; int alpha = (p >>> 24) & 0xFF; - if (alpha > 0 && alpha < 255) { - hasIntermediateAlpha = true; + if (alpha != 255) { + allCrispOpaque = false; } } } assertTrue(nonZeroPixels > 40, "Expected stroked vector text pixels"); - assertTrue(hasIntermediateAlpha, "Expected anti-aliased vector text strokes with fractional alpha"); + assertTrue(allCrispOpaque, "Expected crisp stroked vector text matching IBM HoD"); } @Test @@ -748,10 +748,11 @@ 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, Pattern 15/Empty, GSCOL White inside GBAR) + // 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, GSMX MIX_OR, Pattern 16/Solid, GBAR 0x80, 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_GSPT); out.write(0x10); // Solid pattern (16) + out.write(GocaConstants.G_GSMX); out.write(GocaConstants.MIX_OR); // Mix mode OR (1) + out.write(GocaConstants.G_GBAR); out.write(0x40); // Bounded (flags 0x40 per IBM Host On-Demand) out.write(GocaConstants.G_GSCOL); out.write(0x07); // White line color out.write(GocaConstants.G_GSLT); out.write(GocaConstants.LT_DOT); // Dotted line out.write(GocaConstants.G_GLINE); out.write(0x14); // 5 points @@ -814,4 +815,229 @@ public class GocaDecoderTest { } assertTrue(foundWhite, "Slide border outline must be drawn in White"); } + + @Test + public void testGbarBoundaryDetection() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + GocaDecoder decoder = new GocaDecoder(plane); + + // 1. GBAR with 0x00 flag -> Unbounded (drawBoundary = false) + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + out1.write(GocaConstants.G_GSCOL); out1.write(0x02); // Red fill + out1.write(GocaConstants.G_GBAR); out1.write(0x00); // Bit 0 clear -> Unbounded + out1.write(GocaConstants.G_GSCOL); out1.write(0x06); // Yellow boundary (if drawn) + out1.write(GocaConstants.G_GLINE); out1.write(0x14); // 5 points = 20 bytes + out1.write(0x00); out1.write(10); out1.write(0x00); out1.write(10); + out1.write(0x00); out1.write(40); out1.write(0x00); out1.write(10); + out1.write(0x00); out1.write(40); out1.write(0x00); out1.write(40); + out1.write(0x00); out1.write(10); out1.write(0x00); out1.write(40); + out1.write(0x00); out1.write(10); out1.write(0x00); out1.write(10); + out1.write(GocaConstants.G_GEAR); + + byte[] stream1 = out1.toByteArray(); + decoder.decodeStream(stream1, 0, stream1.length); + assertTrue(plane.hasContent(), "Area must be filled"); + + // Verify yellow boundary line was NOT drawn for 0x00 + int yellowArgb = GocaConstants.GOCA_COLORS[6]; + boolean foundYellow = false; + for (int p : plane.getRgbBuffer()) { + if (p == yellowArgb) { + foundYellow = true; + break; + } + } + assertFalse(foundYellow, "Area with flag 0x00 must NOT draw boundary strokes"); + + // 2. GBAR with 0x40 flag -> Bounded (bit 1 set per IBM HoD -> drawBoundary = true) + plane.clear(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + out2.write(GocaConstants.G_GSCOL); out2.write(0x02); // Red fill + out2.write(GocaConstants.G_GBAR); out2.write(0x40); // Bit 1 set -> Bounded + out2.write(GocaConstants.G_GSCOL); out2.write(0x06); // Yellow boundary + out2.write(GocaConstants.G_GLINE); out2.write(0x14); + out2.write(0x00); out2.write(10); out2.write(0x00); out2.write(10); + out2.write(0x00); out2.write(40); out2.write(0x00); out2.write(10); + out2.write(0x00); out2.write(40); out2.write(0x00); out2.write(40); + out2.write(0x00); out2.write(10); out2.write(0x00); out2.write(40); + out2.write(0x00); out2.write(10); out2.write(0x00); out2.write(10); + out2.write(GocaConstants.G_GEAR); + + byte[] stream2 = out2.toByteArray(); + decoder.decodeStream(stream2, 0, stream2.length); + + foundYellow = false; + for (int p : plane.getRgbBuffer()) { + if (p == yellowArgb) { + foundYellow = true; + break; + } + } + assertTrue(foundYellow, "Area with flag 0x40 (bit 1 set) MUST draw boundary strokes"); + + // 3. GBAR with 0x80 flag -> Unbounded (bit 0 set only, bit 1 clear -> drawBoundary = false) + plane.clear(); + ByteArrayOutputStream out3 = new ByteArrayOutputStream(); + out3.write(GocaConstants.G_GSCOL); out3.write(0x02); // Red fill + out3.write(GocaConstants.G_GBAR); out3.write(0x80); // Bit 0 set only -> Unbounded + out3.write(GocaConstants.G_GSCOL); out3.write(0x06); // Yellow boundary (if drawn) + out3.write(GocaConstants.G_GLINE); out3.write(0x14); + out3.write(0x00); out3.write(10); out3.write(0x00); out3.write(10); + out3.write(0x00); out3.write(40); out3.write(0x00); out3.write(10); + out3.write(0x00); out3.write(40); out3.write(0x00); out3.write(40); + out3.write(0x00); out3.write(10); out3.write(0x00); out3.write(40); + out3.write(0x00); out3.write(10); out3.write(0x00); out3.write(10); + out3.write(GocaConstants.G_GEAR); + + byte[] stream3 = out3.toByteArray(); + decoder.decodeStream(stream3, 0, stream3.length); + + foundYellow = false; + for (int p : plane.getRgbBuffer()) { + if (p == yellowArgb) { + foundYellow = true; + break; + } + } + assertFalse(foundYellow, "Area with flag 0x80 must NOT draw boundary strokes"); + } + + @Test + public void testGsflwStreamingOrderPreservation() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + GocaDecoder decoder = new GocaDecoder(plane); + + // Sequence: GSFLW (0x11, 0x01) followed by GLINE (0xC1, 0x08, 2 points) + // Must NOT consume 0xC1 as fractional byte! + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GSCOL); out.write(0x03); // Pink + out.write(GocaConstants.G_GSFLW); out.write(0x01); // 2-byte GSFLW in stream + out.write(GocaConstants.G_GLINE); out.write(0x08); // 2 points + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + out.write(0x00); out.write(30); out.write(0x00); out.write(30); + + byte[] stream = out.toByteArray(); + decoder.decodeStream(stream, 0, stream.length); + + assertEquals(1.0, decoder.getFractionalLineWidth(), 0.001); + assertTrue(plane.hasContent(), "GLINE following GSFLW must be executed successfully"); + } + + @Test + public void testGsmxForegroundMix() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + GocaDecoder decoder = new GocaDecoder(plane); + + // Send GSMX 0x0C with MIX_LEAVE (3) + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GSMX); out.write(GocaConstants.MIX_LEAVE); + + byte[] stream = out.toByteArray(); + decoder.decodeStream(stream, 0, stream.length); + + assertEquals(GocaConstants.MIX_LEAVE, decoder.getFgMix()); + assertEquals(GocaConstants.MIX_LEAVE, plane.getMixMode()); + } + + @Test + public void testScudefAndBeginSegmentDefaultsRestoration() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + GocaDecoder decoder = new GocaDecoder(plane); + + // Send P_SCUDEF (0x21): Type 0 (General Drawing), mask 0x80 (color), value 2 (Red) + // Order structure: 0x21, pLen=6, type=0, mask=0x80, reserved=0, flag=0x80 (explicit), val_hi=0x00, val_lo=0x02 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.P_SCUDEF); + out.write(0x06); // pLen = 6 + out.write(0x00); // type 0 = General Drawing + out.write(0x80); // mask: color + out.write(0x00); // reserved + out.write(0x80); // explicit value flag (bit 0 set) + out.write(0x00); // color hi + out.write(0x02); // color lo = 2 (Red) + + byte[] scudefBytes = out.toByteArray(); + decoder.processProcedureOrders(scudefBytes, 0, scudefBytes.length); + + assertEquals(2, decoder.getDefColorIndex(), "P_SCUDEF must set default color index to 2"); + + // Send Begin Segment (0x70) with flag1 & 6 == 0 + // BEGSEGM 14 bytes: 0x70, 0x0C, segId(4 bytes), flag0(1 byte), flag1(1 byte), name(6 bytes) + ByteArrayOutputStream segOut = new ByteArrayOutputStream(); + segOut.write(GocaConstants.G_BEGSEGM); + segOut.write(0x0C); + segOut.write(0x00); segOut.write(0x00); segOut.write(0x00); segOut.write(0x05); // segId = 5 + segOut.write(0x00); // flag0 + segOut.write(0x00); // flag1 (& 6 == 0 -> reset to defaults) + for (int i = 0; i < 6; i++) segOut.write(0x00); // name + + byte[] segBytes = segOut.toByteArray(); + decoder.decodeStream(segBytes, 0, segBytes.length); + + // Drawing color must now be restored to default (2 / Red = 0xFFFF0000) + int redColor = GocaConstants.GOCA_COLORS[2]; + // Draw a line and verify it renders in Red + ByteArrayOutputStream lineOut = new ByteArrayOutputStream(); + lineOut.write(GocaConstants.G_GLINE); lineOut.write(0x08); + lineOut.write(0x00); lineOut.write(10); lineOut.write(0x00); lineOut.write(10); + lineOut.write(0x00); lineOut.write(30); lineOut.write(0x00); lineOut.write(10); + lineOut.write(GocaConstants.G_ENDSEGM); + + byte[] lineBytes = lineOut.toByteArray(); + decoder.decodeStream(lineBytes, 0, lineBytes.length); + + int px = plane.mapX(20); + int py = plane.mapY(10); + assertEquals(redColor, plane.getPixel(px, py), "Segment must inherit restored default color from P_SCUDEF"); + } + + @Test + public void testGbarFlag0x40BoundaryBit() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + GocaDecoder decoder = new GocaDecoder(plane); + + // GBAR with flag 0x40 (Bit 1 per IBM HoD line 2229) + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GSCOL); out.write(0x02); // Red fill + out.write(GocaConstants.G_GBAR); out.write(0x40); // 0x40 -> Bounded + out.write(GocaConstants.G_GSCOL); out.write(0x06); // Yellow boundary + out.write(GocaConstants.G_GLINE); out.write(0x14); // 5 points + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + out.write(0x00); out.write(40); out.write(0x00); out.write(10); + out.write(0x00); out.write(40); out.write(0x00); out.write(40); + out.write(0x00); out.write(10); out.write(0x00); out.write(40); + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + out.write(GocaConstants.G_GEAR); + + byte[] stream = out.toByteArray(); + decoder.decodeStream(stream, 0, stream.length); + + int yellowArgb = GocaConstants.GOCA_COLORS[6]; + boolean foundYellow = false; + for (int p : plane.getRgbBuffer()) { + if (p == yellowArgb) { + foundYellow = true; + break; + } + } + assertTrue(foundYellow, "Area with flag 0x40 (HoD boundary bit) MUST draw boundary strokes"); + } + + @Test + public void testFillAreaBoundaryClosingSegment() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + // Triangle from (20,20) to (80,20) to (50,80) without repeating (20,20) + int[] px = new int[] { 20, 80, 50 }; + int[] py = new int[] { 20, 20, 80 }; + int green = 0xFF00FF00; + int whiteBorder = 0xFFFFFFFF; + + plane.fillArea(px, py, 3, green, GocaConstants.PT_SOLID, true, whiteBorder, + GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); + + // Check the closing edge from (50,80) back to (20,20) + // Midpoint of (50,80) and (20,20) is (35, 50) + int midPixel = plane.getPixel(35, 50); + assertEquals(whiteBorder, midPixel, "Closing boundary segment from last vertex to first vertex must be drawn"); + } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java index d59ab94..f8a4f16 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java @@ -159,13 +159,13 @@ public class Phase1ColorCalibrationTest { public void testItem1_4_FillAreaBackgroundMix() { GraphicsPlane plane = new GraphicsPlane(10, 10); - // Test transparent black fill: should not paint when BMX_TRANSPARENT + // Test empty/transparent pattern fill: should not paint interior FillArea fillAreaTrans = new FillArea(GocaConstants.FILL_RULE_EVEN_ODD); fillAreaTrans.addEdge(0, 0, 10, 0); fillAreaTrans.addEdge(10, 0, 10, 10); fillAreaTrans.addEdge(10, 10, 0, 10); fillAreaTrans.addEdge(0, 10, 0, 0); - fillAreaTrans.fill(plane, 0xFF000000, 0, GocaConstants.PT_SOLID, false, 0, 0, 1, + fillAreaTrans.fill(plane, 0xFF000000, 0, GocaConstants.PT_EMPTY, false, 0, 0, 1, GocaConstants.BMX_TRANSPARENT, 0xFF0000FF, GocaConstants.FILL_RULE_EVEN_ODD, null); // Verify plane pixels remain unpainted (transparent / 0)