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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The result is an Android reader that sits somewhere between an ebook app, a spee

- Clean full-screen RSVP playback with minimal interface clutter
- ORP highlighting support
- Adaptive pacing based on word length, syllables, punctuation, clause boundaries, and difficulty
- Offline reading support with bounded word demand, real English word frequencies, and phrase breathing room
- Optional peripheral context cues that stay inside the ORP focal band
- Phrase replay by double tap or playback control, with temporary rereading-aware pace easing
- Phrase chunking, blink modes, readability floors, and punctuation tuning
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package com.kairo.reader.ui.rsvp

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.TestActivity
import com.kairo.reader.core.model.ReaderTheme
import com.kairo.reader.core.model.RsvpConfig
import com.kairo.reader.core.model.RsvpProfile
import com.kairo.reader.core.model.Token
import com.kairo.reader.core.model.TokenType
import com.kairo.reader.core.model.defaultConfig
import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine
import com.kairo.reader.core.rsvp.RsvpGenerationOptions
import com.kairo.reader.core.rsvp.RsvpLanguagePolicy
import com.kairo.reader.ui.theme.KairoTheme
import com.kairo.reader.ui.theme.colorContrast
import com.kairo.reader.ui.theme.materialColorScheme
import com.kairo.reader.ui.theme.rsvpAccentColors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RsvpDifficultyHighlightDeviceTest {
@get:Rule
val composeRule = createAndroidComposeRule<TestActivity>()

@Test
fun darkThemesRenderAContrastingPivotLetterWithoutAHighlightBlock() {
var theme by mutableStateOf(ReaderTheme.DARK)
composeRule.setContent {
KairoTheme(readerTheme = theme) {
val scheme = MaterialTheme.colorScheme
val accents = scheme.rsvpAccentColors()
Box(Modifier.size(FOCUS_STAGE_WIDTH_DP.dp, FOCUS_STAGE_HEIGHT_DP.dp).background(scheme.background)) {
OrpAlignedText(
tokens = listOf(Token("reading", TokenType.WORD)),
typography = OrpTypography(FOCUS_FONT_SIZE_SP, FontFamily.Monospace, FontWeight.Normal),
colors = OrpColors(scheme.onBackground, accents.pivot, scheme.onBackground, accents.wordPart),
layout = OrpTextLayout(
horizontalBias = FOCUS_HORIZONTAL_BIAS,
lockPivot = true,
smoothTranslation = false,
preferWindowing = false,
simplifyPunctuation = false,
guideVisible = true,
pivotHighlightVisible = true,
guideThickness = FOCUS_GUIDE_THICKNESS,
),
)
}
}
}

for (currentTheme in listOf(ReaderTheme.DARK, ReaderTheme.INK)) {
composeRule.runOnIdle { theme = currentTheme }
val scheme = currentTheme.materialColorScheme()
val accents = scheme.rsvpAccentColors()
val text = renderedLayout().layoutInput.text
val pivot = text.spanStyles.last()
assertEquals(PIVOT_CHARACTER_COUNT, pivot.end - pivot.start)
assertEquals(accents.pivot, pivot.item.color)
assertEquals(Color.Unspecified, pivot.item.background)
assertNull(pivot.item.fontWeight)
assertNull(pivot.item.textDecoration)
assertTrue(colorContrast(pivot.item.color, scheme.background) >= WCAG_AA_TEXT_CONTRAST)
}
}

@Test
fun difficultWordsMoveLeftToRightWithoutMovingTheRenderedWord() {
val source = listOf("the", "neuroplasticity", "changed").map { Token(it, TokenType.WORD) }
val config = RsvpConfig(
maxChunkLength = 32,
enablePhraseChunking = false,
longWordMinMs = 400L,
startDelayMs = 0,
endDelayMs = 0,
rampUpFrames = 0,
rampDownFrames = 0,
)
val engine = ComprehensionRsvpEngine()
val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)
val parts = engine.generateFrames(source, 0, config, options).filter { it.originalTokenIndex == 1 }
assertTrue(parts.size > 1)
assertTrue(parts.all { it.durationMs >= config.longWordMinMs + it.protectedWordMs })
var shown by mutableStateOf(parts.first().tokens)
composeRule.setContent {
KairoTheme {
Box(Modifier.size(360.dp, 180.dp)) {
OrpAlignedText(
tokens = shown,
typography = OrpTypography(28f, FontFamily.Monospace, FontWeight.Normal),
colors = OrpColors(Color.Black, Color.Red, Color.Gray, Color.Blue),
layout = OrpTextLayout(
horizontalBias = 0.5f,
lockPivot = true,
smoothTranslation = false,
preferWindowing = false,
simplifyPunctuation = false,
guideVisible = true,
pivotHighlightVisible = true,
guideThickness = 1f,
),
)
}
}
}
var initialPivot: Float? = null
for (part in parts) {
composeRule.runOnIdle { shown = part.tokens }
val token = part.tokens.single()
val layout = renderedLayout()
val text = layout.layoutInput.text
assertEquals("neuroplasticity", text.text)
assertEquals(token.text.substring(token.highlightStart!!, token.highlightEndExclusive!!), highlightedText(layout))
val pivot = text.spanStyles.last { it.item.color == Color.Red }.start
val pivotX = layout.getBoundingBox(pivot).center.x
initialPivot?.let { assertEquals(it, pivotX, 0.001f) }
initialPivot = pivotX
}
}

