diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6bac8bb0..6cd55422 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -79,9 +79,9 @@ dependencies { implementation(libs.multiplatformSettings.coroutines) // Utility - implementation(libs.krokiCoroutines) implementation(libs.dateTime) implementation(libs.immutableCollections) + implementation(libs.kds) // Localization implementation(libs.moko.core) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 4d2e82d5..b7a861e4 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -53,6 +53,7 @@ kotlin { api(libs.multiplatformSettings.coroutines) api(libs.multiplatformSettings.serialization) api(libs.dateTime) + api(libs.kds) api(compose("org.jetbrains.compose.ui:ui-text")) } } diff --git a/core/src/commonMain/kotlin/ca/gosyer/jui/core/lang/PriorityChannel.kt b/core/src/commonMain/kotlin/ca/gosyer/jui/core/lang/PriorityChannel.kt new file mode 100644 index 00000000..c168e021 --- /dev/null +++ b/core/src/commonMain/kotlin/ca/gosyer/jui/core/lang/PriorityChannel.kt @@ -0,0 +1,205 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.core.lang + +import com.soywiz.kds.PriorityQueue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ChannelIterator +import kotlinx.coroutines.channels.ChannelResult +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.selects.SelectClause1 +import kotlinx.coroutines.selects.SelectClause2 +import kotlinx.coroutines.yield + +// Based on https://github.com/kerubistan/kroki/blob/master/kroki-coroutines/src/main/kotlin/io/github/kerubistan/kroki/coroutines/Channels.kt + +/** + * Hides a coroutine between two channels, uniting them as a single channel. + */ +internal open class ProcessChannel( + internal val inChannel: Channel, + internal val outChannel: Channel +) : Channel { + @ExperimentalCoroutinesApi + override val isClosedForReceive: Boolean + get() = outChannel.isClosedForReceive + + @ExperimentalCoroutinesApi + override val isClosedForSend: Boolean + get() = inChannel.isClosedForSend + + @ExperimentalCoroutinesApi + override val isEmpty: Boolean + get() = outChannel.isEmpty + + override val onReceive: SelectClause1 get() = outChannel.onReceive + + override val onSend: SelectClause2> get() = inChannel.onSend + + @Deprecated( + "Since 1.2.0, binary compatibility with versions <= 1.1.x", + level = DeprecationLevel.HIDDEN + ) + override fun cancel(cause: Throwable?): Boolean { + outChannel.cancel() + return true + } + + override fun cancel(cause: CancellationException?) = outChannel.cancel(cause) + + override fun close(cause: Throwable?): Boolean = inChannel.close(cause) + + @ExperimentalCoroutinesApi + override fun invokeOnClose(handler: (cause: Throwable?) -> Unit) { + inChannel.invokeOnClose(handler) + } + + override fun iterator(): ChannelIterator = outChannel.iterator() + + @Deprecated( + "Deprecated in the favour of 'trySend' method", + replaceWith = ReplaceWith("trySend(element).isSuccess"), + level = DeprecationLevel.ERROR + ) + override fun offer(element: T): Boolean = inChannel.trySend(element).isSuccess + + @Deprecated( + "Deprecated in the favour of 'tryReceive'. Please note that the provided replacement does not rethrow channel's close cause as 'poll' did, for the precise replacement please refer to the 'poll' documentation", + replaceWith = ReplaceWith("tryReceive().getOrNull()"), + level = DeprecationLevel.ERROR + ) + override fun poll(): T? = outChannel.tryReceive().getOrNull() + + override suspend fun receive(): T = outChannel.receive() + + override suspend fun send(element: T) = inChannel.send(element) + override val onReceiveCatching: SelectClause1> + get() = TODO("not implemented") + + override suspend fun receiveCatching(): ChannelResult { + TODO("not implemented") + } + + override fun tryReceive(): ChannelResult { + TODO("not implemented") + } + + override fun trySend(element: T): ChannelResult { + TODO("not implemented") + } + +} + +@ExperimentalCoroutinesApi +internal class PriorityChannelImpl( + private val maxCapacity: Int, + scope: CoroutineScope, + comparator: Comparator +) : ProcessChannel( + // why a rendezvous channel should be the input channel? + // because we buffer and sort the messages in the co-routine + // that is where the capacity constraint is enforced + // and the buffer we keep sorted, the input channel we can't + inChannel = Channel(Channel.RENDEZVOUS), + // output channel is rendezvous channel because we may still + // get higher priority input meanwhile and we will send that + // when output consumer is ready to take it + outChannel = Channel(Channel.RENDEZVOUS) +) { + private val buffer = PriorityQueue(comparator) + + private fun PriorityQueue.isNotFull() = this.size < maxCapacity + + private fun PriorityQueue.isFull() = this.size >= maxCapacity + + // non-suspending way to get all messages available at the moment + // as long as we have anything to receive and the buffer is not full + // we should keep receiving + private fun tryGetSome() { + if (buffer.isNotFull()) { + var received = inChannel.tryReceive().getOrNull() + if (received != null) { + buffer.add(received) + while (buffer.isNotFull() && received != null) { + received = inChannel.tryReceive().getOrNull() + received?.let { buffer.add(it) } + } + } + } + } + + private suspend fun getAtLeastOne() { + buffer.add(inChannel.receive()) + tryGetSome() + } + + private suspend fun trySendSome() { + when { + buffer.isEmpty() -> { + yield() + } + buffer.isFull() -> { + outChannel.send(buffer.removeHead()) + } + else -> { + while (buffer.isNotEmpty() && outChannel.trySend(buffer.head).isSuccess) { + buffer.removeHead() + tryGetSome() + } + } + } + } + + private suspend fun sendAll() { + while (buffer.isNotEmpty()) { + outChannel.send(buffer.removeHead()) + } + } + + init { + require(maxCapacity >= 2) { + "priorityChannel maxCapacity < 2 does not make any sense" + } + + scope.async { + try { + getAtLeastOne() + + while (!inChannel.isClosedForReceive) { + trySendSome() + tryGetSome() + } + } finally { + // input channel closed, send the buffer to out channel + sendAll() + // and finally close the output channel, signaling that that this was it + outChannel.close() + } + }.start() + + } +} + +/** + * Creates a channel that always outputs the highest priority element received so far. + * It is important to note here that while the coroutine API channels are all FIFO, this + * one is not. + * @param maxCapacity the number of items the channel can keep inside + * @param scope coroutine-scope to run the sorting in + * @param comparator a comparator for the + */ +@ExperimentalCoroutinesApi +fun PriorityChannel( + maxCapacity: Int = 4096, + scope: CoroutineScope = GlobalScope, + comparator: Comparator +): Channel = PriorityChannelImpl(maxCapacity, scope, comparator) \ No newline at end of file diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index e0c2ce6e..e6bce779 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -84,9 +84,9 @@ dependencies { implementation(libs.multiplatformSettings.coroutines) // Utility - implementation(libs.krokiCoroutines) implementation(libs.dateTime) implementation(libs.immutableCollections) + implementation(libs.kds) // Localization implementation(libs.moko.core) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8a16870..1bda080b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,11 +52,11 @@ appDirs = "1.2.1" multiplatformSettings = "1.0.0-alpha01" # Utility -kroki = "1.23" desugarJdkLibs = "1.2.2" aboutLibraries = "10.5.1" dateTime = "0.4.0" immutableCollections = "0.3.5" +kds = "3.3.1" # Localization moko = "0.20.1" @@ -155,12 +155,12 @@ multiplatformSettings-serialization = { module = "com.russhwolf:multiplatform-se multiplatformSettings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" } # Utility -krokiCoroutines = { module = "io.github.kerubistan.kroki:kroki-coroutines", version.ref = "kroki" } desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } aboutLibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutLibraries-ui = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "dateTime" } immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "immutableCollections" } +kds = { module = "com.soywiz.korlibs.kds:kds", version.ref = "kds" } # Localization moko-core = { module = "dev.icerock.moko:resources", version.ref = "moko" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index c079155b..eacc3f9e 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -50,7 +50,6 @@ kotlin { api(libs.accompanist.pager) api(libs.accompanist.pagerIndicators) api(libs.accompanist.flowLayout) - api(libs.krokiCoroutines) api(libs.dateTime) api(libs.immutableCollections) api(libs.aboutLibraries.core) diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt index a132e14b..ba9e7d93 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt @@ -6,6 +6,7 @@ package ca.gosyer.jui.ui.reader.loader +import ca.gosyer.jui.core.lang.PriorityChannel import ca.gosyer.jui.core.lang.throwIfCancellation import ca.gosyer.jui.domain.chapter.interactor.GetChapterPage import ca.gosyer.jui.domain.reader.service.ReaderPreferences @@ -14,7 +15,6 @@ import ca.gosyer.jui.ui.base.model.StableHolder import ca.gosyer.jui.ui.reader.model.ReaderChapter import ca.gosyer.jui.ui.reader.model.ReaderPage import ca.gosyer.jui.ui.util.compose.asImageBitmap -import ca.gosyer.jui.ui.util.lang.priorityChannel import cafe.adriel.voyager.core.concurrent.AtomicInt32 import com.seiko.imageloader.cache.disk.DiskCache import com.seiko.imageloader.component.decoder.DecodeImageResult @@ -54,7 +54,7 @@ class TachideskPageLoader( /** * A channel used to manage requests one by one while allowing priorities. */ - private val channel = priorityChannel(scope = scope) + private val channel = PriorityChannel(scope = scope, comparator = { i1, i2 -> i1.compareTo(i2) }) /** * The amount of pages to preload before stopping diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/util/lang/PriorityChannel.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/util/lang/PriorityChannel.kt deleted file mode 100644 index 3ea945ac..00000000 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/util/lang/PriorityChannel.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package ca.gosyer.jui.ui.util.lang - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel - -@OptIn(DelicateCoroutinesApi::class) -expect inline fun > priorityChannel( - maxCapacity: Int = 4096, - scope: CoroutineScope = GlobalScope -): Channel diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/jui/ui/util/lang/JvmPriorityChannel.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/jui/ui/util/lang/JvmPriorityChannel.kt deleted file mode 100644 index 9dff2299..00000000 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/jui/ui/util/lang/JvmPriorityChannel.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package ca.gosyer.jui.ui.util.lang - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import io.github.kerubistan.kroki.coroutines.priorityChannel as krokiCoroutinesPriorityChannel - -actual inline fun > priorityChannel( - maxCapacity: Int, - scope: CoroutineScope -): Channel = krokiCoroutinesPriorityChannel()