diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/MainActivity.kt b/app/src/main/java/io/github/naharaoss/canvaslite/MainActivity.kt index d734200..b239058 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/MainActivity.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/MainActivity.kt @@ -19,9 +19,13 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -39,6 +43,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -59,6 +64,8 @@ import io.github.naharaoss.canvaslite.compose.page.LibraryPageScaffold import io.github.naharaoss.canvaslite.compose.page.LibraryRoute import io.github.naharaoss.canvaslite.compose.Overlay import io.github.naharaoss.canvaslite.compose.OverlayState +import io.github.naharaoss.canvaslite.compose.TimeDisplayText +import io.github.naharaoss.canvaslite.compose.page.LibraryItem import io.github.naharaoss.canvaslite.compose.page.PreferencesPage import io.github.naharaoss.canvaslite.compose.page.PreferencesRoute import io.github.naharaoss.canvaslite.compose.panel.LayerBackgroundItem @@ -139,16 +146,57 @@ class MainActivity : ComponentActivity() { LaunchedEffect(Unit) { folderViewModel.refresh() } LibraryPageContent( - items = items, - innerPadding = innerPadding, - onOpen = { - when (it.type) { - Library.ItemType.Folder -> libraryNavController.navigate(LibraryRoute(it.libraryId, it.metadata.name)) - Library.ItemType.Canvas -> navController.navigate(CanvasPage(it.libraryId)) - else -> {} + empty = items?.isEmpty() ?: false, + loading = items == null, + innerPadding = innerPadding + ) { + val items = items + if (items != null) items(items, { it.libraryId }) { item -> + LibraryItem( + onClick = { + when (item.type) { + Library.ItemType.Canvas -> navController.navigate(CanvasPage(item.libraryId)) + Library.ItemType.Folder -> libraryNavController.navigate(LibraryRoute(item.libraryId, item.metadata.name)) + } + }, + name = { Text(item.metadata.name) }, + supportingContent = { + when { + item.type == Library.ItemType.Folder -> Text("Folder") + item.type == Library.ItemType.Canvas && item.metadata.canvasSize == null -> Text("Infinite canvas") + item.type == Library.ItemType.Canvas && item.metadata.canvasSize != null -> Text(item.metadata.canvasSize.toString()) + else -> item.type.toString() + } + + TimeDisplayText(item.metadata.lastModified) + } + ) { + if (item.type == Library.ItemType.Canvas) { + var thumbnail: ImageBitmap? by remember { mutableStateOf(null) } + + LaunchedEffect(item.libraryId) { + thumbnail = folderViewModel.library.loadThumbnail(item.libraryId) + Log.d("MainActivity", "Thumbnail is $thumbnail") + } + + Box(Modifier + .aspectRatio(item.metadata.canvasSize?.let { it.width / it.height.toFloat() } ?: 1f) + .background(Color.White, MaterialTheme.shapes.medium)) { + val thumbnail = thumbnail + + if (thumbnail != null) { + Log.d("MainActivity", "Recompose with $thumbnail") + Image( + modifier = Modifier.fillMaxSize(), + bitmap = thumbnail, + contentDescription = null + ) + } + } + } } } - ) + } } } } diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/compose/page/LibraryPage.kt b/app/src/main/java/io/github/naharaoss/canvaslite/compose/page/LibraryPage.kt index e15f979..56912cd 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/compose/page/LibraryPage.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/compose/page/LibraryPage.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan @@ -51,6 +53,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -191,9 +194,10 @@ fun LibraryPageScaffold( @Composable fun LibraryPageContent( modifier: Modifier = Modifier, - items: List?, + empty: Boolean, + loading: Boolean, innerPadding: PaddingValues, - onOpen: (Library.Item) -> Unit + content: LazyStaggeredGridScope.() -> Unit ) { Box(modifier.padding(innerPadding)) { LazyVerticalStaggeredGrid( @@ -203,55 +207,30 @@ fun LibraryPageContent( verticalItemSpacing = 16.dp, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - if (items != null) { - when (items.isEmpty()) { - true -> item(span = StaggeredGridItemSpan.FullLine) { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { - Column( - modifier = Modifier.padding(0.dp, 96.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - modifier = Modifier.size(96.dp), - painter = painterResource(R.drawable.folder_24px), - contentDescription = null - ) - Text("Umm... nothing here!") - } - } - } - false -> items(items, { it.libraryId }) { item -> - LibraryItem( - modifier = Modifier.fillMaxWidth(), - onClick = { onOpen(item) }, - name = { Text(item.metadata.name) }, - supportingContent = { - Text(when { - item.type == Library.ItemType.Folder -> "Folder" - item.type == Library.ItemType.Canvas && item.metadata.canvasSize == null -> "Infinite canvas" - item.type == Library.ItemType.Canvas && item.metadata.canvasSize != null -> item.metadata.canvasSize.toString() - else -> "Unknown" - }) - TimeDisplayText(item.metadata.lastModified) - } + if (empty) { + item(span = StaggeredGridItemSpan.FullLine) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { + Column( + modifier = Modifier.padding(0.dp, 96.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - when (item.type) { - Library.ItemType.Canvas -> Box(Modifier - .aspectRatio(item.metadata.canvasSize - ?.let { it.width / it.height.toFloat() } - ?: 1f) - .background(Color.White, MaterialTheme.shapes.medium)) - else -> {} - } + Icon( + modifier = Modifier.size(96.dp), + painter = painterResource(R.drawable.folder_24px), + contentDescription = null + ) + Text("Umm... nothing here!") } } } + } else { + content() } } AnimatedContent( modifier = Modifier.align(Alignment.Center), - targetState = items == null, + targetState = loading, content = { loading -> if (loading) LoadingIndicator(Modifier.align(Alignment.Center)) } ) } diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Canvas.kt b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Canvas.kt index 4fbe13f..e002684 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Canvas.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Canvas.kt @@ -1,9 +1,11 @@ package io.github.naharaoss.canvaslite.engine.project import android.annotation.SuppressLint +import android.graphics.Bitmap import androidx.compose.runtime.Composable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalResources import io.github.naharaoss.canvaslite.engine.Blending import io.github.naharaoss.canvaslite.ext.ColorSerializer @@ -38,6 +40,8 @@ interface Canvas { fun removeLayer(layer: Layer) + fun putThumbnail(bitmap: Bitmap) {} + @Serializable data class CanvasSize(val width: Int, val height: Int) { override fun toString() = "${width}x${height}" diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Library.kt b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Library.kt index 0d22615..95011a3 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Library.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/Library.kt @@ -1,5 +1,6 @@ package io.github.naharaoss.canvaslite.engine.project +import androidx.compose.ui.graphics.ImageBitmap import io.github.naharaoss.canvaslite.ext.ZonedDateTimeSerializer import kotlinx.serialization.Serializable import java.time.ZonedDateTime @@ -14,6 +15,8 @@ interface Library { suspend fun loadCanvas(canvasId: String): Canvas suspend fun delete(libraryId: String) + suspend fun loadThumbnail(canvasId: String): ImageBitmap? + data class Item( val libraryId: String, val type: ItemType, diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/LibraryImpl.kt b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/LibraryImpl.kt index 79ff2d0..c5c1067 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/LibraryImpl.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/LibraryImpl.kt @@ -1,5 +1,11 @@ package io.github.naharaoss.canvaslite.engine.project +import android.graphics.ImageDecoder +import android.util.Log +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set import io.github.naharaoss.canvaslite.ext.readAsJson import io.github.naharaoss.canvaslite.ext.writeAsJson import kotlinx.coroutines.Dispatchers @@ -96,6 +102,11 @@ class LibraryImpl(val root: File) : Library { TODO("Not yet implemented") } + override suspend fun loadThumbnail(canvasId: String): ImageBitmap? { + val file = File(contentRoot, "$canvasId/thumbnail.png") + return if (file.exists()) ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)).asImageBitmap() else null + } + @Serializable data class FolderIndex( val parentId: String?, diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/ScuffedFileCanvas.kt b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/ScuffedFileCanvas.kt index 15725f6..4f0e514 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/ScuffedFileCanvas.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/engine/project/ScuffedFileCanvas.kt @@ -1,5 +1,6 @@ package io.github.naharaoss.canvaslite.engine.project +import android.graphics.Bitmap import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import io.github.naharaoss.canvaslite.engine.Blending @@ -9,6 +10,7 @@ import io.github.naharaoss.canvaslite.ext.readAsJson import io.github.naharaoss.canvaslite.ext.writeAsJson import kotlinx.serialization.Serializable import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile import java.io.Serial @@ -83,6 +85,12 @@ class ScuffedFileCanvas(val root: File) : Canvas { if (currentlySelected) currentLayer = if (layers.isNotEmpty()) layers.lastIndex else null } + override fun putThumbnail(bitmap: Bitmap) { + FileOutputStream(File(root, "thumbnail.png")).use { stream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream) + } + } + private class FileLayer( val canvas: ScuffedFileCanvas, val layerId: String, diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/engine/renderer/CanvasRenderer.kt b/app/src/main/java/io/github/naharaoss/canvaslite/engine/renderer/CanvasRenderer.kt index 1b6f3bd..669f399 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/engine/renderer/CanvasRenderer.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/engine/renderer/CanvasRenderer.kt @@ -357,6 +357,28 @@ class CanvasRenderer( } } + /** + * Render to temporary render target then transfer the pixels in RGBA format to provided byte + * buffer. + */ + fun captureAsRgba(worldToViewport: Matrix, width: Int, height: Int, dst: ByteBuffer) { + GPUTexture(GPUTexture.Type.Texture2D).use { color -> + color.bind { initTexture(width, height) } + + GPURenderTarget().use { target -> + target.bind { + attach(GPURenderTarget.Attachment.Color(0), color) + ensureCompleted() + GLES20.glViewport(0, 0, width, height) + renderBackground(worldToViewport) + renderContent(worldToViewport) + readPixels(0, 0, width, height, dst) + dst.position(dst.position() + width * height * 4) + } + } + } + } + override fun close() { trackDrawingTiles = false drawingTiles.clear() diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/model/LibraryViewModel.kt b/app/src/main/java/io/github/naharaoss/canvaslite/model/LibraryViewModel.kt index 4ef9e6c..52399a8 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/model/LibraryViewModel.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/model/LibraryViewModel.kt @@ -1,5 +1,6 @@ package io.github.naharaoss.canvaslite.model +import androidx.compose.ui.graphics.ImageBitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope @@ -33,4 +34,8 @@ class LibraryViewModel(val library: Library) : ViewModel() { onFinished(item) } } + + fun loadThumbnail(canvasId: String, onFinished: (ImageBitmap?) -> Unit) { + viewModelScope.launch { onFinished(library.loadThumbnail(canvasId)) } + } } \ No newline at end of file diff --git a/app/src/main/java/io/github/naharaoss/canvaslite/view/DrawingCanvasView.kt b/app/src/main/java/io/github/naharaoss/canvaslite/view/DrawingCanvasView.kt index fff528a..31f3871 100644 --- a/app/src/main/java/io/github/naharaoss/canvaslite/view/DrawingCanvasView.kt +++ b/app/src/main/java/io/github/naharaoss/canvaslite/view/DrawingCanvasView.kt @@ -2,6 +2,7 @@ package io.github.naharaoss.canvaslite.view import android.annotation.SuppressLint import android.content.Context +import android.graphics.Bitmap import android.opengl.GLES20 import android.opengl.GLSurfaceView import android.util.Log @@ -11,6 +12,8 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Matrix +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set import io.github.naharaoss.canvaslite.engine.Blending import io.github.naharaoss.canvaslite.engine.PenInput import io.github.naharaoss.canvaslite.engine.brush.Brush @@ -19,6 +22,9 @@ import io.github.naharaoss.canvaslite.engine.project.Canvas import io.github.naharaoss.canvaslite.engine.project.Layer import io.github.naharaoss.canvaslite.engine.renderer.CanvasRenderer import io.github.naharaoss.canvaslite.gl.getGLError +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.opengles.GL10 import kotlin.properties.Delegates @@ -241,6 +247,42 @@ open class DrawingCanvasView(context: Context) : GLSurfaceView(context) { } } } + + fun captureThumbnail(): Bitmap? { + val canvas = this.canvas + val renderer = this.renderer + if (canvas == null || renderer == null) return null + + val viewportWidth = if (canvas.canvasSize != null) canvas.canvasSize!!.width else 1024 + val viewportHeight = if (canvas.canvasSize != null) canvas.canvasSize!!.height else 1024 + val thumbnailWidth = if (viewportWidth > viewportHeight) 1024 else viewportWidth * 1024 / viewportHeight + val thumbnailHeight = if (viewportWidth > viewportHeight) viewportHeight * 1024 / viewportWidth else 1024 + + val dst = ByteBuffer.allocateDirect(thumbnailWidth * thumbnailHeight * 4).order(ByteOrder.nativeOrder()) + renderer.captureAsRgba( + worldToViewport = if (canvas.canvasSize == null) worldToViewport else Matrix().apply { + scale(2f / viewportWidth, -2f / viewportHeight) + }, + width = thumbnailWidth, + height = thumbnailHeight, + dst = dst + ) + dst.flip() + + val bitmap = createBitmap(thumbnailWidth, thumbnailHeight) + + for (y in 0..