Merge branch 'master' into ep/profile-badges

This commit is contained in:
Evgeny @ SimpleX Chat committed 2026-09-26 18:57:39 +00:00
commit 25ec817127
33 files changed
+502 -80

No files matched your search

@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there.
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar()
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at.
expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit)
@@ -1,18 +1,18 @@
package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import java.io.File
import kotlin.math.roundToInt
@Composable
expect fun Modifier.desktopOnExternalDrag(
@@ -58,20 +58,23 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<B
}
}
@Composable
fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>,
onLongClick: () -> Unit = {}
): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (enabled && blurred.value) {
this then Modifier.blur(
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp))
)
return if (blurHidesMedia(enabled, blurred)) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
this then Modifier
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
.combinedClickable(
onLongClick = onLongClick,
onClick = {
@@ -90,3 +93,25 @@ fun Modifier.privacyBlur(
this
}
}
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
private const val BLURRED_MEDIA_WIDTH_DP = 400
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
val longest = maxOf(width, height)
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest
scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1))
} else this
// One bilinear step from a large image undersamples it.
while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1))
image = image.scale(w, h)
// Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small.
while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2)
return image
}
@@ -114,7 +114,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -138,7 +138,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
} else {
@@ -149,7 +149,7 @@ fun CIImageView(
onClick = {}
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
@@ -195,22 +195,26 @@ fun CIImageView(
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
// Hidden media is not worth reading, decoding at full size and caching.
val revealed = !blurHidesMedia(!smallView, blurred)
if (revealed) {
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
val loaded = res.value
val loaded = if (revealed) res.value else null
if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
@@ -421,7 +421,7 @@ fun VideoPreviewImageView(
onClick = onClick
)
.onRightClick(onLongClick)
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -779,7 +779,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
} else {
withBGApi {
chatModel.appOpenUrlConnecting.value = true
planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false })
planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false })
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.drawBarsBlurred
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
@@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier(
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
@@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier(
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
handler: AppBarHandler,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(CurrentColors.value.colors.background)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
val barWidth = size.width
val barHeight = size.height
// The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own
// filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved.
handler.contentVersion.value
drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) {
drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight))
if (onTop) {
clipRect(0f, 0f, barWidth, barHeight) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + barHeight + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
}
}
@@ -78,6 +78,10 @@ class AppBarHandler(
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
// Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector
// rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles.
val contentVersion: MutableState<Int> = mutableStateOf(0)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
@Composable
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
val image = base64ToBitmap(linkPreview.image)
val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
Column(
Modifier
.layoutId(CHAT_IMAGE_LAYOUT_ID)
@@ -207,7 +207,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
@@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
return v != lastVersion
}
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat"
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app"
private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im"
@@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo
ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) {
// in the center of the window the page is wide enough for the title to fit on one line
val title = "Get a stake in\nSimpleX Chat"
val title = "Invest in\nSimpleX Chat"
AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false)
// What's new already shows the image of the first slide, above the link that opens this page
if (showFirstImage) {
@@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: (
val simplexTeamUri =
"simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
val simplexNewsUri =
"simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im"
@Composable
fun SettingsLayout(
stopped: Boolean,
@@ -161,6 +164,7 @@ fun HelpAndSupportView(
SectionView(stringResource(MR.strings.settings_section_title_contact)) {
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
@@ -1015,6 +1015,7 @@
<string name="markdown_in_messages">Markdown in messages</string>
<string name="chat_with_the_founder">Send questions and ideas</string>
<string name="send_us_an_email">Send us email</string>
<string name="follow_simplex_network_news">Follow SimpleX Network News</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="message_servers">Message servers</string>
@@ -2678,8 +2679,8 @@
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
<string name="invest_banner_title" translatable="false">Invest in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
@@ -0,0 +1,95 @@
package chat.simplex.common.platform
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.asComposeCanvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.*
import kotlin.math.ceil
// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be
// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to
// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a
// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out.
// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur.
private const val MAX_BLUR_NARROWING = 8
private const val MIN_BLUR_WIDTH = 128
// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it
// back out shows the steps of the narrowing instead of a blur.
private const val MIN_NARROWED_SIGMA = 1.5f
// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at
// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this.
private const val MIN_BLUR_HEIGHT = 24
// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single
// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size
// released once there are more than the window can show at once, allocates each pair once instead.
private const val MAX_BAR_SURFACES = 4
private class BarBlurSurface(width: Int, height: Int) {
val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height))
fun close() = surface.close()
}
private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap<Long, BarBlurSurface>() }
private fun surfaceFor(width: Int, height: Int): BarBlurSurface {
val cached = barBlurSurfaces.get()
val key = (width.toLong() shl 32) or height.toLong()
val surface = cached.remove(key) ?: BarBlurSurface(width, height)
cached[key] = surface
while (cached.size > MAX_BAR_SURFACES) {
cached.remove(cached.keys.first())?.close()
}
return surface
}
private fun narrowing(sigma: Float, barWidth: Float): Int {
var scale = 1
while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2
return scale
}
private fun shortening(sigma: Float, barHeight: Float): Int =
if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) {
val sigma = BlurEffect.convertRadiusToSigma(radiusPx)
val scale = narrowing(sigma, barWidth)
val rows = shortening(sigma, barHeight)
val width = ceil(barWidth / scale).toInt()
val height = ceil(barHeight / rows).toInt()
if (width <= 0 || height <= 0) return
val surface = surfaceFor(width, height).surface
surface.canvas.clear(Color.TRANSPARENT)
// the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to
surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply {
imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP)
})
CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) {
scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() }
}
surface.canvas.restore()
// The surface is whole pixels while the bar is not, so only the bar's own area is taken from it.
drawIntoCanvas {
it.nativeCanvas.drawImageRect(
surface.makeImageSnapshot(),
Rect.makeWH(barWidth / scale, barHeight / rows),
Rect.makeWH(barWidth, barHeight),
SamplingMode.LINEAR,
null,
true
)
}
}
@@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar(
}
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(state, handler) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ }
}
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
val scrollBarDraggingState = remember { mutableStateOf(false) }
@@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar(
}
}
val state = state ?: handler.scrollState
LaunchedEffect(state, handler) {
snapshotFlow { state.value }.collect { handler.contentVersion.value++ }
}
val connection = handler.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
// (exact scroll position is available but in Int, not Float)