TuiLayout.kt
package com.depanalyzer.tui
import com.depanalyzer.report.VulnerabilitySeverity
import com.github.ajalt.mordant.terminal.Terminal
data class TuiDimensions(
val width: Int,
val height: Int,
val leftInnerWidth: Int,
val rightInnerWidth: Int,
val contentRows: Int
)
class TuiLayout(
private val theme: TuiTheme = TuiTheme()
) {
fun contentRows(terminal: Terminal): Int {
val size = terminal.updateSize()
return calculateDimensions(size.width, size.height).contentRows
}
fun render(terminal: Terminal, state: TuiState) {
val size = terminal.updateSize()
val frame = composeFrame(state, size.width, size.height)
terminal.cursor.move {
setPosition(1, 1)
}
terminal.rawPrint(frame.joinToString("\n", postfix = "\n"))
}
internal fun calculateDimensions(width: Int, height: Int): TuiDimensions {
val safeWidth = if (width > 0) width else 120
val safeHeight = if (height > 0) height else 32
val inner = (safeWidth - 3).coerceAtLeast(28)
val leftInner = (inner * 0.32).toInt().coerceIn(22, inner - 20)
val rightInner = (inner - leftInner).coerceAtLeast(20)
val fixedRows = 8
val contentRows = (safeHeight - fixedRows).coerceAtLeast(8)
return TuiDimensions(
width = safeWidth,
height = safeHeight,
leftInnerWidth = leftInner,
rightInnerWidth = rightInner,
contentRows = contentRows
)
}
internal fun composeFrame(state: TuiState, width: Int = 120, height: Int = 32): List<String> {
val dim = calculateDimensions(width, height)
if (state.loadError != null) {
return listOf(
theme.chrome(fit(" dep-analyzer - ${state.summary.projectName} ", dim.width)),
theme.scanDanger(fit("Error durante el escaneo: ${state.loadError}", dim.width)),
theme.muted(fit("Presiona q para salir", dim.width))
)
}
val safeState = if (!state.isTreeTabEnabled && state.activeTab == TuiTab.TREE) {
state.copy(activeTab = TuiTab.DETAIL)
} else {
state
}
val normalizedState = safeState.ensureCursorBounds().ensureScrollVisible(dim.contentRows)
val lines = mutableListOf<String>()
lines += theme.chrome(fit(" dep-analyzer - ${state.summary.projectName} ", dim.width))
val runtimeStatus = if (normalizedState.isLoading) {
normalizedState.loadingMessage.ifBlank { "Escaneo en progreso..." }
} else {
normalizedState.statusLine
}
lines += theme.muted(fit(runtimeStatus, dim.width))
val top = "┌" + "─".repeat(dim.leftInnerWidth) + "┬" + "─".repeat(dim.rightInnerWidth) + "┐"
val leftHeader =
"DEPENDENCIAS (${state.summary.totalEntries}) · ${state.summary.vulnerableCount} CVE · ${state.summary.outdatedCount} desact. · ${state.pendingUpdates.size} pend."
val header = "│" + fit(leftHeader, dim.leftInnerWidth) + "│" +
buildRightTabsCell(normalizedState, dim.rightInnerWidth) + "│"
val separator = "├" + "─".repeat(dim.leftInnerWidth) + "┼" + "─".repeat(dim.rightInnerWidth) + "┤"
lines += top
lines += header
lines += separator
val rightTitle = when (normalizedState.activeTab) {
TuiTab.DETAIL -> "DEPENDENCIA SELECCIONADA"
TuiTab.TREE -> {
val selected = normalizedState.selectedEntry?.coordinate ?: "sin seleccion"
"ARBOL DE DEPENDENCIAS - $selected"
}
}
lines += "│" + buildLeftFilterCell(normalizedState, dim.leftInnerWidth) + "│" +
theme.section(fit(rightTitle, dim.rightInnerWidth)) + "│"
lines += separator
val bodyRows = (dim.contentRows - 2).coerceAtLeast(4)
val leftRows = buildLeftPanelRows(normalizedState, dim.leftInnerWidth, bodyRows)
val rightRows = buildRightPanelRows(normalizedState, dim.rightInnerWidth, bodyRows)
for (i in 0 until bodyRows) {
val left = leftRows.getOrElse(i) { " ".repeat(dim.leftInnerWidth) }
val right = rightRows.getOrElse(i) { " ".repeat(dim.rightInnerWidth) }
lines += "│$left│$right│"
}
val bottom = "└" + "─".repeat(dim.leftInnerWidth) + "┴" + "─".repeat(dim.rightInnerWidth) + "┘"
lines += bottom
lines += buildFooterLine(normalizedState, dim.width, bodyRows)
return lines
}
private fun buildRightTabsCell(state: TuiState, width: Int): String {
val plain = TuiTab.entries.joinToString(" ") {
if (it == TuiTab.TREE && !state.isTreeTabEnabled) {
" ${it.label()} (desactivado) "
} else {
" ${it.label()} "
}
}
if (plain.length >= width) return plain.take(width)
val styled = TuiTab.entries.joinToString(" ") {
val label = if (it == TuiTab.TREE && !state.isTreeTabEnabled) {
" ${it.label()} (desactivado) "
} else {
" ${it.label()} "
}
when {
it == TuiTab.TREE && !state.isTreeTabEnabled -> theme.tabDisabled(label)
it == state.activeTab -> theme.tabActive(label)
else -> theme.tabInactive(label)
}
}
return styled + " ".repeat(width - plain.length)
}
private fun buildLeftFilterCell(state: TuiState, width: Int): String {
val label = " Filtro (f): ${state.activeFilter.label()} "
return theme.chipActive(fit(label, width))
}
private fun buildLeftPanelRows(state: TuiState, width: Int, bodyRows: Int): List<String> {
if (state.entries.isEmpty()) {
return listOf(fit("No hay dependencias directas para mostrar", width))
}
val indexes = state.filteredIndexes
if (indexes.isEmpty()) {
return listOf(fit("Filtro sin resultados", width))
}
val rows = mutableListOf<String>()
val statusWidth = 8
val nameWidth = (width - statusWidth - 2).coerceAtLeast(6)
val start = state.scrollOffset.coerceIn(0, indexes.lastIndex)
val end = (start + bodyRows).coerceAtMost(indexes.size)
for (listIndex in start until end) {
val entry = state.entries[indexes[listIndex]]
val selected = listIndex == state.cursor
val marker = if (selected) "▶" else "•"
val name = fit(entry.coordinate, nameWidth)
val status = theme.statusBadge(entry, statusWidth, state.pendingUpdates.containsKey(entry.coordinate))
val plainLine = "$marker $name"
val line = plainLine + status
rows += if (selected) theme.selectedRow(line) else line
}
return rows
}
private fun buildRightPanelRows(state: TuiState, width: Int, bodyRows: Int): List<String> {
val selected = state.selectedEntry ?: return listOf(fit("Selecciona una dependencia", width))
val rows = when (state.activeTab) {
TuiTab.DETAIL -> buildDetailRows(state, selected, width)
TuiTab.TREE -> buildTreeRows(state, selected, width)
}
return rows.take(bodyRows)
}
private fun buildDetailRows(state: TuiState, selected: TuiDependencyEntry, width: Int): List<String> {
val rows = mutableListOf<String>()
val versionLabel = "${selected.currentVersion} -> ${selected.latestVersion ?: selected.currentVersion}"
rows += fit(versionLabel, width)
val pending = state.pendingUpdates[selected.coordinate]
when {
pending != null -> rows += theme.scanWarn(
fit(
"Pendiente: ${pending.currentVersion} -> ${pending.newVersion}",
width
)
)
selected.updateSuggestion != null -> rows += theme.scanOk(
fit(
"Sugerida: ${selected.updateSuggestion.currentVersion} -> ${selected.updateSuggestion.newVersion}",
width
)
)
else -> rows += fit("Sin actualización sugerida", width)
}
rows += fit("", width)
rows += theme.section(fit("VULNERABILIDADES ENCONTRADAS", width))
if (selected.vulnerabilities.isEmpty()) {
rows += theme.scanOk(fit("No se encontraron CVEs para esta dependencia", width))
} else {
selected.vulnerabilities.forEach { vuln ->
rows += theme.severityBadge(vuln.severity, fit(vuln.cveId, width))
val cvss = vuln.cvssScore?.let { "CVSS %.1f".format(it) } ?: "CVSS n/a"
rows += fit(cvss, width)
val description = vuln.description ?: "Sin descripcion disponible"
rows += wrapToWidth(description, width).map { fit(it, width) }
rows += fit("", width)
}
}
rows += theme.section(fit("CADENA DE VULNERABILIDAD TRANSITIVA", width))
val chain = if (selected.chainPreview.isEmpty()) {
listOf("No se detecto cadena transitiva para este item")
} else {
selected.chainPreview
}
rows += chain.mapIndexed { index, node ->
val prefix = if (index == 0) "▶ " else "└ "
fit(prefix + node, width)
}
if (!state.isTreeTabEnabled) {
rows += fit("", width)
rows += theme.scanWarn(fit("ARBOL TRANSITIVO DESACTIVADO", width))
val message = state.treeUnavailableMessage ?: "No se pudo cargar el arbol transitivo"
rows += wrapToWidth(message, width).map { fit(it, width) }
}
return rows
}
private fun buildTreeRows(state: TuiState, selected: TuiDependencyEntry, width: Int): List<String> {
if (!state.isTreeTabEnabled) {
val message = state.treeUnavailableMessage ?: "No se pudo cargar el arbol transitivo"
return listOf(theme.scanWarn(fit("ARBOL TRANSITIVO DESACTIVADO", width))) +
wrapToWidth(message, width).map { fit(it, width) }
}
if (selected.transitiveTreeLines.isEmpty()) {
return listOf(fit("No se encontró árbol transitivo para esta dependencia", width))
}
return selected.transitiveTreeLines.map { line ->
val fitted = fit(line, width)
when {
line.contains("[CHAIN]", ignoreCase = true) -> theme.scanDanger(fitted)
line.trimStart().startsWith("! ") -> theme.scanDanger(fitted)
line.contains("CVE", ignoreCase = true) -> theme.scanDanger(fitted)
line.contains("desactualizada", ignoreCase = true) -> theme.scanWarn(fitted)
else -> fitted
}
}
}
private fun buildFooterLine(state: TuiState, width: Int, viewportRows: Int): String {
val visible = if (state.filteredIndexes.isEmpty()) {
"0"
} else {
val first = state.scrollOffset + 1
val last = (state.scrollOffset + viewportRows).coerceAtMost(state.filteredIndexes.size)
"$first-$last"
}
val hint = state.confirmationPrompt
?: " ↑↓ navegar u agregar pend. U agregar todo a aplicar x descartar f filtrar q salir tab vista "
val summary = "[$visible / ${state.filteredIndexes.size}]"
val base = fit(hint, (width - summary.length).coerceAtLeast(0)) + summary
return if (state.confirmationPrompt != null) theme.scanWarn(base) else theme.muted(base)
}
private fun wrapToWidth(value: String, width: Int): List<String> {
if (value.isBlank()) return listOf("")
if (value.length <= width) return listOf(value)
val words = value.split(" ")
val lines = mutableListOf<String>()
var current = ""
for (word in words) {
if (current.isEmpty()) {
current = word
} else if (current.length + word.length + 1 <= width) {
current = "$current $word"
} else {
lines += current
current = word
}
}
if (current.isNotEmpty()) lines += current
return lines
}
private fun fit(text: String, width: Int): String {
val safe = text.replace("\n", " ")
return if (safe.length >= width) safe.take(width) else safe.padEnd(width)
}
}
internal fun VulnerabilitySeverity.priority(): Int = when (this) {
VulnerabilitySeverity.CRITICAL -> 5
VulnerabilitySeverity.HIGH -> 4
VulnerabilitySeverity.MEDIUM -> 3
VulnerabilitySeverity.LOW -> 2
VulnerabilitySeverity.UNKNOWN -> 1
}