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 42e5d5163..bfbb7ba85 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 548aec4c8..295c5fd24 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(null) } 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 879e4822b..9244c1614 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -11,10 +11,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.util.fastAll 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.UnsortedPreferences import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.category.interactor.GetCategories @@ -245,7 +250,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() } @@ -324,8 +329,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()) { @@ -366,7 +371,7 @@ class LibraryPresenter( ) } - return this.mapValues { entry -> entry.value.filter(filterFn) } + return this.mapValues { entry -> entry.value.fastFilter(filterFn) } } /** @@ -514,7 +519,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 } @@ -613,8 +618,8 @@ class LibraryPresenter( .groupBy { it.mangaId } .forEach ab@{ (mangaId, chapters) -> val mergedManga = mergedMangas[mangaId] ?: return@ab - val downloadChapters = chapters.filterNot { chapter -> - downloadManager.queue.any { chapter.id == it.chapter.id } || + val downloadChapters = chapters.fastFilterNot { chapter -> + downloadManager.queue.fastAny { chapter.id == it.chapter.id } || downloadManager.isChapterDownloaded( chapter.name, chapter.scanlator, @@ -631,8 +636,8 @@ class LibraryPresenter( // SY <-- val chapters = getNextChapters.await(manga.id) - .filterNot { chapter -> - downloadManager.queue.any { chapter.id == it.chapter.id } || + .fastFilterNot { chapter -> + downloadManager.queue.fastAny { chapter.id == it.chapter.id } || downloadManager.isChapterDownloaded( chapter.name, chapter.scanlator, @@ -839,21 +844,23 @@ class LibraryPresenter( // SY --> @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() } - val items = produceState(initialValue = unfiltered, unfiltered, searchQuery) { + val items = produceState(initialValue = unfiltered, unfiltered, searchQuery, categoryId) { value = withIOContext { - filterLibrary(unfiltered, searchQuery) + filterLibrary(unfiltered, searchQuery, categoryId) } } return items.value } - suspend fun filterLibrary(unfiltered: List, query: String?): List { + suspend fun filterLibrary(unfiltered: List, query: String?, categoryId: Long): List { return if (unfiltered.isNotEmpty() && !query.isNullOrBlank()) { // Prepare filter object val parsedQuery = searchEngine.parseQuery(query) @@ -865,7 +872,7 @@ class LibraryPresenter( } val sources = unfiltered .distinctBy { it.libraryManga.manga.source } - .mapNotNull { sourceManager.get(it.libraryManga.manga.source) } + .fastMapNotNull { sourceManager.get(it.libraryManga.manga.source) } .associateBy { it.id } unfiltered.asFlow().cancellable().filter { item -> val mangaId = item.libraryManga.manga.id @@ -900,8 +907,11 @@ class LibraryPresenter( source = sources[sourceId], ) } - }.toList() + }.toList().also { queriedMangaMap[categoryId] = it } } else { + if (query.isNullOrBlank()) { + queriedMangaMap.clear() + } unfiltered } } @@ -930,12 +940,12 @@ class LibraryPresenter( (source?.name?.contains(query, true) == true) || (sourceIdString != null && sourceIdString == query) || (loggedServices.isNotEmpty() && tracks != null && filterTracks(query, tracks)) || - (genre.any { it.contains(query, true) }) || - (searchTags?.any { it.name.contains(query, true) } == true) || - (searchTitles?.any { it.title.contains(query, true) } == true) + (genre.fastAny { it.contains(query, true) }) || + (searchTags?.fastAny { it.name.contains(query, true) } == true) || + (searchTitles?.fastAny { it.title.contains(query, true) } == true) } is Namespace -> { - searchTags != null && searchTags.any { + searchTags != null && searchTags.fastAny { val tag = queryComponent.tag (it.namespace.equals(queryComponent.namespace, true) && tag?.run { it.name.contains(tag.asQuery(), true) } == true) || (tag == null && it.namespace.equals(queryComponent.namespace, true)) @@ -954,14 +964,14 @@ class LibraryPresenter( (source?.name?.contains(query, true) != true) && (sourceIdString != null && sourceIdString != query) && (loggedServices.isEmpty() || loggedServices.isNotEmpty() && tracks == null || tracks != null && !filterTracks(query, tracks)) && - (genre.none { it.contains(query, true) }) && - (searchTags?.any { it.name.contains(query, true) } != true) && - (searchTitles?.any { it.title.contains(query, true) } != true) + (!genre.fastAny { it.contains(query, true) }) && + (searchTags?.fastAny { it.name.contains(query, true) } != true) && + (searchTitles?.fastAny { it.title.contains(query, true) } != true) ) } is Namespace -> { val searchedTag = queryComponent.tag?.asQuery() - searchTags == null || (queryComponent.namespace.isBlank() && searchedTag.isNullOrBlank()) || searchTags.all { mangaTag -> + searchTags == null || (queryComponent.namespace.isBlank() && searchedTag.isNullOrBlank()) || searchTags.fastAll { mangaTag -> if (queryComponent.namespace.isBlank() && !searchedTag.isNullOrBlank()) { !mangaTag.name.contains(searchedTag, true) } else if (searchedTag.isNullOrBlank()) { @@ -980,7 +990,7 @@ class LibraryPresenter( } private fun filterTracks(constraint: String, tracks: List): Boolean { - return tracks.any { + return tracks.fastAny { val trackService = trackManager.getService(it.syncId) if (trackService != null) { val status = trackService.getStatus(it.status.toInt()) @@ -1023,6 +1033,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 @@ -1035,16 +1059,21 @@ class LibraryPresenter( add(manga) return@apply } - val items = loadedManga[manga.category].orEmpty().run { - filterLibrary(this, 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) } } @@ -1054,11 +1083,12 @@ class LibraryPresenter( presenterScope.launchIO { state.selection = state.selection.toMutableList().apply { val categoryId = categories.getOrNull(index)?.id ?: -1 - val items = loadedManga[categoryId].orEmpty().run { - filterLibrary(this, 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) } } @@ -1068,11 +1098,9 @@ class LibraryPresenter( presenterScope.launchIO { state.selection = selection.toMutableList().apply { val categoryId = categories[index].id - val items = loadedManga[categoryId].orEmpty().run { - filterLibrary(this, 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)