Limitation: for a correct work on the android user is supposed to copy a font file to the android asset directorypull/3921/head
@ -0,0 +1,63 @@
|
||||
package org.jetbrains.compose.resources.demo.shared |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.foundation.verticalScroll |
||||
import androidx.compose.material3.CardDefaults |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.OutlinedCard |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import org.jetbrains.compose.resources.readResourceBytes |
||||
|
||||
@Composable |
||||
fun FileRes(paddingValues: PaddingValues) { |
||||
Column( |
||||
modifier = Modifier.padding(paddingValues) |
||||
) { |
||||
Text( |
||||
modifier = Modifier.padding(16.dp), |
||||
text = "File: 'images/droid_icon.xml'", |
||||
style = MaterialTheme.typography.titleLarge |
||||
) |
||||
OutlinedCard( |
||||
modifier = Modifier.padding(horizontal = 16.dp), |
||||
shape = RoundedCornerShape(4.dp), |
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) |
||||
) { |
||||
var bytes by remember { mutableStateOf(ByteArray(0)) } |
||||
LaunchedEffect(Unit) { |
||||
bytes = readResourceBytes("images/droid_icon.xml") |
||||
} |
||||
Text( |
||||
modifier = Modifier.padding(8.dp).height(200.dp).verticalScroll(rememberScrollState()), |
||||
text = bytes.decodeToString(), |
||||
color = MaterialTheme.colorScheme.onPrimaryContainer, |
||||
softWrap = false |
||||
) |
||||
} |
||||
Text( |
||||
modifier = Modifier.padding(16.dp), |
||||
text = """ |
||||
var bytes by remember { |
||||
mutableStateOf(ByteArray(0)) |
||||
} |
||||
LaunchedEffect(Unit) { |
||||
bytes = readBytes("images/droid_icon.xml") |
||||
} |
||||
Text(bytes.decodeToString()) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,54 @@
|
||||
package org.jetbrains.compose.resources.demo.shared |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material3.CardDefaults |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.OutlinedCard |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.text.font.FontFamily |
||||
import androidx.compose.ui.unit.dp |
||||
import org.jetbrains.compose.resources.Font |
||||
|
||||
@Composable |
||||
fun FontRes(paddingValues: PaddingValues) { |
||||
Column( |
||||
modifier = Modifier.padding(paddingValues) |
||||
) { |
||||
OutlinedCard( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
shape = RoundedCornerShape(4.dp), |
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) |
||||
) { |
||||
Text( |
||||
modifier = Modifier.padding(8.dp), |
||||
text = """ |
||||
val fontAwesome = FontFamily(Font("font_awesome.otf")) |
||||
val symbols = arrayOf(0xf1ba, 0xf238, 0xf21a, 0xf1bb, 0xf1b8, 0xf09b, 0xf269, 0xf1d0, 0xf15a, 0xf293, 0xf1c6) |
||||
Text( |
||||
modifier = Modifier.padding(16.dp), |
||||
fontFamily = fontAwesome, |
||||
style = MaterialTheme.typography.headlineLarge, |
||||
text = symbols.joinToString(" ") { it.toChar().toString() } |
||||
) |
||||
""".trimIndent(), |
||||
color = MaterialTheme.colorScheme.onPrimaryContainer, |
||||
softWrap = false |
||||
) |
||||
} |
||||
|
||||
val fontAwesome = FontFamily(Font("font_awesome.otf")) |
||||
val symbols = arrayOf(0xf1ba, 0xf238, 0xf21a, 0xf1bb, 0xf1b8, 0xf09b, 0xf269, 0xf1d0, 0xf15a, 0xf293, 0xf1c6) |
||||
Text( |
||||
modifier = Modifier.padding(16.dp), |
||||
fontFamily = fontAwesome, |
||||
style = MaterialTheme.typography.headlineLarge, |
||||
text = symbols.joinToString(" ") { it.toChar().toString() } |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,176 @@
|
||||
package org.jetbrains.compose.resources.demo.shared |
||||
|
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.verticalScroll |
||||
import androidx.compose.material3.Icon |
||||
import androidx.compose.material3.OutlinedCard |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import org.jetbrains.compose.resources.imageResource |
||||
import org.jetbrains.compose.resources.vectorResource |
||||
import org.jetbrains.compose.resources.painterResource |
||||
|
||||
@Composable |
||||
fun ImagesRes(contentPadding: PaddingValues) { |
||||
Column( |
||||
modifier = Modifier.padding(contentPadding).verticalScroll(rememberScrollState()), |
||||
) { |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth().fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Image( |
||||
modifier = Modifier.size(100.dp), |
||||
painter = painterResource("images/compose.png"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Image( |
||||
painter = painterResource("images/compose.png") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Image( |
||||
modifier = Modifier.size(100.dp), |
||||
painter = painterResource("images/insta_icon.xml"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Image( |
||||
painter = painterResource("images/insta_icon.xml") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Image( |
||||
modifier = Modifier.size(140.dp), |
||||
bitmap = imageResource("images/land.webp"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Image( |
||||
bitmap = imageResource("images/land.webp") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Image( |
||||
modifier = Modifier.size(100.dp), |
||||
imageVector = vectorResource("images/droid_icon.xml"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Image( |
||||
imageVector = vectorResource("images/droid_icon.xml") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Icon( |
||||
modifier = Modifier.size(100.dp), |
||||
painter = painterResource("images/compose.png"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Icon( |
||||
painter = painterResource("images/compose.png") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Icon( |
||||
modifier = Modifier.size(100.dp), |
||||
painter = painterResource("images/insta_icon.xml"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Icon( |
||||
painter = painterResource("images/insta_icon.xml") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Icon( |
||||
modifier = Modifier.size(140.dp), |
||||
bitmap = imageResource("images/land.webp"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Icon( |
||||
bitmap = imageResource("images/land.webp") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
OutlinedCard(modifier = Modifier.padding(8.dp)) { |
||||
Column( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Icon( |
||||
modifier = Modifier.size(100.dp), |
||||
imageVector = vectorResource("images/droid_icon.xml"), |
||||
contentDescription = null |
||||
) |
||||
Text( |
||||
""" |
||||
Icon( |
||||
imageVector = vectorResource("images/droid_icon.xml") |
||||
) |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,115 @@
|
||||
package org.jetbrains.compose.resources.demo.shared |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.foundation.verticalScroll |
||||
import androidx.compose.material3.CardDefaults |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.OutlinedCard |
||||
import androidx.compose.material3.OutlinedTextField |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.material3.TextFieldDefaults |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import org.jetbrains.compose.resources.getString |
||||
import org.jetbrains.compose.resources.getStringArray |
||||
import org.jetbrains.compose.resources.readResourceBytes |
||||
|
||||
@Composable |
||||
fun StringRes(paddingValues: PaddingValues) { |
||||
Column( |
||||
modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState()) |
||||
) { |
||||
Text( |
||||
modifier = Modifier.padding(16.dp), |
||||
text = "strings.xml", |
||||
style = MaterialTheme.typography.titleLarge |
||||
) |
||||
OutlinedCard( |
||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(), |
||||
shape = RoundedCornerShape(4.dp), |
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) |
||||
) { |
||||
var bytes by remember { mutableStateOf(ByteArray(0)) } |
||||
LaunchedEffect(Unit) { |
||||
bytes = readResourceBytes("strings.xml") |
||||
} |
||||
Text( |
||||
modifier = Modifier.padding(8.dp), |
||||
text = bytes.decodeToString(), |
||||
color = MaterialTheme.colorScheme.onPrimaryContainer, |
||||
softWrap = false |
||||
) |
||||
} |
||||
OutlinedTextField( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
value = getString("app_name"), |
||||
onValueChange = {}, |
||||
label = { Text("Text(getString(\"app_name\"))") }, |
||||
enabled = false, |
||||
colors = TextFieldDefaults.colors( |
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface, |
||||
disabledContainerColor = MaterialTheme.colorScheme.surface, |
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurface, |
||||
) |
||||
) |
||||
OutlinedTextField( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
value = getString("hello"), |
||||
onValueChange = {}, |
||||
label = { Text("Text(getString(\"hello\"))") }, |
||||
enabled = false, |
||||
colors = TextFieldDefaults.colors( |
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface, |
||||
disabledContainerColor = MaterialTheme.colorScheme.surface, |
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurface, |
||||
) |
||||
) |
||||
OutlinedTextField( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
value = getString("multi_line"), |
||||
onValueChange = {}, |
||||
label = { Text("Text(getString(\"multi_line\"))") }, |
||||
enabled = false, |
||||
colors = TextFieldDefaults.colors( |
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface, |
||||
disabledContainerColor = MaterialTheme.colorScheme.surface, |
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurface, |
||||
) |
||||
) |
||||
OutlinedTextField( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
value = getString("str_template", "User_name", 100), |
||||
onValueChange = {}, |
||||
label = { Text("Text(getString(\"str_template\", \"User_name\", 100))") }, |
||||
enabled = false, |
||||
colors = TextFieldDefaults.colors( |
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface, |
||||
disabledContainerColor = MaterialTheme.colorScheme.surface, |
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurface, |
||||
) |
||||
) |
||||
OutlinedTextField( |
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(), |
||||
value = getStringArray("str_arr").toString(), |
||||
onValueChange = {}, |
||||
label = { Text("Text(getStringArray(\"str_arr\").toString())") }, |
||||
enabled = false, |
||||
colors = TextFieldDefaults.colors( |
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface, |
||||
disabledContainerColor = MaterialTheme.colorScheme.surface, |
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurface, |
||||
) |
||||
) |
||||
} |
||||
} |
@ -1,37 +1,65 @@
|
||||
package org.jetbrains.compose.resources.demo.shared |
||||
|
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.material.Icon |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.filled.Abc |
||||
import androidx.compose.material.icons.filled.Attachment |
||||
import androidx.compose.material.icons.filled.Image |
||||
import androidx.compose.material.icons.filled.TextFields |
||||
import androidx.compose.material3.Icon |
||||
import androidx.compose.material3.NavigationBar |
||||
import androidx.compose.material3.NavigationBarItem |
||||
import androidx.compose.material3.Scaffold |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.platform.LocalDensity |
||||
import androidx.compose.ui.unit.dp |
||||
import org.jetbrains.compose.resources.* |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
enum class Screens(val content: @Composable (contentPadding: PaddingValues) -> Unit) { |
||||
Images({ ImagesRes(it) }), |
||||
Strings({ StringRes(it) }), |
||||
Font({ FontRes(it) }), |
||||
File({ FileRes(it) }), |
||||
} |
||||
|
||||
@Composable |
||||
internal fun UseResources() { |
||||
Column { |
||||
Text("Hello, resources") |
||||
Image( |
||||
bitmap = resource("dir/img.png").rememberImageBitmap().orEmpty(), |
||||
contentDescription = null, |
||||
var screen by remember { mutableStateOf(Screens.Images) } |
||||
|
||||
Scaffold( |
||||
modifier = Modifier.fillMaxSize(), |
||||
content = { screen.content(it) }, |
||||
bottomBar = { |
||||
NavigationBar { |
||||
NavigationBarItem( |
||||
selected = screen == Screens.Images, |
||||
onClick = { screen = Screens.Images }, |
||||
icon = { Icon(imageVector = Icons.Default.Image, contentDescription = null) }, |
||||
label = { Text("Images") } |
||||
) |
||||
Image( |
||||
bitmap = resource("img.webp").rememberImageBitmap().orEmpty(), |
||||
contentDescription = null, |
||||
NavigationBarItem( |
||||
selected = screen == Screens.Strings, |
||||
onClick = { screen = Screens.Strings }, |
||||
icon = { Icon(imageVector = Icons.Default.Abc, contentDescription = null) }, |
||||
label = { Text("Strings") } |
||||
) |
||||
Icon( |
||||
imageVector = resource("vector.xml").rememberImageVector(LocalDensity.current).orEmpty(), |
||||
modifier = Modifier.size(150.dp), |
||||
contentDescription = null |
||||
NavigationBarItem( |
||||
selected = screen == Screens.Font, |
||||
onClick = { screen = Screens.Font }, |
||||
icon = { Icon(imageVector = Icons.Default.TextFields, contentDescription = null) }, |
||||
label = { Text("Fonts") } |
||||
) |
||||
Icon( |
||||
painter = painterResource("dir/vector.xml"), |
||||
modifier = Modifier.size(150.dp), |
||||
contentDescription = null |
||||
NavigationBarItem( |
||||
selected = screen == Screens.File, |
||||
onClick = { screen = Screens.File }, |
||||
icon = { Icon(imageVector = Icons.Default.Attachment, contentDescription = null) }, |
||||
label = { Text("Files") } |
||||
) |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,13 @@
|
||||
<resources> |
||||
<string name="app_name">Compose Resources App</string> |
||||
<string name="hello">😊 Hello world!</string> |
||||
<string name="multi_line">Lorem ipsum dolor sit amet, |
||||
consectetur adipiscing elit. |
||||
Donec eget turpis ac sem ultricies consequat.</string> |
||||
<string name="str_template">Hello, %1$s! You have %2$d new messages.</string> |
||||
<string-array name="str_arr"> |
||||
<item>item 1</item> |
||||
<item>item 2</item> |
||||
<item>item 3</item> |
||||
</string-array> |
||||
</resources> |
@ -1,8 +0,0 @@
|
||||
#root { |
||||
width: 100%; |
||||
height: 100vh; |
||||
} |
||||
|
||||
#root > .compose-web-column > div { |
||||
position: relative; |
||||
} |
@ -0,0 +1,107 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.CompositionLocalProvider |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.test.ExperimentalTestApi |
||||
import androidx.compose.ui.test.runComposeUiTest |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Before |
||||
import org.junit.Test |
||||
import kotlin.test.assertEquals |
||||
|
||||
@OptIn(ExperimentalResourceApi::class, ExperimentalTestApi::class) |
||||
class ComposeResourceTest { |
||||
|
||||
@Before |
||||
fun dropCaches() { |
||||
dropStringsCache() |
||||
dropImageCache() |
||||
} |
||||
|
||||
@Test |
||||
fun testCountRecompositions() = runComposeUiTest { |
||||
runBlockingTest { |
||||
val imagePathFlow = MutableStateFlow("1.png") |
||||
val recompositionsCounter = RecompositionsCounter() |
||||
setContent { |
||||
val path by imagePathFlow.collectAsState() |
||||
val res = imageResource(path) |
||||
recompositionsCounter.content { |
||||
Image(bitmap = res, contentDescription = null) |
||||
} |
||||
} |
||||
awaitIdle() |
||||
imagePathFlow.emit("2.png") |
||||
awaitIdle() |
||||
assertEquals(2, recompositionsCounter.count) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun testImageResourceCache() = runComposeUiTest { |
||||
runBlockingTest { |
||||
val testResourceReader = TestResourceReader() |
||||
val imagePathFlow = MutableStateFlow("1.png") |
||||
setContent { |
||||
CompositionLocalProvider(LocalResourceReader provides testResourceReader) { |
||||
val path by imagePathFlow.collectAsState() |
||||
Image(painterResource(path), null) |
||||
} |
||||
} |
||||
awaitIdle() |
||||
imagePathFlow.emit("2.png") |
||||
awaitIdle() |
||||
imagePathFlow.emit("1.png") |
||||
awaitIdle() |
||||
|
||||
assertEquals( |
||||
expected = listOf("1.png", "2.png"), //no second read of 1.png |
||||
actual = testResourceReader.readPaths |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun testStringResourceCache() = runComposeUiTest { |
||||
runBlockingTest { |
||||
val testResourceReader = TestResourceReader() |
||||
val stringIdFlow = MutableStateFlow("app_name") |
||||
setContent { |
||||
CompositionLocalProvider(LocalResourceReader provides testResourceReader) { |
||||
val textId by stringIdFlow.collectAsState() |
||||
Text(getString(textId)) |
||||
Text(getStringArray("str_arr").joinToString()) |
||||
} |
||||
} |
||||
awaitIdle() |
||||
stringIdFlow.emit("hello") |
||||
awaitIdle() |
||||
stringIdFlow.emit("app_name") |
||||
awaitIdle() |
||||
|
||||
assertEquals( |
||||
expected = listOf("strings.xml"), //just one string.xml read |
||||
actual = testResourceReader.readPaths |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun testReadStringResource() = runComposeUiTest { |
||||
runBlockingTest { |
||||
setContent { |
||||
assertEquals("Compose Resources App", getString("app_name")) |
||||
assertEquals( |
||||
"Hello, test-name! You have 42 new messages.", |
||||
getString("str_template", "test-name", 42) |
||||
) |
||||
assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray("str_arr")) |
||||
} |
||||
awaitIdle() |
||||
} |
||||
} |
||||
} |
@ -1,61 +0,0 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.test.junit4.createComposeRule |
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||
import kotlinx.coroutines.delay |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
@OptIn(ExperimentalResourceApi::class, ExperimentalCoroutinesApi::class) |
||||
class ComposeResourceTest { |
||||
|
||||
@get:Rule |
||||
val rule = createComposeRule() |
||||
|
||||
@Test |
||||
fun testMissingResource() = runTest (UnconfinedTestDispatcher()) { |
||||
var recompositionCount = 0 |
||||
rule.setContent { |
||||
CountRecompositions(resource("missing.png").rememberImageBitmap().orEmpty()) { |
||||
recompositionCount++ |
||||
} |
||||
} |
||||
rule.awaitIdle() |
||||
assertEquals(2, recompositionCount) |
||||
} |
||||
|
||||
@Test |
||||
fun testCountRecompositions() = runTest (UnconfinedTestDispatcher()) { |
||||
val mutableStateFlow = MutableStateFlow(true) |
||||
var recompositionCount = 0 |
||||
rule.setContent { |
||||
val state: Boolean by mutableStateFlow.collectAsState(true) |
||||
val resource = resource(if (state) "1.png" else "2.png") |
||||
CountRecompositions(resource.rememberImageBitmap().orEmpty()) { |
||||
recompositionCount++ |
||||
} |
||||
} |
||||
rule.awaitIdle() |
||||
mutableStateFlow.value = false |
||||
rule.awaitIdle() |
||||
assertEquals(4, recompositionCount) |
||||
} |
||||
|
||||
} |
||||
|
||||
@Composable |
||||
private fun CountRecompositions(imageBitmap: ImageBitmap?, onRecomposition: () -> Unit) { |
||||
onRecomposition() |
||||
if (imageBitmap != null) { |
||||
Image(bitmap = imageBitmap, contentDescription = null) |
||||
} |
||||
} |
@ -1,18 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import android.graphics.Bitmap |
||||
import android.graphics.BitmapFactory |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.asImageBitmap |
||||
|
||||
@ExperimentalResourceApi |
||||
internal actual fun ByteArray.toImageBitmap(): ImageBitmap = toAndroidBitmap().asImageBitmap() |
||||
|
||||
private fun ByteArray.toAndroidBitmap(): Bitmap { |
||||
return BitmapFactory.decodeByteArray(this, 0, size); |
||||
} |
@ -0,0 +1,15 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.platform.LocalContext |
||||
import androidx.compose.ui.text.font.Font |
||||
import androidx.compose.ui.text.font.FontStyle |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
|
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
actual fun Font(id: ResourceId, weight: FontWeight, style: FontStyle): Font { |
||||
val path by rememberState(id, { "" }) { getPathById(id) } |
||||
return Font(path, LocalContext.current.assets, weight, style) |
||||
} |
@ -0,0 +1,8 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import android.graphics.BitmapFactory |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.asImageBitmap |
||||
|
||||
internal actual fun ByteArray.toImageBitmap(): ImageBitmap = |
||||
BitmapFactory.decodeByteArray(this, 0, size).asImageBitmap() |
@ -1,27 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import java.io.IOException |
||||
|
||||
@ExperimentalResourceApi |
||||
actual fun resource(path: String): Resource = AndroidResourceImpl(path) |
||||
|
||||
@ExperimentalResourceApi |
||||
private class AndroidResourceImpl(path: String) : AbstractResourceImpl(path) { |
||||
override suspend fun readBytes(): ByteArray { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: (::AndroidResourceImpl.javaClass.classLoader) |
||||
val resource = classLoader.getResourceAsStream(path) |
||||
if (resource != null) { |
||||
return resource.readBytes() |
||||
} else { |
||||
throw MissingResourceException(path) |
||||
} |
||||
} |
||||
} |
||||
|
||||
internal actual class MissingResourceException actual constructor(path: String) : |
||||
IOException("Missing resource with path: $path") |
@ -0,0 +1,14 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.State |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import kotlinx.coroutines.runBlocking |
||||
|
||||
@Composable |
||||
internal actual fun <T> rememberState(key: Any, getDefault: () -> T, block: suspend () -> T): State<T> = remember(key) { |
||||
mutableStateOf( |
||||
runBlocking { block() } |
||||
) |
||||
} |
@ -0,0 +1,9 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher |
||||
import kotlinx.coroutines.DelicateCoroutinesApi |
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||
import kotlinx.coroutines.newSingleThreadContext |
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) |
||||
internal actual val cacheDispatcher: CoroutineDispatcher = newSingleThreadContext("resources_cache_ctx") |
@ -0,0 +1,6 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.runBlocking |
||||
|
||||
actual fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) = runBlocking(block = block) |
@ -1,14 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.runBlocking |
||||
|
||||
internal actual fun isSyncResourceLoadingSupported() = true |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
internal actual fun Resource.readBytesSync(): ByteArray = runBlocking { readBytes() } |
||||
|
@ -1,135 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.painter.BitmapPainter |
||||
import androidx.compose.ui.graphics.painter.Painter |
||||
import androidx.compose.ui.graphics.vector.ImageVector |
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter |
||||
import androidx.compose.ui.platform.LocalDensity |
||||
import androidx.compose.ui.unit.Density |
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.parseVectorRoot |
||||
import androidx.compose.ui.unit.dp |
||||
|
||||
private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) } |
||||
|
||||
private val emptyImageVector: ImageVector by lazy { |
||||
ImageVector.Builder(defaultWidth = 1.dp, defaultHeight = 1.dp, viewportWidth = 1f, viewportHeight = 1f).build() |
||||
} |
||||
|
||||
/** |
||||
* Get and remember resource. While loading and if resource not exists result will be null. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun Resource.rememberImageBitmap(): LoadState<ImageBitmap> { |
||||
val state: MutableState<LoadState<ImageBitmap>> = remember(this) { mutableStateOf(LoadState.Loading()) } |
||||
LaunchedEffect(this) { |
||||
state.value = try { |
||||
LoadState.Success(readBytes().toImageBitmap()) |
||||
} catch (e: Exception) { |
||||
LoadState.Error(e) |
||||
} |
||||
} |
||||
return state.value |
||||
} |
||||
|
||||
/** |
||||
* Get and remember resource. While loading and if resource not exists result will be null. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun Resource.rememberImageVector(density: Density): LoadState<ImageVector> { |
||||
val state: MutableState<LoadState<ImageVector>> = remember(this, density) { mutableStateOf(LoadState.Loading()) } |
||||
LaunchedEffect(this, density) { |
||||
state.value = try { |
||||
LoadState.Success(readBytes().toImageVector(density)) |
||||
} catch (e: Exception) { |
||||
LoadState.Error(e) |
||||
} |
||||
} |
||||
return state.value |
||||
} |
||||
|
||||
private fun <T> LoadState<T>.orEmpty(emptyValue: T): T = when (this) { |
||||
is LoadState.Loading -> emptyValue |
||||
is LoadState.Success -> this.value |
||||
is LoadState.Error -> emptyValue |
||||
} |
||||
|
||||
/** |
||||
* Return current ImageBitmap or return empty while loading. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
fun LoadState<ImageBitmap>.orEmpty(): ImageBitmap = orEmpty(emptyImageBitmap) |
||||
|
||||
/** |
||||
* Return current ImageVector or return empty while loading. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
fun LoadState<ImageVector>.orEmpty(): ImageVector = orEmpty(emptyImageVector) |
||||
|
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
@Composable |
||||
private fun Resource.rememberImageBitmapSync(): ImageBitmap = remember(this) { |
||||
readBytesSync().toImageBitmap() |
||||
} |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
@Composable |
||||
private fun Resource.rememberImageVectorSync(density: Density): ImageVector = remember(this, density) { |
||||
readBytesSync().toImageVector(density) |
||||
} |
||||
|
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
@Composable |
||||
private fun painterResource( |
||||
res: String, |
||||
rememberImageBitmap: @Composable Resource.() -> ImageBitmap, |
||||
rememberImageVector: @Composable Resource.(Density) -> ImageVector |
||||
): Painter = |
||||
if (res.endsWith(".xml")) { |
||||
rememberVectorPainter(resource(res).rememberImageVector(LocalDensity.current)) |
||||
} else { |
||||
BitmapPainter(resource(res).rememberImageBitmap()) |
||||
} |
||||
|
||||
/** |
||||
* Return a Painter from the given resource path. |
||||
* Can load either a BitmapPainter for rasterized images (.png, .jpg) or |
||||
* a VectorPainter for XML Vector Drawables (.xml). |
||||
* |
||||
* XML Vector Drawables have the same format as for Android |
||||
* (https://developer.android.com/reference/android/graphics/drawable/VectorDrawable) |
||||
* except that external references to Android resources are not supported. |
||||
* |
||||
* Note that XML Vector Drawables are not supported for Web target currently. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun painterResource(res: String): Painter = |
||||
if (isSyncResourceLoadingSupported()) { |
||||
painterResource(res, {rememberImageBitmapSync()}, {density->rememberImageVectorSync(density)}) |
||||
} else { |
||||
painterResource(res, {rememberImageBitmap().orEmpty()}, {density->rememberImageVector(density).orEmpty()}) |
||||
} |
||||
|
||||
|
||||
internal expect fun isSyncResourceLoadingSupported(): Boolean |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
internal expect fun Resource.readBytesSync(): ByteArray |
||||
|
||||
internal expect fun ByteArray.toImageBitmap(): ImageBitmap |
||||
|
||||
internal expect fun parseXML(byteArray: ByteArray): Element |
||||
|
||||
internal fun ByteArray.toImageVector(density: Density): ImageVector = parseXML(this).parseVectorRoot(density) |
@ -1,9 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
@RequiresOptIn("This API is experimental and is likely to change in the future.") |
||||
annotation class ExperimentalResourceApi |
@ -0,0 +1,25 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.text.font.Font |
||||
import androidx.compose.ui.text.font.FontStyle |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
|
||||
/** |
||||
* Creates a font with the specified resource ID, weight, and style. |
||||
* |
||||
* @param id The resource ID of the font. |
||||
* @param weight The weight of the font. Default value is [FontWeight.Normal]. |
||||
* @param style The style of the font. Default value is [FontStyle.Normal]. |
||||
* |
||||
* @return The created [Font] object. |
||||
* |
||||
* @throws NotFoundException if the specified resource ID is not found. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
expect fun Font( |
||||
id: ResourceId, |
||||
weight: FontWeight = FontWeight.Normal, |
||||
style: FontStyle = FontStyle.Normal |
||||
): Font |
@ -0,0 +1,101 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.painter.BitmapPainter |
||||
import androidx.compose.ui.graphics.painter.Painter |
||||
import androidx.compose.ui.graphics.vector.ImageVector |
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter |
||||
import androidx.compose.ui.platform.LocalDensity |
||||
import androidx.compose.ui.unit.dp |
||||
import org.jetbrains.compose.resources.vector.toImageVector |
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
|
||||
|
||||
/** |
||||
* Retrieves a [Painter] for the given [ResourceId]. |
||||
* Automatically select a type of the Painter depending on the file extension. |
||||
* |
||||
* @param id The ID of the resource to retrieve the [Painter] from. |
||||
* @return The [Painter] loaded from the resource. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun painterResource(id: ResourceId): Painter { |
||||
val filePath by rememberFilePath(id) |
||||
val isXml = filePath.endsWith(".xml", true) |
||||
if (isXml) { |
||||
return rememberVectorPainter(vectorResource(id)) |
||||
} else { |
||||
return BitmapPainter(imageResource(id)) |
||||
} |
||||
} |
||||
|
||||
private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) } |
||||
|
||||
/** |
||||
* Retrieves an ImageBitmap for the given resource ID. |
||||
* |
||||
* @param id The ID of the resource to load the ImageBitmap from. |
||||
* @return The ImageBitmap loaded from the resource. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun imageResource(id: ResourceId): ImageBitmap { |
||||
val resourceReader = LocalResourceReader.current |
||||
val imageBitmap by rememberState(id, { emptyImageBitmap }) { |
||||
val path = getPathById(id) |
||||
val cached = loadImage(path, resourceReader) { |
||||
ImageCache.Bitmap(it.toImageBitmap()) |
||||
} as ImageCache.Bitmap |
||||
cached.bitmap |
||||
} |
||||
return imageBitmap |
||||
} |
||||
|
||||
private val emptyImageVector: ImageVector by lazy { |
||||
ImageVector.Builder("emptyImageVector", 1.dp, 1.dp, 1f, 1f).build() |
||||
} |
||||
|
||||
/** |
||||
* Retrieves an ImageVector for the given resource ID. |
||||
* |
||||
* @param id The ID of the resource to load the ImageVector from. |
||||
* @return The ImageVector loaded from the resource. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun vectorResource(id: ResourceId): ImageVector { |
||||
val resourceReader = LocalResourceReader.current |
||||
val density = LocalDensity.current |
||||
val imageVector by rememberState(id, { emptyImageVector }) { |
||||
val path = getPathById(id) |
||||
val cached = loadImage(path, resourceReader) { |
||||
ImageCache.Vector(it.toXmlElement().toImageVector(density)) |
||||
} as ImageCache.Vector |
||||
cached.vector |
||||
} |
||||
return imageVector |
||||
} |
||||
|
||||
internal expect fun ByteArray.toImageBitmap(): ImageBitmap |
||||
internal expect fun ByteArray.toXmlElement(): Element |
||||
|
||||
private sealed interface ImageCache { |
||||
class Bitmap(val bitmap: ImageBitmap) : ImageCache |
||||
class Vector(val vector: ImageVector) : ImageCache |
||||
} |
||||
|
||||
private val imageCache = mutableMapOf<String, ImageCache>() |
||||
|
||||
//@TestOnly |
||||
internal fun dropImageCache() { |
||||
imageCache.clear() |
||||
} |
||||
|
||||
private suspend fun loadImage( |
||||
path: String, |
||||
resourceReader: ResourceReader, |
||||
decode: (ByteArray) -> ImageCache |
||||
): ImageCache = imageCache.getOrPut(path) { decode(resourceReader.read(path)) } |
@ -1,114 +0,0 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.setValue |
||||
|
||||
/** |
||||
* Represents the load state of [T]. |
||||
*/ |
||||
sealed class LoadState<T> { |
||||
class Loading<T> : LoadState<T>() |
||||
data class Success<T>(val value: T) : LoadState<T>() |
||||
data class Error<T>(val exception: Exception) : LoadState<T>() |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously, and notify the caller about the load state. |
||||
* Whenever the load state changes (for example it succeeds or fails), the caller will be recomposed with the new state. |
||||
* The load will be cancelled when the [load] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T> load(load: suspend () -> T): LoadState<T> { |
||||
return load(Unit, load) |
||||
} |
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously. Returns null while loading or if the load has failed. |
||||
* Whenever the result changes, the caller will be recomposed with the new value. |
||||
* The load will be cancelled when the [loadOrNull] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T: Any> loadOrNull(load: suspend () -> T): T? { |
||||
return loadOrNull(Unit, load) |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously, and notify the caller about the load state. |
||||
* Whenever the load state changes (for example it succeeds or fails), the caller will be recomposed with the new state. |
||||
* The load will be cancelled and re-launched when [load] is recomposed with a different [key1]. |
||||
* The load will be cancelled when the [load] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T> load(key1: Any?, load: suspend () -> T): LoadState<T> { |
||||
return load(key1, Unit, load) |
||||
} |
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously. Returns null while loading or if the load has failed. |
||||
* Whenever the result changes, the caller will be recomposed with the new value. |
||||
* The load will be cancelled and re-launched when [loadOrNull] is recomposed with a different [key1]. |
||||
* The load will be cancelled when the [loadOrNull] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T: Any> loadOrNull(key1: Any?, load: suspend () -> T): T? { |
||||
return loadOrNull(key1, Unit, load) |
||||
} |
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously, and notify the caller about the load state. |
||||
* Whenever the load state changes (for example it succeeds or fails), the caller will be recomposed with the new state. |
||||
* The load will be cancelled and re-launched when [load] is recomposed with a different [key1] or [key2]. |
||||
* The load will be cancelled when the [load] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T> load(key1: Any?, key2: Any?, load: suspend () -> T): LoadState<T> { |
||||
return load(key1, key2, Unit, load) |
||||
} |
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously. Returns null while loading or if the load has failed. |
||||
* Whenever the result changes, the caller will be recomposed with the new value. |
||||
* The load will be cancelled and re-launched when [loadOrNull] is recomposed with a different [key1] or [key2]. |
||||
* The load will be cancelled when the [loadOrNull] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T: Any> loadOrNull(key1: Any?, key2: Any?, load: suspend () -> T): T? { |
||||
return loadOrNull(key1, key2, Unit, load) |
||||
} |
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously, and notify the caller about the load state. |
||||
* Whenever the load state changes (for example it succeeds or fails), the caller will be recomposed with the new state. |
||||
* The load will be cancelled and re-launched when [load] is recomposed with a different [key1], [key2] or [key3]. |
||||
* The load will be cancelled when the [load] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T> load(key1: Any?, key2: Any?, key3: Any?, load: suspend () -> T): LoadState<T> { |
||||
var state: LoadState<T> by remember(key1, key2, key3) { mutableStateOf(LoadState.Loading()) } |
||||
LaunchedEffect(key1, key2, key3) { |
||||
state = try { |
||||
LoadState.Success(load()) |
||||
} catch (e: Exception) { |
||||
LoadState.Error(e) |
||||
} |
||||
} |
||||
return state |
||||
} |
||||
|
||||
/** |
||||
* Load an item with type [T] asynchronously. Returns null while loading or if the load has failed.. |
||||
* Whenever the result changes, the caller will be recomposed with the new value. |
||||
* The load will be cancelled and re-launched when [loadOrNull] is recomposed with a different [key1], [key2] or [key3]. |
||||
* The load will be cancelled when the [loadOrNull] leaves the composition. |
||||
*/ |
||||
@Composable |
||||
fun <T: Any> loadOrNull(key1: Any?, key2: Any?, key3: Any?, load: suspend () -> T): T? { |
||||
val state = load(key1, key2, key3, load) |
||||
return (state as? LoadState.Success<T>)?.value |
||||
} |
@ -0,0 +1,16 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.State |
||||
|
||||
/** |
||||
* This is a platform-specific function that calculates and remembers a state. |
||||
* For all platforms except a JS it is a blocking function. |
||||
* On the JS platform it loads the state asynchronously and uses `getDefault` as an initial state value. |
||||
*/ |
||||
@Composable |
||||
internal expect fun <T> rememberState( |
||||
key: Any, |
||||
getDefault: () -> T, |
||||
block: suspend () -> T |
||||
): State<T> |
@ -1,38 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
/** |
||||
* Should implement equals() and hashCode() |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
interface Resource { |
||||
suspend fun readBytes(): ByteArray //todo in future use streaming |
||||
} |
||||
|
||||
/** |
||||
* Get a resource from <sourceSet>/resources (for example, from commonMain/resources). |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
expect fun resource(path: String): Resource |
||||
|
||||
internal expect class MissingResourceException(path: String) |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
internal abstract class AbstractResourceImpl(val path: String) : Resource { |
||||
override fun equals(other: Any?): Boolean { |
||||
if (this === other) return true |
||||
return if (other is AbstractResourceImpl) { |
||||
path == other.path |
||||
} else { |
||||
false |
||||
} |
||||
} |
||||
|
||||
override fun hashCode(): Int { |
||||
return path.hashCode() |
||||
} |
||||
} |
@ -0,0 +1,10 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher |
||||
|
||||
internal typealias ResourceId = String |
||||
|
||||
@RequiresOptIn("This API is experimental and is likely to change in the future.") |
||||
annotation class ExperimentalResourceApi |
||||
|
||||
internal expect val cacheDispatcher: CoroutineDispatcher |
@ -0,0 +1,14 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.State |
||||
|
||||
//TODO Here will be logic to map a static ID to a file path in resources dir |
||||
//at the moment ID = file path |
||||
internal suspend fun getPathById(id: ResourceId): String = id |
||||
|
||||
@Composable |
||||
internal fun rememberFilePath(id: ResourceId): State<String> = |
||||
rememberState(id, { "" }) { getPathById(id) } |
||||
|
||||
internal val ResourceId.stringKey get() = this |
@ -0,0 +1,27 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf |
||||
|
||||
@ExperimentalResourceApi |
||||
class MissingResourceException(path: String) : Exception("Missing resource with path: $path") |
||||
|
||||
/** |
||||
* Reads the content of the resource file at the specified path and returns it as a byte array. |
||||
* |
||||
* @param path The path of the file to read in the resource's directory. |
||||
* @return The content of the file as a byte array. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
expect suspend fun readResourceBytes(path: String): ByteArray |
||||
|
||||
internal interface ResourceReader { |
||||
suspend fun read(path: String): ByteArray |
||||
} |
||||
|
||||
internal val DefaultResourceReader: ResourceReader = object : ResourceReader { |
||||
@OptIn(ExperimentalResourceApi::class) |
||||
override suspend fun read(path: String): ByteArray = readResourceBytes(path) |
||||
} |
||||
|
||||
//ResourceReader provider will be overridden for tests |
||||
internal val LocalResourceReader = staticCompositionLocalOf { DefaultResourceReader } |
@ -0,0 +1,153 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import kotlinx.coroutines.withContext |
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.NodeList |
||||
|
||||
private const val STRINGS_XML = "strings.xml" //todo |
||||
private val SimpleStringFormatRegex = Regex("""%(\d)\$[ds]""") |
||||
|
||||
private sealed interface StringItem { |
||||
data class Value(val text: String) : StringItem |
||||
data class Array(val items: List<String>) : StringItem |
||||
} |
||||
|
||||
private val parsedStringsCache = mutableMapOf<String, Map<String, StringItem>>() |
||||
|
||||
//@TestOnly |
||||
internal fun dropStringsCache() { |
||||
parsedStringsCache.clear() |
||||
} |
||||
|
||||
private suspend fun getParsedStrings(path: String, resourceReader: ResourceReader): Map<String, StringItem> = |
||||
withContext(cacheDispatcher) { |
||||
parsedStringsCache.getOrPut(path) { |
||||
val nodes = resourceReader.read(path).toXmlElement().childNodes |
||||
val strings = nodes.getElementsWithName("string").associate { element -> |
||||
element.getAttribute("name") to StringItem.Value(element.textContent.orEmpty()) |
||||
} |
||||
val arrays = nodes.getElementsWithName("string-array").associate { arrayElement -> |
||||
val items = arrayElement.childNodes.getElementsWithName("item").map { element -> |
||||
element.textContent.orEmpty() |
||||
} |
||||
arrayElement.getAttribute("name") to StringItem.Array(items) |
||||
} |
||||
strings + arrays |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Retrieves a string resource using the provided ID. |
||||
* |
||||
* @param id The ID of the string resource to retrieve. |
||||
* @return The retrieved string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun getString(id: ResourceId): String { |
||||
val resourceReader = LocalResourceReader.current |
||||
val str by rememberState(id, { "" }) { loadString(id, resourceReader) } |
||||
return str |
||||
} |
||||
|
||||
/** |
||||
* Loads a string resource using the provided ID. |
||||
* |
||||
* @param id The ID of the string resource to load. |
||||
* @return The loaded string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
suspend fun loadString(id: ResourceId): String = loadString(id, DefaultResourceReader) |
||||
|
||||
private suspend fun loadString(id: ResourceId, resourceReader: ResourceReader): String { |
||||
val nameToValue = getParsedStrings(getPathById(STRINGS_XML), resourceReader) |
||||
val item = nameToValue[id.stringKey] as? StringItem.Value |
||||
?: error("String ID=`${id.stringKey}` is not found!") |
||||
return item.text |
||||
} |
||||
|
||||
/** |
||||
* Retrieves a formatted string resource using the provided ID and arguments. |
||||
* |
||||
* @param id The ID of the string resource to retrieve. |
||||
* @param formatArgs The arguments to be inserted into the formatted string. |
||||
* @return The formatted string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun getString(id: ResourceId, vararg formatArgs: Any): String { |
||||
val resourceReader = LocalResourceReader.current |
||||
val args = formatArgs.map { it.toString() } |
||||
val str by rememberState(id, { "" }) { loadString(id, args, resourceReader) } |
||||
return str |
||||
} |
||||
|
||||
/** |
||||
* Loads a formatted string resource using the provided ID and arguments. |
||||
* |
||||
* @param id The ID of the string resource to load. |
||||
* @param formatArgs The arguments to be inserted into the formatted string. |
||||
* @return The formatted string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
suspend fun loadString(id: ResourceId, vararg formatArgs: Any): String = loadString( |
||||
id, |
||||
formatArgs.map { it.toString() }, |
||||
DefaultResourceReader |
||||
) |
||||
|
||||
private suspend fun loadString(id: ResourceId, args: List<String>, resourceReader: ResourceReader): String { |
||||
val str = loadString(id, resourceReader) |
||||
return SimpleStringFormatRegex.replace(str) { matchResult -> |
||||
args[matchResult.groupValues[1].toInt() - 1] |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Retrieves a list of strings from a string array resource. |
||||
* |
||||
* @param id The ID of the string array resource. |
||||
* @return A list of strings representing the items in the string array. |
||||
* |
||||
* @throws IllegalStateException if the string array with the given ID is not found. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun getStringArray(id: ResourceId): List<String> { |
||||
val resourceReader = LocalResourceReader.current |
||||
val array by rememberState(id, { emptyList() }) { loadStringArray(id, resourceReader) } |
||||
return array |
||||
} |
||||
|
||||
/** |
||||
* Loads a string array from a resource file. |
||||
* |
||||
* @param id The ID of the string array resource. |
||||
* @return A list of strings representing the items in the string array. |
||||
* |
||||
* @throws IllegalStateException if the string array with the given ID is not found. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
suspend fun loadStringArray(id: ResourceId): List<String> = loadStringArray(id, DefaultResourceReader) |
||||
|
||||
private suspend fun loadStringArray(id: ResourceId, resourceReader: ResourceReader): List<String> { |
||||
val nameToValue = getParsedStrings(getPathById(STRINGS_XML), resourceReader) |
||||
val item = nameToValue[id.stringKey] as? StringItem.Array |
||||
?: error("String array ID=`${id.stringKey}` is not found!") |
||||
return item.items |
||||
} |
||||
|
||||
private fun NodeList.getElementsWithName(name: String): List<Element> = |
||||
List(length) { item(it) } |
||||
.filterIsInstance<Element>() |
||||
.filter { it.localName == name } |
@ -0,0 +1,14 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
|
||||
internal class RecompositionsCounter { |
||||
var count = 0 |
||||
private set |
||||
|
||||
@Composable |
||||
fun content(block: @Composable () -> Unit) { |
||||
count++ |
||||
block() |
||||
} |
||||
} |
@ -0,0 +1,11 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
internal class TestResourceReader : ResourceReader { |
||||
private val readPathsList = mutableListOf<String>() |
||||
val readPaths: List<String> get() = readPathsList |
||||
|
||||
override suspend fun read(path: String): ByteArray { |
||||
readPathsList.add(path) |
||||
return DefaultResourceReader.read(path) |
||||
} |
||||
} |
@ -0,0 +1,5 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.CoroutineScope |
||||
|
||||
expect fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) |
Before Width: | Height: | Size: 946 B After Width: | Height: | Size: 946 B |
Before Width: | Height: | Size: 785 B After Width: | Height: | Size: 785 B |
@ -0,0 +1,10 @@
|
||||
<resources> |
||||
<string name="app_name">Compose Resources App</string> |
||||
<string name="hello">😊 Hello world!</string> |
||||
<string name="str_template">Hello, %1$s! You have %2$d new messages.</string> |
||||
<string-array name="str_arr"> |
||||
<item>item 1</item> |
||||
<item>item 2</item> |
||||
<item>item 3</item> |
||||
</string-array> |
||||
</resources> |
@ -1,27 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import java.io.IOException |
||||
|
||||
@ExperimentalResourceApi |
||||
actual fun resource(path: String): Resource = DesktopResourceImpl(path) |
||||
|
||||
@ExperimentalResourceApi |
||||
private class DesktopResourceImpl(path: String) : AbstractResourceImpl(path) { |
||||
override suspend fun readBytes(): ByteArray { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: (::DesktopResourceImpl.javaClass.classLoader) |
||||
val resource = classLoader.getResourceAsStream(path) |
||||
if (resource != null) { |
||||
return resource.readBytes() |
||||
} else { |
||||
throw MissingResourceException(path) |
||||
} |
||||
} |
||||
} |
||||
|
||||
internal actual class MissingResourceException actual constructor(path: String) : |
||||
IOException("Missing resource with path: $path") |
@ -0,0 +1,107 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.CompositionLocalProvider |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.test.ExperimentalTestApi |
||||
import androidx.compose.ui.test.runComposeUiTest |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Before |
||||
import org.junit.Test |
||||
import kotlin.test.assertEquals |
||||
|
||||
@OptIn(ExperimentalResourceApi::class, ExperimentalTestApi::class) |
||||
class ComposeResourceTest { |
||||
|
||||
@Before |
||||
fun dropCaches() { |
||||
dropStringsCache() |
||||
dropImageCache() |
||||
} |
||||
|
||||
@Test |
||||
fun testCountRecompositions() = runComposeUiTest { |
||||
runBlockingTest { |
||||
val imagePathFlow = MutableStateFlow("1.png") |
||||
val recompositionsCounter = RecompositionsCounter() |
||||
setContent { |
||||
val path by imagePathFlow.collectAsState() |
||||
val res = imageResource(path) |
||||
recompositionsCounter.content { |
||||
Image(bitmap = res, contentDescription = null) |
||||
} |
||||
} |
||||
awaitIdle() |
||||
imagePathFlow.emit("2.png") |
||||
awaitIdle() |
||||
assertEquals(2, recompositionsCounter.count) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun testImageResourceCache() = runComposeUiTest { |
||||
runBlockingTest { |
||||
val testResourceReader = TestResourceReader() |
||||
val imagePathFlow = MutableStateFlow("1.png") |
||||
setContent { |
||||
CompositionLocalProvider(LocalResourceReader provides testResourceReader) { |
||||
val path by imagePathFlow.collectAsState() |
||||
Image(painterResource(path), null) |
||||
} |
||||
} |
||||
awaitIdle() |
||||
imagePathFlow.emit("2.png") |
||||
awaitIdle() |
||||
imagePathFlow.emit("1.png") |
||||
awaitIdle() |
||||
|
||||
assertEquals( |
||||
expected = listOf("1.png", "2.png"), //no second read of 1.png |
||||
actual = testResourceReader.readPaths |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun testStringResourceCache() = runComposeUiTest { |
||||
runBlockingTest { |
||||
val testResourceReader = TestResourceReader() |
||||
val stringIdFlow = MutableStateFlow("app_name") |
||||
setContent { |
||||
CompositionLocalProvider(LocalResourceReader provides testResourceReader) { |
||||
val textId by stringIdFlow.collectAsState() |
||||
Text(getString(textId)) |
||||
Text(getStringArray("str_arr").joinToString()) |
||||
} |
||||
} |
||||
awaitIdle() |
||||
stringIdFlow.emit("hello") |
||||
awaitIdle() |
||||
stringIdFlow.emit("app_name") |
||||
awaitIdle() |
||||
|
||||
assertEquals( |
||||
expected = listOf("strings.xml"), //just one string.xml read |
||||
actual = testResourceReader.readPaths |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun testReadStringResource() = runComposeUiTest { |
||||
runBlockingTest { |
||||
setContent { |
||||
assertEquals("Compose Resources App", getString("app_name")) |
||||
assertEquals( |
||||
"Hello, test-name! You have 42 new messages.", |
||||
getString("str_template", "test-name", 42) |
||||
) |
||||
assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray("str_arr")) |
||||
} |
||||
awaitIdle() |
||||
} |
||||
} |
||||
} |
@ -1,61 +0,0 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.test.junit4.createComposeRule |
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||
import kotlinx.coroutines.delay |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
@OptIn(ExperimentalResourceApi::class, ExperimentalCoroutinesApi::class) |
||||
class ComposeResourceTest { |
||||
|
||||
@get:Rule |
||||
val rule = createComposeRule() |
||||
|
||||
@Test |
||||
fun testMissingResource() = runTest (UnconfinedTestDispatcher()) { |
||||
var recompositionCount = 0 |
||||
rule.setContent { |
||||
CountRecompositions(resource("missing.png").rememberImageBitmap().orEmpty()) { |
||||
recompositionCount++ |
||||
} |
||||
} |
||||
rule.awaitIdle() |
||||
assertEquals(2, recompositionCount) |
||||
} |
||||
|
||||
@Test |
||||
fun testCountRecompositions() = runTest (UnconfinedTestDispatcher()) { |
||||
val mutableStateFlow = MutableStateFlow(true) |
||||
var recompositionCount = 0 |
||||
rule.setContent { |
||||
val state: Boolean by mutableStateFlow.collectAsState(true) |
||||
val resource = resource(if (state) "1.png" else "2.png") |
||||
CountRecompositions(resource.rememberImageBitmap().orEmpty()) { |
||||
recompositionCount++ |
||||
} |
||||
} |
||||
rule.awaitIdle() |
||||
mutableStateFlow.value = false |
||||
rule.awaitIdle() |
||||
assertEquals(4, recompositionCount) |
||||
} |
||||
|
||||
} |
||||
|
||||
@Composable |
||||
private fun CountRecompositions(imageBitmap: ImageBitmap?, onRecomposition: () -> Unit) { |
||||
onRecomposition() |
||||
if (imageBitmap != null) { |
||||
Image(bitmap = imageBitmap, contentDescription = null) |
||||
} |
||||
} |
Before Width: | Height: | Size: 946 B |
Before Width: | Height: | Size: 785 B |
@ -1,38 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.cinterop.addressOf |
||||
import kotlinx.cinterop.usePinned |
||||
import platform.Foundation.NSBundle |
||||
import platform.Foundation.NSData |
||||
import platform.Foundation.NSFileManager |
||||
import platform.posix.memcpy |
||||
|
||||
@ExperimentalResourceApi |
||||
actual fun resource(path: String): Resource = UIKitResourceImpl(path) |
||||
|
||||
@ExperimentalResourceApi |
||||
private class UIKitResourceImpl(path: String) : AbstractResourceImpl(path) { |
||||
override suspend fun readBytes(): ByteArray { |
||||
val fileManager = NSFileManager.defaultManager() |
||||
// todo: support fallback path at bundle root? |
||||
val composeResourcesPath = NSBundle.mainBundle.resourcePath + "/compose-resources/" + path |
||||
val contentsAtPath: NSData? = fileManager.contentsAtPath(composeResourcesPath) |
||||
if (contentsAtPath != null) { |
||||
val byteArray = ByteArray(contentsAtPath.length.toInt()) |
||||
byteArray.usePinned { |
||||
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length) |
||||
} |
||||
return byteArray |
||||
} else { |
||||
throw MissingResourceException(path) |
||||
} |
||||
} |
||||
} |
||||
|
||||
internal actual class MissingResourceException actual constructor(path: String) : |
||||
Exception("Missing resource with path: $path") |
@ -0,0 +1,20 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.cinterop.addressOf |
||||
import kotlinx.cinterop.usePinned |
||||
import platform.Foundation.NSBundle |
||||
import platform.Foundation.NSFileManager |
||||
import platform.posix.memcpy |
||||
|
||||
@ExperimentalResourceApi |
||||
actual suspend fun readResourceBytes(path: String): ByteArray { |
||||
val fileManager = NSFileManager.defaultManager() |
||||
// todo: support fallback path at bundle root? |
||||
val composeResourcesPath = NSBundle.mainBundle.resourcePath + "/compose-resources/" + path |
||||
val contentsAtPath = fileManager.contentsAtPath(composeResourcesPath) ?: throw MissingResourceException(path) |
||||
return ByteArray(contentsAtPath.length.toInt()).apply { |
||||
usePinned { |
||||
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,13 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.ElementImpl |
||||
import org.jetbrains.compose.resources.vector.xmldom.MalformedXMLException |
||||
import org.w3c.dom.parsing.DOMParser |
||||
|
||||
internal actual fun ByteArray.toXmlElement(): Element { |
||||
val xmlString = decodeToString() |
||||
val xmlDom = DOMParser().parseFromString(xmlString, "application/xml") |
||||
val domElement = xmlDom.documentElement ?: throw MalformedXMLException("missing documentElement") |
||||
return ElementImpl(domElement) |
||||
} |
@ -0,0 +1,16 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.State |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
|
||||
@Composable |
||||
internal actual fun <T> rememberState(key: Any, getDefault: () -> T, block: suspend () -> T): State<T> { |
||||
val state = remember(key) { mutableStateOf(getDefault()) } |
||||
LaunchedEffect(key) { |
||||
state.value = block() |
||||
} |
||||
return state |
||||
} |
@ -0,0 +1,19 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.browser.window |
||||
import kotlinx.coroutines.await |
||||
import org.khronos.webgl.ArrayBuffer |
||||
import org.khronos.webgl.Int8Array |
||||
|
||||
private fun ArrayBuffer.toByteArray(): ByteArray = |
||||
Int8Array(this, 0, byteLength).unsafeCast<ByteArray>() |
||||
|
||||
@ExperimentalResourceApi |
||||
actual suspend fun readResourceBytes(path: String): ByteArray { |
||||
val resPath = WebResourcesConfiguration.getResourcePath(path) |
||||
val response = window.fetch(resPath).await() |
||||
if (!response.ok) { |
||||
throw MissingResourceException(resPath) |
||||
} |
||||
return response.arrayBuffer().await().toByteArray() |
||||
} |
@ -1,9 +1,12 @@
|
||||
package org.jetbrains.compose.resources.vector.xmldom |
||||
|
||||
import org.w3c.dom.Node as DomNode |
||||
import org.w3c.dom.Element as DomElement |
||||
import org.w3c.dom.Node as DomNode |
||||
|
||||
internal open class NodeImpl(val n: DomNode): Node { |
||||
override val textContent: String? |
||||
get() = n.textContent |
||||
|
||||
override val nodeName: String |
||||
get() = n.nodeName |
||||
|
@ -0,0 +1,9 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.DelicateCoroutinesApi |
||||
import kotlinx.coroutines.GlobalScope |
||||
import kotlinx.coroutines.promise |
||||
|
||||
@OptIn(DelicateCoroutinesApi::class) |
||||
actual fun runBlockingTest(block: suspend CoroutineScope.() -> Unit): dynamic = GlobalScope.promise(block = block) |
@ -0,0 +1,15 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.ElementImpl |
||||
import org.xml.sax.InputSource |
||||
import java.io.ByteArrayInputStream |
||||
import javax.xml.parsers.DocumentBuilderFactory |
||||
|
||||
internal actual fun ByteArray.toXmlElement(): Element = ElementImpl( |
||||
DocumentBuilderFactory.newInstance() |
||||
.apply { isNamespaceAware = true } |
||||
.newDocumentBuilder() |
||||
.parse(InputSource(ByteArrayInputStream(this))) |
||||
.documentElement |
||||
) |
@ -1,22 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.ElementImpl |
||||
import org.xml.sax.InputSource |
||||
import java.io.ByteArrayInputStream |
||||
import javax.xml.parsers.DocumentBuilderFactory |
||||
|
||||
internal actual fun parseXML(byteArray: ByteArray): Element = |
||||
ElementImpl( |
||||
DocumentBuilderFactory |
||||
.newInstance().apply { |
||||
isNamespaceAware = true |
||||
} |
||||
.newDocumentBuilder() |
||||
.parse(InputSource(ByteArrayInputStream(byteArray))) |
||||
.documentElement) |
@ -0,0 +1,10 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
private object JvmResourceReader |
||||
|
||||
@ExperimentalResourceApi |
||||
actual suspend fun readResourceBytes(path: String): ByteArray { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: JvmResourceReader.javaClass.classLoader |
||||
val resource = classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path) |
||||
return resource.readBytes() |
||||
} |
@ -1,9 +1,12 @@
|
||||
package org.jetbrains.compose.resources.vector.xmldom |
||||
|
||||
import org.w3c.dom.Node as DomNode |
||||
import org.w3c.dom.Element as DomElement |
||||
import org.w3c.dom.Node as DomNode |
||||
|
||||
internal open class NodeImpl(val n: DomNode): Node { |
||||
override val textContent: String? |
||||
get() = n.textContent |
||||
|
||||
override val nodeName: String |
||||
get() = n.nodeName |
||||
override val localName: String |
@ -1,40 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.cinterop.addressOf |
||||
import kotlinx.cinterop.usePinned |
||||
import platform.Foundation.NSData |
||||
import platform.Foundation.NSFileManager |
||||
import platform.posix.memcpy |
||||
|
||||
@ExperimentalResourceApi |
||||
actual fun resource(path: String): Resource = MacOSResourceImpl(path) |
||||
|
||||
@ExperimentalResourceApi |
||||
private class MacOSResourceImpl(path: String) : AbstractResourceImpl(path) { |
||||
override suspend fun readBytes(): ByteArray { |
||||
val currentDirectoryPath = NSFileManager.defaultManager().currentDirectoryPath |
||||
val contentsAtPath: NSData? = NSFileManager.defaultManager().run { |
||||
//todo in future bundle resources with app and use all sourceSets (skikoMain, nativeMain) |
||||
contentsAtPath("$currentDirectoryPath/src/macosMain/resources/$path") |
||||
?: contentsAtPath("$currentDirectoryPath/src/commonMain/resources/$path") |
||||
} |
||||
if (contentsAtPath != null) { |
||||
val byteArray = ByteArray(contentsAtPath.length.toInt()) |
||||
byteArray.usePinned { |
||||
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length) |
||||
} |
||||
return byteArray |
||||
} else { |
||||
throw MissingResourceException(path) |
||||
} |
||||
} |
||||
} |
||||
|
||||
internal actual class MissingResourceException actual constructor(path: String) : |
||||
Exception("Missing resource with path: $path") |
||||
|
@ -0,0 +1,21 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.cinterop.addressOf |
||||
import kotlinx.cinterop.usePinned |
||||
import platform.Foundation.NSFileManager |
||||
import platform.posix.memcpy |
||||
|
||||
@ExperimentalResourceApi |
||||
actual suspend fun readResourceBytes(path: String): ByteArray { |
||||
val currentDirectoryPath = NSFileManager.defaultManager().currentDirectoryPath |
||||
val contentsAtPath = NSFileManager.defaultManager().run { |
||||
//todo in future bundle resources with app and use all sourceSets (skikoMain, nativeMain) |
||||
contentsAtPath("$currentDirectoryPath/src/macosMain/resources/$path") |
||||
?: contentsAtPath("$currentDirectoryPath/src/commonMain/resources/$path") |
||||
} ?: throw MissingResourceException(path) |
||||
return ByteArray(contentsAtPath.length.toInt()).apply { |
||||
usePinned { |
||||
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,6 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.parse |
||||
|
||||
internal actual fun ByteArray.toXmlElement(): Element = parse(decodeToString()) |
@ -1,11 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.parse |
||||
|
||||
internal actual fun parseXML(byteArray: ByteArray): Element = parse(byteArray.decodeToString()) |
@ -1,13 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.toComposeImageBitmap |
||||
import org.jetbrains.skia.Image |
||||
|
||||
internal actual fun ByteArray.toImageBitmap(): ImageBitmap = |
||||
Image.makeFromEncoded(this).toComposeImageBitmap() |
@ -0,0 +1,43 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.text.font.Font |
||||
import androidx.compose.ui.text.font.FontStyle |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.text.platform.Font |
||||
import kotlin.io.encoding.Base64 |
||||
import kotlin.io.encoding.ExperimentalEncodingApi |
||||
|
||||
private val emptyFontBase64 = |
||||
"T1RUTwAJAIAAAwAQQ0ZGIML7MfIAAAQIAAAA2U9TLzJmMV8PAAABAAAAAGBjbWFwANUAVwAAA6QAAABEaGVhZCMuU7" + |
||||
"IAAACcAAAANmhoZWECvgAmAAAA1AAAACRobXR4Az4AAAAABOQAAAAQbWF4cAAEUAAAAAD4AAAABm5hbWUpw3nbAAABYAAAAkNwb3N0AAMA" + |
||||
"AAAAA+gAAAAgAAEAAAABAADs7nftXw889QADA+gAAAAA4WWJaQAAAADhZYlpAAAAAAFNAAAAAAADAAIAAAAAAAAAAQAAArz+1AAAAU0AAA" + |
||||
"AAAAAAAQAAAAAAAAAAAAAAAAAAAAQAAFAAAAQAAAADAHwB9AAFAAACigK7AAAAjAKKArsAAAHfADEBAgAAAAAAAAAAAAAAAAAAAAEAAAAA" + |
||||
"AAAAAAAAAABYWFhYAEAAIABfArz+1AAAAAAAAAAAAAEAAAAAAV4AAAAgACAAAAAAACIBngABAAAAAAAAAAIAbwABAAAAAAABAAUAAAABAA" + |
||||
"AAAAACAAcADwABAAAAAAADABAAdQABAAAAAAAEAA0AJAABAAAAAAAFAAIAbwABAAAAAAAGAAwASwABAAAAAAAHAAIAbwABAAAAAAAIAAIA" + |
||||
"bwABAAAAAAAJAAIAbwABAAAAAAAKAAIAbwABAAAAAAALAAIAbwABAAAAAAAMAAIAbwABAAAAAAANAAIAbwABAAAAAAAOAAIAbwABAAAAAA" + |
||||
"AQAAUAAAABAAAAAAARAAcADwADAAEECQAAAAQAcQADAAEECQABAAoABQADAAEECQACAA4AFgADAAEECQADACAAhQADAAEECQAEABoAMQAD" + |
||||
"AAEECQAFAAQAcQADAAEECQAGABgAVwADAAEECQAHAAQAcQADAAEECQAIAAQAcQADAAEECQAJAAQAcQADAAEECQAKAAQAcQADAAEECQALAA" + |
||||
"QAcQADAAEECQAMAAQAcQADAAEECQANAAQAcQADAAEECQAOAAQAcQADAAEECQAQAAoABQADAAEECQARAA4AFmVtcHR5AGUAbQBwAHQAeVJl" + |
||||
"Z3VsYXIAUgBlAGcAdQBsAGEAcmVtcHR5IFJlZ3VsYXIAZQBtAHAAdAB5ACAAUgBlAGcAdQBsAGEAcmVtcHR5UmVndWxhcgBlAG0AcAB0AH" + |
||||
"kAUgBlAGcAdQBsAGEAciIiACIAIiIiOmVtcHR5IFJlZ3VsYXIAIgAiADoAZQBtAHAAdAB5ACAAUgBlAGcAdQBsAGEAcgAAAAABAAMAAQAA" + |
||||
"AAwABAA4AAAACgAIAAIAAgAAACAAQQBf//8AAAAAACAAQQBf//8AAP/h/8H/pAABAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAA" + |
||||
"AAAAAAAAAAAAAAAQAEAQABAQENZW1wdHlSZWd1bGFyAAEBASf4GwD4HAL4HQP4HgSLi/lQ9+EFHQAAAHgPHQAAAH8Rix0AAADZEgAHAQED" + |
||||
"EBUcISIsIiJlbXB0eSBSZWd1bGFyZW1wdHlSZWd1bGFyc3BhY2VBdW5kZXJzY29yZQAAAAGLAYwBjQAEAQFMT1FT+F2f+TcVi4uL/TeLiw" + |
||||
"iLi/g1i4uLCIuLi/k3i4sIi4v8NYuLiwi7/QcVi4uL+NeLiwiLi/fUi4uLCIuLi/zXi4sIi4v71IuLiwgO9+EOnw6fDgAAAAHJAAABTQAA" + |
||||
"ABQAAAAUAAA=" |
||||
|
||||
@OptIn(ExperimentalEncodingApi::class) |
||||
private val defaultEmptyFont by lazy { Font("org.jetbrains.compose.emptyFont", Base64.decode(emptyFontBase64)) } |
||||
|
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
actual fun Font(id: ResourceId, weight: FontWeight, style: FontStyle): Font { |
||||
val resourceReader = LocalResourceReader.current |
||||
val fontFile by rememberState(id, { defaultEmptyFont }) { |
||||
val fontBytes = resourceReader.read(getPathById(id)) |
||||
Font(id, fontBytes, weight, style) |
||||
} |
||||
return fontFile |
||||
} |
@ -0,0 +1,8 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.toComposeImageBitmap |
||||
import org.jetbrains.skia.Image |
||||
|
||||
internal actual fun ByteArray.toImageBitmap(): ImageBitmap = |
||||
Image.makeFromEncoded(this).toComposeImageBitmap() |