@Test
fun ordinaryInflectionsStayWholeWhileLongerTermsReceiveMovingParts() {
val words = listOf("Instructors", "representatives", "respresentatives", "responsibility")
val tokens = words.map { Token(it, TokenType.WORD) }
val config = RsvpProfile.BALANCED.defaultConfig().copy(enablePhraseChunking = false)
val frames = ComprehensionRsvpEngine().generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH))
val ordinary = frames.filter { it.originalTokenIndex == 0 && !it.isWordSeparation }
assertEquals(1, ordinary.size)
assertEquals(null, ordinary.single().tokens.single().highlightStart)
for (index in 1..words.lastIndex) {
val parts = frames.filter { it.originalTokenIndex == index && !it.isWordSeparation }.flatMap { it.tokens }
assertTrue(words[index], parts.size > 1)
assertTrue(parts.all { it.isSubwordChunk && it.text == words[index] })
assertEquals(0, parts.first().highlightStart)
assertEquals(words[index].length, parts.last().highlightEndExclusive)
}
}

@Test
fun wordFamiliesKeepTheSamePresentationWithPackagedDataAndNormalPreset() {
val words =
listOf("instructor", "instructors", "university", "Universities", "representative", "representatives", "representative’s")
val config = RsvpProfile.BALANCED.defaultConfig().copy(enablePhraseChunking = false)
val frames = ComprehensionRsvpEngine().generateFrames(
words.map { Token(it, TokenType.WORD) },
0,
config,
RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH),
)
fun parts(index: Int) = frames.filter { it.originalTokenIndex == index && !it.isWordSeparation }.flatMap { it.tokens }
for (index in 0..3) {
assertEquals(words[index], 1, parts(index).size)
assertEquals(null, parts(index).single().highlightStart)
}
val base = parts(4)
assertTrue(base.size > 1)
for (index in 5..6) {
val inflected = parts(index)
assertEquals(base.size, inflected.size)
assertEquals(base.dropLast(1).map { it.highlightEndExclusive }, inflected.dropLast(1).map { it.highlightEndExclusive })
assertEquals(words[index].length, inflected.last().highlightEndExclusive)
}
}

@Test
fun selectionDistinguishesFamiliarLongWordsFromUncommonShorterFamilies() {
val ordinary = listOf("comfortable", "everything", "conversation", "whispering", "explanation")
val supported = listOf("crucible", "Crucibles", "crystalline", "slateboard", "slateboards", "dormitory", "dormitories")
val frames = ComprehensionRsvpEngine().generateFrames(
(ordinary + supported).map { Token(it, TokenType.WORD) },
0,
RsvpProfile.BALANCED.defaultConfig(),
RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH),
)
for (text in ordinary) {
val shown = frames.flatMap { it.tokens }.filter { it.text == text }
assertEquals(text, 1, shown.size)
assertTrue(shown.none { it.isSubwordChunk })
}
for (text in supported) {
val shown = frames.flatMap { it.tokens }.filter { it.text == text }
assertTrue(text, shown.size > 1 && shown.all { it.isSubwordChunk })
}
}

private fun renderedLayout(): TextLayoutResult {
val layouts = mutableListOf<TextLayoutResult>()
composeRule.onNodeWithTag("rsvp-focus-word").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
return layouts.single()
}

private fun highlightedText(layout: TextLayoutResult): String {
val text = layout.layoutInput.text
return text.spanStyles.filter { it.item.color == Color.Blue }.joinToString("") { text.text.substring(it.start, it.end) }
}

