Files
a3270/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt
T
rudi e82d01003b
Build and Test a3270 / Build Android APK (push) Successful in 7m24s
Release a3270 / Build & Publish Release (push) Successful in 7m53s
IBM-DYNAMIC
2026-09-03 16:49:49 -04:00

522 lines
22 KiB
Kotlin

package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
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 androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenExporterDialog(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager? = null,
graphicsPlane: GraphicsPlane? = null,
maskHidden: Boolean = true,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var exportFormat by remember { mutableStateOf("TEXT") } // TEXT, HTML, PNG
var statusMessage by remember { mutableStateOf("") }
val plainTextContent = remember(screenBuffer) {
generatePlainText(screenBuffer, rows, cols, maskHidden)
}
val htmlContent = remember(screenBuffer) {
generateHtml(screenBuffer, rows, cols, maskHidden)
}
fun shareText(text: String, isHtml: Boolean) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (isHtml) "text/html" else "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3270 Screen Capture")
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share 3270 Screen"))
} catch (e: Exception) {
statusMessage = "Share failed: ${e.message}"
}
}
fun sharePng(bmp: Bitmap) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(cacheDir, "screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Screen PNG"))
} catch (e: Exception) {
statusMessage = "PNG Share failed: ${e.message}"
}
}
fun saveFileToStorage(format: String): String {
return try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
when (format) {
"HTML" -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.html")
file.writeText(htmlContent)
"Saved to Downloads/${file.name}"
}
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
val file = File(downloadsDir, "3270_screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
"Saved to Downloads/${file.name}"
}
else -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.txt")
file.writeText(plainTextContent)
"Saved to Downloads/${file.name}"
}
}
} catch (e: Exception) {
"Save failed: ${e.message}"
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Export / Share Screen",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Row {
FilterChip(
selected = (exportFormat == "TEXT"),
onClick = { exportFormat = "TEXT" },
label = { Text("Plain Text", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "HTML"),
onClick = { exportFormat = "HTML" },
label = { Text("HTML", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "PNG"),
onClick = { exportFormat = "PNG" },
label = { Text("PNG", fontSize = 11.sp) }
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Preview Box
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (exportFormat == "PNG") {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🖼️ PNG Image Export", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Text("Full resolution bitmap with 3270 styling and graphics plane.", fontSize = 12.sp, color = Color.LightGray)
}
} else {
val textToShow = if (exportFormat == "HTML") htmlContent else plainTextContent
Text(
text = textToShow,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (exportFormat != "PNG") {
OutlinedButton(
onClick = {
val content = if (exportFormat == "HTML") htmlContent else plainTextContent
clipboardManager.setText(AnnotatedString(content))
statusMessage = "Copied to clipboard."
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 12.sp)
}
}
Button(
onClick = {
val msg = saveFileToStorage(exportFormat)
statusMessage = msg
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save File", fontSize = 12.sp)
}
Button(
onClick = {
when (exportFormat) {
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
sharePng(bmp)
}
"HTML" -> shareText(htmlContent, true)
else -> shareText(plainTextContent, false)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Share 📤", fontSize = 12.sp)
}
}
}
}
}
}
}
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return ""
val sb = StringBuilder()
var currentHidden = false
for (r in 0 until rows) {
val rowSb = StringBuilder()
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ')
} else {
if (currentHidden && maskHidden) {
rowSb.append(' ')
} else {
val ch = cell.ucs4
rowSb.append(if (ch in ' '..'~' || ch > '\u007F') ch else ' ')
}
}
}
sb.append(rowSb.toString().trimEnd()).append("\n")
}
return sb.toString()
}
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n")
sb.append("<style>\n")
sb.append("body { background-color: #0A0A0A; color: #00FF00; font-family: monospace; font-size: 14px; margin: 20px; }\n")
sb.append("pre { line-height: 1.2; font-family: monospace; }\n")
sb.append(".c-green { color: #00FF00; }\n")
sb.append(".c-white { color: #FFFFFF; }\n")
sb.append(".c-blue { color: #7890F0; }\n")
sb.append(".c-red { color: #FF0000; }\n")
sb.append(".c-yellow { color: #FFFF00; }\n")
sb.append(".c-turquoise { color: #00FFFF; }\n")
sb.append(".c-pink { color: #FF00FF; }\n")
sb.append(".c-orange { color: #FFA200; }\n")
sb.append(".c-mustard { color: #A0A000; }\n")
sb.append(".c-grey { color: #C0C0C0; }\n")
sb.append("</style>\n</head>\n<body>\n<pre>\n")
var currentHidden = false
var currentProt = false
var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
sb.append(' ')
} else {
if (currentHidden && maskHidden) {
sb.append(' ')
} else {
val ch = cell.ucs4
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
val colorClass = if (fgByte in 0xf0..0xff) {
when (fgByte - 0xf0) {
1 -> "c-blue"
2 -> "c-red"
3 -> "c-pink"
4 -> "c-green"
5 -> "c-turquoise"
6 -> "c-yellow"
7 -> "c-white"
9 -> "c-blue"
10 -> "c-orange"
11 -> "c-pink"
12 -> "c-green"
13 -> "c-turquoise"
14 -> "c-mustard"
15 -> "c-grey"
else -> "c-green"
}
} else {
when {
currentProt && currentHi -> "c-white"
currentProt && !currentHi -> "c-turquoise"
!currentProt && currentHi -> "c-red"
else -> "c-green"
}
}
val escChar = when (ch) {
'<' -> "&lt;"
'>' -> "&gt;"
'&' -> "&amp;"
'"' -> "&quot;"
else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
}
sb.append("<span class=\"$colorClass\">$escChar</span>")
}
}
}
sb.append("\n")
}
sb.append("</pre>\n</body>\n</html>")
return sb.toString()
}
private fun generateBitmap(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager?,
graphicsPlane: GraphicsPlane?,
maskHidden: Boolean
): Bitmap {
val charWidth = 14
val charHeight = 24
val width = cols * charWidth
val height = rows * charHeight
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Fill background
val bgPaint = Paint().apply {
color = android.graphics.Color.parseColor("#0A0A0A")
style = Paint.Style.FILL
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap
// Layer 1: Vector graphics plane (under text)
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val rgb = graphicsPlane.rgbBuffer
val gWidth = graphicsPlane.canvasWidth
val gHeight = graphicsPlane.canvasHeight
if (rgb != null && gWidth > 0 && gHeight > 0) {
val gfxBmp = Bitmap.createBitmap(rgb, gWidth, gHeight, Bitmap.Config.ARGB_8888)
val scaledGfx = Bitmap.createScaledBitmap(gfxBmp, width, height, true)
canvas.drawBitmap(scaledGfx, 0f, 0f, null)
}
}
val textPaint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = 18f
}
var currentHidden = false
var currentProt = false
var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat()
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
} else {
val csVal = cell.cs.toInt() and 0xFF
val ecVal = cell.ec.toInt() and 0xFF
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
val cellColor = if (fgByte in 0xf0..0xff) {
when (fgByte - 0xf0) {
1 -> android.graphics.Color.parseColor("#7890F0")
2 -> android.graphics.Color.parseColor("#FF0000")
3 -> android.graphics.Color.parseColor("#FF00FF")
4 -> android.graphics.Color.parseColor("#00FF00")
5 -> android.graphics.Color.parseColor("#00FFFF")
6 -> android.graphics.Color.parseColor("#FFFF00")
7 -> android.graphics.Color.WHITE
9 -> android.graphics.Color.parseColor("#000080")
10 -> android.graphics.Color.parseColor("#FFA200")
11 -> android.graphics.Color.parseColor("#800080")
12 -> android.graphics.Color.parseColor("#008000")
13 -> android.graphics.Color.parseColor("#008080")
14 -> android.graphics.Color.parseColor("#A0A000")
15 -> android.graphics.Color.parseColor("#C0C0C0")
else -> android.graphics.Color.parseColor("#00FF00")
}
} else {
when {
currentProt && currentHi -> android.graphics.Color.WHITE
currentProt && !currentHi -> android.graphics.Color.parseColor("#00FFFF")
!currentProt && currentHi -> android.graphics.Color.parseColor("#FF0000")
else -> android.graphics.Color.parseColor("#00FF00")
}
}
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val bgArgb = android.graphics.Color.TRANSPARENT
val rgbArray = slot.getRgbPixels(cellColor, bgArgb)
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null)
drawnAsPs = true
}
}
}
if (!drawnAsPs && (!currentHidden || !maskHidden)) {
val ch = cell.ucs4
if (ch in ' '..'~' || ch > '\u007F') {
textPaint.color = cellColor
canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint)
}
}
}
}
}
return bitmap
}