522 lines
22 KiB
Kotlin
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) {
|
|
'<' -> "<"
|
|
'>' -> ">"
|
|
'&' -> "&"
|
|
'"' -> """
|
|
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
|
|
}
|