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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ docs/validation/native-input/** -text -whitespace

# Preserve touch-search receipts and raw native hierarchy CRLF bytes.
docs/validation/touch-search/** -text -whitespace

# Preserve empty-feedback evidence and raw Android hierarchy CRLF.
docs/validation/empty-feedback/** -text -whitespace
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.tinycast.android

import android.content.Context
import android.os.Build
import android.os.SystemClock
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.ExtractedTextRequest
import android.view.inputmethod.InputConnection
Expand Down Expand Up @@ -294,4 +295,154 @@ class TouchSearchDeviceTest {
cleared("emoji-search", "emoji-search-clear")
record.put("live_query_clears", 2).put("rejected_callbacks", 2).put("completed", true)
}

private fun settledFeedbackViewport(paletteTag: String) {
var last: Pair<Rect, Boolean>? = null
var since = 0L
compose.waitUntil(5000) {
val sample = rect(paletteTag) to ime()
val now = SystemClock.elapsedRealtime()
if (sample != last) { last = sample; since = now; false } else now - since >= 300
}
}

private fun foregroundFeedback() {
ui { assertTrue("Feedback actions require the app's foreground window", it.window.decorView.hasWindowFocus()) }
}

private fun completeFeedbackTarget(target: SemanticsNodeInteraction, label: String, paletteTag: String) {
val semantics = target.fetchSemanticsNode()
val surface = node(paletteTag).fetchSemanticsNode().boundsInRoot
val sample = compose.runOnUiThread {
val origin = semantics.positionInRoot
Rect(origin.x, origin.y, origin.x + semantics.size.width, origin.y + semantics.size.height) to semantics.boundsInRoot
}
val full = sample.first; val visible = sample.second
val dp = context.resources.displayMetrics.density
events.put(JSONObject().put("kind", "feedback-control").put("label", label)
.put("full", json(full)).put("visible", json(visible)).put("palette", json(surface)))
assertTrue("$label retains 48 dp", full.width >= 48 * dp - 1 && full.height >= 48 * dp - 1)
assertTrue("$label is complete in its palette", full.left >= surface.left - 1 && full.right <= surface.right + 1 &&
full.top >= surface.top - 1 && full.bottom <= surface.bottom + 1 &&
visible.width >= full.width - 1 && visible.height >= full.height - 1)
}

private fun naturalFeedback(text: String, fontSp: Float, paletteTag: String, phase: String) {
val target = compose.onNodeWithText(text)
val layouts = mutableListOf<TextLayoutResult>()
target.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) }
val layout = layouts.single()
val semantics = target.fetchSemanticsNode()
val surface = node(paletteTag).fetchSemanticsNode().boundsInRoot
val sample = compose.runOnUiThread { semantics.positionInRoot to semantics.boundsInRoot }
assertEquals(text, layout.layoutInput.text.text)
assertEquals("Keep the original status font", fontSp, layout.layoutInput.style.fontSize.value, .01f)
assertTrue(layout.lineCount > 0)
for (line in 0 until layout.lineCount) {
val bounds = Rect(sample.first.x + layout.getLineLeft(line), sample.first.y + layout.getLineTop(line),
sample.first.x + layout.getLineRight(line), sample.first.y + layout.getLineBottom(line))
val visible = sample.second
val complete = bounds.left >= visible.left - 1 && bounds.right <= visible.right + 1 &&
bounds.top >= visible.top - 1 && bounds.bottom <= visible.bottom + 1 &&
bounds.left >= surface.left - 1 && bounds.right <= surface.right + 1 &&
bounds.top >= surface.top - 1 && bounds.bottom <= surface.bottom + 1 &&
visible.width > 0 && visible.height > 0
events.put(JSONObject().put("kind", "natural-feedback-line").put("phase", phase).put("text", text)
.put("line", line).put("font_sp", fontSp).put("line_bounds", json(bounds))
.put("declared_line_height", layout.layoutInput.style.lineHeight.toString())
.put("visible", json(visible)).put("palette", json(surface)).put("complete", complete)
.put("ellipsized", layout.isLineEllipsized(line)))
assertFalse("Primary empty feedback must not ellipsize", layout.isLineEllipsized(line))
assertTrue("Primary empty feedback line $line is fully visible", complete)
}
}

private fun panFeedback(up: Boolean, paletteTag: String) {
foregroundFeedback()
val route = node("palette-content-scroll")
val before = route.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
route.performTouchInput {
val x = width * .65f
swipeWithVelocity(androidx.compose.ui.geometry.Offset(x, height * if (up) .85f else .15f),
androidx.compose.ui.geometry.Offset(x, height * if (up) .15f else .85f), endVelocity = 0f, durationMillis = 400)
}
settledFeedbackViewport(paletteTag)
val after = route.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
events.put(JSONObject().put("kind", "feedback-pointer-pan").put("up", up).put("before", before).put("after", after))
}

private fun feedbackTop(paletteTag: String) {
repeat(16) {
val range = node("palette-content-scroll").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
if (range.value() <= 1) return
panFeedback(false, paletteTag)
}
fail("Pointer panning must return the header to its original scroll origin")
}

private fun feedbackReveal(target: SemanticsNodeInteraction, paletteTag: String) {
repeat(16) {
val semantics = target.fetchSemanticsNode()
val surface = node(paletteTag).fetchSemanticsNode().boundsInRoot
val full = compose.runOnUiThread {
val origin = semantics.positionInRoot
Rect(origin.x, origin.y, origin.x + semantics.size.width, origin.y + semantics.size.height)
}
if (full.top >= surface.top - 1 && full.bottom <= surface.bottom + 1) return
panFeedback(full.bottom > surface.bottom, paletteTag)
}
fail("A pointer scroll must expose the complete control")
}

private fun feedbackFlow(emojiRoute: Boolean) {
if (emojiRoute) emoji()
val search = if (emojiRoute) "emoji-search" else "search"
val clear = if (emojiRoute) "emoji-search-clear" else "search-clear"
val surface = if (emojiRoute) "emoji-palette" else "palette"
val absent = if (emojiRoute) "zzownedemptyfeedbackemoji" else "zzownedemptyfeedbackapp"
val message = if (emojiRoute) "No matching emoji or symbols" else "No matching commands"
var origin: Rect? = null
for (visible in listOf(false, true)) {
feedbackTop(surface)
node(search).performTextReplacement(absent)
keyboard(visible, search); settledFeedbackViewport(surface)
capture("feedback-${if(visible)"shown"else"hidden"}-empty", search)
foregroundFeedback()
if (origin == null) origin = rect(search) else assertEquals("Opening the IME retains the search origin", origin, rect(search))
text(search, absent); node(search).assertIsFocused()
ui { if(emojiRoute)assertTrue(it.store.emoji.results().isEmpty())else assertTrue(it.store.results().isEmpty()) }
geometry(search, clear, surface, if(emojiRoute)listOf("emoji-back", "emoji-filter")else emptyList())
naturalFeedback(message, if(emojiRoute)13f else 14f, surface, "initial-${if(visible)"shown"else"hidden"}")
if(rect(surface).height < 180 * context.resources.displayMetrics.density) {
if(emojiRoute) node("emoji-count").assertDoesNotExist()
else compose.onNodeWithText("Try another name or category.").assertDoesNotExist()
events.put(JSONObject().put("kind", "compact-primary-status-only").put("route", if(emojiRoute)"emoji"else"launcher"))
}
if (emojiRoute) {
completeFeedbackTarget(node("emoji-back"), "Emoji Back", surface)
completeFeedbackTarget(node("emoji-filter"), "Emoji category", surface)
}
if (compose.activity.resources.configuration.screenWidthDp >= 700)
completeFeedbackTarget(compose.onNodeWithContentDescription("Show on-screen keyboard"), "Keyboard", surface)
val footer = if(emojiRoute)node("emoji-favorite")else node("palette-menu")
feedbackReveal(footer, surface)
completeFeedbackTarget(footer, if(emojiRoute)"Emoji favorite"else"Droidcast menu", surface)
capture("feedback-${if(visible)"shown"else"hidden"}-footer", search)
feedbackTop(surface)
node(search).assertIsFocused().assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange(absent.length)))
assertEquals(visible, ime()); assertEquals(origin, rect(search))
node(search).performTextReplacement(if(emojiRoute)"face"else"settings")
compose.onNodeWithText(message).assertDoesNotExist()
ui { if(emojiRoute)assertTrue(it.store.emoji.results().isNotEmpty())else assertTrue(it.store.results().isNotEmpty()) }
capture("feedback-${if(visible)"shown"else"hidden"}-matching", search)
foregroundFeedback(); node(clear).performTouchInput { click(center) }
cleared(search, clear); assertEquals(origin, rect(search)); assertEquals(visible, ime())
ui { if(emojiRoute)assertEquals(0,it.store.emoji.selected)else { assertEquals("Apps",it.store.category);assertEquals(0,it.store.selected) } }
capture("feedback-${if(visible)"shown"else"hidden"}-cleared", search)
}
record.put("completed", true)
}

@Test fun launcherEmptyFeedbackIsReadableAboveImeAndControlsRemainReachable() = feedbackFlow(false)
@Test fun emojiEmptyFeedbackIsReadableAboveImeAndControlsRemainReachable() = feedbackFlow(true)
}
27 changes: 20 additions & 7 deletions app/src/main/java/dev/tinycast/android/EmojiUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ fun EmojiPicker(store: EmojiStore, searchEpoch: Int, actionCurrent: () -> Boolea
}
fun back() { if(actionsMenu) actionsMenu=false else if (menu) menu = false else if (queryEditing) queryEditing = false else if (store.query.isNotEmpty()) store.search("") else close() }
BackHandler(onBack = ::back)
PaletteViewport { wide, top, height ->
PaletteViewport(emptyFeedback=results.isEmpty()) { wide, top, height ->
val compactEmpty = results.isEmpty() && height < 180.dp
val columns = ((minOf(maxWidth - 24.dp, PaletteTheme.width).value - 24) / 64).toInt().coerceIn(3, 10)
LaunchedEffect(Unit) { searchFocus.requestFocus() }
val imeVisible = WindowInsets.isImeVisible
Expand Down Expand Up @@ -221,12 +222,16 @@ fun EmojiPicker(store: EmojiStore, searchEpoch: Int, actionCurrent: () -> Boolea
}
}
HorizontalDivider(color = PaletteTheme.separator)
Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${results.size} ${if (results.size == 1) "result" else "results"} · offline", color = PaletteTheme.secondary, fontSize = 11.sp, modifier = Modifier.testTag("emoji-count"))
if (wide) Text(if (queryEditing) "Editing query · ↓ browse results" else "Type to search · arrows select · Ctrl L edit", color = PaletteTheme.secondary, fontSize = 11.sp)
}
if (results.isEmpty()) Box(Modifier.fillMaxWidth().weight(1f).testTag("emoji-empty"), contentAlignment = Alignment.Center) {
Text(when { store.loading -> "Loading catalog…"; store.error != null -> store.error!!; store.query.isNotBlank() -> "No matching emoji or symbols"; store.filter == "Favorites" -> "Favorite a symbol with the star or Ctrl D"; store.filter == "Recent" -> "Symbols you copy will appear here"; else -> "No symbols in this category" }, color = PaletteTheme.secondary, fontSize = 13.sp, modifier = Modifier.verticalScroll(rememberScrollState()).padding(20.dp))
if (!compactEmpty) EmojiResultCount(results.size, wide, queryEditing)
if (results.isEmpty()) Box(Modifier.fillMaxWidth().weight(1f).testTag("emoji-empty"), contentAlignment = if(compactEmpty)Alignment.TopCenter else Alignment.Center) {
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),horizontalAlignment=Alignment.CenterHorizontally) {
// Use one primary status when only a small band remains
// above the keyboard. Keep duplicate counts/hints in the
// normal viewport, not as a clipped extra compact line.
Text(when { store.loading -> "Loading catalog…"; store.error != null -> store.error!!; store.query.isNotBlank() -> "No matching emoji or symbols"; store.filter == "Favorites" -> "Favorite a symbol with the star or Ctrl D"; store.filter == "Recent" -> "Symbols you copy will appear here"; else -> "No symbols in this category" }, color = PaletteTheme.secondary, fontSize = 13.sp,
lineHeight=if(compactEmpty)16.sp else TextUnit.Unspecified,
modifier = Modifier.padding(horizontal=20.dp,vertical=if(compactEmpty)0.dp else 20.dp))
}
} else LazyVerticalGrid(GridCells.Fixed(columns), state = grid,
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp).onFocusChanged { gridFocused = it.hasFocus }.focusable().testTag("emoji-grid"),
contentPadding = PaddingValues(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Expand Down Expand Up @@ -275,3 +280,11 @@ fun EmojiPicker(store: EmojiStore, searchEpoch: Int, actionCurrent: () -> Boolea
}
}
}

@Composable
private fun EmojiResultCount(count: Int, wide: Boolean, queryEditing: Boolean) {
Row(Modifier.fillMaxWidth().padding(horizontal=18.dp,vertical=7.dp),horizontalArrangement=Arrangement.SpaceBetween) {
Text("$count ${if(count==1)"result"else"results"} · offline",color=PaletteTheme.secondary,fontSize=11.sp,modifier=Modifier.testTag("emoji-count"))
if(wide) Text(if(queryEditing)"Editing query · ↓ browse results"else"Type to search · arrows select · Ctrl L edit",color=PaletteTheme.secondary,fontSize=11.sp)
}
}
16 changes: 12 additions & 4 deletions app/src/main/java/dev/tinycast/android/PaletteUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ fun TinycastApp(store: PaletteStore, globalEnabled: Boolean, run: (Command) -> U
if (scrollHistory.viewportMeasured(height, visible)) list.requestScrollToItem(index)
}
}
PaletteViewport(Modifier.onPreviewKeyEvent { handleKey(it.nativeKeyEvent) }) { wide, top, paletteHeight ->
PaletteViewport(Modifier.onPreviewKeyEvent { handleKey(it.nativeKeyEvent) }, emptyFeedback=results.isEmpty()) { wide, top, paletteHeight ->
val compactEmpty = paletteHeight < 180.dp
LaunchedEffect(store.focusEpoch) { focus.requestFocus() }
val imeVisible = WindowInsets.isImeVisible
LaunchedEffect(wide, imeVisible, keyboardRequested, windowFocused, searchFocused) {
Expand Down Expand Up @@ -294,9 +295,16 @@ fun TinycastApp(store: PaletteStore, globalEnabled: Boolean, run: (Command) -> U
}
if (store.category == "Files") FileSearchToolbar(store, run)
LaunchedEffect(store.folderSearch.includeInLauncher) { store.syncFileSearch() }
if(results.isEmpty()) Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp),verticalArrangement=Arrangement.Center,horizontalAlignment=Alignment.CenterHorizontally){
Glyph("search",PaletteTheme.tertiary,28);Spacer(Modifier.height(12.dp))
Text("No matching commands",fontSize=14.sp);Text("Try another name or category.",fontSize=12.sp,color=PaletteTheme.secondary)
if(results.isEmpty()) Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())
.padding(horizontal=24.dp,vertical=if(compactEmpty)0.dp else 24.dp),
verticalArrangement=if(compactEmpty)Arrangement.Top else Arrangement.Center,horizontalAlignment=Alignment.CenterHorizontally){
// A short IME viewport can show the header and one natural
// text line. Keep the status first; decoration must not
// push it below that strip. The route still scrolls to help
// text and full-size footer controls.
if(!compactEmpty) { Glyph("search",PaletteTheme.tertiary,28);Spacer(Modifier.height(12.dp)) }
Text("No matching commands",fontSize=14.sp,lineHeight=if(compactEmpty)18.sp else TextUnit.Unspecified)
if(!compactEmpty) Text("Try another name or category.",fontSize=12.sp,color=PaletteTheme.secondary)
if(store.category=="Files") TextButton(onClick={run(Command("search-folders","Search folders",""))}){Text("Choose search folders")}
if(store.category=="Extensions") TextButton(onClick={store.dialog="extensions"}){Text("Browse Raycast Store")}
} else LazyColumn(state=list,modifier=Modifier.fillMaxWidth().weight(1f).testTag("results"),contentPadding=PaddingValues(horizontal=8.dp,vertical=4.dp)) {
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/dev/tinycast/android/PaletteViewport.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
@Composable
internal fun PaletteViewport(
modifier: Modifier = Modifier,
emptyFeedback: Boolean = false,
content: @Composable BoxWithConstraintsScope.(wide: Boolean, top: Dp, height: Dp) -> Unit
) {
val bars = WindowInsets.systemBars.union(WindowInsets.displayCutout)
Expand All @@ -25,7 +26,12 @@ internal fun PaletteViewport(
(WindowInsets.imeAnimationTarget.getBottom(this) - bars.getBottom(this)).coerceAtLeast(0).toDp()
}
val preferredHeight=PaletteTheme.height*density.fontScale.coerceIn(1f,1.5f)
val height = minOf(preferredHeight, (maxHeight - top - keyboard - 12.dp).coerceAtLeast(0.dp))
val available = maxHeight - top - keyboard
// Spend some of the usual gap on the primary message in a very short
// empty viewport. The top/search origin never moves, and other routes
// retain their existing spacing.
val bottomGap = if (emptyFeedback && available < 180.dp) 4.dp else 12.dp
val height = minOf(preferredHeight, (available - bottomGap).coerceAtLeast(0.dp))
content(wide, top, height)
}
}
Loading
Loading