diff --git a/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt b/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt index 42e5d51630..bfbb7ba853 100644 --- a/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt +++ b/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt @@ -1,6 +1,9 @@ package eu.kanade.core.util +import androidx.compose.ui.util.fastForEach import java.util.concurrent.ConcurrentHashMap +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract fun List.insertSeparators( generator: (T?, T?) -> R?, @@ -33,3 +36,79 @@ fun HashSet.addOrRemove(value: E, shouldAdd: Boolean) { remove(value) } } + +/** + * Returns a list containing only elements matching the given [predicate]. + * + * **Do not use for collections that come from public APIs**, since they may not support random + * access in an efficient way, and this method may actually be a lot slower. Only use for + * collections that are created by code we control and are known to support random access. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +inline fun List.fastFilter(predicate: (T) -> Boolean): List { + contract { callsInPlace(predicate) } + val destination = ArrayList() + fastForEach { if (predicate(it)) destination.add(it) } + return destination +} + +/** + * Returns a list containing all elements not matching the given [predicate]. + * + * **Do not use for collections that come from public APIs**, since they may not support random + * access in an efficient way, and this method may actually be a lot slower. Only use for + * collections that are created by code we control and are known to support random access. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +inline fun List.fastFilterNot(predicate: (T) -> Boolean): List { + contract { callsInPlace(predicate) } + val destination = ArrayList() + fastForEach { if (!predicate(it)) destination.add(it) } + return destination +} + +/** + * Returns a list containing only the non-null results of applying the + * given [transform] function to each element in the original collection. + * + * **Do not use for collections that come from public APIs**, since they may not support random + * access in an efficient way, and this method may actually be a lot slower. Only use for + * collections that are created by code we control and are known to support random access. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +inline fun List.fastMapNotNull(transform: (T) -> R?): List { + contract { callsInPlace(transform) } + val destination = ArrayList() + fastForEach { element -> + transform(element)?.let { destination.add(it) } + } + return destination +} + +/** + * Splits the original collection into pair of lists, + * where *first* list contains elements for which [predicate] yielded `true`, + * while *second* list contains elements for which [predicate] yielded `false`. + * + * **Do not use for collections that come from public APIs**, since they may not support random + * access in an efficient way, and this method may actually be a lot slower. Only use for + * collections that are created by code we control and are known to support random access. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +inline fun List.fastPartition(predicate: (T) -> Boolean): Pair, List> { + contract { callsInPlace(predicate) } + val first = ArrayList() + val second = ArrayList() + fastForEach { + if (predicate(it)) { + first.add(it) + } else { + second.add(it) + } + } + return Pair(first, second) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index d891eab85c..5cb3a04ea0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -18,12 +18,12 @@ class LibraryItem( var sourceLanguage = "" /** - * Filters a manga depending on a query. + * Checks if a query matches the manga * - * @param constraint the query to apply. - * @return true if the manga should be included, false otherwise. + * @param constraint the query to check. + * @return true if the manga matches the query, false otherwise. */ - fun filter(constraint: String): Boolean { + fun matches(constraint: String): Boolean { val sourceName by lazy { sourceManager.getOrStub(libraryManga.manga.source).getNameForMangaInfo() } val genres by lazy { libraryManga.manga.genre } return libraryManga.manga.title.contains(constraint, true) || diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 7ab62f1a69..f0640be585 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -13,6 +13,10 @@ import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastMap import eu.kanade.core.prefs.CheckboxState import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.core.util.fastFilter +import eu.kanade.core.util.fastFilterNot +import eu.kanade.core.util.fastMapNotNull +import eu.kanade.core.util.fastPartition import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.SetMangaCategories @@ -155,7 +159,7 @@ class LibraryPresenter( val filterBookmarked = libraryPreferences.filterBookmarked().get() val filterCompleted = libraryPreferences.filterCompleted().get() - val loggedInTrackServices = trackManager.services.filter { trackService -> trackService.isLogged } + val loggedInTrackServices = trackManager.services.fastFilter { trackService -> trackService.isLogged } .associate { trackService -> trackService.id to libraryPreferences.filterTracking(trackService.id.toInt()).get() } @@ -230,8 +234,8 @@ class LibraryPresenter( val mangaTracks = trackMap[item.libraryManga.id].orEmpty() - val exclude = mangaTracks.filter { it in excludedTracks } - val include = mangaTracks.filter { it in includedTracks } + val exclude = mangaTracks.fastFilter { it in excludedTracks } + val include = mangaTracks.fastFilter { it in includedTracks } // TODO: Simplify the filter logic if (includedTracks.isNotEmpty() && excludedTracks.isNotEmpty()) { @@ -256,7 +260,7 @@ class LibraryPresenter( ) } - return this.mapValues { entry -> entry.value.filter(filterFn) } + return this.mapValues { entry -> entry.value.fastFilter(filterFn) } } /** @@ -355,7 +359,7 @@ class LibraryPresenter( return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga -> val displayCategories = if (libraryManga.isNotEmpty() && libraryManga.containsKey(0).not()) { - categories.filterNot { it.isSystemCategory } + categories.fastFilterNot { it.isSystemCategory } } else { categories } @@ -418,7 +422,7 @@ class LibraryPresenter( presenterScope.launchNonCancellable { mangas.forEach { manga -> val chapters = getNextChapters.await(manga.id) - .filterNot { chapter -> + .fastFilterNot { chapter -> downloadManager.queue.any { chapter.id == it.chapter.id } || downloadManager.isChapterDownloaded( chapter.name, @@ -542,12 +546,20 @@ class LibraryPresenter( @Composable fun getMangaForCategory(page: Int): List { - val unfiltered = remember(categories, loadedManga, page) { - val categoryId = categories.getOrNull(page)?.id ?: -1 + val categoryId = remember(categories, page) { + categories.getOrNull(page)?.id ?: -1 + } + val unfiltered = remember(loadedManga, categoryId) { loadedManga[categoryId] ?: emptyList() } return remember(unfiltered, searchQuery) { - if (searchQuery.isNullOrBlank()) unfiltered else unfiltered.filter { it.filter(searchQuery!!) } + if (searchQuery.isNullOrBlank()) { + queriedMangaMap.clear() + unfiltered + } else { + unfiltered.fastFilter { it.matches(searchQuery!!) } + .also { queriedMangaMap[categoryId] = it } + } } } @@ -565,6 +577,20 @@ class LibraryPresenter( } } + /** + * Map is cleared out via [getMangaForCategory] when [searchQuery] is null or blank + */ + private val queriedMangaMap: MutableMap> = mutableMapOf() + + /** + * Used by select all, inverse and range selection. + * + * If current query is empty then we get manga list from [loadedManga] otherwise from [queriedMangaMap] + */ + private fun getMangaForCategoryWithQuery(categoryId: Long, query: String?): List { + return if (query.isNullOrBlank()) loadedManga[categoryId].orEmpty() else queriedMangaMap[categoryId].orEmpty() + } + /** * Selects all mangas between and including the given manga and the last pressed manga from the * same category as the given manga @@ -576,16 +602,22 @@ class LibraryPresenter( add(manga) return@apply } - val items = loadedManga[manga.category].orEmpty().apply { - if (searchQuery.isNullOrBlank()) toList() else filter { it.filter(searchQuery!!) } - }.fastMap { it.libraryManga } + + val items = getMangaForCategoryWithQuery(manga.category, searchQuery) + .fastMap { it.libraryManga } val lastMangaIndex = items.indexOf(lastSelected) val curMangaIndex = items.indexOf(manga) + val selectedIds = fastMap { it.id } - val newSelections = when (lastMangaIndex >= curMangaIndex + 1) { - true -> items.subList(curMangaIndex, lastMangaIndex) - false -> items.subList(lastMangaIndex, curMangaIndex + 1) - }.filterNot { it.id in selectedIds } + val selectionRange = when { + lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex) + curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex) + // We shouldn't reach this point + else -> return@apply + } + val newSelections = selectionRange.mapNotNull { index -> + items[index].takeUnless { it.id in selectedIds } + } addAll(newSelections) } } @@ -593,11 +625,12 @@ class LibraryPresenter( fun selectAll(index: Int) { state.selection = state.selection.toMutableList().apply { val categoryId = categories.getOrNull(index)?.id ?: -1 - val items = loadedManga[categoryId].orEmpty().apply { - if (searchQuery.isNullOrBlank()) toList() else filter { it.filter(searchQuery!!) } - }.fastMap { it.libraryManga } val selectedIds = fastMap { it.id } - val newSelections = items.filterNot { it.id in selectedIds } + val newSelections = getMangaForCategoryWithQuery(categoryId, searchQuery) + .fastMapNotNull { item -> + item.libraryManga.takeUnless { it.id in selectedIds } + } + addAll(newSelections) } } @@ -605,11 +638,9 @@ class LibraryPresenter( fun invertSelection(index: Int) { state.selection = selection.toMutableList().apply { val categoryId = categories[index].id - val items = loadedManga[categoryId].orEmpty().apply { - if (searchQuery.isNullOrBlank()) toList() else filter { it.filter(searchQuery!!) } - }.fastMap { it.libraryManga } + val items = getMangaForCategoryWithQuery(categoryId, searchQuery).fastMap { it.libraryManga } val selectedIds = fastMap { it.id } - val (toRemove, toAdd) = items.partition { it.id in selectedIds } + val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds } val toRemoveIds = toRemove.fastMap { it.id } removeAll { it.id in toRemoveIds } addAll(toAdd)