+better notes click processing

+android & universal UI tweaks
+settings to change text and background color
This commit is contained in:
Sergey Chernov 2026-06-09 21:30:36 +03:00
parent f344befbdd
commit 85d97d5a90
13 changed files with 925 additions and 193 deletions

View File

@ -499,9 +499,18 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
val saved = db.getAppFlag(LibraryFilterFlag)
when {
saved == null -> {
appendLibraryLog("warning library filter was not saved; using ${LibraryFilter.MyLibrary.name}")
LibraryFilter.MyLibrary
}
else -> runCatching { LibraryFilter.valueOf(saved) }
.getOrElse {
appendLibraryLog("warning invalid saved library filter '$saved'; using ${LibraryFilter.MyLibrary.name}")
LibraryFilter.MyLibrary
}
}
}
}

View File

@ -4,6 +4,7 @@ import android.Manifest
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
@ -55,7 +58,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
}
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, true)
configureEdgeToEdgeBookWindow()
initToreadPlatform(this, this)
rememberPlatformOpenBookIntent(intent)
@ -67,6 +70,11 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
requestNotificationPermissionIfNeeded()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideNavigationBar()
}
override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>()
runOnUiThread {
@ -175,6 +183,27 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
private fun configureEdgeToEdgeBookWindow() {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.navigationBarColor = Color.TRANSPARENT
window.statusBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.navigationBarDividerColor = Color.TRANSPARENT
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
window.isStatusBarContrastEnforced = false
}
hideNavigationBar()
}
private fun hideNavigationBar() {
WindowCompat.getInsetsController(window, window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.navigationBars())
}
}
private fun downloadsPath(): String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: Environment.getExternalStorageDirectory().absolutePath
@ -185,7 +214,7 @@ private fun AndroidAppWindow(content: @Composable () -> Unit) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)),
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
) {
content()
}

View File

@ -13,6 +13,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -104,25 +105,27 @@ fun App() {
}
MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) {
Box(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (localePreferenceLoaded) {
BookReaderApp(
onThemeToggle = {
val next = themeMode.next()
themeMode = next
showToast(strings.themeChanged(next))
scope.launch {
saveThemeMode(next)
}
},
onShowToast = ::showToast,
)
} else {
LoadingScreen(strings.loadingOpeningBook)
CompositionLocalProvider(LocalReaderThemeIsDark provides useDark) {
Box(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (localePreferenceLoaded) {
BookReaderApp(
onThemeToggle = {
val next = themeMode.next()
themeMode = next
showToast(strings.themeChanged(next))
scope.launch {
saveThemeMode(next)
}
},
onShowToast = ::showToast,
)
} else {
LoadingScreen(strings.loadingOpeningBook)
}
}
AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
}
AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
}
}
}
@ -244,6 +247,13 @@ private fun BookReaderApp(
book = book,
libraryItems = emptyList(),
scanPath = scanPath,
selectedFilter = when (val current = state) {
is AppState.Library -> current.selectedFilter
is AppState.Scan -> current.selectedFilter
is AppState.Reader -> current.selectedFilter
is AppState.BookInfo -> current.selectedFilter
is AppState.Error, AppState.LoadingStartup -> loadLibraryFilter()
},
)
}.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter())
@ -357,12 +367,18 @@ private fun BookReaderApp(
libraryItems = current.libraryItems,
scanPath = current.scanPath,
message = current.message,
selectedFilter = current.selectedFilter,
)
is AppState.Reader -> {
scope.launch { saveActiveReadingFileId(null) }
refreshLibraryItem(current.fileId)
val backState = libraryBackState?.copy(message = current.message)
?: AppState.Library(current.libraryItems, current.scanPath, current.message)
?: AppState.Library(
current.libraryItems,
current.scanPath,
current.message,
current.selectedFilter,
)
libraryBackState = null
downloadsRescanRequestGeneration += 1
backState
@ -523,11 +539,12 @@ private fun BookReaderApp(
libraryItems = current.libraryItems,
scanPath = current.scanPath,
message = current.message,
selectedFilter = current.selectedFilter,
)
},
onDeleted = { message ->
state = libraryBackState?.copy(message = message)
?: AppState.Library(emptyList(), current.scanPath, message)
?: AppState.Library(emptyList(), current.scanPath, message, current.selectedFilter)
libraryBackState = null
},
onDeleteRequested = ::requestDelete,

View File

