Snap windows to the desktop grid and to whole lines, font size picker (10 to 18), white text in settings dialogs, bump to 0.1.12
- After a move, windows snap to the Windows desktop icon grid; after a resize, the window height is trimmed so the scrollback ends on a whole line. - The font size slider is replaced by square 10 to 18 buttons. Sizes saved by the old slider round to the nearest picker size. - Settings and font dialogs always use white text, so a dark window text color no longer makes their labels invisible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
026e349db8
commit
5bcd1a7bcd
+2
-2
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.tpuahsiew.wirc"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
|
||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||
@@ -38,7 +38,7 @@ compose.desktop {
|
||||
appResourcesRootDir.set(project.file("installerResources"))
|
||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||
packageName = "wIRC"
|
||||
packageVersion = "0.1.11"
|
||||
packageVersion = "0.1.12"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||
|
||||
@@ -51,6 +51,8 @@ fun main() = application {
|
||||
// Loaded once here (not per-window) so the alias map is truly global - every
|
||||
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
||||
AliasRepository.init()
|
||||
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
|
||||
ui.DesktopGrid.refreshAsync()
|
||||
|
||||
val repo = remember { ServerRepository(appRoot) }
|
||||
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
||||
@@ -85,6 +87,9 @@ fun main() = application {
|
||||
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
|
||||
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
|
||||
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
|
||||
// Snap to the desktop grid after a move, and to whole lines after a resize
|
||||
val mainLineGap = ui.rememberLineGap()
|
||||
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
|
||||
|
||||
// There's no persistent tray/notification surface on this build, so closing the main
|
||||
// server-list window quits the whole app (and every channel window with it) - the
|
||||
@@ -98,6 +103,7 @@ fun main() = application {
|
||||
icon = painterResource("icon.png"),
|
||||
state = mainWindowState
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
MainScreen(
|
||||
@@ -113,6 +119,7 @@ fun main() = application {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openBuffers.forEach { bufferKey ->
|
||||
|
||||
@@ -10,6 +10,13 @@ import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude)
|
||||
const val MIN_FONT_SIZE = 10
|
||||
const val MAX_FONT_SIZE = 18
|
||||
|
||||
private fun pickerFontSize(size: Float): Float =
|
||||
Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat()
|
||||
|
||||
data class WindowDisplaySettings(
|
||||
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
||||
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
||||
@@ -63,7 +70,7 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
|
||||
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
|
||||
|
||||
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) }
|
||||
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) }
|
||||
|
||||
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
|
||||
|
||||
@@ -109,7 +116,8 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
stored[key] = WindowDisplaySettings(
|
||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
||||
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
|
||||
// Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
|
||||
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
|
||||
fontId = o.optString("fontId", "monospace"),
|
||||
showUserList = o.optBoolean("showUserList", true),
|
||||
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
||||
|
||||
@@ -83,6 +83,8 @@ import androidx.compose.ui.window.rememberDialogState
|
||||
import data.ChannelBuffer
|
||||
import data.ChannelUser
|
||||
import data.IrcMessage
|
||||
import data.MAX_FONT_SIZE
|
||||
import data.MIN_FONT_SIZE
|
||||
import data.MessageKind
|
||||
import data.WindowDisplaySettings
|
||||
import irc.extractImageUrls
|
||||
@@ -102,6 +104,7 @@ import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
|
||||
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
|
||||
@@ -417,7 +420,9 @@ fun WindowScope.ChannelScreen(
|
||||
Surface {
|
||||
WindowSettingsPanel(
|
||||
displaySettings = displaySettings,
|
||||
textColor = textColor,
|
||||
// Always white: the dialog is black, so the window's own text
|
||||
// color (black, say) would make every label invisible here
|
||||
textColor = Color.White,
|
||||
onAlphaChange = onAlphaChange,
|
||||
onTextColorChange = onTextColorChange,
|
||||
onFontSizeChange = onFontSizeChange,
|
||||
@@ -859,10 +864,14 @@ fun MessageScrollback(
|
||||
// real cause was a race between the scroll effect and the LazyColumn's
|
||||
// own layout catching up to new items - see the scroll effects below.
|
||||
val snappedHeight = if (lineHeightDp > 0.dp) {
|
||||
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
|
||||
minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
|
||||
} else {
|
||||
maxHeight
|
||||
}
|
||||
// Tells the window how much space is left under the last whole line, so a resize can
|
||||
// trim the window itself to fit (see WindowSnapping.kt)
|
||||
val lineGap = LocalLineGap.current
|
||||
SideEffect { lineGap?.gap = maxHeight - snappedHeight }
|
||||
|
||||
// NOTE: this used to also re-snap the scroll *offset* to the first
|
||||
// visible item on every resize/font-size change, to fix a partial line
|
||||
@@ -1441,13 +1450,24 @@ private fun WindowSettingsPanel(
|
||||
colors = sliderColors
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
Slider(
|
||||
value = displaySettings.fontSize,
|
||||
onValueChange = onFontSizeChange,
|
||||
valueRange = 10f..24f,
|
||||
colors = sliderColors
|
||||
)
|
||||
Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
// Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
|
||||
val currentSize = displaySettings.fontSize.roundToInt()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
|
||||
val selected = size == currentSize
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(if (selected) Color.White.copy(alpha = 0.25f) else Color.Transparent)
|
||||
.border(1.dp, if (selected) Color.White else Color.White.copy(alpha = 0.35f))
|
||||
.clickableNoRipple { onFontSizeChange(size.toFloat()) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(size.toString(), color = textColor, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
FontPickerList(displaySettings.fontId, textColor, onFontChange)
|
||||
|
||||
@@ -3,6 +3,7 @@ package ui
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -74,6 +75,9 @@ fun ChannelWindow(
|
||||
// Restored from window_prefs.json (or cascaded if there's no usable saved position)
|
||||
// and saved back on move/resize/close - see WindowPlacement.kt.
|
||||
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
|
||||
// Snap to the desktop grid after a move, and to whole lines after a resize
|
||||
val lineGap = rememberLineGap()
|
||||
WindowSnapping(windowState, lineGap)
|
||||
|
||||
// undecorated + transparent trade the native OS title bar for real per-pixel window
|
||||
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
||||
@@ -89,6 +93,7 @@ fun ChannelWindow(
|
||||
transparent = true,
|
||||
resizable = true
|
||||
) {
|
||||
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
ChannelScreen(
|
||||
windowState = windowState,
|
||||
@@ -198,5 +203,6 @@ fun ChannelWindow(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,9 +205,9 @@ fun MainScreen(
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
Surface {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) {
|
||||
Text("Server messages font:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
Text("Server messages font:", color = Color.White, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
FontPickerList(displaySettings.fontId, textColor, onFontChange)
|
||||
FontPickerList(displaySettings.fontId, Color.White, onFontChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import java.awt.AWTEvent
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Toolkit
|
||||
import java.awt.event.AWTEventListener
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Space left over under the last whole line of a window's scrollback, reported by
|
||||
* MessageScrollback (which already sizes the scrollback itself to whole lines). When a
|
||||
* resize ends, the window is trimmed by this much so the window itself fits whole lines too.
|
||||
*/
|
||||
class LineGap {
|
||||
@Volatile var gap: Dp = 0.dp
|
||||
}
|
||||
|
||||
val LocalLineGap = staticCompositionLocalOf<LineGap?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberLineGap(): LineGap = remember { LineGap() }
|
||||
|
||||
/**
|
||||
* After the mouse is let go at the end of a drag or resize:
|
||||
* - a move snaps the window's top-left corner to the desktop icon grid
|
||||
* - a resize trims the window to whole lines of scrollback text (keeping whichever edge
|
||||
* didn't move in place)
|
||||
* Channel windows are undecorated, so their moves and resizes happen inside the app (the
|
||||
* title-bar drag area and Compose's own edge resizer) and the mouse release reaches wIRC.
|
||||
* wIRC main has a normal Windows frame, whose drags Windows handles itself without telling
|
||||
* the app when the button comes up, so for it ([osFrame] = true) the snap happens once the
|
||||
* window has sat still for a moment instead.
|
||||
*/
|
||||
@Composable
|
||||
fun WindowSnapping(state: WindowState, lineGap: LineGap, osFrame: Boolean = false) {
|
||||
if (osFrame) {
|
||||
LaunchedEffect(state, lineGap) {
|
||||
var settledPos: WindowPosition = state.position
|
||||
var settledSize: DpSize = state.size
|
||||
snapshotFlow { state.position to state.size }.collectLatest {
|
||||
delay(OS_FRAME_SETTLE_MS)
|
||||
afterMoveOrResize(state, lineGap, settledPos, settledSize)
|
||||
settledPos = state.position
|
||||
settledSize = state.size
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
DisposableEffect(state, lineGap) {
|
||||
var startPos: WindowPosition? = null
|
||||
var startSize: DpSize? = null
|
||||
val listener = AWTEventListener { event ->
|
||||
val e = event as? MouseEvent ?: return@AWTEventListener
|
||||
if (e.button != MouseEvent.BUTTON1) return@AWTEventListener
|
||||
when (e.id) {
|
||||
MouseEvent.MOUSE_PRESSED -> {
|
||||
startPos = state.position
|
||||
startSize = state.size
|
||||
}
|
||||
MouseEvent.MOUSE_RELEASED -> {
|
||||
val p0 = startPos
|
||||
val s0 = startSize
|
||||
startPos = null
|
||||
startSize = null
|
||||
if (p0 != null && s0 != null) afterMoveOrResize(state, lineGap, p0, s0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
|
||||
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
// How long wIRC main has to sit still after a move/resize before it snaps
|
||||
private const val OS_FRAME_SETTLE_MS = 400L
|
||||
|
||||
private fun afterMoveOrResize(state: WindowState, lineGap: LineGap, p0: WindowPosition, s0: DpSize) {
|
||||
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
|
||||
val pos = state.position as? WindowPosition.Absolute ?: return
|
||||
val size = state.size
|
||||
if (!size.isSpecified || !s0.isSpecified) return
|
||||
val start = p0 as? WindowPosition.Absolute
|
||||
|
||||
val resized = (size.width - s0.width).value.let { it > 0.5f || it < -0.5f } ||
|
||||
(size.height - s0.height).value.let { it > 0.5f || it < -0.5f }
|
||||
|
||||
if (resized) {
|
||||
val gap = lineGap.gap
|
||||
if (gap > 0.5.dp && size.height - gap >= 120.dp) {
|
||||
// Dragged the top edge (top moved, bottom stayed): keep the bottom where it is
|
||||
val fromTop = start != null && (pos.y - start.y).value.let { it > 0.5f || it < -0.5f } &&
|
||||
((pos.y + size.height) - (start.y + s0.height)).value.let { it < 0.5f && it > -0.5f }
|
||||
state.size = DpSize(size.width, size.height - gap)
|
||||
if (fromTop) state.position = WindowPosition.Absolute(pos.x, pos.y + gap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (start == null || (start.x == pos.x && start.y == pos.y)) return
|
||||
val (sx, sy) = DesktopGrid.spacing() ?: return
|
||||
val area = workAreaAt(pos.x.value, pos.y.value)
|
||||
val x = area.x + ((pos.x.value - area.x) / sx).roundToInt() * sx
|
||||
val y = area.y + ((pos.y.value - area.y) / sy).roundToInt() * sy
|
||||
if (x != pos.x.value || y != pos.y.value) state.position = WindowPosition.Absolute(x.dp, y.dp)
|
||||
}
|
||||
|
||||
// The usable area (taskbar excluded) of the screen the window's corner is on, in the same
|
||||
// logical units as WindowState positions.
|
||||
private fun workAreaAt(x: Float, y: Float): Rectangle = try {
|
||||
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||
val config = ge.screenDevices.map { it.defaultConfiguration }
|
||||
.firstOrNull { it.bounds.contains(x.toInt(), y.toInt()) } ?: ge.defaultScreenDevice.defaultConfiguration
|
||||
val b = config.bounds
|
||||
val i = Toolkit.getDefaultToolkit().getScreenInsets(config)
|
||||
Rectangle(b.x + i.left, b.y + i.top, b.width - i.left - i.right, b.height - i.top - i.bottom)
|
||||
} catch (e: Exception) {
|
||||
Rectangle(0, 0, 1280, 720)
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop icon grid's spacing, read from the desktop's own icon view (so it follows the
|
||||
* icon size and spacing Windows is really using), falling back to the system icon spacing.
|
||||
* Java can't call Windows directly without extra libraries, so a hidden PowerShell asks once
|
||||
* at startup, in the background; snapping just skips until the answer is in.
|
||||
*/
|
||||
object DesktopGrid {
|
||||
@Volatile private var cached: Pair<Float, Float>? = null
|
||||
@Volatile private var started = false
|
||||
|
||||
fun spacing(): Pair<Float, Float>? {
|
||||
if (!started) refreshAsync()
|
||||
return cached
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refreshAsync() {
|
||||
if (started) return
|
||||
started = true
|
||||
Thread {
|
||||
try {
|
||||
val script = """
|
||||
Add-Type -TypeDefinition @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class WircGrid {
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindow(string c, string n);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr p, IntPtr a, string c, string n);
|
||||
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int m, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint a, uint p, out int v, uint i);
|
||||
public static string Get() {
|
||||
IntPtr dv = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
IntPtr w = IntPtr.Zero;
|
||||
while (dv == IntPtr.Zero) { w = FindWindowEx(IntPtr.Zero, w, "WorkerW", null); if (w == IntPtr.Zero) break; dv = FindWindowEx(w, IntPtr.Zero, "SHELLDLL_DefView", null); }
|
||||
IntPtr lv = dv == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(dv, IntPtr.Zero, "SysListView32", null);
|
||||
if (lv != IntPtr.Zero) { long r = SendMessage(lv, 0x1033, IntPtr.Zero, IntPtr.Zero).ToInt64(); long cx = r & 0xFFFF, cy = (r >> 16) & 0xFFFF; if (cx > 0 && cy > 0) return cx + "," + cy; }
|
||||
int sx, sy; SystemParametersInfo(0x000D, 0, out sx, 0); SystemParametersInfo(0x0018, 0, out sy, 0);
|
||||
return sx + "," + sy;
|
||||
}
|
||||
}
|
||||
'@
|
||||
[WircGrid]::Get()
|
||||
""".trimIndent()
|
||||
val encoded = Base64.getEncoder().encodeToString(script.toByteArray(Charsets.UTF_16LE))
|
||||
val proc = ProcessBuilder("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
|
||||
.redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText()
|
||||
proc.waitFor(30, TimeUnit.SECONDS)
|
||||
val m = Regex("""(\d+),(\d+)""").findAll(out).lastOrNull() ?: return@Thread
|
||||
val px = m.groupValues[1].toFloat()
|
||||
val py = m.groupValues[2].toFloat()
|
||||
// Physical pixels at the primary screen's scaling -> the logical units windows use
|
||||
val scale = try {
|
||||
GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
|
||||
} catch (e: Exception) { 1f }.coerceAtLeast(0.5f)
|
||||
if (px > 8 && py > 8) cached = (px / scale) to (py / scale)
|
||||
} catch (e: Exception) {
|
||||
// No grid info: windows just don't snap to it
|
||||
}
|
||||
}.apply { isDaemon = true; name = "wIRC desktop grid" }.start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole lines that fit in [height] at [lineHeight], with a little slack for rounding. */
|
||||
internal fun wholeLines(height: Dp, lineHeight: Dp): Int =
|
||||
floor((height.value + 0.5f) / lineHeight.value).toInt().coerceAtLeast(1)
|
||||
Reference in New Issue
Block a user