private companion object {
const val FOCUS_STAGE_WIDTH_DP = 360
const val FOCUS_STAGE_HEIGHT_DP = 180
const val FOCUS_FONT_SIZE_SP = 28f
const val FOCUS_HORIZONTAL_BIAS = 0.5f
const val FOCUS_GUIDE_THICKNESS = 1f
const val PIVOT_CHARACTER_COUNT = 1
const val WCAG_AA_TEXT_CONTRAST = 4.5f
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.kairo.reader.TestActivity
import com.kairo.reader.core.model.RsvpConfig
import com.kairo.reader.core.model.Token
import com.kairo.reader.core.model.TokenType
import com.kairo.reader.core.rsvp.RsvpGenerationOptions
import com.kairo.reader.core.rsvp.RsvpLanguagePolicy
import com.kairo.reader.ui.theme.KairoTheme
import org.junit.Assert.assertTrue
import org.junit.Test
Expand Down Expand Up @@ -54,5 +56,39 @@ class RsvpPunctuationTimingDeviceTest {
}
}

@Test
fun difficultWordAllowanceRemainsVisibleAfterEasyWords() {
val easy = Token("cat", TokenType.WORD, frequencyScore = 0.5, syllableCount = 1)
val difficult = easy.copy(text = "quizzacious")
val fixture = RsvpDeviceFixture(
listOf(easy, easy, easy, difficult, easy),
RsvpConfig(
tempoMsPerWord = 150L, smoothingAlpha = 0.01, maxSlowdownFactor = 1.01,
difficultWordSupport = 2.0, maxChunkLength = 0,
useAdaptiveTiming = false, useProsodyPacing = false, useFocalStress = false,
useClausePausing = false, useDialogueDetection = false,
startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, rampDownFrames = 0,
),
)
fixture.state = fixture.state.copy(
book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)),
)
ActivityScenario.launch(TestActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } }
}
val deadline = SystemClock.elapsedRealtime() + 15_000L
while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L)
assertTrue("Playback must finish", fixture.finished)
val index = fixture.consumed.indexOfFirst { it.originalTokenIndex == 3 }
assertTrue("Difficult word must be consumed after the warmup", index > 0)
val frame = fixture.consumed[index]
val displayedMs = fixture.consumedAtMs[index] - fixture.consumedAtMs[index - 1]
assertTrue("Reading allowance must survive smoothing", frame.protectedWordMs >= 160L)
assertTrue("Difficult word must retain its reading beat and allowance", frame.durationMs >= 290L)
assertTrue("Real playback must honour its duration: $displayedMs vs ${frame.durationMs}", displayedMs >= frame.durationMs)
}
}

private fun word(text: String) = Token(text, TokenType.WORD)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.kairo.reader.ui.rsvp

import android.os.SystemClock
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.core.model.RsvpConfig
import com.kairo.reader.core.model.Token
import com.kairo.reader.core.model.TokenType
import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine
import com.kairo.reader.core.rsvp.RsvpGenerationOptions
import com.kairo.reader.core.rsvp.RsvpLanguagePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RsvpReadingDemandDeviceTest {
@Test
fun packagedLookupSupportsEnglishPlaybackAndRepeatedPreparationIsDeterministic() {
val words = listOf("the", "people", "considered", "quizzacious", "12345", "neuroplasticity", "again")
val tokens = List(5000) { Token(words[it % words.size], TokenType.WORD) }
val config = RsvpConfig(
enablePhraseChunking = false,
maxChunkLength = 32,
startDelayMs = 0L,
endDelayMs = 0L,
rampUpFrames = 0,
rampDownFrames = 0,
)
val engine = ComprehensionRsvpEngine()
val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)
val times = mutableListOf<Long>()
var expectedDurations: List<Long>? = null
repeat(3) {
val before = SystemClock.elapsedRealtimeNanos()
val frames = engine.generateFrames(tokens, 0, config, options)
times += (SystemClock.elapsedRealtimeNanos() - before) / 1_000_000L
assertEquals(tokens.indices.toList(), frames.map { it.originalTokenIndex }.distinct())
assertTrue(frames[3].protectedWordMs > frames[0].protectedWordMs)
val durations = frames.map { frame -> frame.durationMs }
expectedDurations?.let { expected -> assertEquals(expected, durations) }
expectedDurations = durations
}
Log.i("RsvpDemandBenchmark", "5000 English words, full generation: first=${times.first()} ms, warm=${times.drop(1)} ms")
}
}
Loading
Loading