Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d027f92
fix(apple): accept pointer metadata on context menu actions
tiensonqin Oct 19, 2026
2e15c0d
Keep drawer scroll environments stable during transitions
tiensonqin Oct 19, 2026
3ccbbcf
Keep mobile outliner windows bounded and reduce layout invalidations
tiensonqin Oct 19, 2026
b79a6fc
Support revealing distant rows in virtual lists without resetting scr…
tiensonqin Oct 19, 2026
8314844
Avoid nesting glass capsules in scrolling toolbars
tiensonqin Oct 19, 2026
1275bdb
Arbitrate drawer swipes with native scroll gestures
tiensonqin Oct 19, 2026
f6702b7
Preserve section titles across virtual row windows
tiensonqin Oct 19, 2026
6e124bc
Support windowed Android lists and retained navigation state
tiensonqin Oct 20, 2026
708f85c
Accept selected state on native context menu actions
tiensonqin Oct 20, 2026
a3f18f6
Keep decorative glass from intercepting native menus
tiensonqin Oct 21, 2026
2fd6c67
Reveal scroll titles only after their content headings pass the top
tiensonqin Oct 21, 2026
424fa20
Merge main into mobile renderer fixes
tiensonqin Oct 21, 2026
a87baaf
Keep scroll titles after standalone headings are evicted
tiensonqin Oct 21, 2026
b30c75d
Export the cached OCaml switch for tests in temporary directories
tiensonqin Oct 21, 2026
4fae8c3
Preserve native input identity when overlays change
tiensonqin Oct 21, 2026
811b2b7
Refine compact sidebar width and main panel curvature
tiensonqin Oct 21, 2026
7a26edc
Fade main content with compact drawer transitions
tiensonqin Oct 21, 2026
b077fb0
Fade drawer content toward its background without dimming the surface
tiensonqin Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/actions/setup-lui-ocaml/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ runs:
_opam
C:/.opam
key: ${{ steps.environment.outputs.cache-primary-key }}
- name: Export active switch for subprocesses outside the workspace
shell: bash
run: echo "OPAMSWITCH=$(opam switch show)" >> "$GITHUB_ENV"
- name: Check active compiler
shell: bash
run: opam exec -- ocamlc -version
3 changes: 2 additions & 1 deletion platform/android/lui/src/main/kotlin/dev/lui/LuiBackend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ class LuiBackend(
/** Resolver hook for `file-image` paths (e.g. remap asset paths). */
var filePathResolver: ((String) -> String)? = null

internal var lastError: String? = null
var lastError: String? = null
private set
internal val scrollPositions = mutableMapOf<String, LuiScrollPosition>()

private fun observed(id: Long): NodeObservation = observedNodes.getOrPut(id) {
NodeObservation().also { refresh(it, id) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ internal object LuiKindRules {
kind == LuiNodeKind.menuItem ||
kind == LuiNodeKind.listItem ||
kind == LuiNodeKind.tableRow ||
kind == LuiNodeKind.virtualList ||
kind == LuiNodeKind.drawer ||
isTreeRowKind(kind))
"long-press-enabled" ->
Expand Down
130 changes: 99 additions & 31 deletions platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
Expand All @@ -33,6 +35,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
Expand Down Expand Up @@ -60,8 +63,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
Expand Down Expand Up @@ -89,19 +90,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

/**
* Renders one node of the retained tree plus its subtree. Ported from the
Expand Down Expand Up @@ -136,7 +144,15 @@ private fun LuiChromeSurface(backend: LuiBackend, node: LuiNodeLike, id: Long) {
var contextMenuAt by remember(id) { mutableStateOf<Offset?>(null) }
val contextMenuId =
node.children.firstOrNull { backend.node(it)?.kind == LuiNodeKind.contextMenu }
val modifier = Modifier
val hidden = node.propString("display") == "none" ||
(node.kind == LuiNodeKind.virtualList && node.prop("selected")?.boolValue == false)
val retainedVisibility = if (hidden) {
Modifier.layout { measurable, constraints ->
val measured = measurable.measure(constraints)
layout(0, 0) { }
}
} else Modifier
val modifier = retainedVisibility
.luiChrome(node)
.luiGestures(backend, node, id) { offset -> contextMenuAt = offset }

Expand Down Expand Up @@ -330,12 +346,7 @@ private fun LuiNodeContent(
}
}

LuiNodeKind.virtualList -> LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(gapOf(node).dp),
) {
items(children, key = { it }) { childId -> LuiNodeView(backend, childId) }
}
LuiNodeKind.virtualList -> LuiVirtualList(backend, node, id, modifier)

LuiNodeKind.grid -> LuiGrid(backend, node, modifier, children)

Expand Down Expand Up @@ -372,7 +383,8 @@ private fun LuiNodeContent(
LuiNodeKind.paragraph,
LuiNodeKind.label,
-> Text(
node.text(),
if (node.text().isEmpty() && node.propString("style-class").orEmpty().split(' ').contains("navigation-title"))
LocalLuiSectionTitle.current.orEmpty() else node.text(),
modifier = modifier,
color = luiThemeColor(node.propString("foreground"), foreground = true)
?: Color.Unspecified,
Expand Down Expand Up @@ -922,10 +934,21 @@ private fun LuiButton(
"lg", "icon" -> 24.dp
else -> 18.dp
}
val defaultContentPadding = androidx.compose.material3.ButtonDefaults.TextButtonContentPadding
val layoutDirection = LocalLayoutDirection.current
val horizontalPadding = node.prop("padding-horizontal")?.intValue ?: node.prop("padding")?.intValue
val verticalPadding = node.prop("padding-vertical")?.intValue ?: node.prop("padding")?.intValue
val contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = horizontalPadding?.dp ?: defaultContentPadding.calculateStartPadding(layoutDirection),
end = horizontalPadding?.dp ?: defaultContentPadding.calculateEndPadding(layoutDirection),
top = verticalPadding?.dp ?: defaultContentPadding.calculateTopPadding(),
bottom = verticalPadding?.dp ?: defaultContentPadding.calculateBottomPadding(),
)

val label: @Composable () -> Unit = {
if (children.isNotEmpty()) {
Row(
modifier = if (node.propString("text-alignment") != null) Modifier.fillMaxWidth() else Modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Expand All @@ -940,6 +963,11 @@ private fun LuiButton(
)
icon == null -> Text(
node.text(),
modifier = if (node.propString("text-alignment") != null) Modifier.fillMaxWidth() else Modifier,
textAlign = textAlignment(node.propString("text-alignment")),
style = if (node.propString("style-class") != null) {
LuiTypography.textStyle(node.propString("size"), node.propString("style-class"))
} else MaterialTheme.typography.labelLarge.luiInherited(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Expand Down Expand Up @@ -1032,8 +1060,9 @@ private fun LuiButton(
} else {
TextButton(
onClick = onClick,
modifier = modifier,
modifier = if (node.propDouble("grow") > 0) modifier.fillMaxWidth() else modifier,
enabled = enabled,
contentPadding = contentPadding,
// Borderless buttons use the accent in Compose, but the Apple
// backend renders them with the dark foreground — match it so
// header/toolbar glyphs are not tinted primary.
Expand Down Expand Up @@ -1084,6 +1113,18 @@ private fun LuiTextControl(
val grouped = node.parent?.let { backend.node(it)?.kind } == LuiNodeKind.inputGroup
val borderless = (node.propString("style-class") ?: "").split(' ')
.contains("composer-input")
val focusRequester = remember(id) { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val autofocus = node.prop("autofocus")?.boolValue == true
var observedAutofocus by remember(id) { mutableStateOf<Boolean?>(null) }
LaunchedEffect(autofocus) {
// The protocol toggles this flag to request focus again on an existing field.
if (enabled && (autofocus || observedAutofocus != null)) {
focusRequester.requestFocus()
keyboard?.show()
}
observedAutofocus = autofocus
}

// Local text keeps typing responsive. The model echoes our own edits
// back through the `text` prop, possibly one patch per keystroke, so a
Expand All @@ -1104,8 +1145,9 @@ private fun LuiTextControl(

val fieldModifier = modifier.then(
if (grouped || borderless) Modifier.fillMaxWidth() else Modifier.width(240.dp),
)
val fieldTextStyle = MaterialTheme.typography.bodyMedium.copy(
).focusRequester(focusRequester)
val fieldTextStyle = (if (borderless) MaterialTheme.typography.bodyLarge.copy(fontSize = 17.sp)
else MaterialTheme.typography.bodyMedium).copy(
color = luiThemeColor(node.propString("foreground"), foreground = true)
?: MaterialTheme.colorScheme.onSurface,
).luiInherited()
Expand Down Expand Up @@ -1140,25 +1182,27 @@ private fun LuiTextControl(
}

if (borderless) {
TextField(
BasicTextField(
value = localText,
onValueChange = onValueChange,
modifier = fieldModifier,
enabled = enabled,
placeholder = placeholderContent,
singleLine = !multiline,
minLines = if (multiline) 3 else 1,
visualTransformation = transformation,
trailingIcon = trailing,
textStyle = fieldTextStyle,
colors = TextFieldDefaults.colors(
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
disabledContainerColor = androidx.compose.ui.graphics.Color.Transparent,
focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
disabledIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (localText.isEmpty()) {
Text(
node.propString("placeholder") ?: "",
style = fieldTextStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
innerTextField()
}
},
)
} else {
OutlinedTextField(
Expand Down Expand Up @@ -1207,7 +1251,9 @@ internal fun LuiMenuEntry(backend: LuiBackend, childId: Long) {
val child = backend.node(childId) ?: return
when (child.kind) {
LuiNodeKind.divider -> HorizontalDivider()
LuiNodeKind.menuItem -> LuiMenuItem(backend, child, childId, Modifier)
LuiNodeKind.menuItem -> LuiThemeScope(child) {
LuiMenuItem(backend, child, childId, Modifier.luiChrome(child))
}
LuiNodeKind.menuTrigger -> LuiMenuTrigger(backend, child, childId, Modifier)
else -> LuiNodeView(backend, childId)
}
Expand All @@ -1221,15 +1267,18 @@ private fun LuiMenuItem(backend: LuiBackend, node: LuiNodeLike, id: Long, modifi
val icon = node.propString("icon")?.let { backend.icons.icon(it) }
val selected = node.propBool("selected")
val destructive = node.propString("variant") == "destructive"
val iconTint = luiThemeColor(node.propString("foreground"), foreground = true)
?: MaterialTheme.colorScheme.onSurface
val iconSize = if (node.propString("size") == "sm") 16.dp else 22.dp

if (submenuId != null) {
var submenuOpen by remember(id) { mutableStateOf(false) }
Box {
DropdownMenuItem(
text = { Text(node.text()) },
text = { Text(node.text(), style = LuiTypography.textStyle(null, node.propString("style-class"))) },
onClick = { submenuOpen = true },
enabled = enabled,
leadingIcon = icon?.let { { Icon(it, null, Modifier.size(16.dp)) } },
leadingIcon = icon?.let { { Icon(it, null, Modifier.size(iconSize), tint = iconTint) } },
trailingIcon = {
Icon(Icons.Filled.KeyboardArrowRight, null, Modifier.size(16.dp))
},
Expand All @@ -1251,13 +1300,14 @@ private fun LuiMenuItem(backend: LuiBackend, node: LuiNodeLike, id: Long, modifi
text = {
Text(
node.text(),
style = LuiTypography.textStyle(null, node.propString("style-class")),
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified,
)
},
onClick = { if (enabled) backend.emit(LuiEvent.Press(id)) },
enabled = enabled,
modifier = modifier,
leadingIcon = icon?.let { { Icon(it, null, Modifier.size(16.dp)) } },
leadingIcon = icon?.let { { Icon(it, null, Modifier.size(iconSize), tint = iconTint) } },
trailingIcon = if (selected) {
{ Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) }
} else {
Expand Down Expand Up @@ -1829,15 +1879,33 @@ private fun LuiDrawer(
val presented = node.propBool("selected")
val width = node.propInt("width", 320)
val enabled = node.isEnabled()
var swiping by remember(id) { mutableStateOf(false) }
val threshold = with(LocalDensity.current) { 48.dp.toPx() }
// Android back closes the drawer instead of leaving the app — mirrors
// tapping the scrim.
androidx.activity.compose.BackHandler(
enabled = presented && enabled && node.propBool("toggle-enabled"),
) {
backend.emit(LuiEvent.ToggleChanged(id, false))
}
Box(modifier.fillMaxSize()) {
children.firstOrNull()?.let { LuiNodeView(backend, it) }
Box(modifier.fillMaxSize().pointerInput(id, enabled, presented) {
if (enabled && node.propBool("toggle-enabled")) {
var distance = 0f
detectHorizontalDragGestures(
onDragStart = { distance = 0f; swiping = true },
onDragCancel = { swiping = false },
onDragEnd = {
if ((!presented && distance > threshold) || (presented && distance < -threshold)) {
backend.emit(LuiEvent.ToggleChanged(id, !presented))
}
swiping = false
},
) { change, amount -> change.consume(); distance += amount }
}
}) {
CompositionLocalProvider(LocalLuiScrollingEnabled provides (!presented && !swiping)) {
children.firstOrNull()?.let { LuiNodeView(backend, it) }
}
if (presented) {
Box(
Modifier.fillMaxSize()
Expand Down
4 changes: 3 additions & 1 deletion platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ internal fun Modifier.luiChrome(node: LuiNodeLike): Modifier {
)
}
if (radius > 0) modifier = modifier.clip(shape)
if (paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0) {
// Ghost buttons own their label padding so the full painted surface stays clickable.
val buttonContentPadding = node.kind == LuiNodeKind.button && node.propString("variant") == "ghost"
if (!buttonContentPadding && (paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0)) {
modifier = modifier.absolutePadding(
left = paddingLeft.dp, top = paddingTop.dp,
right = paddingRight.dp, bottom = paddingBottom.dp,
Expand Down
2 changes: 1 addition & 1 deletion platform/android/lui/src/main/kotlin/dev/lui/LuiTheme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ object LuiTypography {
4 -> theme.titleMedium.copy(fontSize = 20.sp)
5 -> theme.bodyLarge.copy(fontSize = 17.sp)
else -> theme.titleSmall.copy(fontSize = 15.sp)
}.luiInherited()
}.copy(fontWeight = FontWeight.Bold).luiInherited()
}

@Composable
Expand Down
Loading
Loading