Просмотр исходного кода

Merge PR #1662: feat(toc): 添加TXT目录规则预览功能

HapeLee 4 месяцев назад
Родитель
Сommit
8f7f5a5cd2

+ 5 - 0
app/src/main/AndroidManifest.xml

@@ -263,6 +263,11 @@
             android:name=".ui.book.toc.rule.TxtTocRuleActivity"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
+        <!-- txt目录规则预览(阅读界面) -->
+        <activity
+            android:name=".ui.book.toc.rule.preview.TxtTocRulePreviewActivity"
+            android:enableOnBackInvokedCallback="true"
+            android:launchMode="singleTop" />
         <!-- 替换规则界面 -->
         <activity
             android:name=".ui.replace.ReplaceRuleActivity"

+ 6 - 0
app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt

@@ -42,4 +42,10 @@ class TxtTocRuleRepository {
         }
         dao.update(*rules.toTypedArray())
     }
+
+    fun all(): List<TxtTocRule> = dao.all
+
+    fun enabled(): List<TxtTocRule> = dao.enabled
+
+    fun count(): Int = dao.count
 }

+ 4 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -46,6 +46,7 @@ import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.TranslationCacheRepositoryImpl
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
@@ -134,6 +135,7 @@ import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
+import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewViewModel
 import io.legado.app.ui.config.ai.AiConfigViewModel
 import io.legado.app.ui.config.ai.AiModelEditViewModel
 import io.legado.app.ui.config.ai.AiProviderEditViewModel
@@ -188,6 +190,7 @@ val appModule = module {
     singleOf(::BookSourceRepository)
     singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)
