Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ class AndroidFeedbackRecordingTest(private val scenario: String) {
ui.onNodeWithTag("button.outliner.code-language.option.python").performClick(); pause()
}
}
"calculator-live" -> {
"calculator-live", "calculator-toolbar" -> {
mount(2)
val configured = request("nodeProperty", JSONObject().put("action", "setCodeLanguage")
.put("blockUuid", uuid(12)).put("value", "calc").toString())
Expand Down Expand Up @@ -245,13 +245,30 @@ class AndroidFeedbackRecordingTest(private val scenario: String) {
val beforeCount = JSONObject(configured).getJSONObject("result").getJSONArray("outlinerRows").length()
assertEquals("Enter stays inside Calculator", beforeCount,
JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows").length())
editor.performKeyInput { keyDown(Key.ShiftLeft); pressKey(Key.Enter); keyUp(Key.ShiftLeft) }
if (scenario == "calculator-toolbar") {
ui.mainClock.autoAdvance = false
try {
editor.performTextInput("pending🙂")
ui.onNodeWithTag("button.outliner.editor.newBlock").performClick()
drain()
} finally {
ui.mainClock.autoAdvance = true
}
} else {
editor.performKeyInput { keyDown(Key.ShiftLeft); pressKey(Key.Enter); keyUp(Key.ShiftLeft) }
}
ui.waitUntil(10_000) {
drain()
runCatching { editor.assertDoesNotExist() }.isSuccess
}
val after = JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows")
assertEquals("Shift+Enter creates exactly one new block", beforeCount + 1, after.length())
if (scenario == "calculator-toolbar") {
val source = (0 until after.length()).map { after.getJSONObject(it).getJSONObject("block") }
.single { it.getString("uuid") == uuid(12) }
assertEquals("The toolbar preserves typing that has not reached the debounced core update",
"6*7\nlast*3\npending🙂", source.getString("title"))
}
content.performTouchInput { longClick() }
ui.waitUntil(10_000) {
drain()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.closeSoftKeyboard
import androidx.test.platform.app.InstrumentationRegistry
import com.logseq.app.runtime.LogseqNativeBridge
import com.logseq.app.runtime.LuiPatchFilter
Expand All @@ -44,6 +50,51 @@ class AndroidNodePropertiesTest {
if (::bridge.isInitialized) ui.runOnIdle { bridge.close() }
}

@Test fun historicalDatesCanBeOpenedTypedAndSelected() {
val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) },
extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver)
val filter = LuiPatchFilter(backend)
bridge = LogseqNativeBridge(onPatch = { if (it.isNotEmpty()) filter.applyJson(it) })
val root = bridge.initialize(authenticationCode = 3)
val snapshot = JSONObject(InstrumentationRegistry.getInstrumentation().context.assets
.open("node-properties.json").bufferedReader().use { it.readText() })
val properties = snapshot.getJSONObject("result").getJSONObject("nodeProperties").getJSONArray("properties")
for (i in 0 until properties.length()) {
val property = properties.getJSONObject(i)
if (property.getString("attr") == "user.property/Due") property.put("editorValue", "0044-03-15")
}
filter.applyJson(bridge.applySnapshot(snapshot.toString()))
bridge.applyHostUpdate("graph-loading", "false").takeIf { it.isNotEmpty() }?.let(filter::applyJson)
ui.setContent { MaterialExpressiveTheme(colorScheme = LogseqTheme.lightScheme, typography = LogseqTheme.typography) {
Box(Modifier.fillMaxSize()) { backend.Content(root) }
} }
ui.onNodeWithTag("properties.expand.page").performClick()
val editor = "property.page.user.property/Due"
ui.onNodeWithTag("property.value.page.user.property/Due").performScrollTo().performClick()
fun input() = ui.onNodeWithTag("view.edit.date.input.$editor")
input().assertTextEquals("0044-03-15")
val calendar = hasAnyAncestor(hasTestTag("view.edit.date.$editor"))
ui.onNode(hasText("March 15", substring = true) and calendar, useUnmergedTree = true).performScrollTo().performClick()
input().assertTextEquals("0044-03-15")
input().performTextReplacement("2026-10-26")
closeSoftKeyboard()
ui.onNode(hasText("October 23", substring = true) and calendar, useUnmergedTree = true).performScrollTo().performClick()
input().assertTextEquals("2026-10-23")
input().performTextReplacement("0044-03-15")
ui.onNodeWithTag("view.edit.save.$editor").performClick()
ui.runOnIdle {
var selected: JSONObject? = null
repeat(32) {
val encoded = bridge.takeEffect()
if (encoded.isNotEmpty()) {
val effect = JSONObject(encoded).optJSONObject("effect")
if (effect?.optString("kind") == "node-property") selected = JSONObject(effect.getString("text"))
}
}
assertEquals("Historical dates remain calendar dates across the native bridge", "0044-03-15", selected?.getString("value"))
}
}

@Test fun pagePropertiesUseBlocksAndNativeTypedEditors() {
val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) },
extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@ package com.logseq.app.runtime

import dev.lui.LuiEvent

internal object NativeEditorInput {
private var owner: Any? = null
private var pending: (() -> Unit)? = null

fun register(owner: Any, flush: () -> Unit) {
this.owner = owner
pending = flush
}

fun unregister(owner: Any) {
if (this.owner === owner) {
this.owner = null
pending = null
}
}

fun flush() { pending?.invoke() }
}

// Kotlin port of the former lui_dispatch.dart: LUI events → native
// bridge calls, including the extension (text, value) extraction the OCaml
// side reinterprets by identifier/name.
internal fun dispatchLuiEvent(event: LuiEvent, native: LogseqNativeBridge) {
when (event) {
is LuiEvent.Appear -> native.appear(event.node)
is LuiEvent.Press -> native.press(event.node)
is LuiEvent.Press -> {
NativeEditorInput.flush()
native.press(event.node)
}
is LuiEvent.LongPress -> native.longPress(event.node)
is LuiEvent.DoublePress -> native.doublePress(event.node)
is LuiEvent.Change -> native.change(event.node)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
Expand Down Expand Up @@ -48,6 +49,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.lui.LuiExtensionContext
import com.logseq.app.runtime.NativeEditorInput
import kotlinx.coroutines.delay

// Port of the former _OutlinerEditor Dart implementation.
Expand Down Expand Up @@ -169,7 +171,23 @@ internal fun OutlinerEditor(context: LuiExtensionContext) {
"caret-utf16-offset" to caretOffset(value, title),
)
}
DisposableEffect(Unit) { onDispose { debounceSeq++ } }
val inputOwner = remember { Any() }
SideEffect {
if (focusEnabled) NativeEditorInput.register(inputOwner) {
if (!awaitingHandoff && value.text != modelTitle && value.text != publishedTitles.lastOrNull()) {
debounceSeq++
publishedTitles.add(value.text)
context.emit("text-change", "title" to value.text,
"caret-utf16-offset" to caretOffset(value, value.text))
}
} else NativeEditorInput.unregister(inputOwner)
}
DisposableEffect(Unit) {
onDispose {
debounceSeq++
NativeEditorInput.unregister(inputOwner)
}
}

fun submitReturn(title: String, caret: Int) {
if (awaitingHandoff) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDefaults
import androidx.compose.material3.DatePickerFormatter
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TimeInput
import androidx.compose.material3.rememberDatePickerState
Expand Down Expand Up @@ -36,6 +38,12 @@ import dev.lui.LuiExtensionValueKind
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.util.Date
import java.util.Locale
import android.icu.text.DateFormat
import android.icu.text.DisplayContext
import android.icu.util.GregorianCalendar
import android.icu.util.TimeZone
import java.time.ZoneOffset
import kotlinx.coroutines.flow.distinctUntilChanged

Expand Down Expand Up @@ -121,8 +129,26 @@ private fun PropertyDatePicker(context: LuiExtensionContext) {
if (time) (source.toLongOrNull()?.let(Instant::ofEpochMilli) ?: Instant.now()).atZone(zone)
else (runCatching { LocalDate.parse(source) }.getOrDefault(LocalDate.now())).atStartOfDay(zone)
}
val date = rememberDatePickerState(initialSelectedDateMillis = initial.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
val date = rememberDatePickerState(
initialSelectedDateMillis = initial.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
yearRange = 1..9999,
)
val clock = rememberTimePickerState(initialHour = initial.hour, initialMinute = initial.minute)
LaunchedEffect(source) {
val selected = if (time) source.toLongOrNull()?.let { Instant.ofEpochMilli(it).atZone(zone) }
else runCatching { LocalDate.parse(source).atStartOfDay(zone) }.getOrNull()
if (selected != null && selected.year in date.yearRange) {
val millis = selected.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
if (date.selectedDateMillis != millis) {
date.selectedDateMillis = millis
date.displayedMonthMillis = millis
}
if (time) {
clock.hour = selected.hour
clock.minute = selected.minute
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { Triple(date.selectedDateMillis, clock.hour, clock.minute) }.distinctUntilChanged().collect { (millis, hour, minute) ->
if (millis != null) {
Expand All @@ -133,11 +159,36 @@ private fun PropertyDatePicker(context: LuiExtensionContext) {
}
}
Column(Modifier.fillMaxWidth().testTag(context.string("identifier") ?: "")) {
DatePicker(state = date, showModeToggle = false)
DatePicker(state = date, dateFormatter = CalendarDateFormatter, showModeToggle = false)
if (time) TimeInput(state = clock)
}
}

@OptIn(ExperimentalMaterial3Api::class)
private object CalendarDateFormatter : DatePickerFormatter {
private val formatters = mutableMapOf<Pair<String, Locale>, DateFormat>()

private fun format(millis: Long?, skeleton: String, locale: Locale): String? = millis?.let {
val formatter = formatters.getOrPut(skeleton to locale) {
DateFormat.getInstanceForSkeleton(skeleton, locale).apply {
// Keep ICU labels on the same proleptic Gregorian calendar as the picker state.
calendar = GregorianCalendar(TimeZone.GMT_ZONE, locale).apply {
gregorianChange = Date(Long.MIN_VALUE)
}
setContext(DisplayContext.CAPITALIZATION_FOR_STANDALONE)
}
}
formatter.format(Date(it))
}

override fun formatMonthYear(monthMillis: Long?, locale: Locale): String? =
format(monthMillis, DatePickerDefaults.YearMonthSkeleton, locale)

override fun formatDate(dateMillis: Long?, locale: Locale, forContentDescription: Boolean): String? =
format(dateMillis, if (forContentDescription) DatePickerDefaults.YearMonthWeekdayDaySkeleton
else DatePickerDefaults.YearAbbrMonthDaySkeleton, locale)
}

@Composable
private fun TableViewport(context: LuiExtensionContext) {
val scroll = rememberScrollState()
Expand Down
Loading
Loading