@ -27,6 +27,7 @@ internal sealed interface AppState {
val libraryItems: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState
data class BookInfo(
@ -35,6 +36,7 @@ internal sealed interface AppState {
val libraryItems: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState
data class Error(val message: String) : AppState
@ -59,6 +61,7 @@ internal suspend fun loadStartupState(): AppState {
book = book,
libraryItems = emptyList(),
scanPath = scanPath,
selectedFilter = libraryFilter,
)
}.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), libraryFilter)
@ -77,6 +80,7 @@ internal suspend fun loadStartupState(): AppState {
book = parseBookInBackground(bytes, item.storageUri ?: item.title),
libraryItems = emptyList(),
scanPath = scanPath,
selectedFilter = libraryFilter,
)
}.getOrElse {
saveActiveReadingFileId(null)

View File

@ -70,6 +70,14 @@ data class ReaderFontSettings(
val lineHeightSp: Float = DefaultReaderLineHeightSp,
val fontWeight: Int = defaultReaderFontWeight(),
val fontName: String? = null,
val lightBackgroundArgb: Long? = null,
val lightTextArgb: Long? = null,
val darkBackgroundArgb: Long? = null,
val darkTextArgb: Long? = null,
val lightCustomBackgroundArgb: Long? = null,
val lightCustomTextArgb: Long? = null,
val darkCustomBackgroundArgb: Long? = null,
val darkCustomTextArgb: Long? = null,
)
data class BookInfoExtras(
@ -255,6 +263,14 @@ internal fun ReaderFontSettings.coerced(): ReaderFontSettings =
lineHeightSp = lineHeightSp.coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp),
fontWeight = fontWeight.coerceIn(MinReaderFontWeight, MaxReaderFontWeight),
fontName = fontName?.takeIf { it.isNotBlank() },
lightBackgroundArgb = lightBackgroundArgb?.coerceArgb(),
lightTextArgb = lightTextArgb?.coerceArgb(),
darkBackgroundArgb = darkBackgroundArgb?.coerceArgb(),
darkTextArgb = darkTextArgb?.coerceArgb(),
lightCustomBackgroundArgb = lightCustomBackgroundArgb?.coerceArgb(),
lightCustomTextArgb = lightCustomTextArgb?.coerceArgb(),
darkCustomBackgroundArgb = darkCustomBackgroundArgb?.coerceArgb(),
darkCustomTextArgb = darkCustomTextArgb?.coerceArgb(),
)
internal fun ReaderFontSettings.adjustFontSize(steps: Int): ReaderFontSettings {
@ -295,6 +311,14 @@ internal fun ReaderFontSettings.toStorageValue(): String =
current.lineHeightSp.toString(),
current.fontWeight.toString(),
current.fontName.orEmpty(),
current.lightBackgroundArgb?.toStorageArgb().orEmpty(),
current.lightTextArgb?.toStorageArgb().orEmpty(),
current.darkBackgroundArgb?.toStorageArgb().orEmpty(),
current.darkTextArgb?.toStorageArgb().orEmpty(),
current.lightCustomBackgroundArgb?.toStorageArgb().orEmpty(),
current.lightCustomTextArgb?.toStorageArgb().orEmpty(),
current.darkCustomBackgroundArgb?.toStorageArgb().orEmpty(),
current.darkCustomTextArgb?.toStorageArgb().orEmpty(),
).joinToString("|")
}
@ -306,5 +330,71 @@ internal fun readerFontSettingsFromStorageValue(value: String?): ReaderFontSetti
lineHeightSp = parts.getOrNull(1)?.toFloatOrNull() ?: defaults.lineHeightSp,
fontWeight = parts.getOrNull(2)?.toIntOrNull() ?: defaults.fontWeight,
fontName = parts.getOrNull(3)?.takeIf { it.isNotBlank() } ?: defaults.fontName,
lightBackgroundArgb = parts.getOrNull(4)?.storageArgbOrNull(),
lightTextArgb = parts.getOrNull(5)?.storageArgbOrNull(),
darkBackgroundArgb = parts.getOrNull(6)?.storageArgbOrNull(),
darkTextArgb = parts.getOrNull(7)?.storageArgbOrNull(),
lightCustomBackgroundArgb = parts.getOrNull(8)?.storageArgbOrNull(),
lightCustomTextArgb = parts.getOrNull(9)?.storageArgbOrNull(),
darkCustomBackgroundArgb = parts.getOrNull(10)?.storageArgbOrNull(),
darkCustomTextArgb = parts.getOrNull(11)?.storageArgbOrNull(),
).coerced()
}
internal fun ReaderFontSettings.withThemeBackgroundArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings =
if (useDarkTheme) copy(darkBackgroundArgb = argb?.coerceArgb()) else copy(lightBackgroundArgb = argb?.coerceArgb())
internal fun ReaderFontSettings.withThemeTextArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings =
if (useDarkTheme) copy(darkTextArgb = argb?.coerceArgb()) else copy(lightTextArgb = argb?.coerceArgb())
internal fun ReaderFontSettings.withThemeCustomBackgroundArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings =
if (useDarkTheme) {
copy(darkCustomBackgroundArgb = argb?.coerceArgb(), darkBackgroundArgb = argb?.coerceArgb())
} else {
copy(lightCustomBackgroundArgb = argb?.coerceArgb(), lightBackgroundArgb = argb?.coerceArgb())
}
internal fun ReaderFontSettings.withThemeCustomTextArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings =
if (useDarkTheme) {
copy(darkCustomTextArgb = argb?.coerceArgb(), darkTextArgb = argb?.coerceArgb())
} else {
copy(lightCustomTextArgb = argb?.coerceArgb(), lightTextArgb = argb?.coerceArgb())
}
internal fun ReaderFontSettings.themeBackgroundArgb(useDarkTheme: Boolean): Long? =
if (useDarkTheme) darkBackgroundArgb else lightBackgroundArgb
internal fun ReaderFontSettings.themeTextArgb(useDarkTheme: Boolean): Long? =
if (useDarkTheme) darkTextArgb else lightTextArgb
internal fun ReaderFontSettings.themeCustomBackgroundArgb(useDarkTheme: Boolean): Long? =
if (useDarkTheme) darkCustomBackgroundArgb else lightCustomBackgroundArgb
internal fun ReaderFontSettings.themeCustomTextArgb(useDarkTheme: Boolean): Long? =
if (useDarkTheme) darkCustomTextArgb else lightCustomTextArgb
internal fun ReaderFontSettings.resetThemeColorScheme(useDarkTheme: Boolean): ReaderFontSettings =
if (useDarkTheme) {
copy(
darkBackgroundArgb = null,
darkTextArgb = null,
)
} else {
copy(
lightBackgroundArgb = null,
lightTextArgb = null,
)
}
private fun Long.coerceArgb(): Long =
coerceIn(0x00000000L, 0xFFFFFFFFL)
private fun Long.toStorageArgb(): String =
coerceArgb().toString(16).padStart(8, '0').uppercase()
private fun String.storageArgbOrNull(): Long? =
trim()
.removePrefix("#")
.takeIf { it.length == 8 }
?.toLongOrNull(16)
?.coerceArgb()

View File

