13 Commits

Author SHA1 Message Date
rudi 8608babb36 Hotspot detection debugging android specific
Build and Test a3270 / Build Android APK (push) Successful in 3m34s
Release a3270 / Build & Publish Release (push) Successful in 3m18s
2026-10-02 14:23:58 +00:00
rudi 1f98c0d70f Hotspot detection debugging
Build and Test a3270 / Build Android APK (push) Successful in 1m59s
Release a3270 / Build & Publish Release (push) Successful in 5m16s
2026-10-02 13:55:18 +00:00
rudi 3843e0fd13 Fix misinterpreted if statement
Build and Test a3270 / Build Android APK (push) Successful in 5m37s
2026-09-28 04:18:51 +00:00
rudi 4754732642 Update rendering pipeline
Build and Test a3270 / Build Android APK (push) Successful in 1m59s
2026-09-28 02:17:46 +00:00
rudi 79ce0a7dc6 Bring up to date with Connection exporting and file transfer updates
Build and Test a3270 / Build Android APK (push) Successful in 5m37s
Release a3270 / Build & Publish Release (push) Successful in 1m46s
2026-09-24 12:47:50 +00:00
rudi fe185bd1f0 Bring up to date with Connection exporting and file transfer updates 2026-09-24 12:46:55 +00:00
rudi ee44759668 Update Status Bar, Settings, Terminal alignment and codepage testing
Build and Test a3270 / Build Android APK (push) Successful in 1m58s
2026-09-22 13:41:10 +00:00
rudi 69ebe6bfb5 merge
Release a3270 / Build & Publish Release (push) Successful in 4m43s
Build and Test a3270 / Build Android APK (push) Successful in 5m42s
2026-09-18 18:49:09 +00:00
rudi 63d14bd2d9 Hotspot detection and screen buffer 2026-09-18 18:47:39 +00:00
rudi 377626013d Bump version to 1.2.1
Build and Test a3270 / Build Android APK (push) Successful in 5m35s
Release a3270 / Build & Publish Release (push) Successful in 4m35s
2026-09-16 17:17:29 -04:00
rudi bf5eefd190 Fix the builder
Build and Test a3270 / Build Android APK (push) Successful in 1m55s
Release a3270 / Build & Publish Release (push) Successful in 4m42s
2026-09-15 23:49:48 +00:00
rudi c941fd3a2a Add new settings from j3270
Build and Test a3270 / Build Android APK (push) Failing after 26s
2026-09-15 23:40:22 +00:00
rudi b71f0f5ba4 Add icon
Build and Test a3270 / Build Android APK (push) Successful in 2m22s
2026-09-14 12:24:07 +00:00
36 changed files with 5019 additions and 215 deletions
+2
View File
@@ -36,6 +36,8 @@ jobs:
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: ''
- name: Make scripts executable - name: Make scripts executable
run: | run: |
+2
View File
@@ -34,6 +34,8 @@ jobs:
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: ''
- name: Make scripts executable - name: Make scripts executable
run: | run: |
+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="124 738 320 320"
width="320"
height="320"
xml:space="preserve"
id="svg_a3270_icon"
version="1.1">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
<rect x="124" y="738" width="320" height="320" fill="#000000" />
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
<g transform="scale(0.1)" id="g12">
<g id="g14">
<!-- Green Terminal Screen Frame -->
<path
id="path28"
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
id="path30"
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
id="path32"
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
id="path34"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- 'l' -->
<path
id="path36"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<path
id="path38"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
id="path40"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<path
id="path42"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
id="path44"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<path
id="path46"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
id="path48"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<path
id="path50"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
id="path52"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<path
id="path54"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
id="path56"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<path
id="path58"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
id="path60"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<path
id="path62"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
id="path64"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<path
id="path66"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smile -->
<path
id="path68"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye (cyan) -->
<path
id="path70"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye (cyan) -->
<path
id="path72"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
id="path74"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2892.03,1528.23 -29.21,-20.68" />
<path
id="path76"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2862.82,1507.55 h 32.86" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="144 814 280 169"
width="280"
height="169"
xml:space="preserve"
id="svg_lib3270j_cropped"
version="1.1">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
<rect x="144" y="814" width="280" height="169" fill="#000000" />
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
<g transform="scale(0.1)" id="g12">
<g id="g14">
<!-- Green Terminal Screen Frame -->
<path
id="path28"
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
id="path30"
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
id="path32"
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
id="path34"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- 'l' -->
<path
id="path36"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<path
id="path38"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
id="path40"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<path
id="path42"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
id="path44"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<path
id="path46"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
id="path48"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<path
id="path50"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
id="path52"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<path
id="path54"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
id="path56"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<path
id="path58"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
id="path60"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<path
id="path62"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
id="path64"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<path
id="path66"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smile -->
<path
id="path68"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye (cyan) -->
<path
id="path70"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye (cyan) -->
<path
id="path72"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
id="path74"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2892.03,1528.23 -29.21,-20.68" />
<path
id="path76"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2862.82,1507.55 h 32.86" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 6 versionCode 9
versionName "1.0.1" versionName "1.2.4"
} }
buildTypes { buildTypes {
+15 -1
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="9"
android:versionName="1.2.4">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -9,6 +11,8 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="a3270" android:label="a3270"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"> android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
@@ -33,6 +37,16 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="3270 Terminal TCP socket connection persistence" /> android:value="3270 Terminal TCP socket connection persistence" />
</service> </service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="124 738 320 320"
width="320"
height="320"
xml:space="preserve"
id="svg_a3270_icon"
version="1.1">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
<rect x="124" y="738" width="320" height="320" fill="#000000" />
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
<g transform="scale(0.1)" id="g12">
<g id="g14">
<!-- Green Terminal Screen Frame -->
<path
id="path28"
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
id="path30"
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
id="path32"
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
id="path34"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- 'l' -->
<path
id="path36"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<path
id="path38"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
id="path40"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<path
id="path42"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
id="path44"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<path
id="path46"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
id="path48"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<path
id="path50"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
id="path52"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<path
id="path54"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
id="path56"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<path
id="path58"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
id="path60"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<path
id="path62"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
id="path64"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<path
id="path66"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smile -->
<path
id="path68"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye (cyan) -->
<path
id="path70"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye (cyan) -->
<path
id="path72"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
id="path74"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2892.03,1528.23 -29.21,-20.68" />
<path
id="path76"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2862.82,1507.55 h 32.86" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

@@ -22,11 +22,15 @@ import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.KeyCharacterMap import android.view.KeyCharacterMap
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.ui.ConnectChoice
import haus.nightmare.a3270.ui.ConnectChoiceDialog
import haus.nightmare.a3270.ui.ConnectDialog import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar import haus.nightmare.a3270.ui.OiaStatusBar
@@ -39,7 +43,8 @@ import haus.nightmare.a3270.ui.FindDialog
import haus.nightmare.a3270.ui.ScreenExporterDialog import haus.nightmare.a3270.ui.ScreenExporterDialog
import haus.nightmare.a3270.ui.ScriptDialog import haus.nightmare.a3270.ui.ScriptDialog
import haus.nightmare.a3270.ui.PrinterSessionDialog import haus.nightmare.a3270.ui.PrinterSessionDialog
import haus.nightmare.a3270.ui.ScreenHistoryDialog
import haus.nightmare.a3270.ui.MacroDialog
import haus.nightmare.a3270.ui.UntrustedCertificateDialog import haus.nightmare.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -528,21 +533,58 @@ fun MainScreen(
val isCrosshairRuler by viewModel.isCrosshairRuler.collectAsState() val isCrosshairRuler by viewModel.isCrosshairRuler.collectAsState()
val cursorStyle by viewModel.cursorStyle.collectAsState() val cursorStyle by viewModel.cursorStyle.collectAsState()
val blockSelectMode by viewModel.blockSelectMode.collectAsState() val blockSelectMode by viewModel.blockSelectMode.collectAsState()
val blockPaste by viewModel.blockPaste.collectAsState()
val uiTheme by viewModel.uiTheme.collectAsState() val uiTheme by viewModel.uiTheme.collectAsState()
val isInsertMode by viewModel.isInsertMode.collectAsState() val isInsertMode by viewModel.isInsertMode.collectAsState()
val isDocMode by viewModel.isDocMode.collectAsState() val isDocMode by viewModel.isDocMode.collectAsState()
val isWordWrap by viewModel.isWordWrap.collectAsState() val isWordWrap by viewModel.isWordWrap.collectAsState()
val isAplMode by viewModel.isAplMode.collectAsState() val isAplMode by viewModel.isAplMode.collectAsState()
val fourColorOverride by viewModel.fourColorOverride.collectAsState() val fourColorOverride by viewModel.fourColorOverride.collectAsState()
val trimRectSizingHandles by viewModel.trimRectSizingHandles.collectAsState()
val trimRectRemainAfterEdit by viewModel.trimRectRemainAfterEdit.collectAsState()
val pasteWordBreak by viewModel.pasteWordBreak.collectAsState()
val hotspotsEnabled by viewModel.hotspotsEnabled.collectAsState()
val hotspotsUrlEnabled by viewModel.hotspotsUrlEnabled.collectAsState()
val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState()
val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState()
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) { val isDbcsSession = remember(screenBuffer, screenVersion) {
if (screenBuffer != null && screenBuffer?.isFormatted == true) { screenBuffer?.translator?.isDBCS == true
val fa = screenBuffer?.getFieldAttributeAt(cursorAddr) ?: 0.toByte() }
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
} else false val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val cell = buf.getDisplayCell(cursorAddr)
val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT ||
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
if (isDbcs) {
"DBCS"
} else {
val fa = buf.getFieldAttributeAt(cursorAddr)
if ((fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0) {
"NUM"
} else {
"ALPHA"
}
}
} else {
"ALPHA"
}
}
val isNumericField = remember(fieldStatus) {
fieldStatus == "NUM"
}
val activity = context as? ComponentActivity
val openConnectFromIntent = remember {
activity?.intent?.getBooleanExtra("OPEN_CONNECT_DIALOG", false) == true
} }
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
var showConnectChoiceDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) } var showSettingsDialog by remember { mutableStateOf(false) }
var showFieldInspectorDialog by remember { mutableStateOf(false) } var showFieldInspectorDialog by remember { mutableStateOf(false) }
@@ -550,6 +592,8 @@ fun MainScreen(
var showScreenExportDialog by remember { mutableStateOf(false) } var showScreenExportDialog by remember { mutableStateOf(false) }
var showScriptDialog by remember { mutableStateOf(false) } var showScriptDialog by remember { mutableStateOf(false) }
var showPrinterSessionDialog by remember { mutableStateOf(false) } var showPrinterSessionDialog by remember { mutableStateOf(false) }
var showScreenHistoryDialog by remember { mutableStateOf(false) }
var showMacroDialog by remember { mutableStateOf(false) }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) } var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
@@ -585,30 +629,17 @@ fun MainScreen(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (openConnectFromIntent) {
activity?.intent?.removeExtra("OPEN_CONNECT_DIALOG")
showConnectDialog = true
return@LaunchedEffect
}
val startupBehavior = AppSettings.getStartupBehavior(context) val startupBehavior = AppSettings.getStartupBehavior(context)
when (startupBehavior) { when (startupBehavior) {
AppSettings.StartupBehavior.AUTO_CONNECT -> { AppSettings.StartupBehavior.AUTO_CONNECT -> {
val autoHost = HostStorage.getAutoConnectHost(context) val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) { if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect( viewModel.connect(autoHost.toConnectionConfig())
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.dynamicRows,
autoHost.dynamicCols,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode,
autoHost.codePage,
autoHost.proxyType,
autoHost.proxyHost,
autoHost.proxyPort,
autoHost.proxyUsername,
autoHost.proxyPassword
)
} }
} }
AppSettings.StartupBehavior.SHOW_CONNECT -> { AppSettings.StartupBehavior.SHOW_CONNECT -> {
@@ -660,6 +691,25 @@ fun MainScreen(
cursorStyle = cursorStyle, cursorStyle = cursorStyle,
isInsertMode = isInsertMode, isInsertMode = isInsertMode,
blockSelectMode = blockSelectMode, blockSelectMode = blockSelectMode,
blockPaste = blockPaste,
onToggleBlockSelectMode = { viewModel.setBlockSelectMode(!blockSelectMode) },
onToggleBlockPaste = { viewModel.setBlockPaste(!blockPaste) },
trimRectSizingHandles = trimRectSizingHandles,
trimRectRemainAfterEdit = trimRectRemainAfterEdit,
hotspotsEnabled = hotspotsEnabled,
hotspotEngine = viewModel.hotspotEngine,
onSendHotspotAction = { action -> viewModel.sendKeys(action) },
onOpenUrl = { url ->
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open URL: ${e.message}", Toast.LENGTH_SHORT).show()
}
},
onToggleHotspots = { viewModel.toggleHotspots() },
fourColorOverride = fourColorOverride, fourColorOverride = fourColorOverride,
onPasteLineWrap = { text, wrapCol, wordWrap -> onPasteLineWrap = { text, wrapCol, wordWrap ->
viewModel.pasteLineWrap(text, wrapCol, wordWrap) viewModel.pasteLineWrap(text, wrapCol, wordWrap)
@@ -685,6 +735,7 @@ fun MainScreen(
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
TerminalInputView(ctx).apply { TerminalInputView(ctx).apply {
this.isDbcsSession = isDbcsSession
onInputText = { text -> onInputText = { text ->
viewModel.typeString(text) viewModel.typeString(text)
onClearShift() onClearShift()
@@ -706,6 +757,7 @@ fun MainScreen(
} }
}, },
update = { inputView -> update = { inputView ->
inputView.isDbcsSession = isDbcsSession
terminalInputViewRef = inputView terminalInputViewRef = inputView
}, },
modifier = Modifier.size(1.dp) modifier = Modifier.size(1.dp)
@@ -725,7 +777,11 @@ fun MainScreen(
onClearShift = onClearShift, onClearShift = onClearShift,
onConnectClick = { onConnectClick = {
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showConnectDialog = true if (connectionState.isConnected()) {
showConnectChoiceDialog = true
} else {
showConnectDialog = true
}
}, },
onDisconnectClick = { viewModel.disconnect() }, onDisconnectClick = { viewModel.disconnect() },
onFtClick = { onFtClick = {
@@ -744,10 +800,18 @@ fun MainScreen(
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showScreenExportDialog = true showScreenExportDialog = true
}, },
onScreenHistoryClick = {
terminalInputViewRef?.hideSoftKeyboard()
showScreenHistoryDialog = true
},
onScriptClick = { onScriptClick = {
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showScriptDialog = true showScriptDialog = true
}, },
onMacroRuntimeClick = {
terminalInputViewRef?.hideSoftKeyboard()
showMacroDialog = true
},
onPrinterSessionClick = { onPrinterSessionClick = {
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showPrinterSessionDialog = true showPrinterSessionDialog = true
@@ -840,6 +904,7 @@ fun MainScreen(
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0, inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "", luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
isNumericField = isNumericField, isNumericField = isNumericField,
fieldStatus = fieldStatus,
uiTheme = uiTheme, uiTheme = uiTheme,
onToggleDocMode = { viewModel.toggleDocMode() }, onToggleDocMode = { viewModel.toggleDocMode() },
onToggleWordWrap = { viewModel.toggleWordWrap() }, onToggleWordWrap = { viewModel.toggleWordWrap() },
@@ -849,15 +914,65 @@ fun MainScreen(
} }
} }
if (showConnectChoiceDialog) {
ConnectChoiceDialog(
currentTarget = viewModel.getConnectedTarget(),
onChoice = { choice ->
showConnectChoiceDialog = false
when (choice) {
ConnectChoice.NEW_WINDOW -> {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
putExtra("OPEN_CONNECT_DIALOG", true)
}
context.startActivity(intent)
}
ConnectChoice.ABORT_CURRENT -> {
viewModel.disconnect()
showConnectDialog = true
}
ConnectChoice.CANCEL -> {
terminalInputViewRef?.showSoftKeyboard()
}
}
},
onDismiss = {
showConnectChoiceDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { onDismiss = {
showConnectDialog = false showConnectDialog = false
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass -> onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass, savedConn, savedProfId, savedProfName ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass) viewModel.connect(
host = host,
port = port,
modelNum = model,
dynamicRows = dynamicRows,
dynamicCols = dynamicCols,
luName = luName,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsModeStr = graphicsMode,
codePageStr = cp,
proxyTypeStr = pType,
proxyHost = pHost,
proxyPort = pPort,
proxyUser = pUser,
proxyPass = pPass,
savedConnection = savedConn,
savedProfileId = savedProfId,
savedProfileName = savedProfName
)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -897,6 +1012,14 @@ fun MainScreen(
initialCrosshairRuler = isCrosshairRuler, initialCrosshairRuler = isCrosshairRuler,
initialCursorStyle = cursorStyle, initialCursorStyle = cursorStyle,
initialBlockSelectMode = blockSelectMode, initialBlockSelectMode = blockSelectMode,
initialBlockPaste = blockPaste,
initialTrimRectSizingHandles = trimRectSizingHandles,
initialTrimRectRemainAfterEdit = trimRectRemainAfterEdit,
initialPasteWordBreak = pasteWordBreak,
initialHotspotsEnabled = hotspotsEnabled,
initialHotspotsUrlEnabled = hotspotsUrlEnabled,
initialHotspotsPfEnabled = hotspotsPfEnabled,
initialHotspotsMenuChoicesEnabled = hotspotsMenuChoicesEnabled,
initialStartupBehavior = AppSettings.getStartupBehavior(context).name, initialStartupBehavior = AppSettings.getStartupBehavior(context).name,
initialDynamicRows = AppSettings.getDynamicRows(context), initialDynamicRows = AppSettings.getDynamicRows(context),
initialDynamicCols = AppSettings.getDynamicCols(context), initialDynamicCols = AppSettings.getDynamicCols(context),
@@ -911,18 +1034,28 @@ fun MainScreen(
initialFourColorOverride = fourColorOverride, initialFourColorOverride = fourColorOverride,
initialNumericFieldLock = AppSettings.getNumericFieldLock(context), initialNumericFieldLock = AppSettings.getNumericFieldLock(context),
initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context), initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context),
initialDefaultHostType = AppSettings.getDefaultHostType(context),
onDismiss = { onDismiss = {
showSettingsDialog = false showSettingsDialog = false
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, startup, dynRows, dynCols, onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst,
doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk -> sizingHandles, remainAfterEdit, wordBreak, hsEnabled, hsUrl, hsPf, hsChoices,
startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk, defHostType ->
showSettingsDialog = false showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
viewModel.setUiTheme(theme) viewModel.setUiTheme(theme)
viewModel.setCrosshairRuler(crosshair) viewModel.setCrosshairRuler(crosshair)
viewModel.setCursorStyle(style) viewModel.setCursorStyle(style)
viewModel.setBlockSelectMode(blockSel) viewModel.setBlockSelectMode(blockSel)
viewModel.setBlockPaste(blockPst)
viewModel.setTrimRectSizingHandles(sizingHandles)
viewModel.setTrimRectRemainAfterEdit(remainAfterEdit)
viewModel.setPasteWordBreak(wordBreak)
viewModel.setHotspotsEnabled(hsEnabled)
viewModel.setHotspotsUrlEnabled(hsUrl)
viewModel.setHotspotsPfEnabled(hsPf)
viewModel.setHotspotsMenuChoicesEnabled(hsChoices)
AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup)) AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup))
AppSettings.setDynamicRows(context, dynRows) AppSettings.setDynamicRows(context, dynRows)
AppSettings.setDynamicCols(context, dynCols) AppSettings.setDynamicCols(context, dynCols)
@@ -937,6 +1070,7 @@ fun MainScreen(
AppSettings.setFourColorOverride(context, fourCol) AppSettings.setFourColorOverride(context, fourCol)
AppSettings.setNumericFieldLock(context, numLk) AppSettings.setNumericFieldLock(context, numLk)
AppSettings.setAutoSkipEnabled(context, autoSk) AppSettings.setAutoSkipEnabled(context, autoSk)
AppSettings.setDefaultHostType(context, defHostType)
viewModel.reloadSettings() viewModel.reloadSettings()
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
} }
@@ -1014,6 +1148,26 @@ fun MainScreen(
) )
} }
if (showScreenHistoryDialog) {
ScreenHistoryDialog(
historyBuffer = viewModel.getScreenHistoryBuffer(),
onDismiss = {
showScreenHistoryDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showMacroDialog) {
MacroDialog(
client = viewModel.getClient(),
onDismiss = {
showMacroDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
)
}
val prompt = untrustedCertPrompt val prompt = untrustedCertPrompt
if (prompt != null) { if (prompt != null) {
UntrustedCertificateDialog( UntrustedCertificateDialog(
@@ -20,10 +20,13 @@ import haus.nightmare.lib3270j.listener.ConnectionListener
import haus.nightmare.lib3270j.listener.ScreenUpdateListener import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import haus.nightmare.a3270.ft.FileTransfer import haus.nightmare.a3270.ft.FileTransfer
import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import java.io.File import java.io.File
import java.util.Locale
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
@@ -61,6 +64,8 @@ sealed interface TerminalInputAction {
data class SetAplMode(val enabled: Boolean) : TerminalInputAction data class SetAplMode(val enabled: Boolean) : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction data class LightPenSelect(val baddr: Int) : TerminalInputAction
data class PasteText(val text: String) : TerminalInputAction
data class SendKeysAction(val keys: String) : TerminalInputAction
} }
data class UntrustedCertPromptState( data class UntrustedCertPromptState(
@@ -161,6 +166,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _blockSelectMode = MutableStateFlow(AppSettings.isBlockSelectModeEnabled(application)) private val _blockSelectMode = MutableStateFlow(AppSettings.isBlockSelectModeEnabled(application))
val blockSelectMode: StateFlow<Boolean> = _blockSelectMode.asStateFlow() val blockSelectMode: StateFlow<Boolean> = _blockSelectMode.asStateFlow()
private val _blockPaste = MutableStateFlow(AppSettings.isBlockPasteEnabled(application))
val blockPaste: StateFlow<Boolean> = _blockPaste.asStateFlow()
private val _uiTheme = MutableStateFlow(AppSettings.getUiTheme(application)) private val _uiTheme = MutableStateFlow(AppSettings.getUiTheme(application))
val uiTheme: StateFlow<String> = _uiTheme.asStateFlow() val uiTheme: StateFlow<String> = _uiTheme.asStateFlow()
@@ -179,6 +187,80 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application)) private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application))
val fourColorOverride: StateFlow<Boolean> = _fourColorOverride.asStateFlow() val fourColorOverride: StateFlow<Boolean> = _fourColorOverride.asStateFlow()
private val _hotspotsEnabled = MutableStateFlow(AppSettings.isHotspotsEnabled(application))
val hotspotsEnabled: StateFlow<Boolean> = _hotspotsEnabled.asStateFlow()
private val _hotspotsUrlEnabled = MutableStateFlow(AppSettings.isHotspotsUrlEnabled(application))
val hotspotsUrlEnabled: StateFlow<Boolean> = _hotspotsUrlEnabled.asStateFlow()
private val _hotspotsPfEnabled = MutableStateFlow(AppSettings.isHotspotsPfEnabled(application))
val hotspotsPfEnabled: StateFlow<Boolean> = _hotspotsPfEnabled.asStateFlow()
private val _hotspotsMenuChoicesEnabled = MutableStateFlow(AppSettings.isHotspotsMenuChoicesEnabled(application))
val hotspotsMenuChoicesEnabled: StateFlow<Boolean> = _hotspotsMenuChoicesEnabled.asStateFlow()
private val _trimRectSizingHandles = MutableStateFlow(AppSettings.isTrimRectSizingHandlesEnabled(application))
val trimRectSizingHandles: StateFlow<Boolean> = _trimRectSizingHandles.asStateFlow()
private val _trimRectRemainAfterEdit = MutableStateFlow(AppSettings.isTrimRectRemainAfterEditEnabled(application))
val trimRectRemainAfterEdit: StateFlow<Boolean> = _trimRectRemainAfterEdit.asStateFlow()
private val _pasteWordBreak = MutableStateFlow(AppSettings.isPasteWordBreakEnabled(application))
val pasteWordBreak: StateFlow<Boolean> = _pasteWordBreak.asStateFlow()
val hotspotEngine = HotspotEngine().apply {
urlEnabled = _hotspotsUrlEnabled.value
pfKeysEnabled = _hotspotsPfEnabled.value
menuChoicesEnabled = _hotspotsMenuChoicesEnabled.value
}
fun setHotspotsEnabled(enabled: Boolean) {
AppSettings.setHotspotsEnabled(getApplication(), enabled)
_hotspotsEnabled.value = enabled
}
fun toggleHotspots() {
setHotspotsEnabled(!_hotspotsEnabled.value)
}
fun setHotspotsUrlEnabled(enabled: Boolean) {
AppSettings.setHotspotsUrlEnabled(getApplication(), enabled)
_hotspotsUrlEnabled.value = enabled
hotspotEngine.urlEnabled = enabled
hotspotEngine.invalidateCache()
}
fun setHotspotsPfEnabled(enabled: Boolean) {
AppSettings.setHotspotsPfEnabled(getApplication(), enabled)
_hotspotsPfEnabled.value = enabled
hotspotEngine.pfKeysEnabled = enabled
hotspotEngine.invalidateCache()
}
fun setHotspotsMenuChoicesEnabled(enabled: Boolean) {
AppSettings.setHotspotsMenuChoicesEnabled(getApplication(), enabled)
_hotspotsMenuChoicesEnabled.value = enabled
hotspotEngine.menuChoicesEnabled = enabled
hotspotEngine.invalidateCache()
}
fun setTrimRectSizingHandles(enabled: Boolean) {
AppSettings.setTrimRectSizingHandlesEnabled(getApplication(), enabled)
_trimRectSizingHandles.value = enabled
}
fun setTrimRectRemainAfterEdit(enabled: Boolean) {
AppSettings.setTrimRectRemainAfterEditEnabled(getApplication(), enabled)
_trimRectRemainAfterEdit.value = enabled
}
fun setPasteWordBreak(enabled: Boolean) {
val app = getApplication<Application>()
AppSettings.setPasteWordBreakEnabled(app, enabled)
_pasteWordBreak.value = enabled
applyModeSettings()
}
fun toggleLightPen() { fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value _isLightPenMode.value = !_isLightPenMode.value
} }
@@ -206,6 +288,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_blockSelectMode.value = enabled _blockSelectMode.value = enabled
} }
fun setBlockPaste(enabled: Boolean) {
val app = getApplication<Application>()
AppSettings.setBlockPasteEnabled(app, enabled)
_blockPaste.value = enabled
applyModeSettings()
}
fun toggleBlockPaste() {
setBlockPaste(!_blockPaste.value)
}
fun setUiTheme(theme: String) { fun setUiTheme(theme: String) {
AppSettings.setUiTheme(getApplication(), theme) AppSettings.setUiTheme(getApplication(), theme)
_uiTheme.value = theme _uiTheme.value = theme
@@ -270,7 +363,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
ip.isInsertOffOnAid = AppSettings.getInsertOffOnAid(app) ip.isInsertOffOnAid = AppSettings.getInsertOffOnAid(app)
ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app) ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app)
ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app) ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app)
ip.isBlockPaste = AppSettings.isBlockPasteEnabled(app)
ip.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
} }
c.ps?.isBlockPaste = AppSettings.isBlockPasteEnabled(app)
c.ps?.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
c.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
_fourColorOverride.value = AppSettings.getFourColorOverride(app) _fourColorOverride.value = AppSettings.getFourColorOverride(app)
_isDocMode.value = c.isDocMode _isDocMode.value = c.isDocMode
_isWordWrap.value = c.isWordWrap _isWordWrap.value = c.isWordWrap
@@ -290,8 +388,20 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_isCrosshairRuler.value = AppSettings.isCrosshairRulerEnabled(app) _isCrosshairRuler.value = AppSettings.isCrosshairRulerEnabled(app)
_cursorStyle.value = AppSettings.getCursorStyle(app) _cursorStyle.value = AppSettings.getCursorStyle(app)
_blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app) _blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app)
_blockPaste.value = AppSettings.isBlockPasteEnabled(app)
_uiTheme.value = AppSettings.getUiTheme(app) _uiTheme.value = AppSettings.getUiTheme(app)
_fourColorOverride.value = AppSettings.getFourColorOverride(app) _fourColorOverride.value = AppSettings.getFourColorOverride(app)
_hotspotsEnabled.value = AppSettings.isHotspotsEnabled(app)
_hotspotsUrlEnabled.value = AppSettings.isHotspotsUrlEnabled(app)
_hotspotsPfEnabled.value = AppSettings.isHotspotsPfEnabled(app)
_hotspotsMenuChoicesEnabled.value = AppSettings.isHotspotsMenuChoicesEnabled(app)
hotspotEngine.urlEnabled = _hotspotsUrlEnabled.value
hotspotEngine.pfKeysEnabled = _hotspotsPfEnabled.value
hotspotEngine.menuChoicesEnabled = _hotspotsMenuChoicesEnabled.value
hotspotEngine.invalidateCache()
_trimRectSizingHandles.value = AppSettings.isTrimRectSizingHandlesEnabled(app)
_trimRectRemainAfterEdit.value = AppSettings.isTrimRectRemainAfterEditEnabled(app)
_pasteWordBreak.value = AppSettings.isPasteWordBreakEnabled(app)
applyModeSettings() applyModeSettings()
} }
@@ -333,11 +443,29 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private var client: Telnet3270Client? = null private var client: Telnet3270Client? = null
var currentHost: String = "" var currentHost: String = ""
private set private set
var currentPort: Int = 23
private set
var currentProfileName: String? = null
private set
var activeHostType: String = "TSO" var activeHostType: String = "TSO"
private set private set
fun getConnectedTarget(): String {
val pName = currentProfileName
if (!pName.isNullOrBlank()) return pName
val h = currentHost
val p = currentPort
return if (h.isNotBlank()) "$h:$p" else "the host"
}
fun getClient(): Telnet3270Client? = client fun getClient(): Telnet3270Client? = client
fun getScreenHistoryBuffer(): ScreenHistoryBuffer? = client?.screenBuffer?.historyBuffer
fun sendKeys(keys: String) {
inputChannel.trySend(TerminalInputAction.SendKeysAction(keys))
}
private var lastScreenContentHash: Int = 0 private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false private var hasInitialScreenLoaded: Boolean = false
@@ -353,9 +481,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
val c = client val c = client
if (c != null) { if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress _cursorAddress.value = c.screenBuffer.displayCursorAddress
val snapVersion = c.screenBuffer.snapshotVersion
_screenVersion.value = if (snapVersion > 0) snapVersion else System.currentTimeMillis()
} else {
_screenVersion.value = System.currentTimeMillis()
} }
_screenVersion.value = System.currentTimeMillis()
} }
} }
} }
@@ -391,6 +522,31 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
is TerminalInputAction.PasteText -> {
log.info("PasteText: len=${action.text.length}, curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false
if (isNvt) {
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
c.sendNVTString("\r\n")
} else {
c.sendNVTChar(ch)
}
}
} else {
val app = getApplication<Application>()
val blockPaste = AppSettings.isBlockPasteEnabled(app)
val pasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
ip.pasteText(action.text, true, false, blockPaste, pasteWordBreak)
_screenVersion.value = System.currentTimeMillis()
}
}
is TerminalInputAction.SendKeysAction -> {
log.info("SendKeysAction: keys='${action.keys}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false
c.ps?.SendKeys(action.keys)
_screenVersion.value = System.currentTimeMillis()
}
is TerminalInputAction.SendAid -> { is TerminalInputAction.SendAid -> {
if (isNvt) { if (isNvt) {
if (action.aidCode == AID_ENTER) { if (action.aidCode == AID_ENTER) {
@@ -751,9 +907,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
proxyHost: String = "", proxyHost: String = "",
proxyPort: Int = 0, proxyPort: Int = 0,
proxyUser: String = "", proxyUser: String = "",
proxyPass: String = "" proxyPass: String = "",
savedConnection: Boolean = false,
savedProfileId: String? = null,
savedProfileName: String? = null
) { ) {
currentHost = host currentHost = host
currentPort = port
currentProfileName = savedProfileName
activeHostType = hostType activeHostType = hostType
lastScreenContentHash = 0 lastScreenContentHash = 0
hasInitialScreenLoaded = false hasInitialScreenLoaded = false
@@ -762,6 +923,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_codePage.value = codePageStr _codePage.value = codePageStr
clearSearchHighlight() clearSearchHighlight()
if (savedConnection) {
val lastState = haus.nightmare.a3270.ui.getLastTransferState()
if (lastState != null) {
lastState.hostType = when (hostType.uppercase(Locale.US)) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
}
}
AppSettings.setDefaultHostType(getApplication(), hostType)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Always disconnect any existing client before establishing a new connection // Always disconnect any existing client before establishing a new connection
@@ -799,6 +972,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
(if (effectiveTls) " [TLS]" else "") + "..." (if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply { val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
this.hostType = hostType
this.isSavedConnection = savedConnection
if (savedProfileId != null) this.savedProfileId = savedProfileId
if (savedProfileName != null) this.savedProfileName = savedProfileName
if (model == TerminalModel.IBM_DYNAMIC) { if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols) setDynamicDimensions(dynamicRows, dynamicCols)
} }
@@ -921,16 +1099,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val buf = newClient.screenBuffer val buf = newClient.screenBuffer
_screenBuffer.value = buf _screenBuffer.value = buf
if (buf != null) { if (buf != null) {
_rows.value = buf.rows val snap = buf.displaySnapshot
_cols.value = buf.cols if (snap != null) {
_cursorAddress.value = buf.cursorAddress _rows.value = snap.rows
_cols.value = snap.cols
_cursorAddress.value = snap.cursorAddress
_screenVersion.value = snap.version
} else {
_rows.value = buf.displayRows
_cols.value = buf.displayCols
_cursorAddress.value = buf.displayCursorAddress
_screenVersion.value = buf.snapshotVersion
}
} else {
_screenVersion.value = System.currentTimeMillis()
} }
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_isInsertMode.value = newClient.isInsertMode _isInsertMode.value = newClient.isInsertMode
_isDocMode.value = newClient.isDocMode _isDocMode.value = newClient.isDocMode
_isWordWrap.value = newClient.isWordWrap _isWordWrap.value = newClient.isWordWrap
_isAplMode.value = newClient.isAplMode _isAplMode.value = newClient.isAplMode
_screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active // Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated() fileTransferCoordinator?.onScreenUpdated()
@@ -974,6 +1162,38 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
fun connect(config: ConnectionConfig) {
val modelNum = when (config.model) {
TerminalModel.IBM_DYNAMIC -> 0
TerminalModel.IBM_3279_3, TerminalModel.IBM_3278_3 -> 3
TerminalModel.IBM_3279_4, TerminalModel.IBM_3278_4 -> 4
TerminalModel.IBM_3279_5, TerminalModel.IBM_3278_5 -> 5
else -> 2
}
connect(
host = config.host,
port = config.port,
modelNum = modelNum,
dynamicRows = config.dynamicRows,
dynamicCols = config.dynamicCols,
luName = config.luName ?: "",
hostType = config.hostType ?: "TSO",
useTls = config.isUseTls,
tlsVerifyCert = config.isTlsVerifyCert,
tn3270e = config.isTn3270eEnabled,
graphicsModeStr = config.graphicsMode?.name ?: "BOTH",
codePageStr = config.codePage ?: "037",
proxyTypeStr = config.proxyType?.name ?: "NONE",
proxyHost = config.proxyHost ?: "",
proxyPort = config.proxyPort,
proxyUser = config.proxyUsername ?: "",
proxyPass = config.proxyPassword ?: "",
savedConnection = config.isSavedConnection,
savedProfileId = config.savedProfileId,
savedProfileName = config.savedProfileName
)
}
fun disconnect() { fun disconnect() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -1103,7 +1323,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
fun pasteString(text: String) { fun pasteString(text: String) {
if (text.isNotEmpty()) { if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text)) inputChannel.trySend(TerminalInputAction.PasteText(text))
} }
} }
@@ -1271,9 +1491,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String { private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
val sb = StringBuilder() val sb = StringBuilder()
val snap = buf.displaySnapshot
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = row * cols + c val addr = row * cols + c
val cell = buf.getCell(addr) val cell = snap?.getCell(addr) ?: buf.getDisplayCell(addr)
if (cell != null && !cell.isFieldAttribute) { if (cell != null && !cell.isFieldAttribute) {
val ch = cell.ucs4 val ch = cell.ucs4
if (ch in '!'..'~' || ch.code > 127) { if (ch in '!'..'~' || ch.code > 127) {
@@ -1291,9 +1512,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private fun computeScreenContentHash(buf: ScreenBuffer?): Int { private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
if (buf == null) return 0 if (buf == null) return 0
val sb = StringBuilder() val sb = StringBuilder()
val total = buf.rows * buf.cols val snap = buf.displaySnapshot
val total = (snap?.rows ?: buf.displayRows) * (snap?.cols ?: buf.displayCols)
for (i in 0 until total) { for (i in 0 until total) {
val cell = buf.getCell(i) val cell = snap?.getCell(i) ?: buf.getDisplayCell(i)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) { if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
sb.append(cell.ucs4) sb.append(cell.ucs4)
} }
@@ -1,8 +1,10 @@
package haus.nightmare.a3270.storage package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter import java.io.PrintWriter
import java.io.Reader import java.io.Reader
import java.io.Writer import java.io.Writer
@@ -19,12 +21,14 @@ object AppSettings {
private const val KEY_VERIFY_CERTS = "verify_certs" private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode" private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page" private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
private const val KEY_DEFAULT_HOST_TYPE = "default_host_type"
// New settings aligned with j3270 // New settings aligned with j3270
private const val KEY_UI_THEME = "ui_theme" private const val KEY_UI_THEME = "ui_theme"
private const val KEY_CROSSHAIR_RULER = "crosshair_ruler" private const val KEY_CROSSHAIR_RULER = "crosshair_ruler"
private const val KEY_CURSOR_STYLE = "cursor_style" private const val KEY_CURSOR_STYLE = "cursor_style"
private const val KEY_BLOCK_SELECT_MODE = "block_select_mode" private const val KEY_BLOCK_SELECT_MODE = "block_select_mode"
private const val KEY_BLOCK_PASTE = "block_paste"
private const val KEY_STARTUP_BEHAVIOR = "startup_behavior" private const val KEY_STARTUP_BEHAVIOR = "startup_behavior"
private const val KEY_DYNAMIC_ROWS = "dynamic_rows" private const val KEY_DYNAMIC_ROWS = "dynamic_rows"
private const val KEY_DYNAMIC_COLS = "dynamic_cols" private const val KEY_DYNAMIC_COLS = "dynamic_cols"
@@ -42,6 +46,17 @@ object AppSettings {
private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock" private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock"
private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled" private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled"
// Hotspots aligned with j3270 v1.2.2
private const val KEY_HOTSPOTS_ENABLED = "hotspots_enabled"
private const val KEY_HOTSPOTS_URL_ENABLED = "hotspots_url_enabled"
private const val KEY_HOTSPOTS_PF_ENABLED = "hotspots_pf_enabled"
private const val KEY_HOTSPOTS_MENU_CHOICES_ENABLED = "hotspots_menu_choices_enabled"
// Selection & Trim aligned with j3270 v1.2.2
private const val KEY_TRIM_RECT_SIZING_HANDLES = "trim_rect_sizing_handles"
private const val KEY_TRIM_RECT_REMAIN_AFTER_EDIT = "trim_rect_remain_after_edit"
private const val KEY_PASTE_WORD_BREAK = "paste_word_break"
enum class StartupBehavior { enum class StartupBehavior {
SHOW_CONNECT, SHOW_CONNECT,
DO_NOTHING, DO_NOTHING,
@@ -100,6 +115,15 @@ object AppSettings {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply() getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
} }
fun getDefaultHostType(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_HOST_TYPE, "TSO") ?: "TSO"
}
fun setDefaultHostType(context: Context, hostType: String) {
val normalized = if (hostType.isNotBlank()) hostType.trim().uppercase(Locale.US) else "TSO"
getPrefs(context).edit().putString(KEY_DEFAULT_HOST_TYPE, normalized).apply()
}
// ========== UI Theme ========== // ========== UI Theme ==========
fun getUiTheme(context: Context): String { fun getUiTheme(context: Context): String {
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK" return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
@@ -137,6 +161,15 @@ object AppSettings {
getPrefs(context).edit().putBoolean(KEY_BLOCK_SELECT_MODE, enabled).apply() getPrefs(context).edit().putBoolean(KEY_BLOCK_SELECT_MODE, enabled).apply()
} }
// ========== Block Paste Mode ==========
fun isBlockPasteEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_BLOCK_PASTE, true)
}
fun setBlockPasteEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_BLOCK_PASTE, enabled).apply()
}
// ========== Startup Behavior ========== // ========== Startup Behavior ==========
fun getStartupBehavior(context: Context): StartupBehavior { fun getStartupBehavior(context: Context): StartupBehavior {
val s = getPrefs(context).getString(KEY_STARTUP_BEHAVIOR, StartupBehavior.SHOW_CONNECT.name) val s = getPrefs(context).getString(KEY_STARTUP_BEHAVIOR, StartupBehavior.SHOW_CONNECT.name)
@@ -273,6 +306,64 @@ object AppSettings {
getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply() getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply()
} }
// ========== Hotspots (Default DISABLED for touch screens) ==========
fun isHotspotsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_ENABLED, false)
}
fun setHotspotsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_ENABLED, enabled).apply()
}
fun isHotspotsUrlEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_URL_ENABLED, true)
}
fun setHotspotsUrlEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_URL_ENABLED, enabled).apply()
}
fun isHotspotsPfEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_PF_ENABLED, true)
}
fun setHotspotsPfEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_PF_ENABLED, enabled).apply()
}
fun isHotspotsMenuChoicesEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, true)
}
fun setHotspotsMenuChoicesEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, enabled).apply()
}
// ========== Selection Handles & Trim ==========
fun isTrimRectSizingHandlesEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_TRIM_RECT_SIZING_HANDLES, false)
}
fun setTrimRectSizingHandlesEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_SIZING_HANDLES, enabled).apply()
}
fun isTrimRectRemainAfterEditEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, false)
}
fun setTrimRectRemainAfterEditEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, enabled).apply()
}
fun isPasteWordBreakEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_PASTE_WORD_BREAK, true)
}
fun setPasteWordBreakEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_PASTE_WORD_BREAK, enabled).apply()
}
// ========== INI Config Export / Import ========== // ========== INI Config Export / Import ==========
fun exportToIni(context: Context, writer: Writer) { fun exportToIni(context: Context, writer: Writer) {
@@ -299,9 +390,21 @@ object AppSettings {
p.println("autoConnectTn3270e = ${autoHost.tn3270e}") p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
} }
p.println("codePage = ${getDefaultCodePage(context)}") p.println("codePage = ${getDefaultCodePage(context)}")
p.println("hostType = ${getDefaultHostType(context)}")
p.println("dynamicRows = ${getDynamicRows(context)}") p.println("dynamicRows = ${getDynamicRows(context)}")
p.println("dynamicCols = ${getDynamicCols(context)}") p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}") p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
p.println("blockPaste = ${isBlockPasteEnabled(context)}")
p.println("trimRectSizingHandles = ${isTrimRectSizingHandlesEnabled(context)}")
p.println("trimRectRemainAfterEdit = ${isTrimRectRemainAfterEditEnabled(context)}")
p.println("pasteWordBreak = ${isPasteWordBreakEnabled(context)}")
p.println()
p.println("[hotspots]")
p.println("hotspotsEnabled = ${isHotspotsEnabled(context)}")
p.println("hotspotsUrlEnabled = ${isHotspotsUrlEnabled(context)}")
p.println("hotspotsPfEnabled = ${isHotspotsPfEnabled(context)}")
p.println("hotspotsMenuChoicesEnabled = ${isHotspotsMenuChoicesEnabled(context)}")
p.println() p.println()
p.println("[entryassist]") p.println("[entryassist]")
@@ -328,7 +431,31 @@ object AppSettings {
p.flush() p.flush()
} }
fun loadFromIni(context: Context, reader: Reader) { fun shareSettingsIni(context: Context) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(cacheDir, "a3270_settings.ini")
file.bufferedWriter().use { writer ->
exportToIni(context, writer)
}
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "a3270 Terminal Settings")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Terminal Settings"))
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
val bufferedReader = BufferedReader(reader) val bufferedReader = BufferedReader(reader)
var currentSection = "" var currentSection = ""
@@ -342,13 +469,19 @@ object AppSettings {
if (eq >= 0) { if (eq >= 0) {
val key = line.substring(0, eq).trim() val key = line.substring(0, eq).trim()
val value = line.substring(eq + 1).trim() val value = line.substring(eq + 1).trim()
applyConfigEntry(context, currentSection, key, value) callback(currentSection, key, value)
} }
} }
} }
} }
} }
fun loadFromIni(context: Context, reader: Reader) {
parseIni(reader) { section, key, value ->
applyConfigEntry(context, section, key, value)
}
}
private fun applyConfigEntry(context: Context, section: String, key: String, value: String) { private fun applyConfigEntry(context: Context, section: String, key: String, value: String) {
when (section) { when (section) {
"appearance" -> { "appearance" -> {
@@ -366,9 +499,31 @@ object AppSettings {
} catch (_: Exception) {} } catch (_: Exception) {}
} }
"codepage", "charset" -> setDefaultCodePage(context, value) "codepage", "charset" -> setDefaultCodePage(context, value)
"hosttype", "host_type" -> setDefaultHostType(context, value)
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) } "dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) } "dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean()) "blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
"hotspotsenabled" -> setHotspotsEnabled(context, value.toBoolean())
}
}
"clipboard" -> {
when (key.lowercase(Locale.US)) {
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
}
}
"hotspots" -> {
when (key.lowercase(Locale.US)) {
"hotspotsenabled", "enabled" -> setHotspotsEnabled(context, value.toBoolean())
"hotspotsurlenabled", "urlenabled", "url" -> setHotspotsUrlEnabled(context, value.toBoolean())
"hotspotspfenabled", "pfenabled", "pf" -> setHotspotsPfEnabled(context, value.toBoolean())
"hotspotsmenuchoicesenabled", "menuchoicesenabled", "menuchoices", "menu" -> setHotspotsMenuChoicesEnabled(context, value.toBoolean())
} }
} }
"entryassist" -> { "entryassist" -> {
@@ -1,9 +1,21 @@
package haus.nightmare.a3270.storage package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID import java.util.UUID
data class SavedHost( data class SavedHost(
@@ -28,6 +40,47 @@ data class SavedHost(
val proxyUsername: String = "", val proxyUsername: String = "",
val proxyPassword: String = "" val proxyPassword: String = ""
) { ) {
fun toConnectionConfig(): ConnectionConfig {
val tm = when (model) {
3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5
0 -> TerminalModel.IBM_DYNAMIC
else -> TerminalModel.IBM_3279_2
}
val config = ConnectionConfig(host, port, tm, useTls)
config.isSavedConnection = true
config.savedProfileId = this.id
config.savedProfileName = this.name
config.hostType = if (this.hostType.isNotBlank()) this.hostType else "TSO"
if (model == 0 || tm.isDynamic) {
config.setDynamicDimensions(dynamicRows, dynamicCols)
}
if (luName.isNotBlank()) {
config.luName = luName.trim()
}
config.isTlsVerifyCert = tlsVerifyCert
config.isTn3270eEnabled = tn3270e
config.graphicsMode = GraphicsMode.fromString(graphicsMode)
if (codePage.isNotBlank()) {
config.codePage = codePage
}
if (!proxyType.equals("NONE", ignoreCase = true) && proxyHost.isNotBlank()) {
try {
val pt = ConnectionConfig.ProxyType.valueOf(proxyType.uppercase())
config.setProxy(
pt,
proxyHost,
if (proxyPort > 0) proxyPort else (if (pt == ConnectionConfig.ProxyType.HTTP) 8080 else 1080),
proxyUsername.ifBlank { null },
proxyPassword.ifBlank { null }
)
} catch (_: Exception) {}
}
return config
}
fun toJson(): JSONObject { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
put("id", id) put("id", id)
@@ -176,6 +229,10 @@ object HostStorage {
"1149" to "1149 - Iceland (Euro €)", "1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS", "930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS", "939" to "939 - Japanese Latin Mixed DBCS",
"290" to "290 - Japanese Katakana SBCS",
"1027" to "1027 - Japanese Latin Extended SBCS",
"1390" to "1390 - Japanese Katakana DBCS Euro",
"1399" to "1399 - Japanese Latin DBCS Euro",
"935" to "935 - Simplified Chinese Mixed DBCS", "935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS", "937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS", "1388" to "1388 - Simplified Chinese Extended DBCS",
@@ -192,4 +249,156 @@ object HostStorage {
hosts.forEach { array.put(it.toJson()) } hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply() getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
} }
// ================= INI Export / Import =================
fun exportToIni(hosts: List<SavedHost>, writer: Writer) {
val p = PrintWriter(writer)
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
p.println("; a3270 Saved Connections Configuration File")
p.println("; Exported on $timeStamp")
p.println("; Connections count: ${hosts.size}")
p.println()
for (h in hosts) {
val section = h.name.trim().ifBlank { h.host.trim().ifBlank { "Connection" } }
p.println("[$section]")
if (h.id.isNotBlank()) {
p.println("id = ${h.id}")
}
p.println("name = ${h.name}")
p.println("host = ${h.host}")
p.println("port = ${h.port}")
p.println("model = ${h.model}")
p.println("dynamicRows = ${h.dynamicRows}")
p.println("dynamicCols = ${h.dynamicCols}")
p.println("luName = ${h.luName}")
p.println("autoConnect = ${h.autoConnect}")
p.println("hostType = ${h.hostType.ifBlank { "TSO" }}")
p.println("useTls = ${h.useTls}")
p.println("tlsVerifyCert = ${h.tlsVerifyCert}")
p.println("tn3270e = ${h.tn3270e}")
p.println("graphicsMode = ${h.graphicsMode.ifBlank { "BOTH" }}")
p.println("codePage = ${h.codePage.ifBlank { "037" }}")
p.println("proxyType = ${h.proxyType.ifBlank { "NONE" }}")
p.println("proxyHost = ${h.proxyHost}")
p.println("proxyPort = ${h.proxyPort}")
p.println("proxyUsername = ${h.proxyUsername}")
p.println("proxyPassword = ${h.proxyPassword}")
p.println()
}
p.flush()
}
fun loadFromIni(reader: Reader): List<SavedHost> {
val bufferedReader = BufferedReader(reader)
val hosts = mutableListOf<SavedHost>()
var current: SavedHost? = null
bufferedReader.forEachLine { rawLine ->
val line = rawLine.trim()
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
if (line.startsWith("[") && line.endsWith("]")) {
current?.let { hosts.add(it) }
var section = line.substring(1, line.length - 1).trim()
if (section.startsWith("connection:", ignoreCase = true)) {
section = section.substring("connection:".length).trim()
} else if (section.startsWith("host:", ignoreCase = true)) {
section = section.substring("host:".length).trim()
}
current = SavedHost(id = UUID.randomUUID().toString(), name = section, host = "")
} else {
val eq = line.indexOf('=')
if (eq >= 0) {
val key = line.substring(0, eq).trim().lowercase(Locale.US)
val value = line.substring(eq + 1).trim()
if (current == null) {
current = SavedHost(id = UUID.randomUUID().toString(), name = "Default", host = "")
}
current = applyIniProperty(current!!, key, value)
}
}
}
}
current?.let { hosts.add(it) }
return hosts
}
private fun applyIniProperty(h: SavedHost, key: String, value: String): SavedHost {
return when (key) {
"id" -> if (value.isNotBlank()) h.copy(id = value) else h
"name", "profilename", "profile" -> if (value.isNotBlank()) h.copy(name = value) else h
"host", "hostname", "address" -> h.copy(host = value)
"port" -> h.copy(port = value.toIntOrNull() ?: 23)
"model", "terminalmodel" -> h.copy(model = value.toIntOrNull() ?: 2)
"dynamicrows", "dynamic_rows", "rows" -> h.copy(dynamicRows = value.toIntOrNull() ?: 62)
"dynamiccols", "dynamic_cols", "cols" -> h.copy(dynamicCols = value.toIntOrNull() ?: 160)
"luname", "lu_name", "lu" -> h.copy(luName = value)
"autoconnect", "auto_connect" -> h.copy(autoConnect = value.toBoolean())
"hosttype", "host_type", "type" -> h.copy(hostType = value.uppercase(Locale.US))
"usetls", "use_tls", "tls", "ssl" -> h.copy(useTls = value.toBoolean())
"tlsverifycert", "verifycert", "tls_verify_cert" -> h.copy(tlsVerifyCert = value.toBoolean())
"tn3270e", "enabletn3270e" -> h.copy(tn3270e = value.toBoolean())
"graphicsmode", "graphics_mode", "graphics" -> h.copy(graphicsMode = value.uppercase(Locale.US))
"codepage", "code_page", "charset" -> h.copy(codePage = value)
"proxytype", "proxy_type" -> h.copy(proxyType = value.uppercase(Locale.US))
"proxyhost", "proxy_host" -> h.copy(proxyHost = value)
"proxyport", "proxy_port" -> h.copy(proxyPort = value.toIntOrNull() ?: 0)
"proxyusername", "proxy_username", "proxyuser" -> h.copy(proxyUsername = value)
"proxypassword", "proxy_password", "proxypass" -> h.copy(proxyPassword = value)
else -> h
}
}
fun importFromIni(context: Context, reader: Reader): List<SavedHost> {
val imported = loadFromIni(reader)
if (imported.isEmpty()) return getSavedHosts(context)
val current = getSavedHosts(context).toMutableList()
for (host in imported) {
val index = current.indexOfFirst {
(it.id.isNotBlank() && it.id == host.id) ||
(it.name.isNotBlank() && it.name.equals(host.name, ignoreCase = true))
}
if (host.autoConnect) {
current.indices.forEach { i ->
current[i] = current[i].copy(autoConnect = false)
}
}
if (index >= 0) {
val existingId = current[index].id
val toSet = if (host.id.isBlank()) host.copy(id = existingId) else host
current[index] = toSet
} else {
current.add(host)
}
}
saveAll(context, current)
return current
}
fun shareConnectionsIni(context: Context) {
try {
val hosts = getSavedHosts(context)
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(cacheDir, "a3270_connections.ini")
file.bufferedWriter().use { writer ->
exportToIni(hosts, writer)
}
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "a3270 Saved Connections")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Saved Connections"))
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
} }
@@ -0,0 +1,109 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
enum class ConnectChoice {
NEW_WINDOW,
ABORT_CURRENT,
CANCEL
}
@Composable
fun ConnectChoiceDialog(
currentTarget: String,
onChoice: (ConnectChoice) -> Unit,
onDismiss: () -> Unit
) {
val targetText = currentTarget.ifBlank { "the host" }
Dialog(
onDismissRequest = {
onChoice(ConnectChoice.CANCEL)
onDismiss()
},
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.95f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Connection Already Active",
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = Color.White
)
Text(
text = "a3270 is currently connected to $targetText.",
fontSize = 13.sp,
color = Color.LightGray
)
Text(
text = "Would you like to open the connection in a new window or abort the current connection?",
fontSize = 13.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = {
onChoice(ConnectChoice.CANCEL)
onDismiss()
}
) {
Text("Cancel", color = Color.Gray)
}
Button(
onClick = {
onChoice(ConnectChoice.ABORT_CURRENT)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
) {
Text("Abort Current Connection", fontSize = 11.sp)
}
Button(
onClick = {
onChoice(ConnectChoice.NEW_WINDOW)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("New Window", fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@@ -55,12 +55,16 @@ fun ConnectDialog(
proxyHost: String, proxyHost: String,
proxyPort: Int, proxyPort: Int,
proxyUser: String, proxyUser: String,
proxyPass: String proxyPass: String,
savedConnection: Boolean,
savedProfileId: String?,
savedProfileName: String?
) -> Unit ) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) } var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) } var selectedHostId by remember { mutableStateOf<String?>(null) }
var showImportConnectionsDialog by remember { mutableStateOf(false) }
var profileName by remember { mutableStateOf("") } var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("") } var host by remember { mutableStateOf("") }
@@ -70,7 +74,7 @@ fun ConnectDialog(
var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) } var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
var luName by remember { mutableStateOf("") } var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) } var autoConnect by remember { mutableStateOf(false) }
var hostType by remember { mutableStateOf("TSO") } var hostType by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)) }
var useTls by remember { mutableStateOf(false) } var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) } var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) } var tn3270e by remember { mutableStateOf(true) }
@@ -117,7 +121,7 @@ fun ConnectDialog(
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString() dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
luName = "" luName = ""
autoConnect = false autoConnect = false
hostType = "TSO" hostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
useTls = false useTls = false
tlsVerifyCert = true tlsVerifyCert = true
tn3270e = true tn3270e = true
@@ -231,13 +235,36 @@ fun ConnectDialog(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Saved Profiles List Section // Saved Profiles List Section
if (savedHosts.isNotEmpty()) { Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
"Saved Host Profiles:", if (savedHosts.isNotEmpty()) "Saved Host Profiles (${savedHosts.size}):" else "Saved Host Profiles:",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Gray color = Color.Gray
) )
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (savedHosts.isNotEmpty()) {
TextButton(
onClick = { HostStorage.shareConnectionsIni(context) },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("Export (Share)", fontSize = 11.sp, color = Color(0xFF339AF0))
}
}
TextButton(
onClick = { showImportConnectionsDialog = true },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("Import", fontSize = 11.sp, color = Color(0xFF339AF0))
}
}
}
if (savedHosts.isNotEmpty()) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -301,7 +328,10 @@ fun ConnectDialog(
hostToConn.proxyHost, hostToConn.proxyHost,
hostToConn.proxyPort, hostToConn.proxyPort,
hostToConn.proxyUsername, hostToConn.proxyUsername,
hostToConn.proxyPassword hostToConn.proxyPassword,
true,
hostToConn.id,
hostToConn.name
) )
}, },
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
@@ -804,7 +834,10 @@ fun ConnectDialog(
saved.proxyHost, saved.proxyHost,
saved.proxyPort, saved.proxyPort,
saved.proxyUsername, saved.proxyUsername,
saved.proxyPassword saved.proxyPassword,
true,
saved.id,
saved.name
) )
} }
}, },
@@ -816,4 +849,64 @@ fun ConnectDialog(
} }
} }
} }
if (showImportConnectionsDialog) {
var importText by remember { mutableStateOf("") }
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
AlertDialog(
onDismissRequest = { showImportConnectionsDialog = false },
title = { Text("Import Host Connections") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Paste j3270 / a3270 INI connections below:", fontSize = 12.sp)
OutlinedTextField(
value = importText,
onValueChange = { importText = it },
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = {
val clip = clipboardManager.getText()?.text ?: ""
if (clip.isNotBlank()) {
importText = clip
}
}) {
Text("Paste Clipboard", fontSize = 11.sp)
}
}
}
},
confirmButton = {
TextButton(onClick = {
showImportConnectionsDialog = false
if (importText.isNotBlank()) {
try {
val countBefore = savedHosts.size
val merged = HostStorage.importFromIni(context, java.io.StringReader(importText))
savedHosts = merged
val countAfter = merged.size
val diff = countAfter - countBefore
val msg = if (diff > 0) "Imported $diff new connection(s)" else "Updated ${merged.size} connection(s)"
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = { showImportConnectionsDialog = false }) {
Text("Cancel")
}
}
)
}
} }
@@ -38,19 +38,24 @@ private val TerminalKeyboardOptions = KeyboardOptions(
) )
data class LastTransferState( data class LastTransferState(
val hostFile: String = "", var hostFile: String = "",
val localFile: String = "", var localFile: String = "",
val isReceive: Boolean = true, var isReceive: Boolean = true,
val isAscii: Boolean = true, var isAscii: Boolean = true,
val hostType: FTConfig.HostType = FTConfig.HostType.TSO, var hostType: FTConfig.HostType = FTConfig.HostType.TSO,
val recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT, var recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
val lreclStr: String = "", var lreclStr: String = "",
val blksizeStr: String = "", var blksizeStr: String = "",
val spaceStr: String = "", var units: FTConfig.AllocationUnit = FTConfig.AllocationUnit.TRACKS,
val cmsOptions: String = "" var spaceStr: String = "",
var dirBlocks: String = "",
var cmsOptions: String = ""
) )
private var lastTransferState: LastTransferState? = null private var lastTransferState: LastTransferState? = null
fun getLastTransferState(): LastTransferState? = lastTransferState
fun resetSessionState() { lastTransferState = null }
fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String { fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String {
if (hostType == FTConfig.HostType.CMS) { if (hostType == FTConfig.HostType.CMS) {
val lastDot = localName.lastIndexOf('.') val lastDot = localName.lastIndexOf('.')
@@ -104,16 +109,26 @@ fun FileTransferDialog(
else -> FTConfig.HostType.TSO else -> FTConfig.HostType.TSO
} }
val clientConfig = client?.config
val configuredFtType = clientConfig?.ftHostType
val effectiveHostType = if (configuredFtType != null && (clientConfig.isSavedConnection || lastTransferState == null)) {
configuredFtType
} else {
lastTransferState?.hostType ?: initialTypeEnum
}
var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") } var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") } var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
var localDisplayName by remember { mutableStateOf("") } var localDisplayName by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) } var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) } var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) }
var hostType by remember { mutableStateOf(lastTransferState?.hostType ?: initialTypeEnum) } var hostType by remember { mutableStateOf(effectiveHostType) }
var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) } var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") } var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") } var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") }
var units by remember { mutableStateOf(lastTransferState?.units ?: FTConfig.AllocationUnit.TRACKS) }
var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") } var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
var dirBlocks by remember { mutableStateOf(lastTransferState?.dirBlocks ?: "") }
var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") } var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") }
var overwrite by remember { mutableStateOf(true) } var overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) } var showBrowseDialog by remember { mutableStateOf(false) }
@@ -186,15 +201,34 @@ fun FileTransferDialog(
} }
if (showBrowseDialog) { if (showBrowseDialog) {
val initialQuery = resolveDefaultQuery(hostType, hostFile)
HostDirectoryDialog( HostDirectoryDialog(
client = client, client = client,
initialHostType = hostType, initialHostType = hostType,
initialQuery = hostFile, initialQuery = initialQuery,
onDismiss = { showBrowseDialog = false }, onDismiss = { showBrowseDialog = false },
onSelectDataset = { selected -> onSelectEntry = { selectedName, selectedEntry ->
hostFile = selected hostFile = selectedName
if (localFile.isBlank()) { if (selectedEntry != null) {
val safeName = formatLocalFilenameFromHost(selected, hostType) val r = selectedEntry.recfm
if (!r.isNullOrBlank()) {
val mappedRecfm = when (r.trim().uppercase(Locale.US)) {
"F", "FB", "FA", "FBA", "FBM" -> FTConfig.RecordFormat.FIXED
"V", "VB", "VA", "VBA", "VBM" -> FTConfig.RecordFormat.VARIABLE
else -> FTConfig.RecordFormat.DEFAULT
}
recfm = mappedRecfm
}
if (selectedEntry.lrecl > 0) {
lreclStr = selectedEntry.lrecl.toString()
}
if (selectedEntry.blksize > 0) {
blksizeStr = selectedEntry.blksize.toString()
}
}
val shouldAutofill = localFile.isBlank() || (lastTransferState != null && localFile == lastTransferState?.localFile)
if (shouldAutofill) {
val safeName = formatLocalFilenameFromHost(selectedName, hostType)
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() } val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName) val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath localFile = tempFile.absolutePath
@@ -521,16 +555,52 @@ fun FileTransferDialog(
) )
} }
Spacer(modifier = Modifier.height(4.dp)) Row(
OutlinedTextField( modifier = Modifier.fillMaxWidth(),
value = spaceStr, horizontalArrangement = Arrangement.SpaceBetween,
onValueChange = { spaceStr = it }, verticalAlignment = Alignment.CenterVertically
label = { Text("SPACE (Allocation)", fontSize = 11.sp) }, ) {
placeholder = { Text("e.g. (1,1) cyl or 5,2") }, Text("Units:", fontSize = 11.sp)
singleLine = true, Row {
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done), listOf(
modifier = Modifier.fillMaxWidth() FTConfig.AllocationUnit.TRACKS to "Tracks",
) FTConfig.AllocationUnit.CYLINDERS to "Cylinders",
FTConfig.AllocationUnit.AVBLOCK to "Avblock",
FTConfig.AllocationUnit.DEFAULT to "Default"
).forEach { (u, label) ->
FilterChip(
selected = (units == u),
onClick = { units = u },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = spaceStr,
onValueChange = { spaceStr = it },
label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
placeholder = { Text("e.g. 5,2 or 1,1") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.62f)
)
OutlinedTextField(
value = dirBlocks,
onValueChange = { dirBlocks = it.filter { c -> c.isDigit() } },
label = { Text("Dir (PDS)", fontSize = 11.sp) },
placeholder = { Text("e.g. 10") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.weight(0.38f)
)
}
} }
// CMS Options // CMS Options
@@ -571,7 +641,9 @@ fun FileTransferDialog(
setRecfm(recfm) setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) } lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) } blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
setUnits(units)
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim()) if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
} }
if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) { if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
setOptions(cmsOptions.trim()) setOptions(cmsOptions.trim())
@@ -586,7 +658,9 @@ fun FileTransferDialog(
recfm = recfm, recfm = recfm,
lreclStr = lreclStr, lreclStr = lreclStr,
blksizeStr = blksizeStr, blksizeStr = blksizeStr,
units = units,
spaceStr = spaceStr, spaceStr = spaceStr,
dirBlocks = dirBlocks,
cmsOptions = cmsOptions cmsOptions = cmsOptions
) )
onStartTransfer(config) onStartTransfer(config)
@@ -38,7 +38,9 @@ fun TwoRowKeyBar(
onFieldInspectorClick: () -> Unit = {}, onFieldInspectorClick: () -> Unit = {},
onFindClick: () -> Unit = {}, onFindClick: () -> Unit = {},
onScreenExportClick: () -> Unit = {}, onScreenExportClick: () -> Unit = {},
onScreenHistoryClick: () -> Unit = {},
onScriptClick: () -> Unit = {}, onScriptClick: () -> Unit = {},
onMacroRuntimeClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {}, onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {}, onToggleLightPen: () -> Unit = {},
onToggleInsert: () -> Unit = {}, onToggleInsert: () -> Unit = {},
@@ -162,6 +164,13 @@ fun TwoRowKeyBar(
onScreenExportClick() onScreenExportClick()
} }
) )
DropdownMenuItem(
text = { Text("Screen History", color = Color.White) },
onClick = {
menuExpanded = false
onScreenHistoryClick()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Run Keystroke Script", color = Color.White) }, text = { Text("Run Keystroke Script", color = Color.White) },
onClick = { onClick = {
@@ -169,6 +178,13 @@ fun TwoRowKeyBar(
onScriptClick() onScriptClick()
} }
) )
DropdownMenuItem(
text = { Text("Macro Runtime (<HASCRIPT>)", color = Color.White) },
onClick = {
menuExpanded = false
onMacroRuntimeClick()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) }, text = { Text("3287 Printer Session", color = Color.White) },
onClick = { onClick = {
@@ -1,5 +1,8 @@
package haus.nightmare.a3270.ui package haus.nightmare.a3270.ui
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -15,6 +18,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -28,8 +32,139 @@ import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryParser
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
fun resolveDefaultQuery(hostType: FTConfig.HostType, initialQuery: String?): String {
val defaultQuery = initialQuery?.trim() ?: ""
if (hostType == FTConfig.HostType.CMS && (defaultQuery.isEmpty() || defaultQuery == "* * *" || defaultQuery == "*")) {
return "* * A"
}
return defaultQuery
}
fun formatCMSFilter(pattern: String?): String {
if (pattern.isNullOrBlank()) {
return "* * A"
}
val clean = pattern.replace("'", "").replace("\"", "").trim()
if (clean == "* * *" || clean == "*") {
return "* * A"
} else if (clean.contains('.')) {
val dot = clean.indexOf('.')
return clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A"
} else {
val parts = clean.split(Regex("\\s+"))
return when (parts.size) {
1 -> "${parts[0]} * A"
2 -> "${parts[0]} ${parts[1]} A"
else -> "${parts[0]} ${parts[1]} ${parts[2]}"
}
}
}
fun matchesFilter(entry: HostDirectoryEntry, filter: String?): Boolean {
if (filter.isNullOrBlank()) return true
val cleanFilter = filter.replace("'", "").replace("\"", "").trim()
if (cleanFilter.isEmpty() || cleanFilter == "*" || cleanFilter == "* * *" || cleanFilter == "*.*") {
return true
}
return when (entry) {
is CMSDirectoryEntry -> matchesCmsFilter(entry, cleanFilter)
is TSODirectoryEntry -> matchesTsoFilter(entry, cleanFilter)
else -> matchesWildcard(entry.name, cleanFilter)
}
}
fun matchesCmsFilter(cms: CMSDirectoryEntry, filter: String): Boolean {
val fn = cms.filename?.trim() ?: ""
val ft = cms.filetype?.trim() ?: ""
val fm = cms.filemode?.trim() ?: ""
val tokens = filter.split(Regex("\\s+"))
return if (tokens.size >= 3) {
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1]) && matchesCmsFilemode(fm, tokens[2])
} else if (tokens.size == 2) {
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1])
} else {
val tok = tokens[0]
if (tok.contains('.')) {
val dot = tok.indexOf('.')
matchesWildcard(fn, tok.substring(0, dot)) && matchesWildcard(ft, tok.substring(dot + 1))
} else if (matchesWildcard(fn, tok) || matchesWildcard(ft, tok) || matchesCmsFilemode(fm, tok)) {
true
} else {
matchesWildcard(cms.name, tok)
}
}
}
fun matchesCmsFilemode(actualFm: String?, fmPattern: String?): Boolean {
if (fmPattern.isNullOrEmpty() || fmPattern == "*") return true
if (actualFm.isNullOrEmpty()) return false
if (fmPattern.length == 1 && fmPattern[0].isLetter()) {
return actualFm.startsWith(fmPattern, ignoreCase = true)
}
return matchesWildcard(actualFm, fmPattern)
}
fun matchesTsoFilter(tso: TSODirectoryEntry, filter: String): Boolean {
val dsn = tso.datasetName?.trim() ?: ""
if (filter == "*" || filter == "* * *" || filter == "*.*") return true
if (filter.contains('*') || filter.contains('?')) {
return matchesWildcard(dsn, filter)
}
return dsn.contains(filter, ignoreCase = true)
}
fun matchesWildcard(text: String?, pattern: String?): Boolean {
val t = text ?: ""
if (pattern.isNullOrEmpty() || pattern == "*") return true
val regex = buildString {
append("^")
for (c in pattern) {
when (c) {
'*' -> append(".*")
'?' -> append(".")
in "\\.[]{}()+^$|" -> append("\\").append(c)
else -> append(Regex.escape(c.uppercaseChar().toString()))
}
}
append("$")
}
return t.uppercase(Locale.US).matches(Regex(regex))
}
private fun waitForKeyboardUnlock(client: Telnet3270Client?, timeoutMs: Long): Boolean {
if (client == null) return false
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var locked = false
if (client.inputProcessor != null && client.inputProcessor.isKeyboardLocked) {
locked = true
}
if (client.oia != null && (client.oia.isKeyLocked || client.oia.isXSystem)) {
locked = true
}
if (!locked) {
return true
}
try {
Thread.sleep(40)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return false
}
}
return false
}
@Composable @Composable
fun HostDirectoryDialog( fun HostDirectoryDialog(
@@ -37,71 +172,475 @@ fun HostDirectoryDialog(
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO, initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "", initialQuery: String = "",
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectDataset: (String) -> Unit onSelectDataset: (String) -> Unit = {},
onSelectEntry: ((String, HostDirectoryEntry?) -> Unit)? = null
) { ) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var hostType by remember { mutableStateOf(initialHostType) } var hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(initialQuery) } var query by remember { mutableStateOf(resolveDefaultQuery(initialHostType, initialQuery)) }
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) } var allEntries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) } var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") } var statusMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
fun runQuery() { var inspectionEntry by remember { mutableStateOf<TSODirectoryEntry?>(null) }
val c = client var showInspectionDialog by remember { mutableStateOf(false) }
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "Terminal not connected."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val q = query.trim() // Client-side filtering
if (hostType == FTConfig.HostType.CMS) { val currentEntries by remember(allEntries, query, hostType) {
val list = xfer.getCmsDirectory(q) derivedStateOf {
entries = list ?: emptyList() val filter = query.trim()
if (filter.isEmpty()) {
allEntries
} else {
allEntries.filter { matchesFilter(it, filter) }
}
}
}
fun parseTextData(text: String, source: String) {
allEntries = if (hostType == FTConfig.HostType.CMS) {
CMSDirectoryParser.parse(text)
} else { } else {
val list = xfer.getTsoDirectory(q) TSODirectoryParser.parse(text)
entries = list ?: emptyList()
} }
selectedIndex = -1 selectedIndex = -1
statusMessage = "${entries.size} entries found." statusMessage = if (allEntries.isEmpty()) {
"0 entries found from $source. Use 'Query Host' or 'Paste Clipboard'."
} else {
"Parsed ${allEntries.size} entries from $source."
}
} }
fun parseScreen() { fun parseScreen() {
val c = client val c = client
if (c == null || !c.connectionState.isFullSession) { if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active screen to parse." statusMessage = "No active session to parse."
return return
} }
val xfer = c.xfer val rows = c.ps.rows
if (xfer == null) { val cols = c.ps.cols
statusMessage = "ECL transfer interface not available." val screenText = c.ps.copyString(0, 0, rows - 1, cols - 1)
parseTextData(screenText, "presentation space")
}
fun parseClipboard() {
try {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (cm != null && cm.hasPrimaryClip()) {
val clip = cm.primaryClip
if (clip != null && clip.itemCount > 0) {
val clipText = clip.getItemAt(0).coerceToText(context)?.toString()
if (!clipText.isNullOrBlank()) {
parseTextData(clipText, "clipboard")
return
}
}
}
statusMessage = "Clipboard contains no text."
} catch (ex: Exception) {
statusMessage = "Error reading clipboard: ${ex.message}"
}
}
fun queryHost() {
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to query."
return return
} }
val ps = c.ps val pattern = query.trim()
val screenText = ps.getString(0, ps.size) val curHostType = hostType
if (hostType == FTConfig.HostType.CMS) { statusMessage = "Querying host ($curHostType)..."
val list = xfer.getCmsDirectory(screenText) isLoading = true
entries = list ?: emptyList()
} else { coroutineScope.launch(Dispatchers.IO) {
val list = xfer.getTsoDirectory(screenText) try {
entries = list ?: emptyList() val allPages = StringBuilder()
val maxPages = 500
if (curHostType == FTConfig.HostType.CMS) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
val cmsFilter = formatCMSFilter(pattern)
c.ps.SendKeys("LISTFILE $cmsFilter ( nohead alloc date[enter]")
Thread.sleep(100)
for (page in 0 until maxPages) {
val startWait = System.currentTimeMillis()
val timeoutMs = 10000L
var screen = ""
var isMore = false
while (System.currentTimeMillis() - startWait < timeoutMs) {
waitForKeyboardUnlock(c, 500)
val rows = c.ps.rows
val cols = c.ps.cols
screen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val upper = screen.uppercase(Locale.US)
if (upper.contains("MORE...") || upper.contains("HOLDING")) {
isMore = true
break
}
if (upper.contains("READY;") || upper.contains("READY(")) {
break
}
Thread.sleep(150)
}
allPages.append(screen).append("\n")
if (isMore) {
c.ps.SendKeys("[clear]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 3000)
} else {
break
}
}
if (c.ps != null) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
}
} else {
val rows = c.ps.rows
val cols = c.ps.cols
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
val hasCmdArrow = initialScreen.contains("===>")
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
var tsoCmd = "TSO LISTCAT ALL"
if (pattern.isNotEmpty()) {
var dsnPattern = pattern.trim()
if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length > 2) {
dsnPattern = dsnPattern.substring(1, dsnPattern.length - 1)
}
val paren = dsnPattern.indexOf('(')
if (paren > 0) {
dsnPattern = dsnPattern.substring(0, paren)
}
if (dsnPattern == "*" || dsnPattern == "* * *" || dsnPattern == "*.*") {
tsoCmd = "TSO LISTCAT ALL"
} else {
var level = dsnPattern
if (level.endsWith(".*")) {
level = level.substring(0, level.length - 2)
} else if (level.endsWith("*")) {
level = level.substring(0, level.length - 1)
}
tsoCmd = if (level.isEmpty()) "TSO LISTCAT ALL" else "TSO LISTCAT LEVEL($level) ALL"
}
}
if (isIspf) {
val arrowPos = initialScreen.indexOf("===>")
if (arrowPos >= 0 && c.screenBuffer != null) {
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
if (inputAddr > 0) {
c.screenBuffer.cursorAddress = inputAddr
c.screenBuffer.updateDisplaySnapshot()
} else {
c.ps.SendKeys("[home]")
}
} else {
c.ps.SendKeys("[home]")
}
c.ps.SendKeys("[erase_eof]")
c.ps.SendKeys("$tsoCmd[enter]")
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
c.ps.SendKeys("$tsoCmd[enter]")
}
Thread.sleep(100)
val startCmd = System.currentTimeMillis()
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(c, 200)
val r = c.ps.rows
val col = c.ps.cols
val s = c.ps.copyString(0, 0, r - 1, col - 1)
val u = s.uppercase(Locale.US)
if (u.contains("NONVSAM") || u.contains("CLUSTER") || u.contains("GDG")
|| u.contains("ALIAS") || u.contains("IN-CAT") || u.contains("IDCAMS")
|| u.contains("***") || u.contains("READY") || u.contains("NOT FOUND")
|| u.contains("NO ENTRY") || s.contains(" --- ")
|| u.contains("--RECFM-") || u.contains("--VOLUMES--")) {
break
}
Thread.sleep(80)
}
var prevScreen = ""
for (page in 0 until maxPages) {
waitForKeyboardUnlock(c, 300)
val r = c.ps.rows
val col = c.ps.cols
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
val upper = screen.uppercase(Locale.US)
if (screen == prevScreen || screen.isBlank()) {
break
}
prevScreen = screen
allPages.append(screen).append("\n")
if (upper.contains("***")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
} else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG")
|| upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ")
|| upper.contains("--RECFM-") || upper.contains("--VOLUMES--")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 1000)
val r2 = c.ps.rows
val col2 = c.ps.cols
val s2 = c.ps.copyString(0, 0, r2 - 1, col2 - 1)
if (s2.uppercase(Locale.US).contains("***")) {
prevScreen = s2
allPages.append(s2).append("\n")
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
continue
} else {
if (s2 != screen && s2.isNotBlank()) {
allPages.append(s2).append("\n")
}
break
}
} else {
break
}
}
if (c.ps != null) {
if (isIspf) {
c.ps.SendKeys("[enter]")
Thread.sleep(150)
waitForKeyboardUnlock(c, 1500)
val r = c.ps.rows
val col = c.ps.cols
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
if (currentScr.uppercase(Locale.US).contains("NONVSAM") || currentScr.uppercase(Locale.US).contains("IDCAMS")
|| currentScr.contains(" --- ") || currentScr.uppercase(Locale.US).contains("--RECFM-")
|| (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
}
}
val collectedText = allPages.toString()
withContext(Dispatchers.Main) {
parseTextData(collectedText, "host query")
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusMessage = "Host query failed: ${e.message}"
isLoading = false
}
}
}
}
fun inspectSelectedDataset() {
val selected = currentEntries.getOrNull(selectedIndex)
if (selected == null || selected !is TSODirectoryEntry) {
statusMessage = "Please select a TSO dataset entry to inspect."
return
}
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to query host."
return
}
val dsn = selected.datasetName
if (dsn.isNullOrBlank()) return
statusMessage = "Inspecting $dsn via TSO LISTDS..."
isLoading = true
coroutineScope.launch(Dispatchers.IO) {
try {
val output = StringBuilder()
val rows = c.ps.rows
val cols = c.ps.cols
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
val hasCmdArrow = initialScreen.contains("===>")
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
val tsoCmd = "TSO LISTDS '$dsn' STATUS"
if (isIspf) {
val arrowPos = initialScreen.indexOf("===>")
if (arrowPos >= 0 && c.screenBuffer != null) {
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
if (inputAddr > 0) {
c.screenBuffer.cursorAddress = inputAddr
c.screenBuffer.updateDisplaySnapshot()
} else {
c.ps.SendKeys("[home]")
}
} else {
c.ps.SendKeys("[home]")
}
c.ps.SendKeys("[erase_eof]")
c.ps.SendKeys("$tsoCmd[enter]")
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
c.ps.SendKeys("$tsoCmd[enter]")
}
Thread.sleep(100)
val startCmd = System.currentTimeMillis()
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(c, 200)
val r = c.ps.rows
val col = c.ps.cols
val s = c.ps.copyString(0, 0, r - 1, col - 1)
val u = s.uppercase(Locale.US)
if (u.contains("--RECFM-") || u.contains("--VOLUMES--") || u.contains("***")
|| u.contains("READY") || u.contains("NOT IN CATALOG") || u.contains("NOT FOUND")) {
break
}
Thread.sleep(80)
}
var prevScreen = ""
for (page in 0 until 5) {
waitForKeyboardUnlock(c, 300)
val r = c.ps.rows
val col = c.ps.cols
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
val upper = screen.uppercase(Locale.US)
if (screen == prevScreen || screen.isBlank()) {
break
}
prevScreen = screen
output.append(screen).append("\n")
if (upper.contains("***")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
} else {
break
}
}
if (c.ps != null) {
if (isIspf) {
c.ps.SendKeys("[enter]")
Thread.sleep(150)
waitForKeyboardUnlock(c, 1500)
val r = c.ps.rows
val col = c.ps.cols
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
if (currentScr.uppercase(Locale.US).contains("--RECFM-") || (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
}
val outText = output.toString()
val parsed = TSODirectoryParser.parse(outText)
withContext(Dispatchers.Main) {
isLoading = false
if (parsed.isNotEmpty()) {
val updated = parsed[0]
if (updated.recfm != null) selected.recfm = updated.recfm
if (updated.lrecl > 0) selected.lrecl = updated.lrecl
if (updated.blksize > 0) selected.blksize = updated.blksize
if (updated.dsorg != null) selected.dsorg = updated.dsorg
if (updated.volume != null && (selected.volume == null || selected.volume == "******" || selected.volume.isEmpty())) {
selected.volume = updated.volume
}
if (updated.tracksAllocated > 0) selected.tracksAllocated = updated.tracksAllocated
if (updated.extents > 0) selected.extents = updated.extents
statusMessage = "Attributes for $dsn: RECFM=${selected.recfm}, LRECL=${selected.lrecl}, BLKSIZE=${selected.blksize}, VOL=${selected.volume}"
inspectionEntry = selected
showInspectionDialog = true
} else {
statusMessage = "No VTOC attributes found for $dsn"
}
}
} catch (ex: Exception) {
withContext(Dispatchers.Main) {
isLoading = false
statusMessage = "Inspection failed: ${ex.message}"
}
}
} }
selectedIndex = -1
statusMessage = "Parsed ${entries.size} entries from screen."
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (initialQuery.isNotBlank()) { if (client != null && client.connectionState.isFullSession) {
runQuery() queryHost()
} else { } else {
parseScreen() parseScreen()
} }
} }
if (showInspectionDialog && inspectionEntry != null) {
val entry = inspectionEntry!!
AlertDialog(
onDismissRequest = { showInspectionDialog = false },
title = { Text("Dataset Attributes (LISTDS)", fontWeight = FontWeight.Bold, fontSize = 16.sp) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Dataset: ${entry.datasetName}", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = Color.White)
HorizontalDivider(color = Color(0xFF373A40))
Text("Volume: ${entry.volume ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Record Format (RECFM): ${entry.recfm ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Logical Record Length (LRECL): ${if (entry.lrecl > 0) entry.lrecl.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Block Size (BLKSIZE): ${if (entry.blksize > 0) entry.blksize.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Dataset Organization (DSORG): ${entry.dsorg ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
if (entry.tracksAllocated > 0) {
Text("Allocated Tracks: ${entry.tracksAllocated}", fontSize = 12.sp, color = Color.LightGray)
}
if (entry.extents > 0) {
Text("Extents: ${entry.extents}", fontSize = 12.sp, color = Color.LightGray)
}
}
},
confirmButton = {
Button(onClick = { showInspectionDialog = false }) {
Text("OK")
}
}
)
}
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -135,13 +674,33 @@ fun HostDirectoryDialog(
Row { Row {
FilterChip( FilterChip(
selected = (hostType == FTConfig.HostType.TSO), selected = (hostType == FTConfig.HostType.TSO),
onClick = { hostType = FTConfig.HostType.TSO }, onClick = {
hostType = FTConfig.HostType.TSO
if (query == "* * A" || query == "* * *") {
query = ""
}
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
},
label = { Text("TSO", fontSize = 11.sp) } label = { Text("TSO", fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
FilterChip( FilterChip(
selected = (hostType == FTConfig.HostType.CMS), selected = (hostType == FTConfig.HostType.CMS),
onClick = { hostType = FTConfig.HostType.CMS }, onClick = {
hostType = FTConfig.HostType.CMS
if (query.isBlank() || query == "* * *" || query == "*") {
query = "* * A"
}
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
},
label = { Text("CMS", fontSize = 11.sp) } label = { Text("CMS", fontSize = 11.sp) }
) )
} }
@@ -155,8 +714,8 @@ fun HostDirectoryDialog(
OutlinedTextField( OutlinedTextField(
value = query, value = query,
onValueChange = { query = it }, onValueChange = { query = it },
label = { Text("Query Pattern") }, label = { Text("Filter / Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") }, placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* * A") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@@ -164,14 +723,29 @@ fun HostDirectoryDialog(
keyboardType = KeyboardType.Ascii, keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search imeAction = ImeAction.Search
), ),
keyboardActions = KeyboardActions(onSearch = { runQuery() }), keyboardActions = KeyboardActions(onSearch = {
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
parseTextData(query, "query text")
} else if (allEntries.isEmpty() && client != null && client.connectionState.isFullSession) {
queryHost()
}
}),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Button( Button(
onClick = { runQuery() }, onClick = {
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
parseTextData(query, "query text")
} else {
queryHost()
}
},
enabled = !isLoading,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) { ) {
Text("Query") Text(if (isLoading) "Querying..." else "Query Host")
} }
} }
@@ -181,26 +755,36 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = statusMessage.ifBlank { "Enter query or parse screen." }, text = if (statusMessage.isNotBlank()) statusMessage else "${currentEntries.size} entries shown (${allEntries.size} total).",
fontSize = 11.sp, fontSize = 11.sp,
color = Color.Gray color = Color.Gray,
modifier = Modifier.weight(1f)
) )
TextButton(onClick = { parseScreen() }) { Row {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66)) TextButton(onClick = { parseClipboard() }, enabled = !isLoading) {
Text("Paste Clipboard", fontSize = 11.sp, color = Color(0xFF339AF0))
}
TextButton(onClick = { parseScreen() }, enabled = !isLoading) {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
}
} }
} }
HorizontalDivider(color = Color(0xFF2C2D30)) HorizontalDivider(color = Color(0xFF2C2D30))
// Table View // Table View
if (entries.isEmpty()) { if (currentEntries.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("No entries found.", color = Color.Gray, fontSize = 12.sp) Text(
if (isLoading) "Loading entries from host..." else "No entries found.",
color = Color.Gray,
fontSize = 12.sp
)
} }
} else { } else {
val horizScroll = rememberScrollState() val horizScroll = rememberScrollState()
@@ -227,7 +811,7 @@ fun HostDirectoryDialog(
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center) Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp)) Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else { } else {
Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp)) Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(240.dp))
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp)) Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center) Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center) Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
@@ -242,7 +826,7 @@ fun HostDirectoryDialog(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
itemsIndexed(entries) { idx, item -> itemsIndexed(currentEntries) { idx, item ->
val isSelected = (idx == selectedIndex) val isSelected = (idx == selectedIndex)
Surface( Surface(
@@ -257,21 +841,21 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item is CMSDirectoryEntry) { if (item is CMSDirectoryEntry) {
Text(item.filename, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) Text(item.filename ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(item.filetype, color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp)) Text(item.filetype ?: "", color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
Text(item.filemode, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) Text(item.filemode ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center) Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.numRecords}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center) Text(if (item.numRecords > 0) "${item.numRecords}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.date} ${item.time}", color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp)) Text("${item.date ?: ""} ${item.time ?: ""}".trim(), color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) { } else if (item is TSODirectoryEntry) {
Text(item.datasetName, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(220.dp)) Text(item.datasetName ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(240.dp))
Text(item.volume, color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp)) Text(item.volume ?: "", color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center) Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center) Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.blksize}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center) Text(if (item.blksize > 0) "${item.blksize}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.tracksAllocated}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center) Text(if (item.tracksAllocated > 0) "${item.tracksAllocated}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.extents}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) Text(if (item.extents > 0) "${item.extents}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
} }
} }
} }
@@ -283,25 +867,41 @@ fun HostDirectoryDialog(
// Bottom Action Buttons // Bottom Action Buttons
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = onDismiss) { Row {
Text("Cancel", color = Color.LightGray) if (hostType == FTConfig.HostType.TSO) {
} OutlinedButton(
Spacer(modifier = Modifier.width(6.dp)) onClick = { inspectSelectedDataset() },
Button( enabled = selectedIndex in currentEntries.indices && !isLoading,
onClick = { contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)
if (selectedIndex in entries.indices) { ) {
val selectedName = entries[selectedIndex].name Text("Inspect (LISTDS)", fontSize = 11.sp)
onSelectDataset(selectedName)
onDismiss()
} }
}, }
enabled = selectedIndex in entries.indices, }
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Select Dataset") TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
if (selectedIndex in currentEntries.indices) {
val entry = currentEntries[selectedIndex]
val selectedName = entry.name
onSelectEntry?.invoke(selectedName, entry)
onSelectDataset(selectedName)
onDismiss()
}
},
enabled = selectedIndex in currentEntries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Select Dataset")
}
} }
} }
} }
@@ -0,0 +1,382 @@
package haus.nightmare.a3270.ui
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.util.Collections
import java.util.regex.Pattern
/**
* Real-time hotspot and URL auto-detection engine matching IBM Host On-Demand
* ShowURLOptions and ScreenText parity.
*
* Scans screen text using regular expressions for:
* 1. URLs (http://, https://, ftp://, file://, mailto:, www.)
* 2. Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER], ENTER)
* 3. Menu choices ("1.", "2.", "===> 3", "=> 4")
*
* Provides hit-testing for touch/mouse taps and generates keystroke actions.
*/
class HotspotEngine {
enum class HotspotType {
URL,
PF_KEY,
ENTER,
MENU_CHOICE
}
data class Hotspot(
val type: HotspotType,
val startRow: Int,
val startCol: Int,
val endRow: Int,
val endCol: Int,
val displayText: String,
val action: String,
val tooltip: String
) {
val text: String get() = displayText
/**
* Test whether the given row/col coordinate falls within this hotspot.
*/
fun contains(row: Int, col: Int): Boolean {
if (row < startRow || row > endRow) {
return false
}
if (startRow == endRow) {
return col in startCol..endCol
}
if (row == startRow) {
return col >= startCol
}
if (row == endRow) {
return col <= endCol
}
return true // Intermediate rows
}
}
var urlEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
var pfKeysEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
var menuChoicesEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
companion object {
private val URL_PATTERN = Pattern.compile(
"(?:https?|ftp|file)://[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|www\\.[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|mailto:[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+",
Pattern.CASE_INSENSITIVE
)
private val PF_KEY_PATTERN = Pattern.compile(
"(?:(?<=\\W)|^)(PF(?:[1-9]|1[0-9]|2[0-4])|F(?:[1-9]|1[0-9]|2[0-4])|PA[1-3]|\\[ENTER\\]|ENTER)(?:(?=\\W)|$)",
Pattern.CASE_INSENSITIVE
)
private val MENU_NUMBER_PATTERN = Pattern.compile(
"(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)"
)
private val MENU_PROMPT_PATTERN = Pattern.compile(
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
)
private val ISPF_MENU_PATTERN = Pattern.compile(
"(?:^\\s*|\\s{3,})(?:(\\d{1,2}(?:\\.\\d{1,2})?)(?:\\s*[-–—]\\s*|\\s+)|([A-Za-z])(?:\\s*[-–—]\\s*|\\s{2,}))([A-Za-z][A-Za-z0-9/_-]*)"
)
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size)
for (i in s until e) {
arr[i] = true
}
}
private fun isRangeOccupied(arr: BooleanArray, start: Int, end: Int): Boolean {
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size)
for (i in s until e) {
if (arr[i]) return true
}
return false
}
}
private var cachedScreenText: String = ""
private var cachedRows: Int = 0
private var cachedCols: Int = 0
private var cachedHotspots: List<Hotspot> = emptyList()
fun invalidateCache() {
synchronized(this) {
cachedScreenText = ""
cachedHotspots = emptyList()
}
}
/**
* Scan the given ScreenBuffer for interactive hotspots.
*/
fun scan(sb: ScreenBuffer?): List<Hotspot> {
if (sb == null) return emptyList()
val rows = sb.displayRows
val cols = sb.displayCols
val fullText = StringBuilder(rows * (cols + 1))
for (r in 0 until rows) {
for (c in 0 until cols) {
val baddr = r * cols + c
val ea = sb.getDisplayCell(baddr)
val ch = ea?.ucs4 ?: ' '
if (ea == null || ea.isFieldAttribute || ch <= ' ' || ch == '\u00FF') {
fullText.append(' ')
} else {
fullText.append(ch)
}
}
if (r < rows - 1) {
fullText.append('\n')
}
}
return scanText(fullText.toString(), rows, cols)
}
/**
* Scan formatted screen text (lines separated by '\n') for hotspots.
*/
fun scanText(screenText: String?, rows: Int, cols: Int, enabled: Boolean = true): List<Hotspot> {
if (!enabled || screenText == null || rows <= 0 || cols <= 0) {
return emptyList()
}
synchronized(this) {
if (screenText == cachedScreenText && rows == cachedRows && cols == cachedCols) {
return cachedHotspots
}
val hotspots = mutableListOf<Hotspot>()
val lines = screenText.split("\n")
val actualRows = minOf(rows, lines.size)
for (r in 0 until actualRows) {
val line = lines[r]
val occupied = BooleanArray(line.length + 1)
// 1. Scan URLs
if (urlEnabled) {
val mUrl = URL_PATTERN.matcher(line)
while (mUrl.find()) {
val startCol = mUrl.start()
var endCol = mUrl.end()
var match = mUrl.group()
while (match.length > 1) {
val last = match[match.length - 1]
if (last == '.' || last == ',' || last == ';' || last == ':' || last == ')' || last == ']' || last == '>') {
match = match.substring(0, match.length - 1)
endCol--
} else {
break
}
}
if (match.isEmpty()) continue
var actionUrl = match
if (match.startsWith("www.", ignoreCase = true)) {
actionUrl = "http://$match"
}
val h = Hotspot(
type = HotspotType.URL,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = match,
action = actionUrl,
tooltip = "Open URL: $actionUrl"
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
// 2. Scan PF / Function keys
if (pfKeysEnabled) {
val mPf = PF_KEY_PATTERN.matcher(line)
while (mPf.find()) {
val startCol = mPf.start(1)
val endCol = mPf.end(1)
if (isRangeOccupied(occupied, startCol, endCol)) continue
val matchStr = mPf.group(1) ?: continue
val keyText = matchStr.uppercase()
val action: String
val tooltip: String
if (keyText == "ENTER" || keyText == "[ENTER]") {
action = "[enter]"
tooltip = "Send [ENTER]"
} else if (keyText.startsWith("PF")) {
val numStr = keyText.substring(2)
action = "[pf$numStr]"
tooltip = "Send ${action.uppercase()}"
} else if (keyText.startsWith("F")) {
val numStr = keyText.substring(1)
action = "[pf$numStr]"
tooltip = "Send ${action.uppercase()}"
} else if (keyText.startsWith("PA")) {
val numStr = keyText.substring(2)
action = "[pa$numStr]"
tooltip = "Send ${action.uppercase()}"
} else {
continue
}
val h = Hotspot(
type = if (keyText.contains("ENTER")) HotspotType.ENTER else HotspotType.PF_KEY,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = matchStr,
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
// 3. Scan Menu choices ("1.", "2.", "===> 3", "=> 4")
if (menuChoicesEnabled) {
val mNum = MENU_NUMBER_PATTERN.matcher(line)
while (mNum.find()) {
val startCol = mNum.start(1)
val endCol = mNum.end()
if (isRangeOccupied(occupied, startCol, endCol)) continue
val choiceNum = mNum.group(1)
val action = "$choiceNum[enter]"
val tooltip = "Select option $choiceNum"
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = "$choiceNum.",
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
val mPrompt = MENU_PROMPT_PATTERN.matcher(line)
while (mPrompt.find()) {
val startCol = mPrompt.start()
val endCol = mPrompt.end()
if (isRangeOccupied(occupied, startCol, endCol)) continue
val choiceNum = mPrompt.group(1)
val action = "$choiceNum[enter]"
val tooltip = "Execute choice $choiceNum"
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = mPrompt.group(),
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
// 3c. Scan ISPF Menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
val mIspf = ISPF_MENU_PATTERN.matcher(line)
while (mIspf.find()) {
val choiceStr = mIspf.group(1) ?: mIspf.group(2)?.uppercase() ?: continue
val labelStr = mIspf.group(3) ?: continue
val startCol = if (mIspf.group(1) != null) mIspf.start(1) else mIspf.start(2)
val endCol = mIspf.end(3)
if (isRangeOccupied(occupied, startCol, endCol)) continue
val action = "$choiceStr[enter]"
val tooltip = "Select option $choiceStr ($labelStr)"
val display = line.substring(startCol, endCol)
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = display,
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
}
this.cachedScreenText = screenText
this.cachedRows = rows
this.cachedCols = cols
this.cachedHotspots = Collections.unmodifiableList(hotspots)
return cachedHotspots
}
}
/**
* Hit-test for a hotspot at the specified terminal row and column.
*/
fun findHotspot(row: Int, col: Int): Hotspot? {
synchronized(this) {
for (h in cachedHotspots) {
if (h.contains(row, col)) {
return h
}
}
return null
}
}
fun getHotspots(): List<Hotspot> {
synchronized(this) {
return cachedHotspots
}
}
fun findHotspotAt(hotspots: List<Hotspot>, row: Int, col: Int): Hotspot? {
for (h in hotspots) {
if (h.contains(row, col)) {
return h
}
}
return null
}
}
@@ -0,0 +1,365 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.macro.Macro
import haus.nightmare.lib3270j.macro.MacroScreen
import haus.nightmare.lib3270j.macro.action.MacroAction
import haus.nightmare.lib3270j.macro.action.MacroActionPrompt
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
private const val DEFAULT_MACRO_XML = """<HASCRIPT name="SampleLogon" timeout="15000" usevars="true">
<vars>
<create name="${'$'}userId" type="string" value="USER01" />
<create name="${'$'}count" type="integer" value="0" />
</vars>
<screen name="LogonScreen" entryscreen="true">
<description>
<oia status="NOTINHIBITED" />
</description>
<actions>
<input value="${'$'}userId + '[enter]'" movecursor="true" xlatehostkeys="true" />
</actions>
<nextscreens timeout="10000">
<nextscreen name="ReadyScreen" />
</nextscreens>
</screen>
<screen name="ReadyScreen" exitscreen="true">
<description>
<oia status="NOTINHIBITED" />
</description>
<actions>
<message title="Success" value="Logon macro completed successfully!" />
</actions>
</screen>
</HASCRIPT>"""
data class MacroPromptState(
val prompt: MacroActionPrompt,
val deferred: CompletableDeferred<String?>
)
@Composable
fun MacroDialog(
client: Telnet3270Client?,
onDismiss: () -> Unit
) {
var macroXml by remember { mutableStateOf(DEFAULT_MACRO_XML) }
var executionLog by remember { mutableStateOf("") }
var statusText by remember { mutableStateOf("Status: Stopped") }
var isPlaying by remember { mutableStateOf(false) }
var isPaused by remember { mutableStateOf(false) }
var activePrompt by remember { mutableStateOf<MacroPromptState?>(null) }
val coroutineScope = rememberCoroutineScope()
val macroRef = remember { AtomicReference<Macro?>(null) }
fun appendLog(msg: String) {
val line = "[${java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US).format(java.util.Date())}] $msg\n"
executionLog += line
}
DisposableEffect(client) {
val macro = if (client != null) Macro(client) else null
macroRef.set(macro)
if (macro != null) {
val listener = object : Macro.MacroListener {
override fun macroStateChanged(m: Macro, oldState: Int, newState: Int) {
when (newState) {
Macro.STATE_PLAYING -> {
isPlaying = true
isPaused = false
statusText = "Status: Playing"
appendLog("Macro playing...")
}
Macro.STATE_PAUSED -> {
isPaused = true
statusText = "Status: Paused"
appendLog("Macro paused.")
}
Macro.STATE_ERROR -> {
isPlaying = false
isPaused = false
statusText = "Status: Error"
appendLog("Macro error.")
}
Macro.STATE_STOPPED -> {
isPlaying = false
isPaused = false
statusText = "Status: Stopped"
appendLog("Macro stopped.")
}
}
}
override fun macroScreenMatching(screen: MacroScreen) {
appendLog("Matched screen: [${screen.name}]")
}
override fun macroActionExecuting(action: MacroAction) {
appendLog(" -> Executing: ${action.javaClass.simpleName}")
}
override fun macroMessage(title: String, message: String) {
appendLog("MESSAGE [$title]: $message")
}
override fun macroError(error: String) {
isPlaying = false
isPaused = false
statusText = "Status: Error - $error"
appendLog("ERROR: $error")
}
}
macro.addMacroListener(listener)
macro.setPromptHandler { prompt ->
val deferred = CompletableDeferred<String?>()
activePrompt = MacroPromptState(prompt, deferred)
kotlinx.coroutines.runBlocking {
deferred.await() ?: ""
}
}
}
onDispose {
macro?.stop()
macroRef.set(null)
}
}
Dialog(
onDismissRequest = {
if (!isPlaying) onDismiss()
},
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.90f)
.padding(vertical = 10.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"XML Macro Runtime (<HASCRIPT>)",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
statusText,
fontSize = 11.sp,
color = if (isPlaying) Color(0xFF51CF66) else Color.LightGray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Macro XML Editor Pane
Text("Macro Definition XML:", fontSize = 12.sp, color = Color.LightGray)
OutlinedTextField(
value = macroXml,
onValueChange = { macroXml = it },
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color.White
),
modifier = Modifier
.fillMaxWidth()
.weight(0.55f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF339AF0),
unfocusedBorderColor = Color(0xFF495057),
focusedContainerColor = Color(0xFF121212),
unfocusedContainerColor = Color(0xFF121212)
)
)
// Execution Log Pane
Text("Execution Log:", fontSize = 12.sp, color = Color.LightGray)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.45f)
.background(Color(0xFF0F0F0F), RoundedCornerShape(6.dp))
.padding(8.dp)
) {
val logScroll = rememberScrollState()
Text(
text = executionLog.ifBlank { "Ready to execute macro..." },
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = if (executionLog.isBlank()) Color.Gray else Color(0xFF69DB7C),
modifier = Modifier
.fillMaxSize()
.verticalScroll(logScroll)
)
}
// Control Buttons: Play, Pause, Stop, Close
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
val macro = macroRef.get()
if (macro != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val screens = haus.nightmare.lib3270j.macro.MacroXmlParser.parse(macroXml)
macro.setScreens(screens)
macro.setClient(client)
macro.play()
} catch (ex: Exception) {
appendLog("Execution exception: ${ex.message}")
}
}
}
},
enabled = !isPlaying && client != null && client.connectionState?.isFullSession == true,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)),
modifier = Modifier.weight(1f)
) {
Text("▶ Play", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
if (macro != null) {
if (macro.state == Macro.STATE_PLAYING) {
macro.pause()
} else if (macro.state == Macro.STATE_PAUSED) {
macro.resume()
}
}
},
enabled = isPlaying,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67700)),
modifier = Modifier.weight(1f)
) {
Text(if (isPaused) "Resume" else "Pause", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
macro?.stop()
},
enabled = isPlaying,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.weight(1f)
) {
Text("■ Stop", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
macro?.stop()
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
modifier = Modifier.width(75.dp)
) {
Text("Close", fontSize = 12.sp)
}
}
}
}
}
// Modal prompt dialog when macro requests <prompt> parameter input
val promptState = activePrompt
if (promptState != null) {
var inputValue by remember { mutableStateOf(promptState.prompt.defaultValue ?: "") }
AlertDialog(
onDismissRequest = {
promptState.deferred.complete(null)
activePrompt = null
},
title = {
Text(
text = promptState.prompt.title.ifBlank { "Macro Input Prompt" },
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = promptState.prompt.description.ifBlank { "Enter parameter:" },
fontSize = 13.sp,
color = Color.LightGray
)
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
promptState.deferred.complete(inputValue)
activePrompt = null
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(
onClick = {
promptState.deferred.complete(null)
activePrompt = null
}
) {
Text("Cancel")
}
}
)
}
}
@@ -40,6 +40,7 @@ fun OiaStatusBar(
inhibitReason: Int = 0, inhibitReason: Int = 0,
luName: String = "", luName: String = "",
isNumericField: Boolean = false, isNumericField: Boolean = false,
fieldStatus: String = "",
uiTheme: String = "DARK", uiTheme: String = "DARK",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -163,16 +164,22 @@ fun OiaStatusBar(
} }
} }
// Field Status: NUM vs ALPHA // Field Status: DBCS vs NUM vs ALPHA
if (connectionState.isConnected() && rows > 0 && cols > 0) { if (connectionState.isConnected() && rows > 0 && cols > 0) {
val effectiveFieldStatus = if (fieldStatus.isNotBlank()) fieldStatus else (if (isNumericField) "NUM" else "ALPHA")
val badgeColor = when (effectiveFieldStatus) {
"DBCS" -> sysAvailClr
"NUM" -> Color(0xFFFFD43B)
else -> Color(0xFF74C0FC)
}
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Surface( Surface(
color = Color(0xFF339AF0).copy(alpha = 0.2f), color = badgeColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) { ) {
Text( Text(
text = if (isNumericField) "NUM" else "ALPHA", text = effectiveFieldStatus,
color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC), color = badgeColor,
fontSize = 9.sp, fontSize = 9.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -33,6 +33,7 @@ import androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -284,6 +285,7 @@ fun ScreenExporterDialog(
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String { private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "" if (screenBuffer == null) return ""
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder() val sb = StringBuilder()
var currentHidden = false var currentHidden = false
@@ -291,11 +293,15 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
val rowSb = StringBuilder() val rowSb = StringBuilder()
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = r * cols + c val addr = r * cols + c
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) { if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ') rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
rowSb.append(' ')
} else { } else {
if (currentHidden && maskHidden) { if (currentHidden && maskHidden) {
rowSb.append(' ') rowSb.append(' ')
@@ -312,6 +318,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String { private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>" if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n") sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n") sb.append("<meta charset=\"UTF-8\">\n")
@@ -338,7 +345,7 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
for (r in 0 until rows) { for (r in 0 until rows) {
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = r * cols + c val addr = r * cols + c
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) { if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0 currentProt = (faVal and FA_PROTECT) != 0
@@ -346,6 +353,10 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell currentFieldEa = cell
sb.append(' ') sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
sb.append(' ')
} else { } else {
if (currentHidden && maskHidden) { if (currentHidden && maskHidden) {
sb.append(' ') sb.append(' ')
@@ -421,6 +432,7 @@ private fun generateBitmap(
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap if (screenBuffer == null) return bitmap
screenBuffer.updateDisplaySnapshot()
// Layer 1: Vector graphics plane (under text) // Layer 1: Vector graphics plane (under text)
if (graphicsPlane != null && graphicsPlane.hasContent()) { if (graphicsPlane != null && graphicsPlane.hasContent()) {
@@ -448,7 +460,7 @@ private fun generateBitmap(
for (r in 0 until rows) { for (r in 0 until rows) {
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = r * cols + c val addr = r * cols + c
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr) ?: continue
val left = (c * charWidth).toFloat() val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat() val top = (r * charHeight).toFloat()
@@ -0,0 +1,248 @@
package haus.nightmare.a3270.ui
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer.ScreenSnapshot
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenHistoryDialog(
historyBuffer: ScreenHistoryBuffer?,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) }
var historySize by remember { mutableIntStateOf(historyBuffer?.size() ?: 0) }
var selectedIndex by remember { mutableIntStateOf((historyBuffer?.size() ?: 1) - 1) }
// Listen for live updates if host writes new screens
DisposableEffect(historyBuffer) {
if (historyBuffer == null) return@DisposableEffect onDispose {}
val listener = ScreenHistoryBuffer.ScreenHistoryListener { buf, _ ->
val newSize = buf.size()
historySize = newSize
if (selectedIndex >= newSize - 2) {
selectedIndex = newSize - 1
}
}
historyBuffer.addListener(listener)
historySize = historyBuffer.size()
if (historySize > 0 && selectedIndex < 0) {
selectedIndex = historySize - 1
}
onDispose {
historyBuffer.removeListener(listener)
}
}
val currentSnapshot: ScreenSnapshot? = remember(historyBuffer, historySize, selectedIndex) {
if (historyBuffer != null && historySize > 0 && selectedIndex in 0 until historySize) {
try {
historyBuffer.get(selectedIndex)
} catch (_: Exception) {
null
}
} else {
null
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.85f)
.padding(vertical = 12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Top Header: Title & Info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Screen History Buffer",
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
if (currentSnapshot != null) {
Text(
dateFormat.format(Date(currentSnapshot.timestamp)),
fontSize = 12.sp,
color = Color.LightGray
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Scrub navigation slider & buttons
if (historySize > 0 && currentSnapshot != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { if (selectedIndex > 0) selectedIndex-- },
enabled = selectedIndex > 0,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(36.dp)
) {
Text("◄ Prev", fontSize = 12.sp)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Screen ${selectedIndex + 1} of $historySize",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
if (historySize > 1) {
Slider(
value = (selectedIndex + 1).toFloat(),
onValueChange = { selectedIndex = (it.toInt() - 1).coerceIn(0, historySize - 1) },
valueRange = 1f..historySize.toFloat(),
steps = if (historySize > 2) historySize - 2 else 0,
modifier = Modifier.fillMaxWidth().height(24.dp)
)
}
}
Button(
onClick = { if (selectedIndex < historySize - 1) selectedIndex++ },
enabled = selectedIndex < historySize - 1,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(36.dp)
) {
Text("Next ►", fontSize = 12.sp)
}
}
}
// Terminal Preview Pane
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black, RoundedCornerShape(8.dp))
.padding(8.dp)
) {
if (currentSnapshot != null) {
val verticalScroll = rememberScrollState()
val horizontalScroll = rememberScrollState()
Text(
text = currentSnapshot.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF00FF41), // Classic 3270 Green Phosphor
lineHeight = 14.sp,
modifier = Modifier
.fillMaxSize()
.verticalScroll(verticalScroll)
.horizontalScroll(horizontalScroll)
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"No history frames captured yet.",
color = Color.Gray,
fontSize = 14.sp
)
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
if (currentSnapshot != null) {
clipboardManager.setText(AnnotatedString(currentSnapshot.text))
Toast.makeText(context, "Full screen copied to clipboard", Toast.LENGTH_SHORT).show()
}
},
enabled = currentSnapshot != null,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
modifier = Modifier.weight(1f)
) {
Text("Copy Screen", fontSize = 12.sp)
}
Button(
onClick = {
if (currentSnapshot != null) {
clipboardManager.setText(AnnotatedString(currentSnapshot.formattedTable))
Toast.makeText(context, "Table text copied to clipboard", Toast.LENGTH_SHORT).show()
}
},
enabled = currentSnapshot != null,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
modifier = Modifier.weight(1f)
) {
Text("Copy Table", fontSize = 12.sp)
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
modifier = Modifier.width(80.dp)
) {
Text("Close", fontSize = 12.sp)
}
}
}
}
}
}
@@ -30,6 +30,14 @@ fun SettingsDialog(
initialCrosshairRuler: Boolean = false, initialCrosshairRuler: Boolean = false,
initialCursorStyle: String = "BLOCK", initialCursorStyle: String = "BLOCK",
initialBlockSelectMode: Boolean = false, initialBlockSelectMode: Boolean = false,
initialBlockPaste: Boolean = true,
initialTrimRectSizingHandles: Boolean = false,
initialTrimRectRemainAfterEdit: Boolean = false,
initialPasteWordBreak: Boolean = true,
initialHotspotsEnabled: Boolean = false,
initialHotspotsUrlEnabled: Boolean = true,
initialHotspotsPfEnabled: Boolean = true,
initialHotspotsMenuChoicesEnabled: Boolean = true,
initialStartupBehavior: String = "SHOW_CONNECT", initialStartupBehavior: String = "SHOW_CONNECT",
initialDynamicRows: Int = 62, initialDynamicRows: Int = 62,
initialDynamicCols: Int = 160, initialDynamicCols: Int = 160,
@@ -44,6 +52,7 @@ fun SettingsDialog(
initialFourColorOverride: Boolean = false, initialFourColorOverride: Boolean = false,
initialNumericFieldLock: Boolean = true, initialNumericFieldLock: Boolean = true,
initialAutoSkipEnabled: Boolean = true, initialAutoSkipEnabled: Boolean = true,
initialDefaultHostType: String = "TSO",
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: ( onSave: (
maskHiddenInput: Boolean, maskHiddenInput: Boolean,
@@ -56,6 +65,14 @@ fun SettingsDialog(
crosshairRuler: Boolean, crosshairRuler: Boolean,
cursorStyle: String, cursorStyle: String,
blockSelectMode: Boolean, blockSelectMode: Boolean,
blockPaste: Boolean,
trimRectSizingHandles: Boolean,
trimRectRemainAfterEdit: Boolean,
pasteWordBreak: Boolean,
hotspotsEnabled: Boolean,
hotspotsUrlEnabled: Boolean,
hotspotsPfEnabled: Boolean,
hotspotsMenuChoicesEnabled: Boolean,
startupBehavior: String, startupBehavior: String,
dynamicRows: Int, dynamicRows: Int,
dynamicCols: Int, dynamicCols: Int,
@@ -69,7 +86,8 @@ fun SettingsDialog(
insertOffOnAid: Boolean, insertOffOnAid: Boolean,
fourColorOverride: Boolean, fourColorOverride: Boolean,
numericFieldLock: Boolean, numericFieldLock: Boolean,
autoSkipEnabled: Boolean autoSkipEnabled: Boolean,
defaultHostType: String
) -> Unit ) -> Unit
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@@ -86,7 +104,19 @@ fun SettingsDialog(
var crosshairRuler by remember { mutableStateOf(initialCrosshairRuler) } var crosshairRuler by remember { mutableStateOf(initialCrosshairRuler) }
var cursorStyle by remember { mutableStateOf(initialCursorStyle) } var cursorStyle by remember { mutableStateOf(initialCursorStyle) }
var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) } var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) }
var blockPaste by remember { mutableStateOf(initialBlockPaste) }
var trimRectSizingHandles by remember { mutableStateOf(initialTrimRectSizingHandles) }
var trimRectRemainAfterEdit by remember { mutableStateOf(initialTrimRectRemainAfterEdit) }
var pasteWordBreak by remember { mutableStateOf(initialPasteWordBreak) }
var hotspotsEnabled by remember { mutableStateOf(initialHotspotsEnabled) }
var hotspotsUrlEnabled by remember { mutableStateOf(initialHotspotsUrlEnabled) }
var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) }
var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) }
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) } var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
val savedHosts = remember { haus.nightmare.a3270.storage.HostStorage.getSavedHosts(context) }
var selectedAutoHostId by remember {
mutableStateOf(haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)?.id ?: savedHosts.firstOrNull()?.id ?: "")
}
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) } var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) } var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
@@ -101,6 +131,7 @@ fun SettingsDialog(
var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) } var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) }
var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) } var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) }
var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) } var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) }
var defaultHostType by remember { mutableStateOf(initialDefaultHostType) }
var showImportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) }
@@ -262,6 +293,166 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
// Block Paste Mode
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Block Paste Mode", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Align multi-line clipboard pastes with cursor start column.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = blockPaste,
onCheckedChange = { blockPaste = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Trim Rectangle Sizing Handles
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Trim Rectangle Sizing Handles", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Show interactive drag handles around block selection.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = trimRectSizingHandles,
onCheckedChange = { trimRectSizingHandles = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Trim Rectangle Remains After Edit
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Keep Selection After Edit", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Trim rectangle remains after cut/copy/paste.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = trimRectRemainAfterEdit,
onCheckedChange = { trimRectRemainAfterEdit = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Paste Word-Break
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Paste Without Splitting Words", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Avoid splitting words across fields when pasting.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = pasteWordBreak,
onCheckedChange = { pasteWordBreak = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// SECTION: INTERACTIVE HOTSPOTS
Text("Interactive Hotspots", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
Spacer(modifier = Modifier.height(8.dp))
// Master Hotspots Switch (Default DISABLED)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Enable Interactive Hotspots", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Tappable URLs, function keys, and menu choices (disabled by default for touch screens).", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsEnabled,
onCheckedChange = { hotspotsEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
if (hotspotsEnabled) {
Spacer(modifier = Modifier.height(10.dp))
// Hotspots: URL Detection
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect URLs", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("http://, https://, ftp://, www., mailto:", fontSize = 11.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsUrlEnabled,
onCheckedChange = { hotspotsUrlEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(8.dp))
// Hotspots: Function Key Detection
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect Function Keys", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("PF1..PF24, F1..F24, PA1..PA3, [ENTER]", fontSize = 11.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsPfEnabled,
onCheckedChange = { hotspotsPfEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(8.dp))
// Hotspots: Menu Choice Detection
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("ISPF, \"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsMenuChoicesEnabled,
onCheckedChange = { hotspotsMenuChoicesEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
}
Spacer(modifier = Modifier.height(10.dp))
// Haptic Feedback // Haptic Feedback
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -304,6 +495,47 @@ fun SettingsDialog(
) )
} }
} }
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true) && savedHosts.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text("Auto-Connect Target Profile", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(4.dp))
var hostDropdownExpanded by remember { mutableStateOf(false) }
val selectedHost = savedHosts.find { it.id == selectedAutoHostId } ?: savedHosts.first()
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { hostDropdownExpanded = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "${selectedHost.name} (${selectedHost.host}:${selectedHost.port})",
fontSize = 12.sp,
color = Color.White
)
}
DropdownMenu(
expanded = hostDropdownExpanded,
onDismissRequest = { hostDropdownExpanded = false }
) {
savedHosts.forEach { host ->
DropdownMenuItem(
text = {
Text(
text = "${host.name} (${host.host}:${host.port})",
fontSize = 12.sp,
fontWeight = if (host.id == selectedAutoHostId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
selectedAutoHostId = host.id
hostDropdownExpanded = false
}
)
}
}
}
}
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -429,6 +661,29 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Default Host Type
Column(modifier = Modifier.fillMaxWidth()) {
Text("Default Host Type", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Target system environment for IND\$FILE transfers and directory queries.", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("TSO", "CMS", "CICS").forEach { hostTypeKey ->
val isSelected = defaultHostType.equals(hostTypeKey, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = { defaultHostType = hostTypeKey },
label = { Text(hostTypeKey, fontSize = 10.sp) },
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Verify TLS Certs // Verify TLS Certs
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -658,6 +913,15 @@ fun SettingsDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Button(
onClick = {
haus.nightmare.a3270.storage.AppSettings.shareSettingsIni(context)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1971C2)),
modifier = Modifier.weight(1f)
) {
Text("Share INI", fontSize = 12.sp)
}
OutlinedButton( OutlinedButton(
onClick = { onClick = {
val sw = java.io.StringWriter() val sw = java.io.StringWriter()
@@ -668,13 +932,13 @@ fun SettingsDialog(
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Export to INI", fontSize = 12.sp) Text("Copy", fontSize = 12.sp)
} }
OutlinedButton( OutlinedButton(
onClick = { showImportDialog = true }, onClick = { showImportDialog = true },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Import from INI", fontSize = 12.sp) Text("Import", fontSize = 12.sp)
} }
} }
@@ -707,6 +971,14 @@ fun SettingsDialog(
crosshairRuler, crosshairRuler,
cursorStyle, cursorStyle,
blockSelectMode, blockSelectMode,
blockPaste,
trimRectSizingHandles,
trimRectRemainAfterEdit,
pasteWordBreak,
hotspotsEnabled,
hotspotsUrlEnabled,
hotspotsPfEnabled,
hotspotsMenuChoicesEnabled,
startupBehavior, startupBehavior,
dRows, dRows,
dCols, dCols,
@@ -720,8 +992,21 @@ fun SettingsDialog(
insertOffOnAid, insertOffOnAid,
fourColorOverride, fourColorOverride,
numericFieldLock, numericFieldLock,
autoSkipEnabled autoSkipEnabled,
defaultHostType
) )
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
val targetHost = savedHosts.find { it.id == selectedAutoHostId }
if (targetHost != null) {
haus.nightmare.a3270.storage.HostStorage.saveHost(context, targetHost.copy(autoConnect = true))
}
} else {
val autoHost = haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)
if (autoHost != null) {
haus.nightmare.a3270.storage.HostStorage.saveHost(context, autoHost.copy(autoConnect = false))
}
}
}, },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
@@ -760,6 +1045,14 @@ fun SettingsDialog(
crosshairRuler = haus.nightmare.a3270.storage.AppSettings.isCrosshairRulerEnabled(context) crosshairRuler = haus.nightmare.a3270.storage.AppSettings.isCrosshairRulerEnabled(context)
cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context) cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context)
blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(context) blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(context)
blockPaste = haus.nightmare.a3270.storage.AppSettings.isBlockPasteEnabled(context)
trimRectSizingHandles = haus.nightmare.a3270.storage.AppSettings.isTrimRectSizingHandlesEnabled(context)
trimRectRemainAfterEdit = haus.nightmare.a3270.storage.AppSettings.isTrimRectRemainAfterEditEnabled(context)
pasteWordBreak = haus.nightmare.a3270.storage.AppSettings.isPasteWordBreakEnabled(context)
hotspotsEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsEnabled(context)
hotspotsUrlEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsUrlEnabled(context)
hotspotsPfEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsPfEnabled(context)
hotspotsMenuChoicesEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsMenuChoicesEnabled(context)
startupBehavior = haus.nightmare.a3270.storage.AppSettings.getStartupBehavior(context).name startupBehavior = haus.nightmare.a3270.storage.AppSettings.getStartupBehavior(context).name
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString() dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString() dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
@@ -776,6 +1069,7 @@ fun SettingsDialog(
fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context) fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context)
numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context) numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context)
autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(context) autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(context)
defaultHostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) { } catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
@@ -88,6 +88,14 @@ class TerminalInputView(context: Context) : View(context) {
var onBackspace: () -> Unit = {} var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {} var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {} var onBackTab: () -> Unit = {}
var isDbcsSession: Boolean = false
set(value) {
if (field != value) {
field = value
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.restartInput(this)
}
}
init { init {
isFocusable = true isFocusable = true
@@ -97,9 +105,13 @@ class TerminalInputView(context: Context) : View(context) {
override fun onCheckIsTextEditor(): Boolean = true override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or outAttrs.inputType = if (isDbcsSession) {
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS } else {
InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
}
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE EditorInfo.IME_ACTION_NONE
@@ -70,14 +70,24 @@ fun TerminalView(
cursorStyle: String = "BLOCK", cursorStyle: String = "BLOCK",
isInsertMode: Boolean = false, isInsertMode: Boolean = false,
blockSelectMode: Boolean = false, blockSelectMode: Boolean = false,
blockPaste: Boolean = true,
trimRectSizingHandles: Boolean = false,
trimRectRemainAfterEdit: Boolean = false,
hotspotsEnabled: Boolean = false,
hotspotEngine: HotspotEngine? = null,
fourColorOverride: Boolean = false, fourColorOverride: Boolean = false,
searchHighlightAddr: Int = -1, searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0, searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null, onLightPenSelect: ((Int) -> Unit)? = null,
onSendHotspotAction: ((String) -> Unit)? = null,
onOpenUrl: ((String) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null, onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {}, onPasteText: (String) -> Unit = {},
onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null, onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null,
onToggleBlockSelectMode: (() -> Unit)? = null,
onToggleBlockPaste: (() -> Unit)? = null,
onToggleHotspots: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -165,8 +175,10 @@ fun TerminalView(
} else if (totalDistance <= slop) { } else if (totalDistance <= slop) {
// Short Tap // Short Tap
showContextMenu = false showContextMenu = false
selectionStart = null if (!trimRectRemainAfterEdit) {
selectionEnd = null selectionStart = null
selectionEnd = null
}
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows) val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) { if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1) val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
@@ -185,15 +197,74 @@ fun TerminalView(
} else if (isLightPenMode) { } else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr) onLightPenSelect?.invoke(clickAddr)
} else { } else {
var addr = clickAddr // 1. Hotspot tap check when enabled
val buf = screenBuffer var handledHotspot = false
if (buf != null && buf.isFormatted) { if (hotspotsEnabled && hotspotEngine != null) {
val faVal = buf.getFieldAttributeAt(addr) val h = hotspotEngine.findHotspot(row, col)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) { if (h != null) {
addr = buf.findNextUnprotected(addr) if (h.type == HotspotEngine.HotspotType.URL) {
onOpenUrl?.invoke(h.action)
handledHotspot = true
} else if (h.type == HotspotEngine.HotspotType.MENU_CHOICE) {
// In 3270 / ISPF, clicking/tapping a menu choice hotspot positions the cursor
// on a protected screen label. Typing directly into a protected cell triggers
// INHIBIT_PROTECTED_FIELD keyboard lock. Menu choices must be submitted via
// the command/option line (first unprotected field reachable via [home]).
var action = h.action
if (!action.startsWith("[home]")) {
action = "[home][eraseeof]$action"
}
onSendHotspotAction?.invoke(action)
handledHotspot = true
} else {
onSendHotspotAction?.invoke(h.action)
handledHotspot = true
}
}
}
if (!handledHotspot) {
// 2. Detectable field check (HoD parity)
val buf = screenBuffer
var isDetectable = false
if (buf != null && buf.isFormatted) {
val faPos = buf.findFieldAttribute(clickAddr)
if (faPos >= 0) {
val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
if (faIsSelectable(fa) && !faIsZero(fa)) {
val size = buf.displayRows * buf.displayCols
val desPos = if (size > 0) (faPos + 1) % size else -1
val desCell = if (desPos >= 0) buf.getDisplayCell(desPos) else null
val desAscii = desCell?.ucs4 ?: 0.toChar()
val desEbcdic = desCell?.ec?.toInt()?.and(0xFF) ?: 0
val isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' ||
desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E')
val isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06')
// Automatic tap selection applies only to toggle (?/>) and enter (&) fields.
// Standard text fields (space/null designators) must not trigger light-pen selection
// so normal cursor positioning and editing work as expected.
if (isToggleField || isEnterField) {
onLightPenSelect?.invoke(clickAddr)
isDetectable = true
}
}
}
}
// 3. Normal tap cursor positioning
if (!isDetectable) {
var addr = clickAddr
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
} }
} }
onTapAddress(addr)
} }
} }
} }
@@ -202,10 +273,15 @@ fun TerminalView(
} }
) { ) {
val version = currentScreenVersion val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas if (version == -1L) return@Canvas
val metrics = calculateGridMetrics(size.width, size.height, cols, rows) val buf = screenBuffer
val snapshot = buf?.displaySnapshot
val snapRows = snapshot?.rows ?: (buf?.displayRows ?: rows)
val snapCols = snapshot?.cols ?: (buf?.displayCols ?: cols)
val activeCursorAddr = snapshot?.cursorAddress ?: currentCursorAddr
val metrics = calculateGridMetrics(size.width, size.height, snapCols, snapRows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth val cellWidth = metrics.cellWidth
@@ -227,8 +303,7 @@ fun TerminalView(
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW) paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
} }
val buf = screenBuffer val totalCells = snapRows * snapCols
val totalCells = rows * cols
var currentFA: Byte = 0 var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null var currentFieldEa: ExtendedAttribute? = null
@@ -242,10 +317,10 @@ fun TerminalView(
val start = selectionStart val start = selectionStart
val end = selectionEnd val end = selectionEnd
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) { if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1) val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1) val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1) val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1) val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
selMinRow = minOf(startRow, endRow) selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow) selMaxRow = maxOf(startRow, endRow)
@@ -272,11 +347,11 @@ fun TerminalView(
} }
// 2. Draw Crosshair Ruler if enabled // 2. Draw Crosshair Ruler if enabled
if (crosshairRuler && cols > 0 && rows > 0) { if (crosshairRuler && snapCols > 0 && snapRows > 0) {
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1) val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1) val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
val gridW = cols * cellWidth val gridW = snapCols * cellWidth
val gridH = rows * cellHeight val gridH = snapRows * cellHeight
val cx = offsetX + curCol * cellWidth val cx = offsetX + curCol * cellWidth
val cy = offsetY + curRow * cellHeight val cy = offsetY + curRow * cellHeight
@@ -292,9 +367,9 @@ fun TerminalView(
) )
} }
for (r in 0 until rows) { for (r in 0 until snapRows) {
for (c in 0 until cols) { for (c in 0 until snapCols) {
val addr = r * cols + c val addr = r * snapCols + c
if (addr >= totalCells) break if (addr >= totalCells) break
val left = offsetX + c * cellWidth val left = offsetX + c * cellWidth
@@ -310,9 +385,9 @@ fun TerminalView(
var csVal = 0 var csVal = 0
var ecVal = 0 var ecVal = 0
if (buf != null) { val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
val ea = buf.getCell(addr)
if (ea != null) {
if (ea.isFieldAttribute) { if (ea.isFieldAttribute) {
currentFA = ea.fa currentFA = ea.fa
currentFieldEa = ea currentFieldEa = ea
@@ -366,7 +441,10 @@ fun TerminalView(
bgColor = tmp bgColor = tmp
} }
if (bgColor != COLOR_BLACK && bgColor != Color.Transparent) { val tr = if (ea != null && ea.tr != 0.toByte()) ea.tr else (currentFieldEa?.tr ?: 0)
val isOpaque = ((tr.toInt() and 0xFF) == (ExtendedAttribute.TR_OPAQUE.toInt() and 0xFF))
if (isOpaque || (bgColor != COLOR_BLACK && bgColor != Color.Transparent)) {
drawRect( drawRect(
color = bgColor, color = bgColor,
topLeft = Offset(left, top), topLeft = Offset(left, top),
@@ -404,19 +482,22 @@ fun TerminalView(
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode) // Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
if (addr == activeCursorAddr && !isSelected && cursorVisible) { if (addr == activeCursorAddr && !isSelected && cursorVisible) {
val isDbcsCur = ea != null && (ea.db == ExtendedAttribute.DB_LEFT || ea.db == ExtendedAttribute.DB_RIGHT)
val curWidth = if (isDbcsCur) cellWidth * 2f else cellWidth
val curLeft = if (isDbcsCur && ea?.db == ExtendedAttribute.DB_RIGHT && c > 0) left - cellWidth else left
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
if (isUnderlineCursor) { if (isUnderlineCursor) {
val ulH = maxOf(4f, cellHeight / 4f) val ulH = maxOf(4f, cellHeight / 4f)
drawRect( drawRect(
color = Color(255, 255, 255, 180), color = Color(255, 255, 255, 180),
topLeft = Offset(left, top + cellHeight - ulH), topLeft = Offset(curLeft, top + cellHeight - ulH),
size = Size(cellWidth, ulH) size = Size(curWidth, ulH)
) )
} else { } else {
drawRect( drawRect(
color = Color(255, 255, 255, 180), color = Color(255, 255, 255, 180),
topLeft = Offset(left, top), topLeft = Offset(curLeft, top),
size = Size(cellWidth, cellHeight) size = Size(curWidth, cellHeight)
) )
} }
} }
@@ -442,12 +523,14 @@ fun TerminalView(
} }
} }
val suppressText = isBlink && !textBlinkVisible val isDbcsRight = ea?.db == ExtendedAttribute.DB_RIGHT
val isDbcsSoSi = ea?.db == ExtendedAttribute.DB_SO || ea?.db == ExtendedAttribute.DB_SI
val suppressText = (isBlink && !textBlinkVisible) || isDbcsRight || isDbcsSoSi
if (!drawnAsPs && charVal != ' ' && !suppressText) { if (!drawnAsPs && charVal != ' ' && !suppressText) {
paint.color = fgColor.toArgb() paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics val fontMetrics = paint.fontMetrics
val textX = left + cellWidth / 2f val textX = if (ea?.db == ExtendedAttribute.DB_LEFT) left + cellWidth else left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
@@ -466,6 +549,104 @@ fun TerminalView(
strokeWidth = 2f strokeWidth = 2f
) )
} }
// Field Outlining (0xC2 / TY_FOUTLINE)
val cellEa = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
val fEa = currentFieldEa
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
val olOverline = ExtendedAttribute.OL_OVERLINE.toInt() and 0xFF
val olLeft = ExtendedAttribute.OL_LEFT.toInt() and 0xFF
val olRight = ExtendedAttribute.OL_RIGHT.toInt() and 0xFF
if (outline == 0 && fEa != null && fEa.ol != 0.toByte()) {
val fol = fEa.ol.toInt() and 0xFF
if ((fol and olUnderline) != 0) outline = outline or olUnderline
if ((fol and olOverline) != 0) outline = outline or olOverline
val size = totalCells
val prevAddr = (addr - 1 + size) % size
val prevCell = snapshot?.getCell(prevAddr) ?: buf?.getDisplayCell(prevAddr)
if (prevCell?.isFieldAttribute == true || c == 0) {
if ((fol and olLeft) != 0) outline = outline or olLeft
}
val nextAddr = (addr + 1) % size
val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
if ((fol and olRight) != 0) outline = outline or olRight
}
}
if (outline != 0) {
if ((outline and olUnderline) != 0) {
drawLine(fgColor, Offset(left, top + cellHeight - 1f), Offset(left + cellWidth, top + cellHeight - 1f), strokeWidth = 1.5f)
}
if ((outline and olRight) != 0) {
drawLine(fgColor, Offset(left + cellWidth - 1f, top), Offset(left + cellWidth - 1f, top + cellHeight), strokeWidth = 1.5f)
}
if ((outline and olOverline) != 0) {
drawLine(fgColor, Offset(left, top), Offset(left + cellWidth, top), strokeWidth = 1.5f)
}
if ((outline and olLeft) != 0) {
drawLine(fgColor, Offset(left, top), Offset(left, top + cellHeight), strokeWidth = 1.5f)
}
}
}
}
// Rectangular Block Selection Sizing Handles & Box Outline
if (trimRectSizingHandles && selMinRow != -1 && selMinCol != -1) {
val x1 = offsetX + selMinCol * cellWidth
val y1 = offsetY + selMinRow * cellHeight
val x2 = offsetX + (selMaxCol + 1) * cellWidth
val y2 = offsetY + (selMaxRow + 1) * cellHeight
val w = x2 - x1
val h = y2 - y1
val xm = (x1 + x2) / 2f
val ym = (y1 + y2) / 2f
drawRect(
color = Color.Yellow,
topLeft = Offset(x1, y1),
size = Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
val handleSize = 8f
val handleHalf = handleSize / 2f
val handles = listOf(
Offset(x1, y1), Offset(xm, y1), Offset(x2, y1),
Offset(x2, ym), Offset(x2, y2), Offset(xm, y2),
Offset(x1, y2), Offset(x1, ym)
)
for (pt in handles) {
drawRect(
color = Color.Yellow,
topLeft = Offset(pt.x - handleHalf, pt.y - handleHalf),
size = Size(handleSize, handleSize)
)
}
}
// Interactive Hotspot Underline Rendering
if (hotspotsEnabled && hotspotEngine != null && buf != null) {
val activeHotspots = hotspotEngine.scan(buf)
for (h in activeHotspots) {
val hr0 = h.startRow
val hr1 = h.endRow
for (r in hr0..hr1) {
val c0 = if (r == hr0) h.startCol else 0
val c1 = if (r == hr1) h.endCol else cols - 1
val x1 = offsetX + c0 * cellWidth
val x2 = offsetX + (c1 + 1) * cellWidth
val y = offsetY + (r + 1) * cellHeight - 2f
drawLine(
color = Color(0xFF00DCFF),
start = Offset(x1, y),
end = Offset(x2, y),
strokeWidth = 2f
)
}
} }
} }
@@ -569,6 +750,36 @@ fun TerminalView(
showPasteWrapDialog = true showPasteWrapDialog = true
} }
) )
if (onToggleBlockSelectMode != null) {
DropdownMenuItem(
text = { Text(if (blockSelectMode) "✓ Block Select Mode" else " Block Select Mode") },
onClick = {
showContextMenu = false
onToggleBlockSelectMode()
}
)
}
if (onToggleBlockPaste != null) {
DropdownMenuItem(
text = { Text(if (blockPaste) "✓ Block Paste" else " Block Paste") },
onClick = {
showContextMenu = false
onToggleBlockPaste()
}
)
}
if (onToggleHotspots != null) {
DropdownMenuItem(
text = { Text(if (hotspotsEnabled) "✓ Interactive Hotspots" else " Interactive Hotspots") },
onClick = {
showContextMenu = false
onToggleHotspots()
}
)
}
} }
if (showPasteWrapDialog) { if (showPasteWrapDialog) {
@@ -669,8 +880,8 @@ fun calculateGridMetrics(
val gridW = cols * cellW val gridW = cols * cellW
val gridH = rows * cellH val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f val offX = maxOf(0f, (viewWidth - gridW) / 2f)
val offY = (viewHeight - gridH) / 2f val offY = maxOf(0f, (viewHeight - gridH) / 2f)
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY) return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
} }
@@ -710,9 +921,22 @@ private fun copySelection(
for (c in colStart..colEnd) { for (c in colStart..colEnd) {
val addr = r * cols + c val addr = r * cols + c
if (addr < (rows * cols)) { if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) { if (cell != null && !cell.isFieldAttribute) {
line.append(cell.ucs4) if (cell.db == ExtendedAttribute.DB_RIGHT) {
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
if (isSubsequent) {
continue // Character already added by DB_LEFT
} else {
line.append(' ')
}
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
line.append(' ')
} else if (cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
} else { } else {
line.append(' ') line.append(' ')
} }
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#000000"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.029365"
android:scaleY="-0.029365"
android:translateX="-8.57"
android:translateY="103.38">
<!-- Green Terminal Screen Frame -->
<path
android:strokeColor="#00FF00"
android:strokeWidth="40"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
android:strokeColor="#0000FF"
android:strokeWidth="60"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
android:strokeColor="#FF0000"
android:strokeWidth="60"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- Magenta Typography: lib3270j -->
<!-- 'l' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smiley Details -->
<!-- Mouth -->
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye -->
<path
android:strokeColor="#00FFFF"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye -->
<path
android:strokeColor="#00FFFF"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2892.03,1528.23 -29.21,-20.68" />
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2862.82,1507.55 h 32.86" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="exports" path="exports/" />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>
@@ -0,0 +1,318 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.a3270.ui.ConnectChoice
import haus.nightmare.a3270.ui.LastTransferState
import haus.nightmare.a3270.ui.formatCMSFilter
import haus.nightmare.a3270.ui.matchesCmsFilemode
import haus.nightmare.a3270.ui.matchesCmsFilter
import haus.nightmare.a3270.ui.matchesFilter
import haus.nightmare.a3270.ui.matchesTsoFilter
import haus.nightmare.a3270.ui.matchesWildcard
import haus.nightmare.a3270.ui.resolveDefaultQuery
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.StringReader
import java.util.UUID
class ConnectionAndHostDirectoryTest {
@Test
fun testSavedHostToConnectionConfigPreservesMetadata() {
val hostId = UUID.randomUUID().toString()
val host = SavedHost(
id = hostId,
name = "MVS Production LPAR",
host = "mvs.prod.corp",
port = 992,
model = 4,
dynamicRows = 62,
dynamicCols = 160,
luName = "LU3270A",
autoConnect = false,
hostType = "TSO",
useTls = true,
tlsVerifyCert = true,
tn3270e = true,
graphicsMode = "VECTOR_GRAPHICS",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.corp",
proxyPort = 1080,
proxyUsername = "mvsadmin",
proxyPassword = "mypassword"
)
val config = host.toConnectionConfig()
assertEquals("mvs.prod.corp", config.host)
assertEquals(992, config.port)
assertEquals(TerminalModel.IBM_3279_4, config.model)
assertEquals("LU3270A", config.luName)
assertTrue(config.isUseTls)
assertTrue(config.isTlsVerifyCert)
assertTrue(config.isTn3270eEnabled)
assertEquals(GraphicsMode.VECTOR_GRAPHICS, config.graphicsMode)
assertEquals("1047", config.codePage)
assertEquals(ConnectionConfig.ProxyType.SOCKS5, config.proxyType)
assertEquals("proxy.corp", config.proxyHost)
assertEquals(1080, config.proxyPort)
assertEquals("mvsadmin", config.proxyUsername)
assertEquals("mypassword", config.proxyPassword)
// Metadata preservation added in connection hardening
assertTrue("savedConnection flag must be true", config.isSavedConnection)
assertEquals(hostId, config.savedProfileId)
assertEquals("MVS Production LPAR", config.savedProfileName)
assertEquals("TSO", config.hostType)
assertEquals(FTConfig.HostType.TSO, config.ftHostType)
}
@Test
fun testSavedHostToConnectionConfigCmsHostType() {
val host = SavedHost(
id = "cms-1",
name = "VM/CMS Guest",
host = "vm.local",
port = 23,
hostType = "CMS"
)
val config = host.toConnectionConfig()
assertEquals("CMS", config.hostType)
assertEquals(FTConfig.HostType.CMS, config.ftHostType)
}
@Test
fun testResolveDefaultQuery() {
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, null))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, ""))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, " "))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "*"))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "* * *"))
assertEquals("PROFILE EXEC A", resolveDefaultQuery(FTConfig.HostType.CMS, "PROFILE EXEC A"))
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, null))
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, ""))
assertEquals("SYS1.*", resolveDefaultQuery(FTConfig.HostType.TSO, "SYS1.*"))
}
@Test
fun testFormatCMSFilter() {
assertEquals("* * A", formatCMSFilter(null))
assertEquals("* * A", formatCMSFilter(""))
assertEquals("* * A", formatCMSFilter(" "))
assertEquals("* * A", formatCMSFilter("*"))
assertEquals("* * A", formatCMSFilter("* * *"))
assertEquals("MYFILE * A", formatCMSFilter("MYFILE"))
assertEquals("MYFILE EXEC A", formatCMSFilter("MYFILE EXEC"))
assertEquals("MYFILE EXEC B", formatCMSFilter("MYFILE EXEC B"))
assertEquals("PROFILE EXEC A", formatCMSFilter("PROFILE.EXEC"))
assertEquals("PROFILE EXEC A", formatCMSFilter("'PROFILE.EXEC'"))
}
@Test
fun testMatchesWildcard() {
assertTrue(matchesWildcard("HELLO", "*"))
assertTrue(matchesWildcard("HELLO", "H*"))
assertTrue(matchesWildcard("HELLO", "H*O"))
assertTrue(matchesWildcard("HELLO", "H?LLO"))
assertTrue(matchesWildcard("HELLO", "?????"))
assertFalse(matchesWildcard("HELLO", "H?L"))
assertFalse(matchesWildcard("HELLO", "WORLD"))
assertTrue(matchesWildcard("DATA.SET.NAME", "*.NAME"))
assertTrue(matchesWildcard("DATA.SET.NAME", "DATA.*"))
assertFalse(matchesWildcard("DATA.SET.NAME", "SYS1.*"))
}
@Test
fun testMatchesCmsFilterAndFilemode() {
val entry = CMSDirectoryEntry("PROFILE", "EXEC", "A1").apply {
recfm = "F"
lrecl = 80
blksize = 80
}
assertTrue(matchesFilter(entry, null))
assertTrue(matchesFilter(entry, ""))
assertTrue(matchesFilter(entry, "* * *"))
assertTrue(matchesFilter(entry, "* * A"))
assertTrue(matchesFilter(entry, "PROFILE * A"))
assertTrue(matchesFilter(entry, "PROFILE EXEC *"))
assertTrue(matchesFilter(entry, "PROFILE EXEC A1"))
assertTrue(matchesFilter(entry, "PROFILE.EXEC"))
assertTrue(matchesFilter(entry, "PROFILE"))
assertTrue(matchesFilter(entry, "EXEC"))
assertFalse(matchesFilter(entry, "* * B"))
assertFalse(matchesFilter(entry, "TEST * *"))
assertFalse(matchesFilter(entry, "*.COBOL"))
assertFalse(matchesFilter(entry, "PROFILE EXEC A2"))
assertTrue(matchesCmsFilemode("A1", "A"))
assertTrue(matchesCmsFilemode("A1", "A1"))
assertTrue(matchesCmsFilemode("A1", "*"))
assertFalse(matchesCmsFilemode("A1", "B"))
assertFalse(matchesCmsFilemode("A1", "A2"))
}
@Test
fun testMatchesTsoFilter() {
val entry = TSODirectoryEntry("USER.SOURCE.COBOL").apply {
volume = "VOL001"
dsorg = "PO"
}
assertTrue(matchesFilter(entry, null))
assertTrue(matchesFilter(entry, ""))
assertTrue(matchesFilter(entry, "*"))
assertTrue(matchesFilter(entry, "*SOURCE*"))
assertTrue(matchesFilter(entry, "USER.*.COBOL"))
assertTrue(matchesFilter(entry, "COBOL")) // substring search
assertTrue(matchesFilter(entry, "USER.SOURCE"))
assertFalse(matchesFilter(entry, "SYS1.*"))
assertFalse(matchesFilter(entry, "*.PLI"))
assertFalse(matchesFilter(entry, "FORTRAN"))
}
@Test
fun testAppSettingsIniHostTypeParsing() {
val testIni = """
[Connection]
hostType = CMS
[General]
defaultHostType = CICS
""".trimIndent()
var parsedHostType: String? = null
AppSettings.parseIni(StringReader(testIni)) { _, key, value ->
if (key.equals("hostType", ignoreCase = true) ||
key.equals("hosttype", ignoreCase = true) ||
key.equals("host_type", ignoreCase = true) ||
key.equals("defaulthosttype", ignoreCase = true)
) {
parsedHostType = value
}
}
assertNotNull(parsedHostType)
// First or second value parsed
assertTrue(parsedHostType.equals("CMS", ignoreCase = true) || parsedHostType.equals("CICS", ignoreCase = true))
}
@Test
fun testLastTransferStateUnitsAndDirBlocks() {
val state = LastTransferState()
assertEquals(FTConfig.HostType.TSO, state.hostType)
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
assertEquals("", state.dirBlocks)
state.units = FTConfig.AllocationUnit.TRACKS
state.dirBlocks = "20"
state.hostType = FTConfig.HostType.CMS
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
assertEquals("20", state.dirBlocks)
assertEquals(FTConfig.HostType.CMS, state.hostType)
}
@Test
fun testConnectChoiceEnum() {
val choices = ConnectChoice.values()
assertEquals(3, choices.size)
assertTrue(choices.contains(ConnectChoice.NEW_WINDOW))
assertTrue(choices.contains(ConnectChoice.ABORT_CURRENT))
assertTrue(choices.contains(ConnectChoice.CANCEL))
}
@Test
fun testHostStorageExportAndLoadIniRoundtrip() {
val host1 = SavedHost(
id = "test-h1",
name = "Test Mainframe MVS",
host = "mvs.corp.local",
port = 992,
model = 4,
dynamicRows = 62,
dynamicCols = 160,
luName = "LU01",
autoConnect = true,
hostType = "TSO",
useTls = true,
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "VECTOR_GRAPHICS",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.corp",
proxyPort = 1080,
proxyUsername = "admin",
proxyPassword = "secret"
)
val host2 = SavedHost(
id = "test-h2",
name = "VM CMS Guest",
host = "vm.corp.local",
port = 23,
hostType = "CMS"
)
val sw = java.io.StringWriter()
HostStorage.exportToIni(listOf(host1, host2), sw)
val exportedIni = sw.toString()
assertTrue(exportedIni.contains("[Test Mainframe MVS]"))
assertTrue(exportedIni.contains("host = mvs.corp.local"))
assertTrue(exportedIni.contains("port = 992"))
assertTrue(exportedIni.contains("autoConnect = true"))
assertTrue(exportedIni.contains("hostType = TSO"))
assertTrue(exportedIni.contains("useTls = true"))
assertTrue(exportedIni.contains("[VM CMS Guest]"))
assertTrue(exportedIni.contains("hostType = CMS"))
val loaded = HostStorage.loadFromIni(StringReader(exportedIni))
assertEquals(2, loaded.size)
val loadedH1 = loaded.find { it.name == "Test Mainframe MVS" }
assertNotNull(loadedH1)
assertEquals("test-h1", loadedH1!!.id)
assertEquals("mvs.corp.local", loadedH1.host)
assertEquals(992, loadedH1.port)
assertEquals(4, loadedH1.model)
assertEquals(62, loadedH1.dynamicRows)
assertEquals(160, loadedH1.dynamicCols)
assertEquals("LU01", loadedH1.luName)
assertTrue(loadedH1.autoConnect)
assertEquals("TSO", loadedH1.hostType)
assertTrue(loadedH1.useTls)
assertFalse(loadedH1.tlsVerifyCert)
assertTrue(loadedH1.tn3270e)
assertEquals("VECTOR_GRAPHICS", loadedH1.graphicsMode)
assertEquals("1047", loadedH1.codePage)
assertEquals("SOCKS5", loadedH1.proxyType)
assertEquals("proxy.corp", loadedH1.proxyHost)
assertEquals(1080, loadedH1.proxyPort)
assertEquals("admin", loadedH1.proxyUsername)
assertEquals("secret", loadedH1.proxyPassword)
val loadedH2 = loaded.find { it.name == "VM CMS Guest" }
assertNotNull(loadedH2)
assertEquals("test-h2", loadedH2!!.id)
assertEquals("vm.corp.local", loadedH2.host)
assertEquals(23, loadedH2.port)
assertEquals("CMS", loadedH2.hostType)
}
}
@@ -0,0 +1,125 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.lang.reflect.Method
class DBCSAndParityCompatibilityTest {
@Test
fun testNewJapaneseCodePagesAvailableAndResolvable() {
val availableKeys = HostStorage.AVAILABLE_CODE_PAGES.map { it.first }
assertTrue("290 should be available in HostStorage", availableKeys.contains("290"))
assertTrue("1027 should be available in HostStorage", availableKeys.contains("1027"))
assertTrue("1390 should be available in HostStorage", availableKeys.contains("1390"))
assertTrue("1399 should be available in HostStorage", availableKeys.contains("1399"))
val cp290 = CodePageRegistry.getCodePage("290")
assertNotNull("Cp290 must resolve", cp290)
assertFalse("Cp290 is SBCS", cp290.isDBCS)
val cp1027 = CodePageRegistry.getCodePage("1027")
assertNotNull("Cp1027 must resolve", cp1027)
assertFalse("Cp1027 is SBCS", cp1027.isDBCS)
val cp1390 = CodePageRegistry.getCodePage("1390")
assertNotNull("Cp1390 must resolve", cp1390)
assertTrue("Cp1390 is DBCS", cp1390.isDBCS)
val cp1399 = CodePageRegistry.getCodePage("1399")
assertNotNull("Cp1399 must resolve", cp1399)
assertTrue("Cp1399 is DBCS", cp1399.isDBCS)
val trans1390 = EbcdicTranslator("1390")
assertTrue("Translator for 1390 must report isDBCS", trans1390.isDBCS)
}
@Test
fun testScreenExporterDBCSNoDuplication() {
val trans = EbcdicTranslator("930")
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, trans)
screen.erase(false)
// Put a 2-cell DBCS character at cells 0 and 1: '東' (\u6771)
val cell0 = screen.getCell(0)
cell0.db = ExtendedAttribute.DB_LEFT
cell0.ucs4 = '\u6771'
val cell1 = screen.getCell(1)
cell1.db = ExtendedAttribute.DB_RIGHT
cell1.ucs4 = '\u6771'
// Put SBCS 'X' at cell 2
val cell2 = screen.getCell(2)
cell2.db = ExtendedAttribute.DB_NONE
cell2.ucs4 = 'X'
// Invoke private generatePlainText via reflection
val exporterClass = Class.forName("haus.nightmare.a3270.ui.ScreenExporterDialogKt")
val method: Method = exporterClass.getDeclaredMethod(
"generatePlainText",
ScreenBuffer::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType
)
method.isAccessible = true
val plainText = method.invoke(null, screen, 1, 80, true) as String
assertTrue("Output should start with single DBCS char + X: expected '東X', got: '$plainText'", plainText.startsWith("東X"))
assertFalse("DBCS char must not be doubled into '東東X'", plainText.startsWith("東東"))
}
@Test
fun testTerminalGridMetricsCenteringClampedAtZero() {
val terminalViewClass = Class.forName("haus.nightmare.a3270.ui.TerminalViewKt")
val method = terminalViewClass.getDeclaredMethod(
"calculateGridMetrics",
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType
)
method.isAccessible = true
// View is constrained / non-negative offsets
val constrainedMetrics = method.invoke(null, 400f, 300f, 80, 24)
val offXField = constrainedMetrics.javaClass.getDeclaredField("offsetX")
offXField.isAccessible = true
val offX = offXField.getFloat(constrainedMetrics)
val offYField = constrainedMetrics.javaClass.getDeclaredField("offsetY")
offYField.isAccessible = true
val offY = offYField.getFloat(constrainedMetrics)
assertTrue("Constrained offX must be non-negative", offX >= 0f)
assertTrue("Constrained offY must be non-negative", offY >= 0f)
// Wide view (pillarbox with positive offX)
val wideMetrics = method.invoke(null, 3000f, 1000f, 80, 24)
val wideOffX = offXField.getFloat(wideMetrics)
val wideOffY = offYField.getFloat(wideMetrics)
assertTrue("Wide view offX should be positive", wideOffX > 0f)
assertEquals(0f, wideOffY, 0.001f)
// Tall view (letterbox with positive offY)
val tallMetrics = method.invoke(null, 1000f, 3000f, 80, 24)
val tallOffX = offXField.getFloat(tallMetrics)
val tallOffY = offYField.getFloat(tallMetrics)
assertEquals(0f, tallOffX, 0.001f)
assertTrue("Tall view offY should be positive", tallOffY > 0f)
}
@Test
fun testExtendedAttributeTransparencyConstants() {
assertEquals(0x00.toByte(), ExtendedAttribute.TR_DEFAULT)
assertEquals(0xF0.toByte(), ExtendedAttribute.TR_OR)
assertEquals(0xFF.toByte(), ExtendedAttribute.TR_OPAQUE)
}
}
@@ -0,0 +1,204 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
class DisplaySnapshotIntegrationTest {
@Test
fun testSnapshotCaptureAndLockFreeReads() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
sb.erase(false)
val initialVersion = sb.snapshotVersion
sb.getCell(0).ucs4 = 'H'
sb.getCell(1).ucs4 = 'E'
sb.getCell(2).ucs4 = 'L'
sb.getCell(3).ucs4 = 'L'
sb.getCell(4).ucs4 = 'O'
sb.setCursorAddress(42)
sb.updateDisplaySnapshot()
val snap = sb.displaySnapshot
assertNotNull("DisplaySnapshot must not be null after updateDisplaySnapshot()", snap)
assertTrue("Snapshot version must increase", sb.snapshotVersion > initialVersion)
assertEquals(24, snap.rows)
assertEquals(80, snap.cols)
assertEquals(42, snap.cursorAddress)
assertEquals('H', snap.getCell(0)?.ucs4)
assertEquals('E', snap.getCell(1)?.ucs4)
assertEquals('L', snap.getCell(2)?.ucs4)
assertEquals('L', snap.getCell(3)?.ucs4)
assertEquals('O', snap.getCell(4)?.ucs4)
// getDisplayCell fallback
assertEquals('H', sb.getDisplayCell(0)?.ucs4)
assertEquals(42, sb.displayCursorAddress)
assertEquals(24, sb.displayRows)
assertEquals(80, sb.displayCols)
}
@Test
fun testTearFreeRenderingUnderConcurrentBufferMutation() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
sb.erase(false)
val size = 24 * 80
val totalUpdates = 2000
val workerCount = 4
val executor = Executors.newFixedThreadPool(workerCount + 1)
val running = AtomicBoolean(true)
val tornCellCount = AtomicInteger(0)
val exceptionCount = AtomicInteger(0)
val totalReads = AtomicLong(0)
val startLatch = CountDownLatch(1)
val writersDoneLatch = CountDownLatch(1)
// Reader workers simulating Compose Canvas draw passes
for (w in 0 until workerCount) {
executor.submit {
try {
startLatch.await()
while (running.get()) {
val snap = sb.displaySnapshot ?: continue
val snapSize = snap.rows * snap.cols
for (addr in 0 until snapSize) {
val cell = snap.getCell(addr) ?: continue
totalReads.incrementAndGet()
// Patterned validation:
// ucs4 = 'A' + mod, ec = 0x10 + mod, fg = 0x30 + mod, bg = 0x50 + mod
if (cell.ucs4 in 'A'..'Z') {
val ucsOffset = cell.ucs4 - 'A'
val ecOffset = (cell.ec.toInt() and 0xFF) - 0x10
val fgOffset = (cell.fg.toInt() and 0xFF) - 0x30
val bgOffset = (cell.bg.toInt() and 0xFF) - 0x50
if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
tornCellCount.incrementAndGet()
}
}
}
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
}
}
}
// Writer task simulating background Telnet network receiver thread
executor.submit {
try {
startLatch.await()
for (iter in 0 until totalUpdates) {
val mod = iter % 26
val ch = ('A' + mod)
val ec = (0x10 + mod).toByte()
val fg = (0x30 + mod).toByte()
val bg = (0x50 + mod).toByte()
val rawBuf = sb.buffer
for (i in 0 until size) {
val cell = rawBuf[i]
cell.ucs4 = ch
cell.ec = ec
cell.fg = fg
cell.bg = bg
}
sb.setCursorAddress(iter % size)
sb.updateDisplaySnapshot()
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
} finally {
writersDoneLatch.countDown()
}
}
startLatch.countDown()
val finished = writersDoneLatch.await(10, TimeUnit.SECONDS)
assertTrue("Writer must complete updates without deadlock", finished)
running.set(false)
executor.shutdown()
assertTrue("Executor must terminate cleanly", executor.awaitTermination(5, TimeUnit.SECONDS))
assertEquals("Zero exceptions must occur during concurrent execution", 0, exceptionCount.get())
assertEquals("Zero torn cell states must occur during DisplaySnapshot access", 0, tornCellCount.get())
assertTrue("Total reads must be significant", totalReads.get() > 50_000)
}
@Test
fun testConcurrentReadsDuringScreenResizing() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(24, 80, 27, 132, translator)
val workerCount = 4
val executor = Executors.newFixedThreadPool(workerCount + 1)
val running = AtomicBoolean(true)
val exceptionCount = AtomicInteger(0)
val successfulReads = AtomicLong(0)
val startLatch = CountDownLatch(1)
val writerLatch = CountDownLatch(1)
for (w in 0 until workerCount) {
executor.submit {
try {
startLatch.await()
while (running.get()) {
val snap = sb.displaySnapshot ?: continue
val total = snap.rows * snap.cols
for (i in 0 until total) {
val cell = snap.getCell(i)
if (cell != null) {
successfulReads.incrementAndGet()
}
}
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
}
}
}
// Writer alternates dimensions between 24x80 and 27x132
executor.submit {
try {
startLatch.await()
for (iter in 0 until 500) {
sb.erase((iter % 2) != 0)
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
} finally {
writerLatch.countDown()
}
}
startLatch.countDown()
assertTrue("Resize writer must complete without deadlock", writerLatch.await(10, TimeUnit.SECONDS))
running.set(false)
executor.shutdown()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
assertEquals("Zero exceptions must occur during dynamic dimension resizing", 0, exceptionCount.get())
assertTrue("Successful reads across dimension resizing", successfulReads.get() > 20_000)
}
}
@@ -4,6 +4,7 @@ import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.ecl.ECLOIA import haus.nightmare.lib3270j.ecl.ECLOIA
import haus.nightmare.lib3270j.ecl.ECLPS
import haus.nightmare.lib3270j.input.InputProcessor import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
@@ -191,6 +192,13 @@ class EntryAssistAndModesTest {
@Test @Test
fun testIniSectionSyntaxMatchingJ3270() { fun testIniSectionSyntaxMatchingJ3270() {
val sampleIni = """ val sampleIni = """
[behavior]
blockSelectMode = false
blockPaste = true
[clipboard]
blockPasteMode = true
[entryassist] [entryassist]
docMode = true docMode = true
wordWrap = true wordWrap = true
@@ -223,6 +231,10 @@ class EntryAssistAndModesTest {
} }
} }
assertEquals("false", parsed["behavior.blockselectmode"])
assertEquals("true", parsed["behavior.blockpaste"])
assertEquals("true", parsed["clipboard.blockpastemode"])
assertEquals("true", parsed["entryassist.docmode"]) assertEquals("true", parsed["entryassist.docmode"])
assertEquals("true", parsed["entryassist.wordwrap"]) assertEquals("true", parsed["entryassist.wordwrap"])
assertEquals("5", parsed["entryassist.startcol"]) assertEquals("5", parsed["entryassist.startcol"])
@@ -237,6 +249,57 @@ class EntryAssistAndModesTest {
assertEquals("true", parsed["modes.autoskip"]) assertEquals("true", parsed["modes.autoskip"])
} }
@Test
fun testBlockPasteMultiLineAlignment() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3278_2, translator)
val input = InputProcessor(screen, translator, null)
val ps = ECLPS(screen, input, translator)
// Verify default blockPaste is true
assertTrue(input.isBlockPaste)
assertTrue(ps.isBlockPaste)
// 1. Unformatted screen: verify start column 15 is preserved across newlines
screen.erase(false)
val startPos = screen.rowColToAddress(0, 15)
screen.cursorAddress = startPos
val multilineText = "FIRST\nSECOND\nTHIRD"
val pasted = input.pasteText(multilineText, true, false, true)
assertEquals(16, pasted)
assertEquals("FIRST", ps.getString(startPos, 5))
val row1Col15 = screen.rowColToAddress(1, 15)
assertEquals("SECOND", ps.getString(row1Col15, 6))
val row2Col15 = screen.rowColToAddress(2, 15)
assertEquals("THIRD", ps.getString(row2Col15, 5))
// 2. Formatted screen with blockPaste = true
screen.erase(false)
screen.setFieldAttribute(0, FA_PRINTABLE.toByte())
screen.setFieldAttribute(80, FA_PRINTABLE.toByte())
screen.setFieldAttribute(160, FA_PRINTABLE.toByte())
screen.cursorAddress = screen.rowColToAddress(0, 25)
val pastedFormatted = input.pasteText("ALPHA\r\nBETA\r\nGAMMA", true, false, true)
assertEquals(14, pastedFormatted)
assertEquals("ALPHA", ps.getString(screen.rowColToAddress(0, 25), 5))
assertEquals("BETA", ps.getString(screen.rowColToAddress(1, 25), 4))
assertEquals("GAMMA", ps.getString(screen.rowColToAddress(2, 25), 5))
// 3. Formatted screen with blockPaste = false: reverts to first unprotected field on next row (pos 81)
screen.erase(false)
screen.setFieldAttribute(0, FA_PRINTABLE.toByte())
screen.setFieldAttribute(80, FA_PRINTABLE.toByte())
screen.cursorAddress = screen.rowColToAddress(0, 25)
input.pasteText("LINE1\nLINE2", true, false, false)
assertEquals("LINE1", ps.getString(screen.rowColToAddress(0, 25), 5))
assertEquals("LINE2", ps.getString(81, 5))
}
@Test @Test
fun testCmsFilenameFormatting() { fun testCmsFilenameFormatting() {
// Upload (local -> host) // Upload (local -> host)
@@ -0,0 +1,312 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import haus.nightmare.a3270.ui.HotspotEngine.HotspotType
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import org.junit.Assert.*
import org.junit.Test
class HotspotAndHistoryFeaturesTest {
@Test
fun testHotspotUrlDetection() {
val engine = HotspotEngine()
val text = "Visit https://ibm.com/support or http://example.org:8080/test for help"
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val urls = hotspots.filter { it.type == HotspotType.URL }
assertEquals(2, urls.size)
assertEquals("https://ibm.com/support", urls[0].action)
assertEquals("http://example.org:8080/test", urls[1].action)
}
@Test
fun testHotspotPfAndPaKeyDetection() {
val engine = HotspotEngine()
val text = "Press PF3 to Exit, PF12 to Cancel, F1 for Help, and PA1 for Attention"
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val pfHotspots = hotspots.filter { it.type == HotspotType.PF_KEY }
assertTrue(pfHotspots.any { it.action == "[pf3]" && it.text.equals("PF3", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pf12]" && it.text.equals("PF12", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pf1]" && it.text.equals("F1", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pa1]" && it.text.equals("PA1", ignoreCase = true) })
}
@Test
fun testHotspotMenuChoiceAndEnterDetection() {
val engine = HotspotEngine()
val text = "1. Logon\n2. Logoff\n===> 3\n[Enter] to submit"
val hotspots = engine.scanText(text, rows = 4, cols = 30)
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
assertTrue(menuChoices.any { it.action == "1[enter]" })
assertTrue(menuChoices.any { it.action == "2[enter]" })
assertTrue(menuChoices.any { it.action == "3[enter]" })
val enterHotspots = hotspots.filter { it.type == HotspotType.ENTER }
assertTrue(enterHotspots.any { it.action == "[enter]" })
}
@Test
fun testIspfPrimaryOptionMenuDetection() {
val engine = HotspotEngine()
val screen =
" Menu Utilities Compilers Options Status Help\n" +
" ──────────────────────────────────────────────────────────────────────────────\n" +
" ISPF Primary Option Menu\n" +
" Option ===>\n" +
" \n" +
" 0 Settings Terminal and user parameters User ID . : RUDI\n" +
" 1 View Display source data or listings Time. . . : 12:31\n" +
" 2 Edit Create or change source data Terminal. : 3278\n" +
" 3 Utilities Perform utility functions Screen. . : 1\n" +
" 4 Foreground Interactive language processing Language. : ENGLISH\n" +
" 5 Batch Submit job for language processing Appl ID . : ISR\n" +
" 6 Command Enter TSO or Workstation commands TSO logon : DEVPROC\n" +
" 7 Dialog Test Perform dialog testing TSO prefix: RUDI\n" +
" 9 IBM Products IBM program development products System ID : VS01\n" +
" 10 SCLM SW Configuration Library Manager Release . : ISPF 7.5\n" +
" 11 Workplace ISPF Object/Action Workplace\n" +
" M More Additional workplace options\n" +
" X Exit Terminate ISPF using log and list defaults"
val hotspots = engine.scanText(screen, 18, 80)
// 14 total hotspots: 13 ISPF menu choices (0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, M, X) + 1 [ENTER] key on row 11
assertEquals(14, hotspots.size)
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
assertEquals(13, menuChoices.size)
// Option 0
val h0 = menuChoices[0]
assertEquals(HotspotType.MENU_CHOICE, h0.type)
assertEquals("0 Settings", h0.displayText)
assertEquals("0[enter]", h0.action)
assertEquals("Select option 0 (Settings)", h0.tooltip)
assertEquals(5, h0.startRow)
// Option 1
val h1 = menuChoices[1]
assertEquals("1 View", h1.displayText)
assertEquals("1[enter]", h1.action)
assertEquals("Select option 1 (View)", h1.tooltip)
assertEquals(6, h1.startRow)
// Option 2
val h2 = menuChoices[2]
assertEquals("2 Edit", h2.displayText)
assertEquals("2[enter]", h2.action)
assertEquals("Select option 2 (Edit)", h2.tooltip)
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
val h10 = menuChoices[9]
assertEquals("10 SCLM", h10.displayText)
assertEquals("10[enter]", h10.action)
assertEquals("Select option 10 (SCLM)", h10.tooltip)
// Option M (letter, index 11)
val hM = menuChoices[11]
assertEquals("M More", hM.displayText)
assertEquals("M[enter]", hM.action)
assertEquals("Select option M (More)", hM.tooltip)
// Option X (exit, index 12)
val hX = menuChoices[12]
assertEquals("X Exit", hX.displayText)
assertEquals("X[enter]", hX.action)
assertEquals("Select option X (Exit)", hX.tooltip)
}
@Test
fun testIspfSubOptionsAndMultiColumn() {
val engine = HotspotEngine()
val screen =
" 3.1 Library - Compress or print data set\n" +
" 3.2 DataSet - Allocate, rename, or delete\n" +
" 1 - View 2 - Edit"
val hotspots = engine.scanText(screen, 3, 80)
assertEquals(4, hotspots.size)
val h31 = hotspots[0]
assertEquals("3.1 Library", h31.displayText)
assertEquals("3.1[enter]", h31.action)
assertEquals("Select option 3.1 (Library)", h31.tooltip)
val h32 = hotspots[1]
assertEquals("3.2 DataSet", h32.displayText)
assertEquals("3.2[enter]", h32.action)
val h1 = hotspots[2]
assertEquals("1 - View", h1.displayText)
assertEquals("1[enter]", h1.action)
val h2 = hotspots[3]
assertEquals("2 - Edit", h2.displayText)
assertEquals("2[enter]", h2.action)
}
@Test
fun testHotspotHitTesting() {
val engine = HotspotEngine()
val text = "Row 0: PF7 Backward PF8 Forward "
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val pf7 = hotspots.find { it.action == "[pf7]" }
assertNotNull(pf7)
val hit = engine.findHotspotAt(hotspots, 0, pf7!!.startCol + 1)
assertNotNull(hit)
assertEquals("[pf7]", hit?.action)
// Pointing outside any hotspot returns null
val miss = engine.findHotspotAt(hotspots, 0, 0)
assertNull(miss)
}
@Test
fun testHotspotCategoryDisabling() {
val engine = HotspotEngine()
val text = "Visit https://example.com or press PF3 or choose 1. Option"
// Master toggle disabled
val disabledScan = engine.scanText(text, rows = 1, cols = text.length, enabled = false)
assertTrue(disabledScan.isEmpty())
// Disable URLs only
engine.urlEnabled = false
engine.pfKeysEnabled = true
engine.menuChoicesEnabled = true
val noUrls = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
assertTrue(noUrls.none { it.type == HotspotType.URL })
assertTrue(noUrls.any { it.type == HotspotType.PF_KEY })
// Disable PF keys only
engine.urlEnabled = true
engine.pfKeysEnabled = false
val noPfs = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
assertTrue(noPfs.none { it.type == HotspotType.PF_KEY })
assertTrue(noPfs.any { it.type == HotspotType.URL })
}
@Test
fun testScreenHistoryBufferCapturesSnapshots() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val history = screen.historyBuffer
assertNotNull(history)
assertEquals(0, history.size())
// Write first screen
screen.setCursorPosition(0, 0)
screen.setChar(0, 0, 'A')
screen.setChar(0, 1, 'B')
screen.setChar(0, 2, 'C')
history.snapshot(screen)
assertEquals(1, history.size())
val snap0 = history.get(0)
assertNotNull(snap0)
assertEquals('A', snap0?.getChar(0, 0))
assertEquals('B', snap0?.getChar(0, 1))
assertEquals('C', snap0?.getChar(0, 2))
// Consecutive identical snapshot is suppressed
history.snapshot(screen)
assertEquals(1, history.size())
// Modify screen and take second snapshot
screen.setChar(0, 0, 'X')
history.snapshot(screen)
assertEquals(2, history.size())
assertEquals('X', history.get(1)?.getChar(0, 0))
// Verify table formatting
val formattedTable = snap0?.formattedTable
assertNotNull(formattedTable)
assertTrue(formattedTable!!.startsWith("ABC"))
}
@Test
fun testFieldOutliningConstantsAndAttribute() {
assertEquals(0x01.toByte(), ExtendedAttribute.OL_UNDERLINE)
assertEquals(0x02.toByte(), ExtendedAttribute.OL_RIGHT)
assertEquals(0x04.toByte(), ExtendedAttribute.OL_OVERLINE)
assertEquals(0x08.toByte(), ExtendedAttribute.OL_LEFT)
assertEquals(0x0F.toByte(), ExtendedAttribute.OL_BOX)
val ea = ExtendedAttribute()
ea.ol = ExtendedAttribute.OL_BOX
assertEquals(ExtendedAttribute.OL_BOX, ea.ol)
}
@Test
fun testAppSettingsIniRoundtripWithHotspotsAndSelectionPreferences() {
// Check that default for hotspots is false (disabled for touch screens)
val testIni = """
[behavior]
hotspots_enabled=false
trim_rect_sizing_handles=true
trim_rect_remain_after_edit=true
paste_word_break=false
[hotspots]
url=true
pf_keys=true
menu_choices=false
""".trimIndent()
var hsEnabled: Boolean? = null
var trimHandles: Boolean? = null
var trimRemain: Boolean? = null
var wordBreak: Boolean? = null
var hsUrl: Boolean? = null
var hsPf: Boolean? = null
var hsChoices: Boolean? = null
AppSettings.parseIni(java.io.StringReader(testIni)) { _, k, v ->
when (k) {
"hotspots_enabled" -> hsEnabled = v.toBoolean()
"trim_rect_sizing_handles" -> trimHandles = v.toBoolean()
"trim_rect_remain_after_edit" -> trimRemain = v.toBoolean()
"paste_word_break" -> wordBreak = v.toBoolean()
"hotspots_url_enabled", "url" -> hsUrl = v.toBoolean()
"hotspots_pf_enabled", "pf_keys" -> hsPf = v.toBoolean()
"hotspots_menu_choices_enabled", "menu_choices" -> hsChoices = v.toBoolean()
}
}
assertEquals(false, hsEnabled)
assertEquals(true, trimHandles)
assertEquals(true, trimRemain)
assertEquals(false, wordBreak)
assertEquals(true, hsUrl)
assertEquals(true, hsPf)
assertEquals(false, hsChoices)
}
@Test
fun testMenuChoiceActionPrefixingForSafeSubmission() {
val engine = HotspotEngine()
val screen = " 1 View Display source data or listings"
val hotspots = engine.scanText(screen, 1, 80)
assertEquals(1, hotspots.size)
val h = hotspots[0]
assertEquals(HotspotType.MENU_CHOICE, h.type)
assertEquals("1[enter]", h.action)
// Menu choices must be submitted via [home][eraseeof] to prevent protected field keyboard lock
var safeAction = h.action
if (!safeAction.startsWith("[home]")) {
safeAction = "[home][eraseeof]$safeAction"
}
assertEquals("[home][eraseeof]1[enter]", safeAction)
}
}