+    singleOf(::TxtTocRuleRepository)
     singleOf(::SearchContentRepository)
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
@@ -303,6 +306,7 @@ val appModule = module {
     viewModelOf(::ReplaceRuleViewModel)
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
+    viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
     viewModelOf(::OtherConfigViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -667,7 +667,7 @@ sealed interface ReadBookEffect {
     data object MenuBookChangeSource : ReadBookEffect
     data object MenuChapterChangeSource : ReadBookEffect
     data object MenuSettingReplace : ReadBookEffect
-    data class MenuTocRegex(val tocRegex: String?) : ReadBookEffect
+    data class MenuTocRegex(val bookUrl: String, val tocRegex: String?) : ReadBookEffect
     data class MenuImageStyleChanged(val style: String) : ReadBookEffect
     data class SyncBookProgress(val book: Book) : ReadBookEffect
 

+ 5 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -45,7 +45,6 @@ import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
-import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
@@ -344,7 +343,11 @@ fun ReadBookRouteScreen(
                                 replaceLauncher.launch(ReplaceRuleActivity.startIntent(context, editRoute))
                             }
                             is ReadBookEffect.MenuTocRegex -> {
-                                val intent = Intent(context, TxtTocRuleActivity::class.java)
+                                val intent = Intent(
+                                    context,
+                                    io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewActivity::class.java
+                                )
+                                intent.putExtra("bookUrl", effect.bookUrl)
                                 intent.putExtra("tocRegex", effect.tocRegex)
                                 txtTocRuleLauncher.launch(intent)
                             }

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -567,7 +567,8 @@ class ReadBookViewModel(
             }
             is ReadBookIntent.MenuTocRegex -> {
                 closeReadMenu()
-                _effects.tryEmit(ReadBookEffect.MenuTocRegex(ReadBook.book?.tocUrl))
+                val book = ReadBook.book
+                _effects.tryEmit(ReadBookEffect.MenuTocRegex(book?.bookUrl ?: "", book?.tocUrl))
             }
             is ReadBookIntent.TocRegexResult -> {
                 ReadBook.book?.let {

+ 27 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewActivity.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.book.toc.rule.preview
+
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
+
+class TxtTocRulePreviewActivity : BaseComposeActivity() {
+
+    @Composable
+    override fun Content() {
+        val bookUrl = intent.getStringExtra("bookUrl") ?: ""
+        val currentTocRegex = intent.getStringExtra("tocRegex")
+
+        TxtTocRulePreviewRouteScreen(
+            bookUrl = bookUrl,
+            currentTocRegex = currentTocRegex,
+            onBack = { finish() },
+            onApplyRule = { rule ->
+                val data = Intent().apply {
+                    putExtra("tocRegex", rule)
+                }
+                setResult(RESULT_OK, data)
+                finish()
+            },
+        )
+    }
+}

+ 46 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewContract.kt

@@ -0,0 +1,46 @@
+package io.legado.app.ui.book.toc.rule.preview
+
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.TxtTocRule
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class TxtTocRulePreviewUiState(
+    val loading: Boolean = true,
+    val rules: ImmutableList<TocRulePreviewItem> = persistentListOf(),
+    val currentRule: String = "",
+    val selectedRule: String = "",
+    val activeSheet: TxtTocRulePreviewSheet? = null,
+    val isGridLayout: Boolean = true,
+    val editingRule: TxtTocRule? = null,
+) {
+    val hasSelection: Boolean get() = selectedRule.isNotEmpty()
+}
+
+@Stable
+data class TocRulePreviewItem(
+    val rule: TxtTocRule,
+    val chapterCount: Int = 0,
+    val totalCount: Int = 0,
+    val chapters: ImmutableList<String> = persistentListOf(),
+)
+
+sealed interface TxtTocRulePreviewSheet {
+    data class ChapterList(val item: TocRulePreviewItem) : TxtTocRulePreviewSheet
+}
+
+sealed interface TxtTocRulePreviewIntent {
+    data object DismissSheet : TxtTocRulePreviewIntent
+    data class ShowChapterList(val item: TocRulePreviewItem) : TxtTocRulePreviewIntent
+    data class SelectRule(val rule: String) : TxtTocRulePreviewIntent
+    data object ToggleLayout : TxtTocRulePreviewIntent
+    data class EditRule(val rule: TxtTocRule) : TxtTocRulePreviewIntent
+    data object DismissEditDialog : TxtTocRulePreviewIntent
+    data class SaveRule(val rule: TxtTocRule) : TxtTocRulePreviewIntent
+}
+
+sealed interface TxtTocRulePreviewEffect {
+    data class ApplyRule(val rule: String) : TxtTocRulePreviewEffect
+    data class ShowToast(val message: String) : TxtTocRulePreviewEffect
+}

+ 462 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewScreen.kt

@@ -0,0 +1,462 @@
+package io.legado.app.ui.book.toc.rule.preview
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.itemsIndexed
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.data.entities.TxtTocRule
+import io.legado.app.utils.toastOnUi
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.rules.RuleEditFields
+import io.legado.app.ui.widget.components.rules.RuleEditSheet
+import io.legado.app.ui.widget.components.text.AppText
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun TxtTocRulePreviewRouteScreen(
+    bookUrl: String,
+    currentTocRegex: String?,
+    viewModel: TxtTocRulePreviewViewModel = koinViewModel(),
+    onBack: () -> Unit,
+    onApplyRule: (String) -> Unit,
+) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+
+    LaunchedEffect(bookUrl) {
+        viewModel.init(bookUrl, currentTocRegex)
+    }
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is TxtTocRulePreviewEffect.ApplyRule -> onApplyRule(effect.rule)
+                is TxtTocRulePreviewEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    TxtTocRulePreviewScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+        onBack = onBack,
+        onApplyRule = onApplyRule,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun TxtTocRulePreviewScreen(
+    state: TxtTocRulePreviewUiState,
+    onIntent: (TxtTocRulePreviewIntent) -> Unit,
+    onBack: () -> Unit,
+    onApplyRule: (String) -> Unit,
+) {
+    // Chapter list bottom sheet
+    when (val sheet = state.activeSheet) {
+        is TxtTocRulePreviewSheet.ChapterList -> {
+            AppModalBottomSheet(
+                show = true,
+                onDismissRequest = { onIntent(TxtTocRulePreviewIntent.DismissSheet) },
+            ) {
+                ChapterListSheetContent(
+                    item = sheet.item,
+                    onEditRule = { rule ->
+                        onIntent(TxtTocRulePreviewIntent.EditRule(rule))
+                    },
+                )
+            }
+        }
+
+        null -> { /* no sheet */ }
+    }
+
+    // Rule edit sheet
+    state.editingRule?.let { rule ->
+        RuleEditSheet(
+            show = true,
+            rule = rule,
+            title = stringResource(R.string.txt_toc_rule),
+            label1 = stringResource(R.string.regex),
+            label2 = stringResource(R.string.example),
+            onDismissRequest = { onIntent(TxtTocRulePreviewIntent.DismissEditDialog) },
+            onSave = { updatedRule ->
+                onIntent(TxtTocRulePreviewIntent.SaveRule(updatedRule))
+            },
+            onCopy = { /* no-op */ },
+            onPaste = { null },
+            toFields = { r ->
+                RuleEditFields(
+                    name = r?.name ?: "",
+                    rule1 = r?.rule ?: "",
+                    rule2 = r?.example ?: ""
+                )
+            },
+            fromFields = { fields, old ->
+                old?.copy(
+                    name = fields.name,
+                    rule = fields.rule1,
+                    example = fields.rule2
+                ) ?: TxtTocRule(
+                    name = fields.name,
+                    rule = fields.rule1,
+                    example = fields.rule2
+                )
+            }
+        )
+    }
+
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = { _ ->
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.select_toc_rule),
+                subtitle = stringResource(R.string.select_rule_to_preview_chapters),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = { TopBarNavigationButton(onBack) },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(TxtTocRulePreviewIntent.ToggleLayout) },
+                        imageVector = if (state.isGridLayout) {
+                            Icons.AutoMirrored.Outlined.FormatListBulleted
+                        } else {
+                            Icons.Default.GridView
+                        },
+                        contentDescription = stringResource(
+                            if (state.isGridLayout) R.string.layout_mode_list else R.string.layout_mode_grid
+                        ),
+                    )
+                },
+            )
+        },
+        floatingActionButton = {
+            if (state.hasSelection) {
+                AppFloatingActionButton(
+                    onClick = { onApplyRule(state.selectedRule) },
+                    icon = Icons.Default.Check,
+                    tooltipText = stringResource(R.string.ok),
+                )
+            }
+        },
+    ) { contentPadding ->
+        if (state.loading) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(contentPadding),
+                contentAlignment = Alignment.Center,
+            ) {
+                CircularProgressIndicator()
+            }
+        } else if (state.isGridLayout) {
+            LazyVerticalGrid(
+                columns = GridCells.Fixed(2),
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = adaptiveContentPadding(
+                    top = contentPadding.calculateTopPadding(),
+                    bottom = contentPadding.calculateBottomPadding() + 80.dp,
+                ),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                itemsIndexed(state.rules, key = { _, item -> item.rule.id }) { _, item ->
+                    RulePreviewCard(
+                        item = item,
+                        isSelected = item.rule.rule == state.selectedRule,
+                        onClick = {
+                            onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
+                            if (item.totalCount > 0) {
+                                onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
+                            }
+                        },
+                    )
+                }
+            }
+        } else {
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = adaptiveContentPadding(
+                    top = contentPadding.calculateTopPadding(),
+                    bottom = contentPadding.calculateBottomPadding() + 80.dp,
+                ),
+                verticalArrangement = Arrangement.spacedBy(4.dp),
+            ) {
+                itemsIndexed(state.rules, key = { _, item -> item.rule.id }) { _, item ->
+                    RulePreviewListItem(
+                        item = item,
+                        isSelected = item.rule.rule == state.selectedRule,
+                        onClick = {
+                            onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
+                            if (item.totalCount > 0) {
+                                onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
+                            }
+                        },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun RulePreviewCard(
+    item: TocRulePreviewItem,
+    isSelected: Boolean,
+    onClick: () -> Unit,
+) {
+    NormalCard(
+        onClick = onClick,
+        border = if (isSelected) {
+            BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary)
+        } else {
+            null
+        },
+        modifier = Modifier.fillMaxWidth(),
+    ) {
+        Box(modifier = Modifier.fillMaxWidth()) {
+            Column(
+                modifier = Modifier
+                    .padding(12.dp)
+                    .padding(bottom = 28.dp),
+            ) {
+                AppText(
+                    text = item.rule.name,
+                    style = LegadoTheme.typography.titleSmall,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                Spacer(modifier = Modifier.height(4.dp))
+                item.rule.example?.let { example ->
+                    AppText(
+                        text = example,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+            Surface(
+                shape = RoundedCornerShape(12.dp),
+                color = LegadoTheme.colorScheme.primaryContainer,
+                modifier = Modifier
+                    .align(Alignment.BottomEnd)
+                    .padding(8.dp),
+            ) {
+                if (item.totalCount < 0) {
+                    AppCircularProgressIndicator(
+                        modifier = Modifier
+                            .size(16.dp)
+                            .padding(4.dp)
+                    )
+                } else {
+                    AppText(
+                        text = stringResource(R.string.chapter_count_format, item.totalCount),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.onPrimaryContainer,
+                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun RulePreviewListItem(
+    item: TocRulePreviewItem,
+    isSelected: Boolean,
+    onClick: () -> Unit,
+) {
+    NormalCard(
+        onClick = onClick,
+        border = if (isSelected) {
+            BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary)
+        } else {
+            null
+        },
+        modifier = Modifier.fillMaxWidth(),
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp, vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.SpaceBetween,
+        ) {
+            Column(
+                modifier = Modifier.weight(1f),
+            ) {
+                Text(
+                    text = item.rule.name,
+                    style = LegadoTheme.typography.bodyLarge,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                item.rule.example?.let { example ->
+                    Spacer(modifier = Modifier.height(2.dp))
+                    Text(
+                        text = example,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+
+            Spacer(modifier = Modifier.width(12.dp))
+
+            Surface(
+                shape = RoundedCornerShape(12.dp),
+                color = LegadoTheme.colorScheme.primaryContainer,
+            ) {
+                if (item.totalCount < 0) {
+                    CircularProgressIndicator(
+                        modifier = Modifier
+                            .size(16.dp)
+                            .padding(4.dp),
+                        strokeWidth = 2.dp,
+                    )
+                } else {
+                    Text(
+                        text = stringResource(
+                            R.string.chapter_count_format,
+                            item.totalCount
+                        ),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.onPrimaryContainer,
+                        modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ChapterListSheetContent(
+    item: TocRulePreviewItem,
+    onEditRule: (TxtTocRule) -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(16.dp),
+    ) {
+        // Header
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Text(
+                text = item.rule.name,
+                style = LegadoTheme.typography.titleMedium,
+                modifier = Modifier.weight(1f),
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+
+            Spacer(modifier = Modifier.width(8.dp))
+
+            TopBarActionButton(
+                onClick = { onEditRule(item.rule) },
+                imageVector = AppIcons.Edit,
+                contentDescription = stringResource(R.string.edit),
+            )
+
+            Spacer(modifier = Modifier.width(8.dp))
+
+            Text(
+                text = stringResource(
+                    R.string.chapter_count_format,
+                    item.totalCount
+                ),
+                style = LegadoTheme.typography.bodyMedium,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
+
+        Spacer(modifier = Modifier.height(12.dp))
+
+        // Chapter list
+        FastScrollLazyColumn(
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(max = 400.dp),
+        ) {
+            itemsIndexed(item.chapters.toList()) { _, chapter ->
+                Text(
+                    text = chapter,
+                    style = LegadoTheme.typography.bodyMedium,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(vertical = 8.dp, horizontal = 4.dp),
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+            if (item.chapters.size >= 500) {
+                item {
+                    Text(
+                        text = stringResource(R.string.chapter_list_preview_limit),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        modifier = Modifier.padding(8.dp),
+                    )
+                }
+            }
+        }
+    }
+}

+ 231 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewViewModel.kt

@@ -0,0 +1,231 @@
+package io.legado.app.ui.book.toc.rule.preview
+
+import android.app.Application
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.TxtTocRule
+import io.legado.app.data.repository.TxtTocRuleRepository
+import io.legado.app.help.DefaultData
+import io.legado.app.model.localBook.LocalBook
+import io.legado.app.utils.Utf8BomUtils
+import io.legado.app.R
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.ensureActive
+import kotlin.coroutines.coroutineContext
+import kotlinx.coroutines.launch
+import java.util.regex.Pattern
+import java.util.regex.PatternSyntaxException
+
+class TxtTocRulePreviewViewModel(
+    private val app: Application,
+    private val repository: TxtTocRuleRepository,
+) : ViewModel() {
+
+    private val context get() = app.applicationContext
+
+    private val _uiState = MutableStateFlow(TxtTocRulePreviewUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<TxtTocRulePreviewEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    private var book: Book? = null
+
+    fun init(bookUrl: String, currentTocRegex: String?) {
+        viewModelScope.launch(Dispatchers.IO) {
+            loadRules(bookUrl, currentTocRegex)
+        }
+    }
+
+    fun onIntent(intent: TxtTocRulePreviewIntent) {
+        when (intent) {
+            is TxtTocRulePreviewIntent.SelectRule -> {
+                _uiState.update { it.copy(selectedRule = intent.rule) }
+            }
+            is TxtTocRulePreviewIntent.ShowChapterList -> {
+                _uiState.update { it.copy(activeSheet = TxtTocRulePreviewSheet.ChapterList(intent.item)) }
+            }
+            is TxtTocRulePreviewIntent.DismissSheet -> {
+                _uiState.update { it.copy(activeSheet = null) }
+            }
+            is TxtTocRulePreviewIntent.ToggleLayout -> {
+                _uiState.update { it.copy(isGridLayout = !it.isGridLayout) }
+            }
+            is TxtTocRulePreviewIntent.EditRule -> {
+                _uiState.update { it.copy(activeSheet = null, editingRule = intent.rule) }
+            }
+            is TxtTocRulePreviewIntent.DismissEditDialog -> {
+                _uiState.update { it.copy(editingRule = null) }
+            }
+            is TxtTocRulePreviewIntent.SaveRule -> {
+                viewModelScope.launch(Dispatchers.IO) {
+                    saveRuleAndRefresh(intent.rule)
+                }
+            }
+        }
+    }
+
+    private suspend fun loadRules(bookUrl: String, currentTocRegex: String?) {
+        _uiState.update { it.copy(loading = true) }
+
+        val book = runCatching { appDb.bookDao.getBook(bookUrl) }.getOrNull()
+        this.book = book
+        val currentRule = currentTocRegex ?: book?.tocUrl ?: ""
+
+        val allRules = getAllRules()
+
+        // Add all rules as placeholders first (totalCount = -1 means not computed yet)
+        val previewItems = allRules.map { tocRule ->
+            TocRulePreviewItem(rule = tocRule, totalCount = -1)
+        }.toMutableList()
+
+        _uiState.update {
+            it.copy(
+                loading = false,
+                rules = previewItems.toImmutableList(),
+                currentRule = currentRule,
+                selectedRule = currentRule,
+            )
+        }
+
+        // Lazy compute chapter counts in background
+        if (book != null) {
+            computeChaptersLazy(book, allRules)
+        }
+    }
+
+    private fun computeChaptersLazy(book: Book, rules: List<TxtTocRule>) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val results = rules.map { tocRule -> computePreview(book, tocRule) }
+            _uiState.update { state ->
+                val newRules = state.rules.map { existing ->
+                    results.find { it.rule.id == existing.rule.id } ?: existing
+                }.toImmutableList()
+                state.copy(rules = newRules)
+            }
+        }
+    }
+
+    private suspend fun computePreview(book: Book?, tocRule: TxtTocRule): TocRulePreviewItem {
+        if (book == null) return TocRulePreviewItem(rule = tocRule)
+        return try {
+            val pattern = try {
+                tocRule.rule.toPattern(Pattern.MULTILINE)
+            } catch (e: PatternSyntaxException) {
+                return TocRulePreviewItem(rule = tocRule, chapterCount = 0)
+            }
+            val (chapters, total) = analyzeWithPattern(book, pattern)
+            TocRulePreviewItem(
+                rule = tocRule,
+                chapterCount = chapters.size,
+                totalCount = total,
+                chapters = chapters.take(500).toImmutableList(),
+            )
+        } catch (e: Exception) {
+            TocRulePreviewItem(rule = tocRule, chapterCount = 0)
+        }
+    }
+
+    private suspend fun saveRuleAndRefresh(updatedRule: TxtTocRule) {
+        // Validate
+        if (updatedRule.name.isBlank() || updatedRule.rule.isBlank()) {
+            _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.cannot_empty)))
+            _uiState.update { it.copy(editingRule = null) }
+            return
+        }
+        if (runCatching { updatedRule.rule.toPattern(Pattern.MULTILINE) }.isFailure) {
+            _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.invalid_format)))
+            _uiState.update { it.copy(editingRule = null) }
+            return
+        }
+
+        // Save to DB
+        val existing = runCatching { appDb.txtTocRuleDao.getByIds(setOf(updatedRule.id)) }.getOrNull()?.firstOrNull()
+        if (existing != null) {
+            repository.update(updatedRule)
+        } else {
+            repository.insert(updatedRule)
+        }
+
+        // Refresh the specific rule preview
+        val currentRules = _uiState.value.rules.toMutableList()
+        val index = currentRules.indexOfFirst { it.rule.id == updatedRule.id }
+        val book = this.book
+        if (index >= 0 && book != null) {
+            val refreshed = computePreview(book, updatedRule)
+            currentRules[index] = refreshed
+            _uiState.update {
+                it.copy(
+                    rules = currentRules.toImmutableList(),
+                    editingRule = null,
+                )
+            }
+        } else {
+            _uiState.update { it.copy(editingRule = null) }
+        }
+    }
+
+    private suspend fun getAllRules(): List<TxtTocRule> {
+        var rules = repository.all()
+        if (repository.count() == 0) {
+            val defaultRules = DefaultData.txtTocRules
+            repository.insert(*defaultRules.toTypedArray())
+            rules = repository.all()
+        }
+        return rules.filter { it.rule.isNotBlank() }.sortedBy { it.serialNumber }
+    }
+
+       private suspend fun analyzeWithPattern(book: Book, pattern: Pattern): Pair<List<String>, Int> {
+        val chapters = mutableListOf<String>()
+        var totalCount = 0
+        val charset = book.fileCharset()
+        val blank = 0x0a.toByte()
+        val bufferSize = 512000
+
+        runCatching {
+            LocalBook.getBookInputStream(book).use { bis ->
+                val buffer = ByteArray(bufferSize)
+                var bufferStart = 3
+                bis.read(buffer, 0, 3)
+                if (Utf8BomUtils.hasBom(buffer)) {
+                    bufferStart = 0
+                }
+                var length: Int
+                while (bis.read(buffer, bufferStart, bufferSize - bufferStart).also { length = it } > 0) {
+                    coroutineContext.ensureActive()
+                    var end = bufferStart + length
+                    if (end == bufferSize) {
+                        for (i in bufferStart + length - 1 downTo (bufferStart + length - 4096).coerceAtLeast(0)) {
+                            if (buffer[i] == blank) {
+                                end = i
+                                break
+                            }
+                        }
+                    }
+                    val blockContent = String(buffer, 0, end, charset)
+                    buffer.copyInto(buffer, 0, end, bufferStart + length)
+                    bufferStart = bufferStart + length - end
+
+                    val matcher = pattern.matcher(blockContent)
+                    while (matcher.find()) {
+                        totalCount++
+                        if (chapters.size < 500) {
+                            chapters.add(matcher.group())
+                        }
+                    }
+                }
+            }
+        }
+        return chapters to totalCount
+    }
+}
+
+private fun String.toPattern(flags: Int): Pattern = Pattern.compile(this, flags)

+ 3 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1561,6 +1561,9 @@
     <string name="local_book_options">本地书籍选项</string>
     <string name="local_book_toc_rule">本地书籍目录规则</string>
     <string name="select_toc_rule">选择目录规则</string>
+    <string name="select_rule_to_preview_chapters">选择规则立即预览章节列表</string>
+    <string name="chapter_count_format">%d 章</string>
+    <string name="chapter_list_preview_limit">仅显示前 500 章</string>
     <string name="split_long_chapters">拆分超长章节</string>
     <string name="export_bookmarks_json">导出书签为 JSON</string>
     <string name="export_bookmarks_markdown">导出书签为 Markdown</string>

+ 3 - 0
app/src/main/res/values/strings.xml

@@ -1596,6 +1596,9 @@
     <string name="local_book_options">Local Book Options</string>
     <string name="local_book_toc_rule">Local Book Chapter Rule</string>
     <string name="select_toc_rule">Select Chapter Rule</string>
+    <string name="select_rule_to_preview_chapters">Select rule to preview chapter list</string>
+    <string name="chapter_count_format">%d chapters</string>
+    <string name="chapter_list_preview_limit">Only showing first 500 chapters</string>
     <string name="split_long_chapters">Split Long Chapters</string>
     <string name="export_bookmarks_json">Export Bookmarks as JSON</string>
     <string name="export_bookmarks_markdown">Export Bookmarks as Markdown</string>