@ -530,6 +530,7 @@ internal fun LibraryScreen(
libraryItems = readerLibraryItems,
scanPath = state.scanPath,
message = message,
selectedFilter = state.selectedFilter,
)
}.getOrElse {
AppState.Library(

View File

@ -44,6 +44,7 @@ internal open class AppStrings {
open val undo = "Undo"
open val retry = "Retry"
open val done = "Done"
open val cancel = "Cancel"
open val libraryError = "Library error"
open val couldNotOpenLibrary = "Could not open library."
@ -147,6 +148,19 @@ internal open class AppStrings {
open val readerFontWeightIncrease = "Increase font weight"
open val readerFontWeightDecrease = "Decrease font weight"
open val resetReaderFontSettings = "Reset font settings"
open val revertColorScheme = "Revert color scheme"
open val readerBackgroundColor = "Reader background color"
open val readerTextColor = "Reader text color"
open val extraReaderSettings = "Extra reader settings"
open val customColor = "Custom color"
open val red = "R"
open val green = "G"
open val blue = "B"
open val readerColors = "Colors"
open val readerTypography = "Typography"
open val readerLayout = "Layout"
open val readerNavigation = "Navigation"
open val readerProgress = "Progress"
open val closeReaderSettings = "Close reader settings"
open val share = "Share"
open val viewFile = "View file"
@ -255,6 +269,7 @@ internal object RussianStrings : AppStrings() {
override val undo = "Отменить"
override val retry = "Повторить"
override val done = "Готово"
override val cancel = "Отмена"
override val libraryError = "Ошибка библиотеки"
override val couldNotOpenLibrary = "Не удалось открыть библиотеку."
@ -358,6 +373,19 @@ internal object RussianStrings : AppStrings() {
override val readerFontWeightIncrease = "Увеличить насыщенность шрифта"
override val readerFontWeightDecrease = "Уменьшить насыщенность шрифта"
override val resetReaderFontSettings = "Сбросить настройки шрифта"
override val revertColorScheme = "Вернуть цветовую схему"
override val readerBackgroundColor = "Цвет фона чтения"
override val readerTextColor = "Цвет текста чтения"
override val extraReaderSettings = "Дополнительные настройки"
override val customColor = "Свой цвет"
override val red = "К"
override val green = "З"
override val blue = "С"
override val readerColors = "Цвета"
override val readerTypography = "Типографика"
override val readerLayout = "Макет"
override val readerNavigation = "Навигация"
override val readerProgress = "Прогресс"
override val closeReaderSettings = "Закрыть настройки чтения"
override val share = "Поделиться"
override val viewFile = "Показать файл"

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -57,10 +59,8 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@ -81,8 +81,10 @@ import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
@ -122,6 +124,8 @@ internal fun ContinuousBookReader(
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
highlightedSentence: ReadAloudSentence? = null,
readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(),
readerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
readerTextColor: Color = MaterialTheme.colorScheme.onSurface,
onReaderFontZoom: (Int) -> Unit = {},
onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {},
@ -145,7 +149,7 @@ internal fun ContinuousBookReader(
LazyColumn(
state = listState,
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.background(readerBackgroundColor)
.nestedScroll(userScrollConnection)
.readerFontZoomGesture(onReaderFontZoom)
.pageTurnOnTouchTap(
@ -202,7 +206,7 @@ internal fun ContinuousBookReader(
0 -> MaterialTheme.typography.headlineMedium
1 -> MaterialTheme.typography.titleLarge
else -> MaterialTheme.typography.titleMedium
},
}.copy(color = readerTextColor),
fontWeight = FontWeight.Bold,
lineHeight = if (element.depth == 0) 36.sp else 28.sp,
modifier = titleModifier,
@ -219,7 +223,7 @@ internal fun ContinuousBookReader(
text = element.text,
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language, readerFontSettings),
style = readerParagraphTextStyle(book.language, readerFontSettings).copy(color = readerTextColor),
highlightedRange = highlightedRange,
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
@ -230,7 +234,7 @@ internal fun ContinuousBookReader(
text = element.text,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold, color = readerTextColor),
highlightedRange = highlightedRange,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
@ -243,6 +247,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = element.depth,
textColor = readerTextColor,
onLinkOpen = onNoteOpen,
)
is ReaderElement.Cite -> ReaderCite(
@ -251,6 +256,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = element.depth,
textColor = readerTextColor,
onLinkOpen = onNoteOpen,
)
is ReaderElement.Poem -> ReaderPoem(
@ -259,6 +265,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = element.depth,
textColor = readerTextColor,
onLinkOpen = onNoteOpen,
)
}
@ -332,6 +339,8 @@ private fun Modifier.pageTurnOnTouchTap(
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) {
cancelled = true
} else if (change.isConsumed) {
cancelled = true
} else if (change.pressed) {
val dx = change.position.x - down.position.x
val dy = change.position.y - down.position.y
@ -699,27 +708,19 @@ private fun ReaderText(
onLinkOpen: (String) -> Unit = {},
) {
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
val annotatedText = text.toAnnotatedString(
language = language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
highlightColor = highlightColor,
)
val hasLinks = annotatedText.hasReaderLinks()
val needsSoftHyphenPaintWorkaround = isDesktopPlatform
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
val desktopHyphenColor = if (style.color == Color.Unspecified) MaterialTheme.colorScheme.onSurface else style.color
val desktopHyphenGutter = 8.dp
val textModifier = modifier
.fillMaxWidth()
.then(
if (hasLinks) {
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.readerLinkTapHandler(
annotatedText = annotatedText,
textLayout = textLayout,
onLinkOpen = onLinkOpen,
)
} else {
Modifier
},
)
.then(
if (needsSoftHyphenPaintWorkaround) {
Modifier.drawWithContent {
@ -751,16 +752,26 @@ private fun ReaderText(
},
)
Text(
text = annotatedText,
style = style,
textAlign = textAlign,
modifier = textModifier,
onTextLayout = {
textLayout = it
onTextLayout(it)
},
)
Box(modifier = textModifier) {
Text(
text = annotatedText,
style = style,
textAlign = textAlign,
modifier = Modifier.fillMaxWidth(),
onTextLayout = {
textLayout = it
onTextLayout(it)
},
)
val layout = textLayout
if (hasLinks && layout != null) {
ReaderLinkTouchOverlays(
annotatedText = annotatedText,
textLayout = layout,
onLinkOpen = onLinkOpen,
)
}
}
}
@Composable
@ -770,6 +781,7 @@ private fun ReaderPoem(
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {},
) {
@ -786,6 +798,7 @@ private fun ReaderPoem(
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = 0,
textColor = textColor,
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
onLinkOpen = onLinkOpen,
)
@ -798,7 +811,7 @@ private fun ReaderPoem(
text = segment.text,
language = language,
hyphenation = hyphenation,
style = poemTextStyle(segment.kind, language),
style = poemTextStyle(segment.kind, language).copy(color = textColor),
highlightedRange = highlightedRange?.forSegment(segment),
textAlign = when (segment.kind) {
ReaderPoemSegmentKind.Title,
@ -824,6 +837,7 @@ private fun ReaderEpigraph(
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {},
) {
@ -840,6 +854,7 @@ private fun ReaderEpigraph(
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
textColor = textColor,
onLinkOpen = onLinkOpen,
)
}
@ -848,7 +863,7 @@ private fun ReaderEpigraph(
text = author,
language = language,
hyphenation = hyphenation,
style = epigraphAuthorTextStyle(language),
style = epigraphAuthorTextStyle(language).copy(color = textColor),
highlightedRange = null,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
@ -865,6 +880,7 @@ private fun ReaderCite(
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {},
) {
@ -881,6 +897,7 @@ private fun ReaderCite(
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
textColor = textColor,
onLinkOpen = onLinkOpen,
)
}
@ -889,7 +906,7 @@ private fun ReaderCite(
text = author,
language = language,
hyphenation = hyphenation,
style = epigraphAuthorTextStyle(language),
style = epigraphAuthorTextStyle(language).copy(color = textColor),
highlightedRange = null,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
@ -906,6 +923,7 @@ private fun ReaderEpigraphBlock(
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
onLinkOpen: (String) -> Unit,
) {
when (block) {
@ -914,7 +932,7 @@ private fun ReaderEpigraphBlock(
text = block.content,
language = language,
hyphenation = hyphenation,
style = epigraphTextStyle(language),
style = epigraphTextStyle(language).copy(color = textColor),
highlightedRange = highlightedRange,
textAlign = TextAlign.Start,
onLinkOpen = onLinkOpen,
@ -923,7 +941,7 @@ private fun ReaderEpigraphBlock(
text = block.content,
language = language,
hyphenation = hyphenation,
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold),
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold, color = textColor),
highlightedRange = highlightedRange,
textAlign = TextAlign.Center,
onLinkOpen = onLinkOpen,
@ -934,6 +952,7 @@ private fun ReaderEpigraphBlock(
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = depth,
textColor = textColor,
onLinkOpen = onLinkOpen,
)
is Fb2EpigraphBlock.Cite -> ReaderCite(
@ -942,6 +961,7 @@ private fun ReaderEpigraphBlock(
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = depth,
textColor = textColor,
onLinkOpen = onLinkOpen,
)
}
@ -1174,80 +1194,120 @@ private fun Modifier.softImageEdgeFade(): Modifier =
)
}
private fun Modifier.readerLinkTapHandler(
@Composable
private fun BoxScope.ReaderLinkTouchOverlays(
annotatedText: AnnotatedString,
textLayout: TextLayoutResult?,
textLayout: TextLayoutResult,
onLinkOpen: (String) -> Unit,
): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main)
val link = textLayout?.readerLinkAt(
text = annotatedText,
position = down.position,
minimumTouchWidthPx = ReaderLinkMinimumTouchWidth.toPx(),
minimumTouchHeightPx = ReaderLinkMinimumTouchHeight.toPx(),
horizontalPaddingPx = ReaderLinkHorizontalTouchPadding.toPx(),
verticalPaddingPx = ReaderLinkVerticalTouchPadding.toPx(),
)
if (link == null) {
waitForUpOrCancellation(pass = PointerEventPass.Main)
return@awaitEachGesture
) {
val density = LocalDensity.current
val touchAreas = remember(annotatedText, textLayout, density) {
with(density) {
textLayout.readerLinkTouchAreas(
text = annotatedText,
trimLeftPx = ReaderLinkTouchTrimLeft.toPx(),
extendRightPx = ReaderLinkTouchExtendRight.toPx(),
minimumHeightPx = ReaderLinkTouchMinimumHeight.toPx(),
verticalPaddingPx = ReaderLinkTouchVerticalPadding.toPx(),
)
}
}
down.consume()
val up = waitForUpOrCancellation(pass = PointerEventPass.Main)
if (up != null) {
up.consume()
onLinkOpen(link)
Box(Modifier.matchParentSize()) {
touchAreas.forEach { area ->
Box(
Modifier
.offset {
IntOffset(
x = area.rect.left.roundToInt(),
y = area.rect.top.roundToInt(),
)
}
.width(with(density) { area.rect.width.toDp() })
.height(with(density) { area.rect.height.toDp() })
// Link hit-area debugging: uncomment this background to visualize the
// actual touch overlay, including the right-side extension.
// .background(ReaderLinkTouchDebugBackground)
.clickable { onLinkOpen(area.target) },
)
}
}
}
private fun TextLayoutResult.readerLinkAt(
private data class ReaderLinkTouchArea(
val target: String,
val rect: Rect,
)
private data class ReaderLinkLineBounds(
val annotationIndex: Int,
val target: String,
val line: Int,
val rect: Rect,
)
private fun TextLayoutResult.readerLinkTouchAreas(
text: AnnotatedString,
position: Offset,
minimumTouchWidthPx: Float,
minimumTouchHeightPx: Float,
horizontalPaddingPx: Float,
trimLeftPx: Float,
extendRightPx: Float,
minimumHeightPx: Float,
verticalPaddingPx: Float,
): String? =
text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length)
.firstOrNull { annotation ->
readerLinkTouchBounds(
start = annotation.start,
end = annotation.end,
minimumTouchWidthPx = minimumTouchWidthPx,
minimumTouchHeightPx = minimumTouchHeightPx,
horizontalPaddingPx = horizontalPaddingPx,
verticalPaddingPx = verticalPaddingPx,
).any { it.contains(position) }
): List<ReaderLinkTouchArea> {
val linkBounds = text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length)
.sortedBy { it.start }
.flatMapIndexed { annotationIndex, annotation ->
val safeStart = min(annotation.start, layoutInput.text.length)
val safeEnd = min(annotation.end, layoutInput.text.length)
if (safeStart >= safeEnd) return@flatMapIndexed emptyList()
val startLine = getLineForOffset(safeStart)
val endLine = getLineForOffset(safeEnd - 1)
(startLine..endLine).mapNotNull { line ->
val lineStart = max(safeStart, getLineStart(line))
val lineEnd = min(safeEnd, getLineEnd(line))
if (lineStart >= lineEnd) return@mapNotNull null
val rangeBounds = getPathForRange(lineStart, lineEnd).getBounds()
val horizontalBounds = Rect(
left = rangeBounds.left,
top = rangeBounds.top,
right = rangeBounds.right,
bottom = rangeBounds.bottom,
)
ReaderLinkLineBounds(
annotationIndex = annotationIndex,
target = annotation.item,
line = line,
rect = horizontalBounds,
)
}
}
?.item
private fun TextLayoutResult.readerLinkTouchBounds(
start: Int,
end: Int,
minimumTouchWidthPx: Float,
minimumTouchHeightPx: Float,
horizontalPaddingPx: Float,
verticalPaddingPx: Float,
): List<Rect> {
if (start >= end) return emptyList()
return linkBounds.map { bounds ->
val nextLinkLeft = linkBounds
.asSequence()
.filter {
it.line == bounds.line &&
it.annotationIndex != bounds.annotationIndex &&
it.rect.left >= bounds.rect.right
}
.map { it.rect.left }
.minOrNull()
val rightLimit = nextLinkLeft ?: size.width.toFloat()
val right = minOf(bounds.rect.right + extendRightPx, rightLimit, size.width.toFloat())
.coerceAtLeast(bounds.rect.right)
val touchHeight = max(bounds.rect.height + verticalPaddingPx * 2f, minimumHeightPx)
val centerY = bounds.rect.center.y
val boundsByLine = linkedMapOf<Int, Rect>()
val safeEnd = min(end, layoutInput.text.length)
for (offset in start until safeEnd) {
val line = getLineForOffset(offset)
val bounds = getBoundingBox(offset)
boundsByLine[line] = boundsByLine[line]?.union(bounds) ?: bounds
}
return boundsByLine.values.map { bounds ->
bounds.withCenteredTouchPadding(
minimumWidth = minimumTouchWidthPx,
minimumHeight = minimumTouchHeightPx,
horizontalPadding = horizontalPaddingPx,
verticalPadding = verticalPaddingPx,
ReaderLinkTouchArea(
target = bounds.target,
rect = Rect(
left = min(bounds.rect.left + trimLeftPx, bounds.rect.right),
top = max(0f, centerY - touchHeight / 2f),
right = right,
bottom = centerY + touchHeight / 2f,
),
)
}
}
@ -1260,23 +1320,6 @@ private fun Rect.union(other: Rect): Rect =
bottom = max(bottom, other.bottom),
)
private fun Rect.withCenteredTouchPadding(
minimumWidth: Float,
minimumHeight: Float,
horizontalPadding: Float,
verticalPadding: Float,
): Rect {
val touchWidth = max(width + horizontalPadding * 2f, minimumWidth)
val touchHeight = max(height + verticalPadding * 2f, minimumHeight)
val center = center
return Rect(
left = center.x - touchWidth / 2f,
top = center.y - touchHeight / 2f,
right = center.x + touchWidth / 2f,
bottom = center.y + touchHeight / 2f,
)
}
private fun AnnotatedString.hasReaderLinks(): Boolean =
getStringAnnotations(ReaderLinkAnnotationTag, 0, length).isNotEmpty()
@ -1295,6 +1338,9 @@ private fun Fb2Text.toAnnotatedString(
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
color = if (isLink) LinkTextColor else Color.Unspecified,
// Link hit-area debugging: set this to ReaderLinkDebugBackground for links
// to compare the annotated text range with the touch overlay.
background = Color.Unspecified,
textDecoration = when {
isLink && Fb2TextStyle.Strikethrough in span.styles ->
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
@ -1308,12 +1354,11 @@ private fun Fb2Text.toAnnotatedString(
else -> null
},
)
span.href?.takeIf { it.isNotBlank() }?.let {
pushStringAnnotation(ReaderLinkAnnotationTag, it)
}
if (isLink) {
withStyle(spanStyle) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
withAnnotation(ReaderLinkAnnotationTag, span.href!!) {
withStyle(spanStyle) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
}
}
} else {
appendWithDetectedWebLinks(
@ -1326,7 +1371,6 @@ private fun Fb2Text.toAnnotatedString(
hyphenation = hyphenation,
)
}
if (isLink) pop()
plainOffset += span.text.length
}
}
@ -1356,18 +1400,19 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
}
val url = text.substring(range)
pushStringAnnotation(ReaderLinkAnnotationTag, url)
withStyle(spanStyle.readerLinkStyle()) {
appendWithHighlight(
text = url,
plainOffset = plainOffset + range.start,
highlightedRange = highlightedRange,
highlightColor = highlightColor,
language = null,
hyphenation = hyphenation,
)
val linkStyle = spanStyle.readerLinkStyle()
withAnnotation(ReaderLinkAnnotationTag, url) {
withStyle(linkStyle) {
appendWithHighlight(
text = url,
plainOffset = plainOffset + range.start,
highlightedRange = highlightedRange,
highlightColor = highlightColor,
language = null,
hyphenation = hyphenation,
)
}
}
pop()
cursor = range.endInclusive + 1
}
@ -1388,6 +1433,9 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
private fun SpanStyle.readerLinkStyle(): SpanStyle =
copy(
color = LinkTextColor,
// Link hit-area debugging: set this to ReaderLinkDebugBackground to compare
// detected URL text ranges with the touch overlay.
background = Color.Unspecified,
textDecoration = when (textDecoration) {
TextDecoration.LineThrough ->
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
@ -1798,14 +1846,18 @@ private const val StarBreakPauseMillis = 1_200L
private const val EllipsisPauseAfterMillis = 350L
private const val CombiningAcuteAccent = '\u0301'
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
private const val ReaderLinkAnnotationTag = "fb2-link"
private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’')
private val LinkTextColor = Color(0xFF0B57D0)
private val ReaderLinkMinimumTouchWidth = 44.dp
private val ReaderLinkMinimumTouchHeight = 40.dp
private val ReaderLinkHorizontalTouchPadding = 10.dp
private val ReaderLinkVerticalTouchPadding = 6.dp
// Link hit-area debugging colors. Kept here so the temporary backgrounds above can be
// re-enabled quickly when continuing touch overlay investigation.
// private val ReaderLinkDebugBackground = Color(0x33FFB300)
// private val ReaderLinkTouchDebugBackground = Color(0x3300BCD4)
private const val ReaderLinkAnnotationTag = "fb2-link"
private val ReaderLinkTouchTrimLeft = 8.dp
private val ReaderLinkTouchExtendRight = 48.dp
private val ReaderLinkTouchMinimumHeight = 34.dp
private val ReaderLinkTouchVerticalPadding = 4.dp
private val ReadAloudHardcodedTextReplacements = listOf(
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),

View File

@ -6,9 +6,11 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@ -66,6 +69,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -87,7 +91,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@ -110,6 +116,11 @@ private data class ReaderBookPreparation(
val contentPlan: ReaderContentPlan,
)
private enum class ReaderColorPickerTarget {
BACKGROUND,
TEXT,
}
@Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
internal fun BookView(
@ -137,6 +148,8 @@ internal fun BookView(
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
var readerExtraSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerColorPickerTarget by remember(fileId) { mutableStateOf<ReaderColorPickerTarget?>(null) }
var fullScreenReader by remember { mutableStateOf(false) }
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
@ -147,6 +160,7 @@ internal fun BookView(
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val useDarkReaderTheme = LocalReaderThemeIsDark.current
LaunchedEffect(book) {
preparation = withContext(Dispatchers.Default) {
@ -172,6 +186,12 @@ internal fun BookView(
val readingProgress by remember(listState) {
derivedStateOf { listState.readerProgress() }
}
val readerBackgroundColor = readerFontSettings.themeBackgroundArgb(useDarkReaderTheme)
?.toComposeColor()
?: MaterialTheme.colorScheme.surface
val readerTextColor = readerFontSettings.themeTextArgb(useDarkReaderTheme)
?.toComposeColor()
?: MaterialTheme.colorScheme.onSurface
val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) }
?.takeIf { readAloudState.active }
@ -407,6 +427,9 @@ internal fun BookView(
onReaderSettings = {
readerSettingsPanelVisible = true
},
onReaderExtraSettings = {
readerExtraSettingsVisible = true
},
showReadAloudAction = showReadAloudAction,
onReadAloud = {
val position = currentReadingPosition()
@ -449,7 +472,7 @@ internal fun BookView(
modifier = Modifier
.fillMaxSize()
.padding(it)
.background(readerBackground()),
.background(readerBackgroundColor),
) {
Column(Modifier.fillMaxSize()) {
ContinuousBookReader(
@ -460,6 +483,8 @@ internal fun BookView(
contentPlan = contentPlan,
highlightedSentence = highlightedSentence,
readerFontSettings = readerFontSettings,
readerBackgroundColor = readerBackgroundColor,
readerTextColor = readerTextColor,
onReaderFontZoom = { steps ->
updateReaderFontSettings { settings -> settings.adjustFontSize(steps) }
},
@ -488,8 +513,35 @@ internal fun BookView(
onFontWeightIncrease = {
updateReaderFontSettings { settings -> settings.adjustFontWeight(1) }
},
onReset = {
updateReaderFontSettings { defaultReaderFontSettings() }
onBackgroundColorChange = {
updateReaderFontSettings { settings ->
settings.withThemeBackgroundArgb(
useDarkReaderTheme,
nextReaderBackgroundColorArgb(
currentArgb = settings.themeBackgroundArgb(useDarkReaderTheme),
customArgb = settings.themeCustomBackgroundArgb(useDarkReaderTheme),
useDarkTheme = useDarkReaderTheme,
),
)
}
},
onBackgroundColorPick = {
readerColorPickerTarget = ReaderColorPickerTarget.BACKGROUND
},
onTextColorChange = {
updateReaderFontSettings { settings ->
settings.withThemeTextArgb(
useDarkReaderTheme,
nextReaderTextColorArgb(
currentArgb = settings.themeTextArgb(useDarkReaderTheme),
customArgb = settings.themeCustomTextArgb(useDarkReaderTheme),
useDarkTheme = useDarkReaderTheme,
),
)
}
},
onTextColorPick = {
readerColorPickerTarget = ReaderColorPickerTarget.TEXT
},
onClose = { readerSettingsPanelVisible = false },
)
@ -517,6 +569,8 @@ internal fun BookView(
progress = readingProgress,
fullScreen = fullScreenReader,
fullScreenTitle = fullScreenProgressTitle,
backgroundColor = readerBackgroundColor,
textColor = readerTextColor,
onFullScreenToggle = { updateReaderFullScreen(!fullScreenReader) },
)
}
@ -532,6 +586,50 @@ internal fun BookView(
)
}
if (readerExtraSettingsVisible) {
ReaderExtraSettingsDialog(
onRevertColorScheme = {
updateReaderFontSettings { settings -> settings.resetThemeColorScheme(useDarkReaderTheme) }
},
onDismiss = { readerExtraSettingsVisible = false },
)
}
readerColorPickerTarget?.let { target ->
ReaderColorPickerDialog(
title = when (target) {
ReaderColorPickerTarget.BACKGROUND -> strings.readerBackgroundColor
ReaderColorPickerTarget.TEXT -> strings.readerTextColor
},
initialArgb = when (target) {
ReaderColorPickerTarget.BACKGROUND ->
readerFontSettings.themeBackgroundArgb(useDarkReaderTheme) ?: readerBackgroundColor.toReaderArgb()
ReaderColorPickerTarget.TEXT ->
readerFontSettings.themeTextArgb(useDarkReaderTheme) ?: readerTextColor.toReaderArgb()
},
presets = when (target) {
ReaderColorPickerTarget.BACKGROUND -> readerBackgroundPalette(useDarkReaderTheme)
ReaderColorPickerTarget.TEXT -> readerTextPalette(useDarkReaderTheme)
},
customArgb = when (target) {
ReaderColorPickerTarget.BACKGROUND -> readerFontSettings.themeCustomBackgroundArgb(useDarkReaderTheme)
ReaderColorPickerTarget.TEXT -> readerFontSettings.themeCustomTextArgb(useDarkReaderTheme)
},
onDismiss = { readerColorPickerTarget = null },
onApply = { argb ->
updateReaderFontSettings { settings ->
when (target) {
ReaderColorPickerTarget.BACKGROUND ->
settings.withThemeCustomBackgroundArgb(useDarkReaderTheme, argb)
ReaderColorPickerTarget.TEXT ->
settings.withThemeCustomTextArgb(useDarkReaderTheme, argb)
}
}
readerColorPickerTarget = null
},
)
}
selectedNoteId?.let { noteId ->
Fb2NoteReferenceDialog(
book = book,
@ -577,12 +675,14 @@ private fun ReaderProgressPane(
progress: Float,
fullScreen: Boolean,
fullScreenTitle: String,
backgroundColor: Color,
textColor: Color,
onFullScreenToggle: () -> Unit,
) {
val percent = (progress.coerceIn(0f, 1f) * 100f).roundToInt()
val trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)
val readColor = MaterialTheme.colorScheme.primary
val fullScreenFillColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
val trackColor = textColor.copy(alpha = 0.34f)
val readColor = textColor.copy(alpha = 0.84f)
val fullScreenFillColor = fullScreenProgressFillColor(textColor)
val contentDescription = strings.progressLabel(progress.toDouble())
val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp)
val progressRowHeight by animateDpAsState(24.dp)
@ -593,7 +693,7 @@ private fun ReaderProgressPane(
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
color = MaterialTheme.colorScheme.surface,
color = backgroundColor,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onFullScreenToggle),
@ -628,7 +728,7 @@ private fun ReaderProgressPane(
Text(
text = fullScreenTitle,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
color = textColor,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -640,7 +740,7 @@ private fun ReaderProgressPane(
text = "$percent%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
color = textColor,
maxLines = 1,
)
}
@ -680,7 +780,7 @@ private fun ReaderProgressPane(
text = "$percent%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
color = textColor,
maxLines = 1,
)
}
@ -689,9 +789,21 @@ private fun ReaderProgressPane(
}
}
private fun fullScreenProgressFillColor(textColor: Color): Color {
val luminance = textColor.readerLuminance()
return if (luminance < 0.55f) {
lerp(textColor, Color.White, 0.45f).copy(alpha = 0.22f)
} else {
textColor.copy(alpha = 0.12f)
}
}
private fun Color.readerLuminance(): Float =
0.2126f * red + 0.7152f * green + 0.0722f * blue
private fun Fb2Book.fullScreenProgressTitle(): String {
val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }
return "$author. $title"
return "$title. $author."
}
private fun LazyListState.readerProgress(): Float {
@ -739,6 +851,7 @@ private fun CompactReaderTopBar(
showReadAloudAction: Boolean,
onReadAloud: () -> Unit,
onReaderSettings: () -> Unit,
onReaderExtraSettings: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
) {
@ -795,6 +908,16 @@ private fun CompactReaderTopBar(
onReaderSettings()
},
)
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
text = { Text(strings.extraReaderSettings) },
onClick = {
menuOpen = false
onReaderExtraSettings()
},
)
DropdownMenuItem(
leadingIcon = {
Checkbox(checked = fullScreen, onCheckedChange = null)
@ -1062,9 +1185,16 @@ private fun ReaderFontSettingsPanel(
onLineHeightIncrease: () -> Unit,
onFontWeightDecrease: () -> Unit,
onFontWeightIncrease: () -> Unit,
onReset: () -> Unit,
onBackgroundColorChange: () -> Unit,
onBackgroundColorPick: () -> Unit,
onTextColorChange: () -> Unit,
onTextColorPick: () -> Unit,
onClose: () -> Unit,
) {
val useDarkTheme = LocalReaderThemeIsDark.current
val backgroundSwatch = settings.themeBackgroundArgb(useDarkTheme)?.toComposeColor() ?: MaterialTheme.colorScheme.surface
val textSwatch = settings.themeTextArgb(useDarkTheme)?.toComposeColor() ?: MaterialTheme.colorScheme.onSurface
Surface(
tonalElevation = 3.dp,
shadowElevation = 4.dp,
@ -1074,34 +1204,48 @@ private fun ReaderFontSettingsPanel(
Column {
Row(
modifier = Modifier.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(bottom = 0.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally),
.padding(horizontal = 2.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// Text("A ${settings.fontSizeSp.roundToInt()}", style = MaterialTheme.typography.labelMedium)
IconButton(onClick = onFontSizeDecrease) {
ReaderSettingsButton(
contentDescription = strings.readerFontSizeDecrease,
onClick = onFontSizeDecrease,
) {
ReaderLetterChangeIcon(
letter = "A",
increase = false,
contentDescription = strings.readerFontSizeDecrease,
)
}
IconButton(onClick = onFontSizeIncrease) {
ReaderSettingsButton(
contentDescription = strings.readerFontSizeIncrease,
onClick = onFontSizeIncrease,
) {
ReaderLetterChangeIcon(
letter = "A",
increase = true,
contentDescription = strings.readerFontSizeIncrease,
)
}
IconButton(onClick = onLineHeightDecrease) {
Icon(Icons.Filled.UnfoldLess, contentDescription = strings.readerLineHeightDecrease)
ReaderSettingsButton(
contentDescription = strings.readerLineHeightDecrease,
onClick = onLineHeightDecrease,
) {
Icon(Icons.Filled.UnfoldLess, contentDescription = null)
}
IconButton(onClick = onLineHeightIncrease) {
Icon(Icons.Filled.UnfoldMore, contentDescription = strings.readerLineHeightIncrease)
ReaderSettingsButton(
contentDescription = strings.readerLineHeightIncrease,
onClick = onLineHeightIncrease,
) {
Icon(Icons.Filled.UnfoldMore, contentDescription = null)
}
// Text("W ${settings.fontWeight}", style = MaterialTheme.typography.labelMedium)
IconButton(onClick = onFontWeightDecrease) {
ReaderSettingsButton(
contentDescription = strings.readerFontWeightDecrease,
onClick = onFontWeightDecrease,
) {
ReaderLetterChangeIcon(
letter = "B",
increase = false,
@ -1109,7 +1253,10 @@ private fun ReaderFontSettingsPanel(
fontWeight = FontWeight.Bold,
)
}
IconButton(onClick = onFontWeightIncrease) {
ReaderSettingsButton(
contentDescription = strings.readerFontWeightIncrease,
onClick = onFontWeightIncrease,
) {
ReaderLetterChangeIcon(
letter = "B",
increase = true,
@ -1117,11 +1264,31 @@ private fun ReaderFontSettingsPanel(
fontWeight = FontWeight.Bold,
)
}
IconButton(onClick = onReset) {
Icon(Icons.Filled.RestartAlt, contentDescription = strings.resetReaderFontSettings)
ColorSettingButton(
contentDescription = strings.readerBackgroundColor,
onClick = onBackgroundColorChange,
onLongClick = onBackgroundColorPick,
) {
ColorSwatchIcon(color = backgroundSwatch)
}
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = strings.closeReaderSettings)
ColorSettingButton(
contentDescription = strings.readerTextColor,
onClick = onTextColorChange,
onLongClick = onTextColorPick,
) {
ReaderLetterChangeIcon(
letter = "T",
increase = true,
contentDescription = strings.readerTextColor,
fontWeight = FontWeight.Bold,
color = textSwatch,
)
}
ReaderSettingsButton(
contentDescription = strings.closeReaderSettings,
onClick = onClose,
) {
Icon(Icons.Filled.Close, contentDescription = null)
}
}
// Row(Modifier.fillMaxWidth().padding(bottom = 2.dp, top = 0.dp), horizontalArrangement = Arrangement.Center) {
@ -1142,17 +1309,320 @@ private fun ReaderLetterChangeIcon(
increase: Boolean,
contentDescription: String,
fontWeight: FontWeight = FontWeight.SemiBold,
color: Color = Color.Unspecified,
) {
Text(
text = letter + if (increase) "" else "",
style = MaterialTheme.typography.titleMedium,
fontWeight = fontWeight,
color = color,
modifier = Modifier.semantics {
this.contentDescription = contentDescription
},
)
}
@Composable
private fun RowScope.ReaderSettingsButton(
contentDescription: String,
onClick: () -> Unit,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.clickable(onClick = onClick)
.semantics {
this.contentDescription = contentDescription
},
contentAlignment = Alignment.Center,
) {
content()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RowScope.ColorSettingButton(
contentDescription: String,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.semantics {
this.contentDescription = contentDescription
},
contentAlignment = Alignment.Center,
) {
content()
}
}
@Composable
private fun ColorSwatchIcon(color: Color) {
Box(
modifier = Modifier
.size(22.dp)
.background(color),
)
}
@Composable
private fun ReaderExtraSettingsDialog(
onRevertColorScheme: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(strings.extraReaderSettings) },
text = {
Column(Modifier.fillMaxWidth()) {
ReaderExtraSettingsActionRow(
text = strings.revertColorScheme,
onClick = onRevertColorScheme,
)
HorizontalDivider()
readerExtraSettingOptions().forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { }
.padding(horizontal = 4.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = option,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Text(
text = ">",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline,
)
}
HorizontalDivider()
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(strings.done)
}
},
)
}
@Composable
private fun ReaderExtraSettingsActionRow(text: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun ReaderColorPickerDialog(
title: String,
initialArgb: Long,
presets: List<Long>,
customArgb: Long?,
onDismiss: () -> Unit,
onApply: (Long) -> Unit,
) {
val initialRgb = remember(initialArgb) { initialArgb.toRgbComponents() }
var red by remember(initialArgb) { mutableStateOf(initialRgb.red) }
var green by remember(initialArgb) { mutableStateOf(initialRgb.green) }
var blue by remember(initialArgb) { mutableStateOf(initialRgb.blue) }
val selectedArgb = rgbToReaderArgb(red, green, blue)
val swatches = remember(presets, customArgb) { presets.withCustomColor(customArgb) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
color = selectedArgb.toComposeColor(),
modifier = Modifier.fillMaxWidth().height(48.dp),
) {}
if (customArgb != null) {
Text(strings.customColor, style = MaterialTheme.typography.labelMedium)
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
swatches.forEach { argb ->
Box(
modifier = Modifier
.size(36.dp)
.background(argb.toComposeColor())
.clickable {
val rgb = argb.toRgbComponents()
red = rgb.red
green = rgb.green
blue = rgb.blue
},
)
}
}
ReaderColorSlider(label = strings.red, value = red, onValueChange = { red = it })
ReaderColorSlider(label = strings.green, value = green, onValueChange = { green = it })
ReaderColorSlider(label = strings.blue, value = blue, onValueChange = { blue = it })
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(strings.cancel)
}
},
confirmButton = {
TextButton(onClick = { onApply(selectedArgb) }) {
Text(strings.done)
}
},
)
}
@Composable
private fun ReaderColorSlider(label: String, value: Int, onValueChange: (Int) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.size(36.dp),
)
Slider(
value = value.toFloat(),
onValueChange = { onValueChange(it.roundToInt().coerceIn(0, 255)) },
valueRange = 0f..255f,
modifier = Modifier.weight(1f),
)
Text(
text = value.toString(),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.End,
modifier = Modifier.size(36.dp),
)
}
}
private fun readerExtraSettingOptions(): List<String> =
listOf(
strings.readerColors,
strings.readerTypography,
strings.readerLayout,
strings.readerNavigation,
strings.readerProgress,
)
private fun nextReaderBackgroundColorArgb(currentArgb: Long?, customArgb: Long?, useDarkTheme: Boolean): Long? {
val palette = readerBackgroundPalette(useDarkTheme).withCustomColor(customArgb)
return nextReaderPaletteArgb(currentArgb, palette)
}
private fun nextReaderTextColorArgb(currentArgb: Long?, customArgb: Long?, useDarkTheme: Boolean): Long? {
val palette = readerTextPalette(useDarkTheme).withCustomColor(customArgb)
return nextReaderPaletteArgb(currentArgb, palette)
}
private fun nextReaderPaletteArgb(currentArgb: Long?, palette: List<Long>): Long? {
val index = palette.indexOf(currentArgb)
return when {
index < 0 -> palette.firstOrNull()
index < palette.lastIndex -> palette[index + 1]
else -> null
}
}
private fun readerBackgroundPalette(useDarkTheme: Boolean): List<Long> =
if (useDarkTheme) DarkReaderBackgroundPalette else LightReaderBackgroundPalette
private fun readerTextPalette(useDarkTheme: Boolean): List<Long> =
if (useDarkTheme) DarkReaderTextPalette else LightReaderTextPalette
private fun List<Long>.withCustomColor(customArgb: Long?): List<Long> =
if (customArgb == null || customArgb in this) this else this + customArgb
private data class RgbComponents(
val red: Int,
val green: Int,
val blue: Int,
)
private fun Long.toRgbComponents(): RgbComponents =
RgbComponents(
red = ((this shr 16) and 0xFF).toInt(),
green = ((this shr 8) and 0xFF).toInt(),
blue = (this and 0xFF).toInt(),
)
private fun Color.toReaderArgb(): Long {
fun Float.componentToByte(): Long = (coerceIn(0f, 1f) * 255f).roundToInt().coerceIn(0, 255).toLong()
return (alpha.componentToByte() shl 24) or
(red.componentToByte() shl 16) or
(green.componentToByte() shl 8) or
blue.componentToByte()
}
private fun rgbToReaderArgb(red: Int, green: Int, blue: Int): Long =
(0xFFL shl 24) or
(red.coerceIn(0, 255).toLong() shl 16) or
(green.coerceIn(0, 255).toLong() shl 8) or
blue.coerceIn(0, 255).toLong()
private val LightReaderBackgroundPalette = listOf(
0xFFF7F2EAL,
0xFFF4F0D8L,
0xFFEAF3F0L,
0xFFECEFF5L,
)
private val DarkReaderBackgroundPalette = listOf(
0xFF171411L,
0xFF1B2421L,
0xFF181E29L,
0xFF24211DL,
)
private val LightReaderTextPalette = listOf(
0xFF005C51L,
0xFF7A1F1FL,
0xFF243A8FL,
0xFF6B2A6BL,
)
private val DarkReaderTextPalette = listOf(
0xFFD8E8E2L,
0xFFE2C58FL,
0xFFECEFF5L,
)
@Composable
private fun ReadAloudPanel(
playing: Boolean,

View File

@ -141,6 +141,14 @@ internal fun themedTopAppBarColors(): TopAppBarColors =
@Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)
internal fun Long.toComposeColor(): Color =
Color(
alpha = ((this shr 24) and 0xFF).toInt(),
red = ((this shr 16) and 0xFF).toInt(),
green = ((this shr 8) and 0xFF).toInt(),
blue = (this and 0xFF).toInt(),
)
@Composable
internal fun readerImageBackgroundColor(): Color {
val surface = MaterialTheme.colorScheme.surface

View File

@ -1,7 +1,10 @@
package net.sergeych.toread
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
internal val LocalReaderThemeIsDark = compositionLocalOf { false }
internal fun ThemeMode.next(): ThemeMode =
when (this) {
ThemeMode.SYSTEM -> ThemeMode.LIGHT

View File

@ -475,9 +475,18 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
val saved = db.getAppFlag(LibraryFilterFlag)
when {
saved == null -> {
appendLibraryLog("warning library filter was not saved; using ${LibraryFilter.MyLibrary.name}")
LibraryFilter.MyLibrary
}
else -> runCatching { LibraryFilter.valueOf(saved) }
.getOrElse {
appendLibraryLog("warning invalid saved library filter '$saved'; using ${LibraryFilter.MyLibrary.name}")
LibraryFilter.MyLibrary
}
}
}
}

View File

@ -95,9 +95,17 @@ actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
internal actual suspend fun loadLibraryFilter(): LibraryFilter =
window.localStorage.getItem(LibraryFilterStorageKey)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
when (val saved = window.localStorage.getItem(LibraryFilterStorageKey)) {
null -> {
warnLibraryFilterFallback("library filter was not saved")
LibraryFilter.MyLibrary
}
else -> runCatching { LibraryFilter.valueOf(saved) }
.getOrElse {
warnLibraryFilterFallback("invalid saved library filter '$saved'")
LibraryFilter.MyLibrary
}
}
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) {
window.localStorage.setItem(LibraryFilterStorageKey, filter.name)
@ -136,6 +144,10 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null
private fun warnLibraryFilterFallback(reason: String) {
println("warning $reason; using ${LibraryFilter.MyLibrary.name}")
}
private const val AppLocaleStorageKey = "toread.appLocaleTag"
private const val LibraryFilterStorageKey = "toread.libraryFilter"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"