diff --git a/CLAUDE.md b/CLAUDE.md index 6191618c..1046938c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,21 @@ pkill -9 -f "gradle" # Kill stuck gradle ``` +## JVM Requirements (Java 16+) + +For full functionality on Java 16+, add these JVM arguments: + +``` +--add-opens java.desktop/java.awt=ALL-UNNAMED # Windows: HWND access for global hotkeys +--add-opens java.desktop/sun.awt.X11=ALL-UNNAMED # Linux: WM_CLASS for desktop integration +``` + +Without these flags: +- **Windows**: Global hotkey window toggle falls back to standard show/hide +- **Linux**: Desktop integration (taskbar grouping) may not work correctly + +Add to IDE run configurations or gradle.properties for development. + ## Git Workflow ```bash diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index 63cacda1..ad885656 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.sp import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* @@ -22,7 +26,10 @@ import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import ai.rever.bossterm.compose.update.UpdateBanner import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar +import ai.rever.bossterm.compose.window.GlobalHotKeyManager +import ai.rever.bossterm.compose.window.HotKeyConfig import ai.rever.bossterm.compose.window.WindowManager +import ai.rever.bossterm.compose.window.WindowVisibilityController import ai.rever.bossterm.compose.window.configureWindowTransparency import androidx.compose.foundation.background import androidx.compose.foundation.shape.RoundedCornerShape @@ -58,8 +65,14 @@ fun main() { WindowManager.createWindow() } + // Start global hotkey manager after initial window creation + // Use LaunchedEffect to run only once + LaunchedEffect(Unit) { + startGlobalHotKeyManager() + } + // Detect platform - val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + val isMacOS = ShellCustomizationUtils.isMacOS() // Render all windows for (window in WindowManager.windows) { @@ -163,6 +176,9 @@ fun main() { // Set initial focus state window.isWindowFocused.value = awtWindow.isFocused + // Store AWT window reference for global hotkey toggle + window.awtWindow = awtWindow + // Configure window transparency and blur (only for custom title bar mode) if (!useNativeTitleBar) { configureWindowTransparency( @@ -174,6 +190,7 @@ fun main() { onDispose { awtWindow.removeWindowFocusListener(focusListener) + window.awtWindow = null } } @@ -520,6 +537,23 @@ fun main() { shape = RoundedCornerShape(cornerRadius) ) { Box(modifier = Modifier.fillMaxSize()) { + // Compute global hotkey hint (used for both title bar modes) + val globalHotkeyHint = remember( + windowSettings.globalHotkeyEnabled, + windowSettings.globalHotkeyCtrl, + windowSettings.globalHotkeyAlt, + windowSettings.globalHotkeyShift, + windowSettings.globalHotkeyWin, + window.windowNumber + ) { + if (windowSettings.globalHotkeyEnabled && window.windowNumber in 1..9) { + val config = HotKeyConfig.fromSettings(windowSettings) + config.toWindowDisplayString(window.windowNumber, useMacSymbols = isMacOS) + } else { + null + } + } + // Background layer: either image or glass blur effect if (backgroundImage != null) { // Background image with blur @@ -585,7 +619,8 @@ fun main() { }, backgroundColor = windowSettings.defaultBackgroundColor.copy( alpha = (windowSettings.backgroundOpacity * 1.1f).coerceAtMost(1f) - ) + ), + globalHotkeyHint = globalHotkeyHint ) } @@ -633,6 +668,23 @@ fun main() { modifier = Modifier.fillMaxSize().weight(1f) ) } + + // Hotkey hint overlay (top-right corner, like iTerm2) + // Shows for native title bar; custom title bar shows it in the title bar itself + if (useNativeTitleBar && globalHotkeyHint != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 12.dp) + ) { + Text( + text = globalHotkeyHint, + color = Color.White.copy(alpha = 0.5f), + fontSize = 11.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Normal + ) + } + } } } @@ -686,13 +738,33 @@ fun main() { } } +/** + * Load BossTerm settings with error handling. + * Returns null if loading fails for optional features, default settings for critical features. + * + * @param context Description of what the settings are being loaded for + * @param allowNull If true, returns null on error; if false, returns default settings + */ +private fun loadSettings( + context: String = "general", + allowNull: Boolean = false +): ai.rever.bossterm.compose.settings.TerminalSettings? { + return try { + ai.rever.bossterm.compose.settings.SettingsLoader.loadFromPathOrDefault(null) + } catch (e: Exception) { + System.err.println("Could not load settings for $context: ${e.message}") + if (!allowNull) e.printStackTrace() + if (allowNull) null else ai.rever.bossterm.compose.settings.TerminalSettings() + } +} + /** * Set WM_CLASS for proper Linux desktop integration. * Must be called before any windows are created. * Requires JVM arg: --add-opens java.desktop/sun.awt.X11=ALL-UNNAMED */ private fun setLinuxWMClass() { - if (!System.getProperty("os.name").lowercase().contains("linux")) return + if (!ShellCustomizationUtils.isLinux()) return try { // Get toolkit instance (creates it if needed) @@ -718,18 +790,11 @@ private fun setLinuxWMClass() { * - GPU resource cache size */ private fun configureGpuRendering() { - val osName = System.getProperty("os.name").lowercase() - val isMacOS = osName.contains("mac") - val isWindows = osName.contains("windows") + val isMacOS = ShellCustomizationUtils.isMacOS() + val isWindows = ShellCustomizationUtils.isWindows() - // Load settings using SettingsLoader (handles JSON parsing and defaults) - val settings = try { - ai.rever.bossterm.compose.settings.SettingsLoader.loadFromPathOrDefault(null) - } catch (e: Exception) { - System.err.println("Could not load settings for GPU config, using defaults: ${e.message}") - e.printStackTrace() - ai.rever.bossterm.compose.settings.TerminalSettings() - } + // Load settings using helper function (returns defaults on failure) + val settings = loadSettings("GPU config", allowNull = false)!! // Configure render API val renderApi = if (!settings.gpuAcceleration) { @@ -784,3 +849,72 @@ private fun configureGpuRendering() { println("GPU: Acceleration=${settings.gpuAcceleration}, API=${settings.gpuRenderApi}, " + "Priority=${settings.gpuPriority}, VSync=${settings.gpuVsyncEnabled}, Cache=${settings.gpuCacheSizeMb}MB") } + +/** + * Start the global hotkey manager (Windows, macOS, Linux). + * Allows summoning specific BossTerm windows from anywhere with system-wide hotkeys. + * Each window gets a unique hotkey: Modifiers+1, Modifiers+2, etc. + */ +private fun startGlobalHotKeyManager() { + // Load settings (returns null on error, skip initialization in that case) + val settings = loadSettings("global hotkey", allowNull = true) ?: return + + // Check if enabled + val config = HotKeyConfig.fromSettings(settings) + if (!config.enabled) { + println("GlobalHotKey: Disabled in settings") + return + } + + // Validate configuration (need at least one modifier) + if (!(config.ctrl || config.alt || config.shift || config.win)) { + println("GlobalHotKey: Invalid configuration (no modifiers)") + return + } + + // Set up window lifecycle callbacks for hotkey registration + WindowManager.onWindowCreated = { window -> + // Validate window number is in valid range before registering + if (window.windowNumber in 1..9) { + GlobalHotKeyManager.registerWindow(window.windowNumber) + } + } + WindowManager.onWindowClosed = { window -> + // Validate window number is in valid range before unregistering + if (window.windowNumber in 1..9) { + GlobalHotKeyManager.unregisterWindow(window.windowNumber) + } + } + + // Start the manager with window-specific callback + GlobalHotKeyManager.start(config) { windowNumber -> + // Find the window with this number + val window = WindowManager.getWindowByNumber(windowNumber) + if (window != null) { + // Window exists - toggle its visibility + val awtWindow = window.awtWindow + if (awtWindow != null) { + WindowVisibilityController.toggleWindow(listOf(awtWindow)) + } + } else { + // No window with this number - create one if it's window 1 + if (windowNumber == 1) { + javax.swing.SwingUtilities.invokeLater { + WindowManager.createWindow() + } + } + } + } + + // Register existing windows (in case any were created before hotkey manager started) + WindowManager.windows.forEach { window -> + GlobalHotKeyManager.registerWindow(window.windowNumber) + } + + // Register shutdown hook to clean up + Runtime.getRuntime().addShutdownHook(Thread { + GlobalHotKeyManager.stop() + }) + + println("GlobalHotKey: Started with modifiers for windows 1-9") +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsCategory.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsCategory.kt index 7336118a..dd8130a3 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsCategory.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsCategory.kt @@ -83,6 +83,11 @@ enum class SettingsCategory( icon = Icons.Default.Face, description = "AI coding assistant integration" ), + GLOBAL_HOTKEY( + displayName = "Global Hotkey", + icon = Icons.Default.KeyboardArrowUp, + description = "System-wide hotkey to summon BossTerm" + ), ABOUT( displayName = "About", icon = Icons.Default.Favorite, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsManager.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsManager.kt index fe71b2a0..9ce93951 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsManager.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsManager.kt @@ -93,7 +93,9 @@ class SettingsManager(private val customSettingsPath: String? = null) { } /** - * Load settings from file + * Load settings from file. + * After loading, re-saves to ensure any new fields (added in updates) are persisted + * with their default values. This provides automatic settings migration. */ fun loadFromFile() { try { @@ -102,6 +104,10 @@ class SettingsManager(private val customSettingsPath: String? = null) { val loadedSettings = json.decodeFromString(jsonString) _settings.value = loadedSettings println("Settings loaded from: ${settingsFile.absolutePath}") + + // Re-save to migrate settings file with any new fields added in updates + // This ensures new settings (like globalHotkey*) are written with defaults + saveToFile() } else { println("No settings file found, using defaults") // Save defaults on first run diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsPanel.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsPanel.kt index 5ef3cbf1..18622108 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsPanel.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/SettingsPanel.kt @@ -342,6 +342,11 @@ private fun SettingsContent( onSettingsChange = onSettingsChange, onSettingsSave = onSettingsSave ) + SettingsCategory.GLOBAL_HOTKEY -> GlobalHotkeySection( + settings = settings, + onSettingsChange = onSettingsChange, + onSettingsSave = onSettingsSave + ) SettingsCategory.ABOUT -> AboutSection() } } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt index cfb4b096..13447acd 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt @@ -647,6 +647,42 @@ data class TerminalSettings( */ val onboardingCompleted: Boolean = false, + // ===== Global Hotkey Settings ===== + + /** + * Enable global hotkey to summon BossTerm from anywhere. + * Default: Disabled on macOS and Linux (opt-in due to desktop environment conflicts). + * Enabled on Windows. + * Hotkey: Configured via modifiers + 1-9 for window-specific summoning. + */ + val globalHotkeyEnabled: Boolean = ShellCustomizationUtils.isWindows(), + + /** + * Ctrl modifier for global hotkey. + */ + val globalHotkeyCtrl: Boolean = true, + + /** + * Alt modifier for global hotkey. + */ + val globalHotkeyAlt: Boolean = false, + + /** + * Shift modifier for global hotkey. + */ + val globalHotkeyShift: Boolean = false, + + /** + * Windows key modifier for global hotkey. + */ + val globalHotkeyWin: Boolean = false, + + /** + * Key for global hotkey. + * Valid values: "GRAVE" (`), "SPACE", "ESCAPE", A-Z, 0-9, F1-F12 + */ + val globalHotkeyKey: String = "GRAVE", + /** * Automatically inject shell integration (OSC 133) into new terminal sessions. * When enabled, BossTerm hijacks shell environment variables (ZDOTDIR for zsh, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/GlobalHotkeySection.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/GlobalHotkeySection.kt new file mode 100644 index 00000000..1e0f959b --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/GlobalHotkeySection.kt @@ -0,0 +1,255 @@ +package ai.rever.bossterm.compose.settings.sections + +import ai.rever.bossterm.compose.settings.SettingsTheme.AccentColor +import ai.rever.bossterm.compose.settings.SettingsTheme.SurfaceColor +import ai.rever.bossterm.compose.settings.SettingsTheme.TextMuted +import ai.rever.bossterm.compose.settings.SettingsTheme.TextPrimary +import ai.rever.bossterm.compose.settings.TerminalSettings +import ai.rever.bossterm.compose.settings.components.SettingsSection +import ai.rever.bossterm.compose.settings.components.SettingsToggle +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import ai.rever.bossterm.compose.window.GlobalHotKeyManager +import ai.rever.bossterm.compose.window.HotKeyConfig +import ai.rever.bossterm.compose.window.HotKeyRegistrationStatus +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Global hotkey settings section. + * Allows configuring system-wide hotkeys to summon specific BossTerm windows. + * Each window gets a unique hotkey: Modifiers+1, Modifiers+2, etc. + */ +@Composable +fun GlobalHotkeySection( + settings: TerminalSettings, + onSettingsChange: (TerminalSettings) -> Unit, + onSettingsSave: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + val registrationStatus by GlobalHotKeyManager.registrationStatus.collectAsState() + val currentConfig = remember(settings) { HotKeyConfig.fromSettings(settings) } + val isMacOS = remember { ShellCustomizationUtils.isMacOS() } + + Column(modifier = modifier) { + SettingsSection(title = "Global Hotkey") { + SettingsToggle( + label = "Enable Global Hotkeys", + checked = settings.globalHotkeyEnabled, + onCheckedChange = { onSettingsChange(settings.copy(globalHotkeyEnabled = it)) }, + description = "Press modifiers+number to summon specific windows" + ) + + // Show current hotkey display + if (settings.globalHotkeyEnabled) { + CurrentHotkeyDisplay( + config = currentConfig, + status = registrationStatus, + isMacOS = isMacOS + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + SettingsSection(title = "Modifier Keys") { + SettingsToggle( + label = if (isMacOS) "Control (⌃)" else "Ctrl", + checked = settings.globalHotkeyCtrl, + onCheckedChange = { onSettingsChange(settings.copy(globalHotkeyCtrl = it)) }, + description = "Include Control modifier", + enabled = settings.globalHotkeyEnabled + ) + + SettingsToggle( + label = if (isMacOS) "Option (⌥)" else "Alt", + checked = settings.globalHotkeyAlt, + onCheckedChange = { onSettingsChange(settings.copy(globalHotkeyAlt = it)) }, + description = if (isMacOS) "Include Option modifier" else "Include Alt modifier", + enabled = settings.globalHotkeyEnabled + ) + + SettingsToggle( + label = if (isMacOS) "Shift (⇧)" else "Shift", + checked = settings.globalHotkeyShift, + onCheckedChange = { onSettingsChange(settings.copy(globalHotkeyShift = it)) }, + description = "Include Shift modifier", + enabled = settings.globalHotkeyEnabled + ) + + SettingsToggle( + label = if (isMacOS) "Command (⌘)" else "Win", + checked = settings.globalHotkeyWin, + onCheckedChange = { onSettingsChange(settings.copy(globalHotkeyWin = it)) }, + description = if (isMacOS) "Include Command modifier" else "Include Windows key modifier", + enabled = settings.globalHotkeyEnabled + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // How it works section + HowItWorksSection(config = currentConfig, isMacOS = isMacOS) + + Spacer(modifier = Modifier.height(24.dp)) + + // Restart notice + RestartNotice() + } +} + +/** + * Display current hotkey configuration and registration status. + */ +@Composable +private fun CurrentHotkeyDisplay( + config: HotKeyConfig, + status: HotKeyRegistrationStatus, + isMacOS: Boolean +) { + val hasModifiers = config.ctrl || config.alt || config.shift || config.win + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(SurfaceColor) + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Window Hotkeys", + color = TextPrimary, + fontSize = 13.sp + ) + Text( + text = if (hasModifiers) { + // Show example: "⌃⌥1, ⌃⌥2, ⌃⌥3..." or "Ctrl+Alt+1, Ctrl+Alt+2..." + val example1 = config.toWindowDisplayString(1, isMacOS) + val example2 = config.toWindowDisplayString(2, isMacOS) + val example3 = config.toWindowDisplayString(3, isMacOS) + "$example1, $example2, $example3..." + } else { + "Select at least one modifier" + }, + color = if (hasModifiers) AccentColor else Color(0xFFE04040), + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 2.dp) + ) + } + + // Status indicator + StatusIndicator(status = status) + } +} + +/** + * Explains how window-specific hotkeys work. + */ +@Composable +private fun HowItWorksSection(config: HotKeyConfig, isMacOS: Boolean) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(SurfaceColor) + .padding(12.dp) + ) { + Text( + text = "How It Works", + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = buildString { + append("Each BossTerm window gets a unique number (1-9).\n") + append("Press your chosen modifiers + the window number to summon that window.\n\n") + append("Examples:\n") + val ex1 = config.toWindowDisplayString(1, isMacOS).ifEmpty { "Modifiers+1" } + val ex2 = config.toWindowDisplayString(2, isMacOS).ifEmpty { "Modifiers+2" } + append(" $ex1 - Focus/show window 1\n") + append(" $ex2 - Focus/show window 2") + }, + color = TextMuted, + fontSize = 12.sp, + lineHeight = 18.sp + ) + } +} + +/** + * Status indicator showing registration status. + */ +@Composable +private fun StatusIndicator(status: HotKeyRegistrationStatus) { + val (color, text) = when (status) { + HotKeyRegistrationStatus.INACTIVE -> Color.Gray to "Inactive" + HotKeyRegistrationStatus.REGISTERED -> Color(0xFF28C941) to "Active" + HotKeyRegistrationStatus.FAILED -> Color(0xFFE04040) to "Conflict" + HotKeyRegistrationStatus.UNAVAILABLE -> Color.Gray to "N/A" + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) + Text( + text = text, + color = TextMuted, + fontSize = 12.sp + ) + } +} + +/** + * Notice that changes require restart. + */ +@Composable +private fun RestartNotice() { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF3A3A3A)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Note: Changes to global hotkey settings take effect after restarting BossTerm.", + color = TextMuted, + fontSize = 12.sp + ) + } +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/splits/SplitDivider.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/splits/SplitDivider.kt index 214cd953..d7ccf3fd 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/splits/SplitDivider.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/splits/SplitDivider.kt @@ -12,13 +12,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.awtEventOrNull import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -36,32 +34,18 @@ private val DRAG_AREA_SIZE = 16.dp /** * Modifier extension for horizontal resize cursor (↔️). - * Uses direct AWT cursor manipulation for reliable cross-platform support. + * Uses Compose's declarative pointerHoverIcon API for reliable hover state management. */ -@OptIn(ExperimentalComposeUiApi::class) private fun Modifier.cursorForHorizontalResize(): Modifier { - return this - .onPointerEvent(PointerEventType.Enter) { pointerEvent -> - pointerEvent.awtEventOrNull?.component?.cursor = Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR) - } - .onPointerEvent(PointerEventType.Exit) { pointerEvent -> - pointerEvent.awtEventOrNull?.component?.cursor = Cursor.getDefaultCursor() - } + return this.pointerHoverIcon(PointerIcon(Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR))) } /** * Modifier extension for vertical resize cursor (↕️). - * Uses direct AWT cursor manipulation for reliable cross-platform support. + * Uses Compose's declarative pointerHoverIcon API for reliable hover state management. */ -@OptIn(ExperimentalComposeUiApi::class) private fun Modifier.cursorForVerticalResize(): Modifier { - return this - .onPointerEvent(PointerEventType.Enter) { pointerEvent -> - pointerEvent.awtEventOrNull?.component?.cursor = Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR) - } - .onPointerEvent(PointerEventType.Exit) { pointerEvent -> - pointerEvent.awtEventOrNull?.component?.cursor = Cursor.getDefaultCursor() - } + return this.pointerHoverIcon(PointerIcon(Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR))) } /** diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/GlobalHotKeyManager.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/GlobalHotKeyManager.kt new file mode 100644 index 00000000..7b5a210e --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/GlobalHotKeyManager.kt @@ -0,0 +1,755 @@ +package ai.rever.bossterm.compose.window + +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.platform.win32.WinDef.LPARAM +import com.sun.jna.platform.win32.WinDef.WPARAM +import com.sun.jna.platform.win32.WinUser.MSG +import com.sun.jna.ptr.PointerByReference +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.awt.event.KeyEvent +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +/** + * Registration status for the global hotkey. + */ +enum class HotKeyRegistrationStatus { + /** Not started or disabled */ + INACTIVE, + /** Hotkeys registered successfully */ + REGISTERED, + /** Failed to register (likely hotkey conflict) */ + FAILED, + /** Native API not available on this platform */ + UNAVAILABLE +} + +/** + * Singleton manager for window-specific global hotkey registration. + * Supports Windows, macOS, and Linux. + * + * Registers hotkeys for windows 1-9 (e.g., Ctrl+Alt+1, Ctrl+Alt+2, etc.) + */ +object GlobalHotKeyManager { + private const val HOTKEY_SIGNATURE = 0x424F5353 // 'BOSS' + + // Linux registration success threshold: if less than this fraction succeed, + // treat as overall failure. 50% threshold chosen because: + // - Allows 4-5 windows to work even if some hotkeys conflict + // - Provides clear feedback that something is wrong (not just 1-2 conflicts) + // - Matches user expectation that "most" should work for success + private const val LINUX_SUCCESS_THRESHOLD = 0.5 + + private var handlerThread: Thread? = null + @Volatile private var isRunning = false + private var baseConfig: HotKeyConfig? = null + private var onWindowHotKeyPressed: ((Int) -> Unit)? = null + private var initializationLatch: CountDownLatch? = null + + // Platform-specific state + private var winThreadId: Int = 0 + private val macHotKeyRefs: MutableMap = Collections.synchronizedMap(mutableMapOf()) + private var macEventHandlerRef: Pointer? = null + private var macEventHandler: EventHandlerUPP? = null // Keep reference to prevent GC + private var macRunLoopMode: Pointer? = null + private var linuxDisplay: Pointer? = null + private val linuxKeycodes: MutableMap = Collections.synchronizedMap(mutableMapOf()) + + // Track which window numbers have registered hotkeys (accessed from multiple threads) + private val registeredWindows: MutableSet = Collections.synchronizedSet(mutableSetOf()) + + private val _registrationStatus = MutableStateFlow(HotKeyRegistrationStatus.INACTIVE) + val registrationStatus: StateFlow = _registrationStatus.asStateFlow() + + /** + * Start the global hotkey manager with the given base configuration. + * Will register hotkeys for all existing windows and listen for new ones. + * + * @param config Base hotkey configuration (modifiers only, key is ignored - numbers 1-9 are used) + * @param onWindowHotKeyPressed Callback with window number (1-9) when hotkey is pressed + */ + @Synchronized + fun start(config: HotKeyConfig, onWindowHotKeyPressed: (Int) -> Unit) { + if (!config.enabled || !(config.ctrl || config.alt || config.shift || config.win)) { + println("GlobalHotKeyManager: Invalid or disabled configuration") + _registrationStatus.value = HotKeyRegistrationStatus.INACTIVE + return + } + + // Stop existing manager if running + if (isRunning) { + stop() + } + + this.baseConfig = config + this.onWindowHotKeyPressed = onWindowHotKeyPressed + this.initializationLatch = CountDownLatch(1) + + // Start platform-specific handler + isRunning = true + handlerThread = Thread({ + when { + ShellCustomizationUtils.isWindows() -> runWindowsHandler(config) + ShellCustomizationUtils.isMacOS() -> runMacOSHandler(config) + ShellCustomizationUtils.isLinux() -> runLinuxHandler(config) + else -> { + println("GlobalHotKeyManager: Unsupported platform") + _registrationStatus.value = HotKeyRegistrationStatus.UNAVAILABLE + } + } + }, "GlobalHotKeyManager-Handler").apply { + isDaemon = true + start() + } + + println("GlobalHotKeyManager: Started with base modifiers") + } + + /** + * Register hotkey for a specific window number. + * Called when a new window is created. + */ + @Synchronized + fun registerWindow(windowNumber: Int) { + if (windowNumber < 1 || windowNumber > 9) return + if (!isRunning) return + if (windowNumber in registeredWindows) return + + // Wait for platform-specific initialization to complete + val initialized = initializationLatch?.await(5, TimeUnit.SECONDS) ?: false + if (!initialized) { + println("GlobalHotKeyManager: Initialization timeout, cannot register window $windowNumber") + return + } + + val config = baseConfig ?: return + + when { + ShellCustomizationUtils.isWindows() -> registerWindowsHotKey(windowNumber, config) + ShellCustomizationUtils.isMacOS() -> registerMacOSHotKey(windowNumber, config) + ShellCustomizationUtils.isLinux() -> registerLinuxHotKey(windowNumber, config) + } + + registeredWindows.add(windowNumber) + println("GlobalHotKeyManager: Registered hotkey for window $windowNumber") + } + + /** + * Unregister hotkey for a specific window number. + * Called when a window is closed. + */ + @Synchronized + fun unregisterWindow(windowNumber: Int) { + if (windowNumber < 1 || windowNumber > 9) return + if (windowNumber !in registeredWindows) return + + when { + ShellCustomizationUtils.isWindows() -> unregisterWindowsHotKey(windowNumber) + ShellCustomizationUtils.isMacOS() -> unregisterMacOSHotKey(windowNumber) + ShellCustomizationUtils.isLinux() -> unregisterLinuxHotKey(windowNumber) + } + + registeredWindows.remove(windowNumber) + println("GlobalHotKeyManager: Unregistered hotkey for window $windowNumber") + } + + /** + * Stop the global hotkey manager. + */ + @Synchronized + fun stop() { + if (!isRunning) return + + // Save caller's interrupt status to restore it later + val wasInterrupted = Thread.interrupted() // Returns and clears interrupt status + + try { + isRunning = false + + when { + ShellCustomizationUtils.isWindows() -> stopWindows() + ShellCustomizationUtils.isMacOS() -> stopMacOS() + ShellCustomizationUtils.isLinux() -> stopLinux() + } + + handlerThread?.let { thread -> + try { + // Wait up to 2 seconds for graceful shutdown + thread.join(2000) + if (thread.isAlive) { + // Interrupt if still running + thread.interrupt() + // Give it another second to cleanup after interrupt + thread.join(1000) + } + } catch (e: InterruptedException) { + // Handler thread join was interrupted - restore status and continue cleanup + Thread.currentThread().interrupt() + } + } + + handlerThread = null + baseConfig = null + onWindowHotKeyPressed = null + initializationLatch = null + // Note: macEventHandler, macEventHandlerRef, macRunLoopMode are cleared by cleanupMacOS() + // which runs in the handler thread's finally block. Don't clear them here to avoid + // race condition where CFString leaks if we null the reference before cleanup runs. + registeredWindows.clear() + _registrationStatus.value = HotKeyRegistrationStatus.INACTIVE + + println("GlobalHotKeyManager: Stopped") + } finally { + // Restore caller's original interrupt status + if (wasInterrupted) { + Thread.currentThread().interrupt() + } + } + } + + /** + * Check if the global hotkey feature is available on this platform. + */ + fun isAvailable(): Boolean { + return when { + ShellCustomizationUtils.isWindows() -> Win32HotKeyApi.INSTANCE != null + ShellCustomizationUtils.isMacOS() -> MacOSHotKeyApi.INSTANCE != null + ShellCustomizationUtils.isLinux() -> LinuxHotKeyApi.INSTANCE != null + else -> false + } + } + + // ===== Windows Implementation ===== + + private fun runWindowsHandler(config: HotKeyConfig) { + val api = Win32HotKeyApi.INSTANCE + if (api == null) { + println("GlobalHotKeyManager: Win32 API not available") + _registrationStatus.value = HotKeyRegistrationStatus.UNAVAILABLE + initializationLatch?.countDown() + return + } + + winThreadId = api.GetCurrentThreadId() + if (winThreadId == 0) { + println("GlobalHotKeyManager: Failed to get thread ID") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + + // Register hotkeys for windows 1-9 + val modifiers = config.toWin32Modifiers() + var anyRegistered = false + + for (windowNum in 1..9) { + val vk = KeyEvent.VK_0 + windowNum // VK_1 through VK_9 + val registered = try { + api.RegisterHotKey(null, windowNum, modifiers, vk) + } catch (e: Exception) { + false + } + if (registered) { + registeredWindows.add(windowNum) + anyRegistered = true + } + } + + if (!anyRegistered) { + println("GlobalHotKeyManager: Failed to register any Windows hotkeys") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + + println("GlobalHotKeyManager: Registered Windows hotkeys for windows 1-9") + _registrationStatus.value = HotKeyRegistrationStatus.REGISTERED + + // Signal that initialization is complete + initializationLatch?.countDown() + + val msg = MSG() + try { + while (isRunning && !Thread.currentThread().isInterrupted) { + val result = api.GetMessage(msg, null, 0, 0) + if (result == 0 || result == -1) break + + if (msg.message == Win32HotKeyApi.WM_HOTKEY) { + val windowNum = msg.wParam.toInt() + if (windowNum in 1..9) { + invokeCallback(windowNum) + } + } + } + } catch (e: Exception) { + println("GlobalHotKeyManager: Windows message pump error: ${e.message}") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + } finally { + // Unregister all hotkeys + for (windowNum in 1..9) { + try { + api.UnregisterHotKey(null, windowNum) + } catch (e: Exception) { + // Ignore + } + } + // Note: winThreadId is cleared in stopWindows() after posting WM_QUIT + registeredWindows.clear() + _registrationStatus.value = HotKeyRegistrationStatus.INACTIVE + } + } + + private fun registerWindowsHotKey(windowNumber: Int, config: HotKeyConfig) { + // Already registered in runWindowsHandler for all 1-9 + } + + private fun unregisterWindowsHotKey(windowNumber: Int) { + // We keep all hotkeys registered, just ignore callbacks for closed windows + } + + private fun stopWindows() { + val api = Win32HotKeyApi.INSTANCE ?: return + val threadId = winThreadId + if (threadId != 0) { + winThreadId = 0 // Reset immediately to prevent duplicate WM_QUIT on re-entry + try { + api.PostThreadMessage(threadId, Win32HotKeyApi.WM_QUIT, WPARAM(0), LPARAM(0)) + } catch (e: Exception) { + // Ignore + } + } + } + + // ===== macOS Implementation ===== + + private fun runMacOSHandler(config: HotKeyConfig) { + val carbonApi = MacOSHotKeyApi.INSTANCE + val cfApi = CoreFoundationApi.INSTANCE + + if (carbonApi == null) { + println("GlobalHotKeyManager: Carbon API not available") + _registrationStatus.value = HotKeyRegistrationStatus.UNAVAILABLE + initializationLatch?.countDown() + return + } + + if (cfApi == null) { + println("GlobalHotKeyManager: CoreFoundation API not available") + _registrationStatus.value = HotKeyRegistrationStatus.UNAVAILABLE + initializationLatch?.countDown() + return + } + + try { + // Use GetEventDispatcherTarget() for truly global hotkeys (like iTerm2) + // This works even when the app is not focused + val target = carbonApi.GetEventDispatcherTarget() + if (target == null) { + println("GlobalHotKeyManager: Failed to get event dispatcher target") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + + // Create the event handler callback + // IMPORTANT: Keep a reference to prevent garbage collection + macEventHandler = object : EventHandlerUPP { + override fun invoke(nextHandler: Pointer?, event: Pointer?, userData: Pointer?): Int { + if (event == null) return MacOSHotKeyApi.eventNotHandledErr + + try { + // Extract the hotkey ID from the event + val hotKeyID = EventHotKeyID.ByReference() + val result = carbonApi.GetEventParameter( + event, + MacOSHotKeyApi.kEventParamDirectObject, + MacOSHotKeyApi.typeEventHotKeyID, + null, + hotKeyID.size(), + null, + hotKeyID.pointer + ) + + if (result == MacOSHotKeyApi.noErr) { + hotKeyID.read() + val windowNum = hotKeyID.id + if (windowNum in 1..9) { + invokeCallback(windowNum) + } + } + } catch (e: Exception) { + println("GlobalHotKeyManager: Error in macOS hotkey handler: ${e.message}") + } + + return MacOSHotKeyApi.noErr + } + } + + // Install the event handler for hotkey events + val eventType = EventTypeSpec.ByReference() + eventType.eventClass = MacOSHotKeyApi.kEventClassKeyboard + eventType.eventKind = MacOSHotKeyApi.kEventHotKeyPressed + eventType.write() + + val handlerRef = PointerByReference() + val installResult = carbonApi.InstallEventHandler( + target, + macEventHandler, + 1, + eventType.pointer, + null, + handlerRef + ) + + if (installResult != MacOSHotKeyApi.noErr) { + println("GlobalHotKeyManager: Failed to install event handler: $installResult") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + macEventHandlerRef = handlerRef.value + + // Register hotkeys for windows 1-9 + val modifiers = MacOSHotKeyApi.configToModifiers(config) + var anyRegistered = false + + for (windowNum in 1..9) { + val keyCode = getMacKeyCodeForNumber(windowNum) + val hotKeyID = EventHotKeyID.ByReference() + hotKeyID.signature = HOTKEY_SIGNATURE + hotKeyID.id = windowNum + hotKeyID.write() + + val hotKeyRef = PointerByReference() + val result = carbonApi.RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + target, + 0, + hotKeyRef + ) + + if (result == MacOSHotKeyApi.noErr) { + macHotKeyRefs[windowNum] = hotKeyRef.value + registeredWindows.add(windowNum) + anyRegistered = true + } + } + + if (!anyRegistered) { + println("GlobalHotKeyManager: Failed to register any macOS hotkeys") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + + println("GlobalHotKeyManager: Registered macOS hotkeys for windows 1-9") + _registrationStatus.value = HotKeyRegistrationStatus.REGISTERED + + // Signal that initialization is complete + initializationLatch?.countDown() + + // Create the run loop mode string (kCFRunLoopDefaultMode) + // Use encoding 0x08000100 for kCFStringEncodingUTF8 + macRunLoopMode = cfApi.CFStringCreateWithCString(null, "kCFRunLoopDefaultMode", 0x08000100) + + // Process events using CFRunLoopRunInMode instead of RunApplicationEventLoop + // This allows us to periodically check if we should stop + while (isRunning && !Thread.currentThread().isInterrupted) { + // Run the run loop for a short time (100ms) + // This will process any pending events including hotkey events + cfApi.CFRunLoopRunInMode(macRunLoopMode, 0.1, false) + } + + } catch (e: Exception) { + println("GlobalHotKeyManager: macOS handler error: ${e.message}") + e.printStackTrace() + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + } finally { + cleanupMacOS() + } + } + + private fun getMacKeyCodeForNumber(num: Int): Int { + // macOS virtual key codes for number keys + return when (num) { + 1 -> MacOSHotKeyApi.kVK_ANSI_1 + 2 -> MacOSHotKeyApi.kVK_ANSI_2 + 3 -> MacOSHotKeyApi.kVK_ANSI_3 + 4 -> MacOSHotKeyApi.kVK_ANSI_4 + 5 -> MacOSHotKeyApi.kVK_ANSI_5 + 6 -> MacOSHotKeyApi.kVK_ANSI_6 + 7 -> MacOSHotKeyApi.kVK_ANSI_7 + 8 -> MacOSHotKeyApi.kVK_ANSI_8 + 9 -> MacOSHotKeyApi.kVK_ANSI_9 + else -> MacOSHotKeyApi.kVK_ANSI_1 + } + } + + private fun registerMacOSHotKey(windowNumber: Int, config: HotKeyConfig) { + // Already registered in runMacOSHandler for all 1-9 + } + + private fun unregisterMacOSHotKey(windowNumber: Int) { + // We keep all hotkeys registered + } + + private fun stopMacOS() { + // The event loop will exit when isRunning becomes false + // No need to call QuitApplicationEventLoop since we're using CFRunLoopRunInMode + } + + private fun cleanupMacOS() { + val carbonApi = MacOSHotKeyApi.INSTANCE + val cfApi = CoreFoundationApi.INSTANCE + + // Unregister all hotkeys + if (carbonApi != null) { + try { + for ((_, ref) in macHotKeyRefs) { + carbonApi.UnregisterEventHotKey(ref) + } + } catch (e: Exception) { + // Ignore + } + + // Remove the event handler + try { + macEventHandlerRef?.let { carbonApi.RemoveEventHandler(it) } + } catch (e: Exception) { + // Ignore + } + } + + // Release the run loop mode string + // Capture the pointer locally to avoid race with concurrent access + val runLoopModeToRelease = macRunLoopMode + macRunLoopMode = null // Clear reference first to prevent further use + + if (cfApi != null && runLoopModeToRelease != null) { + try { + cfApi.CFRelease(runLoopModeToRelease) + } catch (e: Exception) { + // Ignore + } + } + + macHotKeyRefs.clear() + macEventHandlerRef = null + macEventHandler = null + registeredWindows.clear() + _registrationStatus.value = HotKeyRegistrationStatus.INACTIVE + } + + // ===== Linux Implementation ===== + + private fun runLinuxHandler(config: HotKeyConfig) { + val api = LinuxHotKeyApi.INSTANCE + if (api == null) { + println("GlobalHotKeyManager: X11 API not available") + _registrationStatus.value = HotKeyRegistrationStatus.UNAVAILABLE + initializationLatch?.countDown() + return + } + + try { + val display = api.XOpenDisplay(null) + if (display == null) { + println("GlobalHotKeyManager: Failed to open X11 display") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + linuxDisplay = display + + val rootWindow = api.XDefaultRootWindow(display) + val modifiers = LinuxHotKeyApi.configToModifiers(config) + var anyRegistered = false + + // Modifier variants to handle Caps Lock and Num Lock + val modifierVariants = listOf( + modifiers, + modifiers or LinuxHotKeyApi.LockMask, + modifiers or LinuxHotKeyApi.Mod2Mask, + modifiers or LinuxHotKeyApi.LockMask or LinuxHotKeyApi.Mod2Mask + ) + + // Register hotkeys for windows 1-9 + val failedWindows = mutableListOf() + for (windowNum in 1..9) { + val keysym = LinuxHotKeyApi.XK_0 + windowNum + val keycode = api.XKeysymToKeycode(display, NativeLong(keysym.toLong())) + + if (keycode == 0) { + println("GlobalHotKeyManager: Failed to get keycode for window $windowNum") + failedWindows.add(windowNum) + continue + } + + linuxKeycodes[windowNum] = keycode + + try { + for (mods in modifierVariants) { + api.XGrabKey( + display, + keycode, + mods, + rootWindow, + 1, + LinuxHotKeyApi.GrabModeAsync, + LinuxHotKeyApi.GrabModeAsync + ) + } + // Sync to flush any errors + api.XSync(display, 0) + registeredWindows.add(windowNum) + anyRegistered = true + } catch (e: Exception) { + println("GlobalHotKeyManager: Failed to register hotkey for window $windowNum: ${e.message}") + println(" This may indicate a conflict with an existing system hotkey") + failedWindows.add(windowNum) + } + } + + if (!anyRegistered) { + println("GlobalHotKeyManager: Failed to register any Linux hotkeys") + println(" This usually indicates conflicts with existing system hotkeys") + println(" Try using different modifier keys in BossTerm settings") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } + + api.XSelectInput(display, rootWindow, NativeLong(LinuxHotKeyApi.KeyPressMask)) + api.XFlush(display) + + // Evaluate success based on threshold + val totalAttempted = 9 + val successCount = registeredWindows.size + val successRate = successCount.toDouble() / totalAttempted + + if (failedWindows.isEmpty()) { + println("GlobalHotKeyManager: Registered Linux hotkeys for windows 1-9") + _registrationStatus.value = HotKeyRegistrationStatus.REGISTERED + } else if (successRate < LINUX_SUCCESS_THRESHOLD) { + // Below threshold - too many failures to be useful + val failurePercent = ((1.0 - successRate) * 100).toInt() + println("GlobalHotKeyManager: Failed to register most Linux hotkeys ($failurePercent% failed)") + println(" Registered: ${registeredWindows.sorted()} (${successCount}/9)") + println(" Failed: ${failedWindows.sorted()} - likely conflicts with desktop environment hotkeys") + println(" Action required: Change modifier keys in BossTerm settings") + println(" Example: Try using Ctrl+Alt+Shift instead of just Ctrl+Alt") + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + initializationLatch?.countDown() + return + } else { + // Above threshold - partial success is acceptable + println("GlobalHotKeyManager: Registered Linux hotkeys for windows ${registeredWindows.sorted()} (${successCount}/9)") + println(" Failed: ${failedWindows.sorted()} - likely conflicts with desktop environment") + println(" Tip: Some window numbers may not respond to hotkeys") + _registrationStatus.value = HotKeyRegistrationStatus.REGISTERED + } + + // Signal that initialization is complete + initializationLatch?.countDown() + + // Event loop - uses a hybrid polling/event-driven approach + // XNextEvent() blocks indefinitely, so we use XPending() + short sleep + // to allow periodic checking of isRunning flag + // + // Performance tradeoff: 10ms polling adds ~0.1% CPU usage when idle. + // Alternative: XConnectionNumber() + select() for true event-driven waiting, + // but requires additional JNA bindings for POSIX select(). + val event = XEvent() + while (isRunning && !Thread.currentThread().isInterrupted) { + if (api.XPending(display) > 0) { + // Events available - process immediately + api.XNextEvent(display, event) + if (event.type == LinuxHotKeyApi.KeyPress) { + // Extract the actual keycode from the XEvent + // This properly handles the X11 union structure without hard-coded offsets + // Note: getKeycode() creates a Java wrapper but doesn't allocate new native memory + val eventKeycode = event.getKeycode() + if (eventKeycode != null) { + // Find the matching window number by comparing keycodes + for ((windowNum, keycode) in linuxKeycodes) { + if (eventKeycode == keycode) { + invokeCallback(windowNum) + break + } + } + } + } + } else { + // No events - sleep briefly to reduce CPU usage while remaining responsive + // 10ms sleep provides ~5ms average latency for hotkey response + try { + Thread.sleep(10) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + } + + // Ungrab all keys + for ((windowNum, keycode) in linuxKeycodes) { + for (mods in modifierVariants) { + api.XUngrabKey(display, keycode, mods, rootWindow) + } + } + + } catch (e: Exception) { + println("GlobalHotKeyManager: Linux handler error: ${e.message}") + e.printStackTrace() + _registrationStatus.value = HotKeyRegistrationStatus.FAILED + } finally { + cleanupLinux() + } + } + + private fun registerLinuxHotKey(windowNumber: Int, config: HotKeyConfig) { + // Already registered in runLinuxHandler for all 1-9 + } + + private fun unregisterLinuxHotKey(windowNumber: Int) { + // We keep all hotkeys registered + } + + private fun stopLinux() { + // The event loop will exit when isRunning becomes false + } + + private fun cleanupLinux() { + val api = LinuxHotKeyApi.INSTANCE ?: return + try { + linuxDisplay?.let { api.XCloseDisplay(it) } + } catch (e: Exception) { + // Ignore + } + linuxDisplay = null + linuxKeycodes.clear() + registeredWindows.clear() + _registrationStatus.value = HotKeyRegistrationStatus.INACTIVE + } + + // ===== Common ===== + + private fun invokeCallback(windowNumber: Int) { + val callback = onWindowHotKeyPressed ?: return + SwingUtilities.invokeLater { + try { + callback(windowNumber) + } catch (e: Exception) { + println("GlobalHotKeyManager: Error in hotkey callback: ${e.message}") + } + } + } +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/HotKeyConfig.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/HotKeyConfig.kt new file mode 100644 index 00000000..fd276ef9 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/HotKeyConfig.kt @@ -0,0 +1,236 @@ +package ai.rever.bossterm.compose.window + +import ai.rever.bossterm.compose.settings.TerminalSettings +import java.awt.event.KeyEvent + +/** + * Configuration for a global hotkey. + * Contains the key combination and methods to convert to Win32 API values. + */ +data class HotKeyConfig( + val enabled: Boolean, + val ctrl: Boolean, + val alt: Boolean, + val shift: Boolean, + val win: Boolean, + val key: String +) { + /** + * Convert modifier settings to Win32 modifier flags. + */ + fun toWin32Modifiers(): Int { + var modifiers = Win32HotKeyApi.MOD_NOREPEAT // Always prevent key repeat + if (ctrl) modifiers = modifiers or Win32HotKeyApi.MOD_CONTROL + if (alt) modifiers = modifiers or Win32HotKeyApi.MOD_ALT + if (shift) modifiers = modifiers or Win32HotKeyApi.MOD_SHIFT + if (win) modifiers = modifiers or Win32HotKeyApi.MOD_WIN + return modifiers + } + + /** + * Convert key string to Win32 virtual key code. + */ + fun toVirtualKeyCode(): Int { + return when (key.uppercase()) { + // Special keys + "GRAVE", "`" -> KeyEvent.VK_BACK_QUOTE // 0xC0 + "SPACE" -> KeyEvent.VK_SPACE + "ESCAPE", "ESC" -> KeyEvent.VK_ESCAPE + "TAB" -> KeyEvent.VK_TAB + "ENTER", "RETURN" -> KeyEvent.VK_ENTER + + // Letters A-Z + "A" -> KeyEvent.VK_A + "B" -> KeyEvent.VK_B + "C" -> KeyEvent.VK_C + "D" -> KeyEvent.VK_D + "E" -> KeyEvent.VK_E + "F" -> KeyEvent.VK_F + "G" -> KeyEvent.VK_G + "H" -> KeyEvent.VK_H + "I" -> KeyEvent.VK_I + "J" -> KeyEvent.VK_J + "K" -> KeyEvent.VK_K + "L" -> KeyEvent.VK_L + "M" -> KeyEvent.VK_M + "N" -> KeyEvent.VK_N + "O" -> KeyEvent.VK_O + "P" -> KeyEvent.VK_P + "Q" -> KeyEvent.VK_Q + "R" -> KeyEvent.VK_R + "S" -> KeyEvent.VK_S + "T" -> KeyEvent.VK_T + "U" -> KeyEvent.VK_U + "V" -> KeyEvent.VK_V + "W" -> KeyEvent.VK_W + "X" -> KeyEvent.VK_X + "Y" -> KeyEvent.VK_Y + "Z" -> KeyEvent.VK_Z + + // Numbers 0-9 + "0" -> KeyEvent.VK_0 + "1" -> KeyEvent.VK_1 + "2" -> KeyEvent.VK_2 + "3" -> KeyEvent.VK_3 + "4" -> KeyEvent.VK_4 + "5" -> KeyEvent.VK_5 + "6" -> KeyEvent.VK_6 + "7" -> KeyEvent.VK_7 + "8" -> KeyEvent.VK_8 + "9" -> KeyEvent.VK_9 + + // Function keys F1-F12 + "F1" -> KeyEvent.VK_F1 + "F2" -> KeyEvent.VK_F2 + "F3" -> KeyEvent.VK_F3 + "F4" -> KeyEvent.VK_F4 + "F5" -> KeyEvent.VK_F5 + "F6" -> KeyEvent.VK_F6 + "F7" -> KeyEvent.VK_F7 + "F8" -> KeyEvent.VK_F8 + "F9" -> KeyEvent.VK_F9 + "F10" -> KeyEvent.VK_F10 + "F11" -> KeyEvent.VK_F11 + "F12" -> KeyEvent.VK_F12 + + else -> KeyEvent.VK_BACK_QUOTE // Default to grave/backtick + } + } + + /** + * Convert to human-readable display string. + * Uses macOS symbols (⌃⌥⇧⌘) on Mac, text (Ctrl+Alt+Shift+Win) on Windows. + */ + fun toDisplayString(useMacSymbols: Boolean = false): String { + if (!enabled) return "" + + return if (useMacSymbols) { + // macOS style: ⌃⌥⇧⌘` (no separators, just symbols) + buildString { + if (ctrl) append("⌃") // Control + if (alt) append("⌥") // Option + if (shift) append("⇧") // Shift + if (win) append("⌘") // Command (map Win key to Cmd on Mac) + append(when (key.uppercase()) { + "GRAVE", "`" -> "`" + "SPACE" -> "␣" + "ESCAPE", "ESC" -> "⎋" + "TAB" -> "⇥" + "ENTER", "RETURN" -> "↩" + else -> key.uppercase() + }) + } + } else { + // Windows style: Ctrl+Alt+Shift+Win+Key + val parts = mutableListOf() + if (ctrl) parts.add("Ctrl") + if (alt) parts.add("Alt") + if (shift) parts.add("Shift") + if (win) parts.add("Win") + + val keyDisplay = when (key.uppercase()) { + "GRAVE", "`" -> "`" + "SPACE" -> "Space" + "ESCAPE", "ESC" -> "Esc" + "TAB" -> "Tab" + "ENTER", "RETURN" -> "Enter" + else -> key.uppercase() + } + parts.add(keyDisplay) + + parts.joinToString("+") + } + } + + /** + * Check if the configuration is valid (has at least one modifier and a key). + */ + fun isValid(): Boolean { + return enabled && (ctrl || alt || shift || win) && key.isNotEmpty() + } + + /** + * Get display string for a specific window number. + * Uses the base modifiers + window number (1-9). + * + * @param windowNumber The window number (1-9) + * @param useMacSymbols Use macOS-style symbols (⌃⌥⇧⌘) + */ + fun toWindowDisplayString(windowNumber: Int, useMacSymbols: Boolean = false): String { + if (!enabled || windowNumber < 1 || windowNumber > 9) return "" + + return if (useMacSymbols) { + // macOS style: ⌃⌥⇧⌘1 (no separators, just symbols) + buildString { + if (ctrl) append("⌃") // Control + if (alt) append("⌥") // Option + if (shift) append("⇧") // Shift + if (win) append("⌘") // Command + append(windowNumber) + } + } else { + // Windows/Linux style: Ctrl+Alt+1 + val parts = mutableListOf() + if (ctrl) parts.add("Ctrl") + if (alt) parts.add("Alt") + if (shift) parts.add("Shift") + if (win) parts.add("Win") + parts.add(windowNumber.toString()) + parts.joinToString("+") + } + } + + companion object { + /** + * Create HotKeyConfig from TerminalSettings. + */ + fun fromSettings(settings: TerminalSettings): HotKeyConfig { + return HotKeyConfig( + enabled = settings.globalHotkeyEnabled, + ctrl = settings.globalHotkeyCtrl, + alt = settings.globalHotkeyAlt, + shift = settings.globalHotkeyShift, + win = settings.globalHotkeyWin, + key = settings.globalHotkeyKey + ) + } + + /** + * List of supported keys for the settings UI dropdown. + */ + val SUPPORTED_KEYS = listOf( + "GRAVE", + "SPACE", + "ESCAPE", + "TAB" + ) + ('A'..'Z').map { it.toString() } + + ('0'..'9').map { it.toString() } + + (1..12).map { "F$it" } + + /** + * Map key codes to display names for the settings UI. + */ + fun keyToDisplayName(key: String): String { + return when (key.uppercase()) { + "GRAVE" -> "` (Backtick)" + "SPACE" -> "Space" + "ESCAPE" -> "Escape" + "TAB" -> "Tab" + else -> key.uppercase() + } + } + + /** + * Map display names back to key codes. + */ + fun displayNameToKey(displayName: String): String { + return when { + displayName.contains("Backtick") -> "GRAVE" + displayName == "Space" -> "SPACE" + displayName == "Escape" -> "ESCAPE" + displayName == "Tab" -> "TAB" + else -> displayName.uppercase() + } + } + } +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/LinuxHotKeyApi.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/LinuxHotKeyApi.kt new file mode 100644 index 00000000..9e125c95 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/LinuxHotKeyApi.kt @@ -0,0 +1,321 @@ +package ai.rever.bossterm.compose.window + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.Structure + +/** + * JNA interface for Linux X11 global hotkey APIs. + * Uses X11's XGrabKey for system-wide hotkeys. + */ +interface LinuxHotKeyApi : Library { + companion object { + val INSTANCE: LinuxHotKeyApi? = try { + Native.load("X11", LinuxHotKeyApi::class.java) + } catch (e: Throwable) { + null + } + + // Modifier masks + const val ShiftMask = 1 shl 0 + const val LockMask = 1 shl 1 // Caps Lock + const val ControlMask = 1 shl 2 + const val Mod1Mask = 1 shl 3 // Alt + const val Mod2Mask = 1 shl 4 // Num Lock + const val Mod3Mask = 1 shl 5 + const val Mod4Mask = 1 shl 6 // Super/Win + const val Mod5Mask = 1 shl 7 + + // Grab modes + const val GrabModeAsync = 1 + + // Event masks + const val KeyPressMask = 1L shl 0 + const val KeyReleaseMask = 1L shl 1 + + // Event types + const val KeyPress = 2 + const val KeyRelease = 3 + + // X keysyms (subset - common keys) + const val XK_grave = 0x0060 + const val XK_space = 0x0020 + const val XK_Escape = 0xFF1B + const val XK_Tab = 0xFF09 + const val XK_Return = 0xFF0D + const val XK_a = 0x0061 + const val XK_b = 0x0062 + const val XK_c = 0x0063 + const val XK_d = 0x0064 + const val XK_e = 0x0065 + const val XK_f = 0x0066 + const val XK_g = 0x0067 + const val XK_h = 0x0068 + const val XK_i = 0x0069 + const val XK_j = 0x006A + const val XK_k = 0x006B + const val XK_l = 0x006C + const val XK_m = 0x006D + const val XK_n = 0x006E + const val XK_o = 0x006F + const val XK_p = 0x0070 + const val XK_q = 0x0071 + const val XK_r = 0x0072 + const val XK_s = 0x0073 + const val XK_t = 0x0074 + const val XK_u = 0x0075 + const val XK_v = 0x0076 + const val XK_w = 0x0077 + const val XK_x = 0x0078 + const val XK_y = 0x0079 + const val XK_z = 0x007A + const val XK_0 = 0x0030 + const val XK_1 = 0x0031 + const val XK_2 = 0x0032 + const val XK_3 = 0x0033 + const val XK_4 = 0x0034 + const val XK_5 = 0x0035 + const val XK_6 = 0x0036 + const val XK_7 = 0x0037 + const val XK_8 = 0x0038 + const val XK_9 = 0x0039 + const val XK_F1 = 0xFFBE + const val XK_F2 = 0xFFBF + const val XK_F3 = 0xFFC0 + const val XK_F4 = 0xFFC1 + const val XK_F5 = 0xFFC2 + const val XK_F6 = 0xFFC3 + const val XK_F7 = 0xFFC4 + const val XK_F8 = 0xFFC5 + const val XK_F9 = 0xFFC6 + const val XK_F10 = 0xFFC7 + const val XK_F11 = 0xFFC8 + const val XK_F12 = 0xFFC9 + + /** + * Convert HotKeyConfig key to X11 keysym. + */ + fun keyToKeysym(key: String): Int { + return when (key.uppercase()) { + "GRAVE", "`" -> XK_grave + "SPACE" -> XK_space + "ESCAPE", "ESC" -> XK_Escape + "TAB" -> XK_Tab + "ENTER", "RETURN" -> XK_Return + "A" -> XK_a + "B" -> XK_b + "C" -> XK_c + "D" -> XK_d + "E" -> XK_e + "F" -> XK_f + "G" -> XK_g + "H" -> XK_h + "I" -> XK_i + "J" -> XK_j + "K" -> XK_k + "L" -> XK_l + "M" -> XK_m + "N" -> XK_n + "O" -> XK_o + "P" -> XK_p + "Q" -> XK_q + "R" -> XK_r + "S" -> XK_s + "T" -> XK_t + "U" -> XK_u + "V" -> XK_v + "W" -> XK_w + "X" -> XK_x + "Y" -> XK_y + "Z" -> XK_z + "0" -> XK_0 + "1" -> XK_1 + "2" -> XK_2 + "3" -> XK_3 + "4" -> XK_4 + "5" -> XK_5 + "6" -> XK_6 + "7" -> XK_7 + "8" -> XK_8 + "9" -> XK_9 + "F1" -> XK_F1 + "F2" -> XK_F2 + "F3" -> XK_F3 + "F4" -> XK_F4 + "F5" -> XK_F5 + "F6" -> XK_F6 + "F7" -> XK_F7 + "F8" -> XK_F8 + "F9" -> XK_F9 + "F10" -> XK_F10 + "F11" -> XK_F11 + "F12" -> XK_F12 + else -> XK_grave + } + } + + /** + * Convert HotKeyConfig modifiers to X11 modifier mask. + */ + fun configToModifiers(config: HotKeyConfig): Int { + var mods = 0 + if (config.ctrl) mods = mods or ControlMask + if (config.alt) mods = mods or Mod1Mask + if (config.shift) mods = mods or ShiftMask + if (config.win) mods = mods or Mod4Mask + return mods + } + } + + /** + * Open a connection to the X server. + */ + fun XOpenDisplay(displayName: String?): Pointer? + + /** + * Close the connection to the X server. + */ + fun XCloseDisplay(display: Pointer?): Int + + /** + * Get the default root window. + */ + fun XDefaultRootWindow(display: Pointer?): NativeLong + + /** + * Convert keysym to keycode. + */ + fun XKeysymToKeycode(display: Pointer?, keysym: NativeLong): Int + + /** + * Grab a key globally. + */ + fun XGrabKey( + display: Pointer?, + keycode: Int, + modifiers: Int, + grabWindow: NativeLong, + ownerEvents: Int, + pointerMode: Int, + keyboardMode: Int + ): Int + + /** + * Ungrab a key. + */ + fun XUngrabKey( + display: Pointer?, + keycode: Int, + modifiers: Int, + grabWindow: NativeLong + ): Int + + /** + * Select input events. + */ + fun XSelectInput(display: Pointer?, window: NativeLong, eventMask: NativeLong): Int + + /** + * Get the next event (blocks). + */ + fun XNextEvent(display: Pointer?, event: XEvent?): Int + + /** + * Check for pending events. + */ + fun XPending(display: Pointer?): Int + + /** + * Flush the output buffer. + */ + fun XFlush(display: Pointer?): Int + + /** + * Sync and discard events. + */ + fun XSync(display: Pointer?, discard: Int): Int +} + +/** + * X11 XEvent union structure (simplified for key events). + * + * XEvent is a union that can hold various event types. The size varies by architecture: + * - x86_64: 192 bytes + * - x86 (32-bit): 96 bytes + * - ARM64: 192 bytes + * + * We calculate padding dynamically based on pointer size to ensure correct alignment. + */ +@Structure.FieldOrder("type", "pad") +open class XEvent : Structure() { + @JvmField var type: Int = 0 + @JvmField var pad: ByteArray = ByteArray(calculatePaddingSize()) + + class ByReference : XEvent(), Structure.ByReference + + companion object { + /** + * Calculate XEvent padding size based on architecture. + * XEvent size = 192 bytes on 64-bit, 96 bytes on 32-bit. + */ + private fun calculatePaddingSize(): Int { + // Type field is 4 bytes, rest is padding + // On 64-bit (POINTER_SIZE=8): 192 - 4 = 188 bytes + // On 32-bit (POINTER_SIZE=4): 96 - 4 = 92 bytes + return if (Native.POINTER_SIZE == 8) { + 188 // 64-bit + } else { + 92 // 32-bit + } + } + } + + /** + * Extract keycode from this XEvent if it's a key event. + * Returns null if this is not a key press/release event. + * + * Reads directly from memory to avoid creating wrapper objects on every event. + */ + fun getKeycode(): Int? { + if (type != LinuxHotKeyApi.KeyPress && type != LinuxHotKeyApi.KeyRelease) { + return null + } + // Calculate offset to keycode field in XKeyEvent structure + // XKeyEvent layout: type(4) + serial(NativeLong) + sendEvent(4) + display(Pointer) + + // window(NativeLong) + root(NativeLong) + subwindow(NativeLong) + + // time(NativeLong) + x(4) + y(4) + xRoot(4) + yRoot(4) + state(4) + keycode(4) + val pointerSize = Native.POINTER_SIZE + val nativeLongSize = Native.LONG_SIZE + val offset = 4 + nativeLongSize + 4 + pointerSize + + nativeLongSize + nativeLongSize + nativeLongSize + + nativeLongSize + 4 + 4 + 4 + 4 + 4 + return pointer.getInt(offset.toLong()) + } +} + +/** + * X11 XKeyEvent structure (simplified). + */ +@Structure.FieldOrder("type", "serial", "sendEvent", "display", "window", "root", "subwindow", + "time", "x", "y", "xRoot", "yRoot", "state", "keycode", "sameScreen") +open class XKeyEvent : Structure() { + @JvmField var type: Int = 0 + @JvmField var serial: NativeLong = NativeLong(0) + @JvmField var sendEvent: Int = 0 + @JvmField var display: Pointer? = null + @JvmField var window: NativeLong = NativeLong(0) + @JvmField var root: NativeLong = NativeLong(0) + @JvmField var subwindow: NativeLong = NativeLong(0) + @JvmField var time: NativeLong = NativeLong(0) + @JvmField var x: Int = 0 + @JvmField var y: Int = 0 + @JvmField var xRoot: Int = 0 + @JvmField var yRoot: Int = 0 + @JvmField var state: Int = 0 + @JvmField var keycode: Int = 0 + @JvmField var sameScreen: Int = 0 + + class ByReference : XKeyEvent(), Structure.ByReference +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/MacOSHotKeyApi.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/MacOSHotKeyApi.kt new file mode 100644 index 00000000..e46bcd0f --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/MacOSHotKeyApi.kt @@ -0,0 +1,341 @@ +package ai.rever.bossterm.compose.window + +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.ptr.IntByReference +import com.sun.jna.ptr.PointerByReference + +/** + * Callback interface for Carbon Event Handler. + * This is called when a hotkey event is received. + */ +interface EventHandlerUPP : Callback { + /** + * @param nextHandler Reference to the next handler in the chain + * @param event The event being handled + * @param userData User data passed when installing the handler + * @return OSStatus (0 = noErr) + */ + fun invoke(nextHandler: Pointer?, event: Pointer?, userData: Pointer?): Int +} + +/** + * JNA interface for macOS Carbon global hotkey APIs. + * Uses Carbon's RegisterEventHotKey for system-wide hotkeys. + */ +interface MacOSHotKeyApi : Library { + companion object { + val INSTANCE: MacOSHotKeyApi? = try { + Native.load("Carbon", MacOSHotKeyApi::class.java) + } catch (e: Throwable) { + null + } + + // OSStatus codes + const val noErr = 0 + const val eventNotHandledErr = -9874 + + // Event class and kind for hotkey events + const val kEventClassKeyboard = 0x6B657962 // 'keyb' + const val kEventHotKeyPressed = 5 + + // Event parameter names (for GetEventParameter) + const val kEventParamDirectObject = 0x2D2D2D2D // '----' + + // Type codes + const val typeEventHotKeyID = 0x686B6964 // 'hkid' + + // Modifier key masks (Carbon) + const val cmdKey = 0x0100 // Command + const val shiftKey = 0x0200 // Shift + const val optionKey = 0x0800 // Option/Alt + const val controlKey = 0x1000 // Control + + // Virtual key codes (macOS) + const val kVK_ANSI_Grave = 0x32 + const val kVK_Space = 0x31 + const val kVK_Escape = 0x35 + const val kVK_Tab = 0x30 + const val kVK_Return = 0x24 + const val kVK_ANSI_A = 0x00 + const val kVK_ANSI_B = 0x0B + const val kVK_ANSI_C = 0x08 + const val kVK_ANSI_D = 0x02 + const val kVK_ANSI_E = 0x0E + const val kVK_ANSI_F = 0x03 + const val kVK_ANSI_G = 0x05 + const val kVK_ANSI_H = 0x04 + const val kVK_ANSI_I = 0x22 + const val kVK_ANSI_J = 0x26 + const val kVK_ANSI_K = 0x28 + const val kVK_ANSI_L = 0x25 + const val kVK_ANSI_M = 0x2E + const val kVK_ANSI_N = 0x2D + const val kVK_ANSI_O = 0x1F + const val kVK_ANSI_P = 0x23 + const val kVK_ANSI_Q = 0x0C + const val kVK_ANSI_R = 0x0F + const val kVK_ANSI_S = 0x01 + const val kVK_ANSI_T = 0x11 + const val kVK_ANSI_U = 0x20 + const val kVK_ANSI_V = 0x09 + const val kVK_ANSI_W = 0x0D + const val kVK_ANSI_X = 0x07 + const val kVK_ANSI_Y = 0x10 + const val kVK_ANSI_Z = 0x06 + const val kVK_ANSI_0 = 0x1D + const val kVK_ANSI_1 = 0x12 + const val kVK_ANSI_2 = 0x13 + const val kVK_ANSI_3 = 0x14 + const val kVK_ANSI_4 = 0x15 + const val kVK_ANSI_5 = 0x17 + const val kVK_ANSI_6 = 0x16 + const val kVK_ANSI_7 = 0x1A + const val kVK_ANSI_8 = 0x1C + const val kVK_ANSI_9 = 0x19 + const val kVK_F1 = 0x7A + const val kVK_F2 = 0x78 + const val kVK_F3 = 0x63 + const val kVK_F4 = 0x76 + const val kVK_F5 = 0x60 + const val kVK_F6 = 0x61 + const val kVK_F7 = 0x62 + const val kVK_F8 = 0x64 + const val kVK_F9 = 0x65 + const val kVK_F10 = 0x6D + const val kVK_F11 = 0x67 + const val kVK_F12 = 0x6F + + /** + * Convert HotKeyConfig key to macOS virtual key code. + */ + fun keyToVirtualKeyCode(key: String): Int { + return when (key.uppercase()) { + "GRAVE", "`" -> kVK_ANSI_Grave + "SPACE" -> kVK_Space + "ESCAPE", "ESC" -> kVK_Escape + "TAB" -> kVK_Tab + "ENTER", "RETURN" -> kVK_Return + "A" -> kVK_ANSI_A + "B" -> kVK_ANSI_B + "C" -> kVK_ANSI_C + "D" -> kVK_ANSI_D + "E" -> kVK_ANSI_E + "F" -> kVK_ANSI_F + "G" -> kVK_ANSI_G + "H" -> kVK_ANSI_H + "I" -> kVK_ANSI_I + "J" -> kVK_ANSI_J + "K" -> kVK_ANSI_K + "L" -> kVK_ANSI_L + "M" -> kVK_ANSI_M + "N" -> kVK_ANSI_N + "O" -> kVK_ANSI_O + "P" -> kVK_ANSI_P + "Q" -> kVK_ANSI_Q + "R" -> kVK_ANSI_R + "S" -> kVK_ANSI_S + "T" -> kVK_ANSI_T + "U" -> kVK_ANSI_U + "V" -> kVK_ANSI_V + "W" -> kVK_ANSI_W + "X" -> kVK_ANSI_X + "Y" -> kVK_ANSI_Y + "Z" -> kVK_ANSI_Z + "0" -> kVK_ANSI_0 + "1" -> kVK_ANSI_1 + "2" -> kVK_ANSI_2 + "3" -> kVK_ANSI_3 + "4" -> kVK_ANSI_4 + "5" -> kVK_ANSI_5 + "6" -> kVK_ANSI_6 + "7" -> kVK_ANSI_7 + "8" -> kVK_ANSI_8 + "9" -> kVK_ANSI_9 + "F1" -> kVK_F1 + "F2" -> kVK_F2 + "F3" -> kVK_F3 + "F4" -> kVK_F4 + "F5" -> kVK_F5 + "F6" -> kVK_F6 + "F7" -> kVK_F7 + "F8" -> kVK_F8 + "F9" -> kVK_F9 + "F10" -> kVK_F10 + "F11" -> kVK_F11 + "F12" -> kVK_F12 + else -> kVK_ANSI_Grave + } + } + + /** + * Convert HotKeyConfig modifiers to Carbon modifier mask. + */ + fun configToModifiers(config: HotKeyConfig): Int { + var mods = 0 + if (config.ctrl) mods = mods or controlKey + if (config.alt) mods = mods or optionKey + if (config.shift) mods = mods or shiftKey + if (config.win) mods = mods or cmdKey // Map Win to Cmd on macOS + return mods + } + } + + /** + * Get the application event target. + * Note: For truly global hotkeys, use GetEventDispatcherTarget() instead. + */ + fun GetApplicationEventTarget(): Pointer? + + /** + * Get the event dispatcher target. + * This is the key to making hotkeys work globally (even when app is not focused). + * iTerm2 uses this for their global hotkey implementation. + */ + fun GetEventDispatcherTarget(): Pointer? + + /** + * Install an event handler. + * @param target The event target (from GetApplicationEventTarget) + * @param handler The callback function that handles events + * @param numTypes Number of event types in typeList + * @param typeList Array of EventTypeSpec structures + * @param userData User data passed to the handler + * @param outRef Receives the handler reference + * @return OSStatus (0 = noErr) + */ + fun InstallEventHandler( + target: Pointer?, + handler: EventHandlerUPP?, + numTypes: Int, + typeList: Pointer?, + userData: Pointer?, + outRef: PointerByReference? + ): Int + + /** + * Remove an event handler. + */ + fun RemoveEventHandler(handlerRef: Pointer?): Int + + /** + * Register a global hotkey. + */ + fun RegisterEventHotKey( + keyCode: Int, + modifiers: Int, + hotKeyID: EventHotKeyID.ByReference?, + target: Pointer?, + options: Int, + outRef: PointerByReference? + ): Int + + /** + * Unregister a global hotkey. + */ + fun UnregisterEventHotKey(hotKeyRef: Pointer?): Int + + /** + * Run the application event loop (blocks). + */ + fun RunApplicationEventLoop() + + /** + * Quit the application event loop. + */ + fun QuitApplicationEventLoop() + + /** + * Get event parameter. + * Used to extract the hotkey ID from a hotkey event. + */ + fun GetEventParameter( + event: Pointer?, + name: Int, + type: Int, + outType: IntByReference?, + bufferSize: Int, + outSize: IntByReference?, + outData: Pointer? + ): Int +} + +/** + * JNA interface for CoreFoundation run loop functions. + * Used for processing events without blocking. + */ +interface CoreFoundationApi : Library { + companion object { + val INSTANCE: CoreFoundationApi? = try { + Native.load("CoreFoundation", CoreFoundationApi::class.java) + } catch (e: Throwable) { + null + } + + // Run loop result codes + const val kCFRunLoopRunFinished = 1 + const val kCFRunLoopRunStopped = 2 + const val kCFRunLoopRunTimedOut = 3 + const val kCFRunLoopRunHandledSource = 4 + } + + /** + * Get the current thread's run loop. + */ + fun CFRunLoopGetCurrent(): Pointer? + + /** + * Get the main run loop. + */ + fun CFRunLoopGetMain(): Pointer? + + /** + * Run the run loop for a specified duration. + * @param mode The run loop mode (use kCFRunLoopDefaultMode) + * @param seconds How long to run (0 = don't wait, just check) + * @param returnAfterSourceHandled Return after handling one source + * @return Result code + */ + fun CFRunLoopRunInMode(mode: Pointer?, seconds: Double, returnAfterSourceHandled: Boolean): Int + + /** + * Stop the run loop. + */ + fun CFRunLoopStop(runLoop: Pointer?) + + /** + * Create a CFString from a C string. + */ + fun CFStringCreateWithCString(allocator: Pointer?, cStr: String?, encoding: Int): Pointer? + + /** + * Release a CoreFoundation object. + */ + fun CFRelease(cf: Pointer?) +} + +/** + * EventHotKeyID structure for Carbon API. + */ +@Structure.FieldOrder("signature", "id") +open class EventHotKeyID : Structure() { + @JvmField var signature: Int = 0 + @JvmField var id: Int = 0 + + class ByReference : EventHotKeyID(), Structure.ByReference + class ByValue : EventHotKeyID(), Structure.ByValue +} + +/** + * EventTypeSpec structure for Carbon API. + */ +@Structure.FieldOrder("eventClass", "eventKind") +open class EventTypeSpec : Structure() { + @JvmField var eventClass: Int = 0 + @JvmField var eventKind: Int = 0 + + class ByReference : EventTypeSpec(), Structure.ByReference +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/TransparentWindowTitleBar.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/TransparentWindowTitleBar.kt index 2874a7c4..edbef64a 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/TransparentWindowTitleBar.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/TransparentWindowTitleBar.kt @@ -42,6 +42,7 @@ fun WindowScope.CustomTitleBar( onFullscreen: () -> Unit, onMaximize: () -> Unit, backgroundColor: Color, + globalHotkeyHint: String? = null, modifier: Modifier = Modifier ) { WindowDraggableArea( @@ -100,8 +101,20 @@ fun WindowScope.CustomTitleBar( ) } - // Spacer to balance the traffic lights - Spacer(modifier = Modifier.width(72.dp)) + // Hotkey hint or spacer to balance the traffic lights + Box( + modifier = Modifier.width(72.dp), + contentAlignment = Alignment.CenterEnd + ) { + if (globalHotkeyHint != null) { + Text( + text = globalHotkeyHint, + color = Color.White.copy(alpha = 0.5f), + fontSize = 11.sp, + fontWeight = FontWeight.Normal + ) + } + } } } } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/Win32HotKeyApi.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/Win32HotKeyApi.kt new file mode 100644 index 00000000..6e3ac2b1 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/Win32HotKeyApi.kt @@ -0,0 +1,87 @@ +package ai.rever.bossterm.compose.window + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.platform.win32.WinDef.HWND +import com.sun.jna.platform.win32.WinDef.LPARAM +import com.sun.jna.platform.win32.WinDef.WPARAM +import com.sun.jna.platform.win32.WinUser.MSG + +/** + * JNA interface for Win32 global hotkey APIs. + * Used to register system-wide hotkeys that work regardless of which app is focused. + */ +interface Win32HotKeyApi : Library { + companion object { + /** + * Singleton instance of the Win32 user32 library. + * Returns null if loading fails (e.g., on non-Windows platforms). + */ + val INSTANCE: Win32HotKeyApi? = try { + Native.load("user32", Win32HotKeyApi::class.java) + } catch (e: Throwable) { + null + } + + // Modifier key flags for RegisterHotKey + const val MOD_ALT = 0x0001 + const val MOD_CONTROL = 0x0002 + const val MOD_SHIFT = 0x0004 + const val MOD_WIN = 0x0008 + const val MOD_NOREPEAT = 0x4000 // Prevents repeated WM_HOTKEY when key held + + // Window message for hotkey activation + const val WM_HOTKEY = 0x0312 + const val WM_QUIT = 0x0012 + } + + /** + * Registers a system-wide hotkey. + * + * @param hWnd Handle to the window that will receive WM_HOTKEY messages. + * Pass null to associate with the calling thread. + * @param id Unique identifier for this hotkey (used to unregister later). + * @param fsModifiers Modifier key flags (MOD_ALT, MOD_CONTROL, etc.) + * @param vk Virtual key code. + * @return true if registration succeeded, false otherwise. + */ + fun RegisterHotKey(hWnd: HWND?, id: Int, fsModifiers: Int, vk: Int): Boolean + + /** + * Unregisters a previously registered hotkey. + * + * @param hWnd Handle to the window that registered the hotkey (or null). + * @param id The identifier used when registering. + * @return true if unregistration succeeded. + */ + fun UnregisterHotKey(hWnd: HWND?, id: Int): Boolean + + /** + * Retrieves a message from the calling thread's message queue. + * Blocks until a message is available. + * + * @param lpMsg Pointer to MSG structure that receives message data. + * @param hWnd Filter messages to this window (null for all). + * @param wMsgFilterMin Minimum message value to retrieve. + * @param wMsgFilterMax Maximum message value to retrieve. + * @return Non-zero for messages, 0 for WM_QUIT, -1 for error. + */ + fun GetMessage(lpMsg: MSG, hWnd: HWND?, wMsgFilterMin: Int, wMsgFilterMax: Int): Int + + /** + * Posts a message to the message queue of a specific thread. + * Used to signal the message pump thread to exit. + * + * @param idThread Thread identifier to post to. + * @param msg Message type. + * @param wParam Additional message data. + * @param lParam Additional message data. + * @return true if posted successfully. + */ + fun PostThreadMessage(idThread: Int, msg: Int, wParam: WPARAM, lParam: LPARAM): Boolean + + /** + * Gets the thread identifier of the calling thread. + */ + fun GetCurrentThreadId(): Int +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowManager.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowManager.kt index d137ec98..87d1157f 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowManager.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowManager.kt @@ -15,7 +15,11 @@ data class TerminalWindow( val id: String = UUID.randomUUID().toString(), val title: MutableState = mutableStateOf("BossTerm"), val menuActions: MenuActions = MenuActions(), - val isWindowFocused: MutableState = mutableStateOf(true) + val isWindowFocused: MutableState = mutableStateOf(true), + /** AWT window reference for global hotkey toggle (set after Window composable renders) */ + var awtWindow: java.awt.Window? = null, + /** Window number for global hotkey (1-9, or 0 if no hotkey assigned) */ + val windowNumber: Int = 0 ) /** @@ -33,9 +37,35 @@ object WindowManager { // Pending split state to transfer along with the tab var pendingSplitStateForNewWindow: SplitViewState? = null + // Callback for when a new window is created (for hotkey registration) + var onWindowCreated: ((TerminalWindow) -> Unit)? = null + // Callback for when a window is closed (for hotkey unregistration) + var onWindowClosed: ((TerminalWindow) -> Unit)? = null + + /** + * Get the next available window number (1-9). + * Returns 0 if all numbers are taken. + */ + private fun getNextWindowNumber(): Int { + val usedNumbers = _windows.map { it.windowNumber }.toSet() + for (i in 1..9) { + if (i !in usedNumbers) return i + } + return 0 // All numbers taken + } + + /** + * Get a window by its number. + */ + fun getWindowByNumber(number: Int): TerminalWindow? { + return _windows.find { it.windowNumber == number } + } + fun createWindow(): TerminalWindow { - val window = TerminalWindow() + val windowNumber = getNextWindowNumber() + val window = TerminalWindow(windowNumber = windowNumber) _windows.add(window) + onWindowCreated?.invoke(window) return window } @@ -53,7 +83,11 @@ object WindowManager { } fun closeWindow(id: String) { - _windows.removeAll { it.id == id } + val window = _windows.find { it.id == id } + if (window != null) { + onWindowClosed?.invoke(window) + _windows.removeAll { it.id == id } + } } fun hasWindows(): Boolean = _windows.isNotEmpty() diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowVisibilityController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowVisibilityController.kt new file mode 100644 index 00000000..6d461e4d --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/WindowVisibilityController.kt @@ -0,0 +1,184 @@ +package ai.rever.bossterm.compose.window + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.platform.win32.WinDef.HWND +import java.awt.Frame +import java.awt.Window +import java.lang.reflect.InaccessibleObjectException +import javax.swing.SwingUtilities + +/** + * JNA interface for additional Win32 window management APIs. + */ +private interface Win32WindowApi : Library { + companion object { + val INSTANCE: Win32WindowApi? = try { + Native.load("user32", Win32WindowApi::class.java) + } catch (e: Throwable) { + null + } + + const val SW_RESTORE = 9 + } + + /** + * Brings the specified window to the foreground and activates it. + * This is more reliable than Java's toFront() on Windows. + */ + fun SetForegroundWindow(hWnd: HWND): Boolean + + /** + * Shows a window in a specified state. + */ + fun ShowWindow(hWnd: HWND, nCmdShow: Int): Boolean + + /** + * Checks if a window is minimized (iconic). + */ + fun IsIconic(hWnd: HWND): Boolean + + /** + * Checks if a window is visible. + */ + fun IsWindowVisible(hWnd: HWND): Boolean +} + +/** + * Controller for toggling window visibility in response to global hotkey. + * + * Implements iTerm2-style toggle behavior: + * - Not visible/minimized → Show and focus + * - Visible but not focused → Bring to front and focus + * - Visible and focused → Minimize (iconify) + */ +object WindowVisibilityController { + + /** + * Toggle visibility of the given windows. + * Uses the most recently focused window if multiple are provided. + * + * @param windows List of AWT windows to toggle. + */ + fun toggleWindow(windows: List) { + if (windows.isEmpty()) return + + // Find the most appropriate window to toggle + // Prefer: focused window > visible non-minimized window > any window + val targetWindow = windows.find { it.isFocused } + ?: windows.find { it.isVisible && (it as? Frame)?.state != Frame.ICONIFIED } + ?: windows.firstOrNull() + ?: return + + SwingUtilities.invokeLater { + toggleSingleWindow(targetWindow) + } + } + + /** + * Toggle a single window's visibility. + */ + private fun toggleSingleWindow(window: Window) { + val frame = window as? Frame + + // Determine current state + val isMinimized = frame?.state == Frame.ICONIFIED + val isVisible = window.isVisible + val isFocused = window.isFocused + + when { + // Not visible → Show and focus + !isVisible -> { + window.isVisible = true + bringToFrontAndFocus(window) + } + + // Minimized → Restore and focus + isMinimized -> { + frame?.state = Frame.NORMAL + bringToFrontAndFocus(window) + } + + // Visible and focused → Minimize + isFocused -> { + frame?.state = Frame.ICONIFIED + } + + // Visible but not focused → Bring to front and focus + else -> { + bringToFrontAndFocus(window) + } + } + } + + /** + * Bring window to front and focus it. + * Uses Win32 API on Windows for reliable focusing. + */ + private fun bringToFrontAndFocus(window: Window) { + val api = Win32WindowApi.INSTANCE + val hwnd = getWindowHandle(window) + + if (api != null && hwnd != null) { + // Windows-specific: Use native API for reliable focus + if (api.IsIconic(hwnd)) { + api.ShowWindow(hwnd, Win32WindowApi.SW_RESTORE) + } + api.SetForegroundWindow(hwnd) + } else { + // Fallback for non-Windows or if JNA fails + window.toFront() + window.requestFocus() + } + } + + /** + * Get the native HWND handle for an AWT window. + * + * Requires JVM arg on Java 16+: --add-opens java.desktop/java.awt=ALL-UNNAMED + * + * Without this flag, reflection access to Component.peer will fail on modern JVMs + * and the hotkey toggle functionality will fall back to standard show/hide. + */ + private fun getWindowHandle(window: Window): HWND? { + return try { + // Try to get the native peer and extract HWND + val peerField = java.awt.Component::class.java.getDeclaredField("peer") + peerField.isAccessible = true + val peer = peerField.get(window) ?: return null + + // Try WComponentPeer.getHWnd() method + val getHWndMethod = try { + peer.javaClass.getMethod("getHWnd") + } catch (e: NoSuchMethodException) { + // Try alternative method name + peer.javaClass.getDeclaredMethod("getHWnd") + } + + getHWndMethod.isAccessible = true + val hwndLong = getHWndMethod.invoke(peer) as Long + + if (hwndLong != 0L) { + HWND(com.sun.jna.Pointer(hwndLong)) + } else { + null + } + } catch (e: InaccessibleObjectException) { + // Java 16+ requires explicit module access + println("WindowVisibilityController: Reflection blocked by Java module system") + println(" Add JVM flag: --add-opens java.desktop/java.awt=ALL-UNNAMED") + println(" Window toggle will fall back to standard show/hide") + null + } catch (e: SecurityException) { + // SecurityManager denies reflection access + println("WindowVisibilityController: Reflection blocked by SecurityManager") + println(" Grant reflection permission or disable SecurityManager for window toggle") + println(" Window toggle will fall back to standard show/hide") + null + } catch (e: Exception) { + // Other reflection or JNA failures - return null to use fallback + println("WindowVisibilityController: Failed to get HWND: ${e.javaClass.simpleName} - ${e.message}") + null + } + } +}