package git.s00d.widgets
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.ColumnScope
import androidx.glance.layout.Row
import androidx.glance.layout.RowScope
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.width
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
/** True when a flex spacer should absorb adjacent VStack/HStack spacing. */
private fun isExpandingSpacer(child: JSONObject): Boolean {
if (child.widgetString("type", "") != "spacer") return false
return !child.has("minLength") || child.optDouble("flex", 0.0) > 0.0
}
/**
* ZStack children that need the full slot (so Column spacers expand) vs badges that must
* keep intrinsic size for [Box] contentAlignment.
*/
private fun zstackChildModifier(child: JSONObject): GlanceModifier {
val type = child.widgetString("type", "")
if (type in setOf("vstack", "hstack", "container", "grid", "zstack")) {
return GlanceModifier.fillMaxSize()
}
if (type == "shape") {
val frame = child.optJSONObject("frame")
val hasFrame =
(frame?.optDouble("width", -1.0) ?: -1.0) > 0 &&
(frame?.optDouble("height", -1.0) ?: -1.0) > 0
val hasSize = child.optDouble("size", -1.0) > 0
// Bare rectangle underlay (stress-align) → fill; sized circle/capsule → hug.
if (!hasFrame && !hasSize) return GlanceModifier.fillMaxSize()
val shapeType = child.widgetString("shapeType", "circle").lowercase(Locale.US)
if (shapeType == "rectangle" && !hasFrame) return GlanceModifier.fillMaxSize()
}
return GlanceModifier
}
@Composable
internal fun RenderLayout(scope: RenderScope, el: El, modifier: GlanceModifier) {
val inHorizontal = scope.axis == RenderScope.Axis.Horizontal
val spacing = el.num("spacing", 0.0).toInt().coerceAtLeast(0)
when (el.type) {
"vstack" -> {
val hAlign = parseHorizontalStackAlignment(el.str("alignment", ""))
// Inside a Row, fillMaxWidth steals space from trailing spacer/button.
val colMod = if (inHorizontal) modifier else modifier.fillMaxWidth()
Column(
modifier = colMod,
horizontalAlignment = hAlign
) {
renderChildrenVertical(
scope,
el.arr("children"),
spacing,
expandWidth = !inHorizontal,
)
}
}
"hstack" -> {
val vAlign = parseVerticalStackAlignment(el.str("alignment", ""))
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = vAlign
) {
renderChildrenHorizontal(scope, el.arr("children"), spacing)
}
}
"zstack" -> {
val alignment = parseContentAlignment(el.str("alignment", "center").ifBlank { "center" })
// fillMaxSize inside a Row steals all remaining width/height and zeros siblings
// (nested-dashboard badge crushed the title/button/body). Wrap in hstacks.
val boxMod = if (inHorizontal) modifier else modifier.fillMaxWidth().fillMaxHeight()
Box(modifier = boxMod, contentAlignment = alignment) {
val children = el.arr("children")
if (children != null && children.length() > 0) {
for (i in 0 until children.length()) {
val child = children.optJSONObject(i) ?: continue
// Stacks / full-bleed shapes must fill so Spacers get a height budget.
// Fixed badges (text/image/circle) stay intrinsic — fillMaxSize on those
// breaks Box contentAlignment.
RenderElement(scope, El(child), zstackChildModifier(child))
}
}
}
}
"container" -> {
val alignment = parseContentAlignment(el.str("contentAlignment", "center"))
Box(modifier = modifier, contentAlignment = alignment) {
val children = el.arr("children")
when {
children != null && children.length() == 1 -> {
// Single child: let Box contentAlignment position it (no full-width Column).
RenderElement(
scope.vertical(),
El(children.getJSONObject(0)),
GlanceModifier,
)
}
children != null && children.length() > 0 -> {
Column(
modifier = GlanceModifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
renderChildrenVertical(
scope,
children,
spacing,
expandWidth = false,
)
}
}
else -> {
val elType = el.opt("type")?.toString().orEmpty()
Log.w(TAG, "container has no children; elType=$elType")
}
}
}
}
else -> {
val children = el.arr("children") ?: JSONArray()
val cols = el.int("columns", 2).coerceAtLeast(1)
val rowSpacing = el.num("rowSpacing", spacing.toDouble()).toInt().coerceAtLeast(0)
Column(modifier = modifier.fillMaxWidth()) {
val maxRows = GLANCE_CONTAINER_LIMIT
val visibleCols = cols.coerceAtMost(GLANCE_CONTAINER_LIMIT)
val maxChildren = maxRows * visibleCols
if (children.length() > maxChildren) {
Log.w(
TAG,
"grid truncated: requested=${children.length()} rendered=$maxChildren cols=$cols visibleCols=$visibleCols"
)
}
var i = 0
var rowIdx = 0
while (i < children.length() && i < maxChildren) {
if (rowIdx > 0 && rowSpacing > 0) {
Spacer(GlanceModifier.height(rowSpacing.dp))
}
Row(modifier = GlanceModifier.fillMaxWidth()) {
for (c in 0 until visibleCols) {
if (c > 0 && spacing > 0) {
Spacer(GlanceModifier.width(spacing.dp))
}
val child = children.optJSONObject(i + c)
if (child != null) {
Box(modifier = GlanceModifier.defaultWeight()) {
RenderElement(scope.horizontal(), El(child), GlanceModifier.fillMaxWidth())
}
} else {
Spacer(GlanceModifier.defaultWeight())
}
}
}
i += visibleCols
rowIdx += 1
}
}
}
}
}
@Composable
private fun ColumnScope.renderChildrenVertical(
scope: RenderScope,
children: JSONArray?,
spacing: Int,
expandWidth: Boolean = true,
) {
if (children == null) return
val visible = mutableListOf<JSONObject>()
for (i in 0 until children.length()) {
val child = children.optJSONObject(i) ?: continue
visible += child
if (visible.size >= GLANCE_CONTAINER_LIMIT) break
}
if (children.length() > visible.size) {
Log.w(
TAG,
"column children truncated: requested=${children.length()} rendered=${visible.size} limit=$GLANCE_CONTAINER_LIMIT"
)
}
if (visible.isEmpty()) return
val childScope = scope.vertical()
for (idx in visible.indices) {
val child = visible[idx]
if (idx > 0 && spacing > 0) {
val prev = visible[idx - 1]
// Fixed spacing Spacers around flex spacers steal height and crush the last row
// (stress-align BL/BR → 2dp lines). Flex spacer already provides the gap.
if (!isExpandingSpacer(prev) && !isExpandingSpacer(child)) {
Spacer(GlanceModifier.height(spacing.dp))
}
}
val flex = child.optDouble("flex", 0.0)
val type = child.widgetString("type", "")
// Buttons/images/shapes hug content — fillMaxWidth makes Tap a full-bleed bar.
val hugWidth = type in setOf("button", "toggle", "image", "shape", "label")
val widthMod = when {
hugWidth -> GlanceModifier
expandWidth -> GlanceModifier.fillMaxWidth()
else -> GlanceModifier
}
val base = when {
flex > 0.0 -> widthMod.defaultWeight()
type == "spacer" && !child.has("minLength") -> widthMod.defaultWeight()
type == "spacer" -> {
val h = child.optDouble("minLength", 8.0).toInt().coerceAtLeast(1)
widthMod.height(h.dp)
}
else -> widthMod
}
RenderElement(childScope, El(child), base)
}
}
@Composable
private fun RowScope.renderChildrenHorizontal(
scope: RenderScope,
children: JSONArray?,
spacing: Int,
) {
if (children == null) return
val visible = mutableListOf<JSONObject>()
for (i in 0 until children.length()) {
val child = children.optJSONObject(i) ?: continue
visible += child
if (visible.size >= GLANCE_CONTAINER_LIMIT) break
}
if (children.length() > visible.size) {
Log.w(
TAG,
"row children truncated: requested=${children.length()} rendered=${visible.size} limit=$GLANCE_CONTAINER_LIMIT"
)
}
val hasFlex = visible.any { it.optDouble("flex", 0.0) > 0.0 }
val allButtons = visible.isNotEmpty() && visible.all {
it.widgetString("type", "") in setOf("button", "toggle")
}
val childScope = scope.horizontal()
for (idx in visible.indices) {
val child = visible[idx]
if (idx > 0 && spacing > 0) {
val prev = visible[idx - 1]
if (!isExpandingSpacer(prev) && !isExpandingSpacer(child)) {
Spacer(GlanceModifier.width(spacing.dp))
}
}
val t = child.widgetString("type", "")
when (t) {
"divider" -> {
val th = child.optDouble("thickness", 1.0).toFloat().coerceAtLeast(2f).toInt()
Box(modifier = GlanceModifier.width(th.dp).height(48.dp)) {
RenderElement(childScope, El(child), GlanceModifier.fillMaxSize())
}
}
"spacer" -> {
val min = child.optDouble("minLength", 8.0).toInt().coerceAtLeast(1)
val flex = child.optDouble("flex", 0.0)
val base = if (flex > 0.0 || !child.has("minLength")) {
GlanceModifier.defaultWeight()
} else {
GlanceModifier.width(min.dp)
}
Spacer(modifier = base)
}
else -> {
// Only flex / expanding spacer / equal buttons take weight.
// Title columns wrap to intrinsic width so labels aren't ellipsized
// while the trailing spacer still pushes Sync to the end.
val wantsWeight = when {
child.optDouble("flex", 0.0) > 0.0 -> true
hasFlex -> false
allButtons -> true
else -> false
}
val base = if (wantsWeight) GlanceModifier.defaultWeight() else GlanceModifier
Box(modifier = base) {
RenderElement(childScope, El(child), GlanceModifier)
}
}
}
}
}