Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bcd1a7bcd | ||
|
|
026e349db8 |
+2
-2
@@ -6,7 +6,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "com.tpuahsiew.wirc"
|
group = "com.tpuahsiew.wirc"
|
||||||
version = "0.1.10"
|
version = "0.1.12"
|
||||||
|
|
||||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||||
@@ -38,7 +38,7 @@ compose.desktop {
|
|||||||
appResourcesRootDir.set(project.file("installerResources"))
|
appResourcesRootDir.set(project.file("installerResources"))
|
||||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||||
packageName = "wIRC"
|
packageName = "wIRC"
|
||||||
packageVersion = "0.1.10"
|
packageVersion = "0.1.12"
|
||||||
windows {
|
windows {
|
||||||
menuGroup = "wIRC"
|
menuGroup = "wIRC"
|
||||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
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
|
// Loaded once here (not per-window) so the alias map is truly global - every
|
||||||
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
||||||
AliasRepository.init()
|
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 repo = remember { ServerRepository(appRoot) }
|
||||||
val prefsRepo = remember { WindowPrefsRepository(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.
|
// ("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 mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
|
||||||
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
|
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
|
// 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
|
// 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"),
|
icon = painterResource("icon.png"),
|
||||||
state = mainWindowState
|
state = mainWindowState
|
||||||
) {
|
) {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||||
Surface {
|
Surface {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
@@ -114,6 +120,7 @@ fun main() = application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
openBuffers.forEach { bufferKey ->
|
openBuffers.forEach { bufferKey ->
|
||||||
key(bufferKey) {
|
key(bufferKey) {
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import java.nio.file.Files
|
|||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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(
|
data class WindowDisplaySettings(
|
||||||
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
||||||
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
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 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) }
|
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
|
||||||
|
|
||||||
@@ -109,7 +116,8 @@ class WindowPrefsRepository(rootDir: File) {
|
|||||||
stored[key] = WindowDisplaySettings(
|
stored[key] = WindowDisplaySettings(
|
||||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||||
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
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"),
|
fontId = o.optString("fontId", "monospace"),
|
||||||
showUserList = o.optBoolean("showUserList", true),
|
showUserList = o.optBoolean("showUserList", true),
|
||||||
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ import androidx.compose.ui.window.rememberDialogState
|
|||||||
import data.ChannelBuffer
|
import data.ChannelBuffer
|
||||||
import data.ChannelUser
|
import data.ChannelUser
|
||||||
import data.IrcMessage
|
import data.IrcMessage
|
||||||
|
import data.MAX_FONT_SIZE
|
||||||
|
import data.MIN_FONT_SIZE
|
||||||
import data.MessageKind
|
import data.MessageKind
|
||||||
import data.WindowDisplaySettings
|
import data.WindowDisplaySettings
|
||||||
import irc.extractImageUrls
|
import irc.extractImageUrls
|
||||||
@@ -102,6 +104,7 @@ import java.net.URL
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
|
// 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
|
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
|
||||||
@@ -417,7 +420,9 @@ fun WindowScope.ChannelScreen(
|
|||||||
Surface {
|
Surface {
|
||||||
WindowSettingsPanel(
|
WindowSettingsPanel(
|
||||||
displaySettings = displaySettings,
|
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,
|
onAlphaChange = onAlphaChange,
|
||||||
onTextColorChange = onTextColorChange,
|
onTextColorChange = onTextColorChange,
|
||||||
onFontSizeChange = onFontSizeChange,
|
onFontSizeChange = onFontSizeChange,
|
||||||
@@ -859,10 +864,14 @@ fun MessageScrollback(
|
|||||||
// real cause was a race between the scroll effect and the LazyColumn's
|
// 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.
|
// own layout catching up to new items - see the scroll effects below.
|
||||||
val snappedHeight = if (lineHeightDp > 0.dp) {
|
val snappedHeight = if (lineHeightDp > 0.dp) {
|
||||||
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
|
minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
|
||||||
} else {
|
} else {
|
||||||
maxHeight
|
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
|
// 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
|
// visible item on every resize/font-size change, to fix a partial line
|
||||||
@@ -1031,7 +1040,10 @@ fun MessageScrollback(
|
|||||||
) { msg ->
|
) { msg ->
|
||||||
val ts = timeFormat.format(Date(msg.timestampMillis))
|
val ts = timeFormat.format(Date(msg.timestampMillis))
|
||||||
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
|
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
|
||||||
val mentioned = isMention(msg.rawText)
|
// Only real messages get the mention tint. Join/part/quit/nick/mode lines
|
||||||
|
// always contain your nick when they're about you ("you joined #chan"),
|
||||||
|
// which used to light up every one of your own joins.
|
||||||
|
val mentioned = msg.kind in MENTION_KINDS && isMention(msg.rawText)
|
||||||
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
||||||
val mutedColor = textColor.copy(alpha = 0.7f)
|
val mutedColor = textColor.copy(alpha = 0.7f)
|
||||||
val prefix = rowPrefix(msg, ts, mutedColor, nickColor)
|
val prefix = rowPrefix(msg, ts, mutedColor, nickColor)
|
||||||
@@ -1399,6 +1411,8 @@ private fun MentionColorPickerDialog(currentArgb: Int, onDismiss: () -> Unit, on
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MENTION_KINDS = setOf(MessageKind.CHAT, MessageKind.ACTION, MessageKind.NOTICE)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WindowSettingsPanel(
|
private fun WindowSettingsPanel(
|
||||||
displaySettings: WindowDisplaySettings,
|
displaySettings: WindowDisplaySettings,
|
||||||
@@ -1436,13 +1450,24 @@ private fun WindowSettingsPanel(
|
|||||||
colors = sliderColors
|
colors = sliderColors
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
|
Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
Slider(
|
// Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
|
||||||
value = displaySettings.fontSize,
|
val currentSize = displaySettings.fontSize.roundToInt()
|
||||||
onValueChange = onFontSizeChange,
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
valueRange = 10f..24f,
|
for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
|
||||||
colors = sliderColors
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
FontPickerList(displaySettings.fontId, textColor, onFontChange)
|
FontPickerList(displaySettings.fontId, textColor, onFontChange)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ui
|
|||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
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)
|
// Restored from window_prefs.json (or cascaded if there's no usable saved position)
|
||||||
// and saved back on move/resize/close - see WindowPlacement.kt.
|
// and saved back on move/resize/close - see WindowPlacement.kt.
|
||||||
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
|
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
|
// undecorated + transparent trade the native OS title bar for real per-pixel window
|
||||||
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
||||||
@@ -89,6 +93,7 @@ fun ChannelWindow(
|
|||||||
transparent = true,
|
transparent = true,
|
||||||
resizable = true
|
resizable = true
|
||||||
) {
|
) {
|
||||||
|
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||||
MaterialTheme(colorScheme = colorScheme) {
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
ChannelScreen(
|
ChannelScreen(
|
||||||
windowState = windowState,
|
windowState = windowState,
|
||||||
@@ -200,3 +205,4 @@ fun ChannelWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,9 +205,9 @@ fun MainScreen(
|
|||||||
MaterialTheme(colorScheme = colorScheme) {
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
Surface {
|
Surface {
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) {
|
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))
|
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