diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e8e8ff0584..319c538822 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -324,6 +324,7 @@ dependencies {
implementation(projects.layouteditor)
implementation(projects.idetooltips)
implementation(projects.composePreview)
+ implementation(projects.floatingWindow)
implementation(projects.gitCore)
// This is to build the tooling-api-impl project before the app is built
diff --git a/floating-window/build.gradle.kts b/floating-window/build.gradle.kts
new file mode 100644
index 0000000000..cfbbbe8a0b
--- /dev/null
+++ b/floating-window/build.gradle.kts
@@ -0,0 +1,41 @@
+
+
+import com.itsaky.androidide.build.config.BuildConfig
+
+plugins {
+ id("com.android.library")
+ id("kotlin-android")
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "${BuildConfig.PACKAGE_NAME}.floating"
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.activity)
+ implementation(libs.compose.ui.tooling.preview)
+ debugImplementation(libs.compose.ui.tooling)
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.fragment)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.common.kotlin.coroutines.android)
+ implementation(libs.google.material)
+
+ implementation(projects.editorApi)
+ implementation(projects.common)
+ implementation(projects.resources)
+ implementation(projects.logger)
+}
diff --git a/floating-window/src/main/AndroidManifest.xml b/floating-window/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..aec23ebc72
--- /dev/null
+++ b/floating-window/src/main/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockAction.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockAction.kt
new file mode 100644
index 0000000000..24bcacccfd
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockAction.kt
@@ -0,0 +1,26 @@
+
+package com.itsaky.androidide.floating.model
+
+import android.view.View
+
+/**
+ * A content-provided action shown in a floating window's title bar (e.g. Save, Run).
+ *
+ * Actions are how a window advertises its capabilities: a file editor contributes Save/Run, a
+ * plugin tab may contribute none. The window chrome renders one button per action, so what a window
+ * can do is driven entirely by its [DockableContent].
+ *
+ * @property iconRes A drawable resource id (from any module on the classpath) for the button icon.
+ * @property confirmIconRes If non-null and [onInvoke] returns `true`, the button briefly swaps to
+ * this icon (e.g. a checkmark) as success feedback, then reverts.
+ * @property onLongPress Optional long-press handler given the anchor [View], e.g. to show a tooltip.
+ * @property onInvoke Performs the action; returns `true` to play the [confirmIconRes] confirmation.
+ */
+class DockAction(
+ val id: String,
+ val label: String,
+ val iconRes: Int,
+ val confirmIconRes: Int? = null,
+ val onLongPress: ((View) -> Unit)? = null,
+ val onInvoke: suspend () -> Boolean,
+)
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockableContent.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockableContent.kt
new file mode 100644
index 0000000000..715d28c540
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockableContent.kt
@@ -0,0 +1,47 @@
+
+package com.itsaky.androidide.floating.model
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.view.View
+import com.itsaky.androidide.floating.window.FloatingWindowHost
+
+/**
+ * The contract a tab must satisfy to be hosted in a floating overlay window.
+ *
+ * Implementations live in the `app` module (e.g. an adapter over a `CodeEditorView`/`IEditorPanel`
+ * or a plugin Fragment); the floating window system renders them without knowing anything about
+ * editors or plugins.
+ *
+ * [onCreateView] receives the window-scoped, long-lived themed [Context] — built from
+ * [Context.createWindowContext] and therefore NOT tied to the IDE activity. Implementations should
+ * build their content view against this context so it survives the activity being destroyed while
+ * the window floats over another app.
+ */
+interface DockableContent {
+ /** Stable identifier, shared with the docked tab this content was undocked from. */
+ val id: String
+
+ /** Human-readable title shown in the window chrome. */
+ val title: String
+
+ /** Optional icon shown in the window chrome. */
+ val icon: Drawable?
+ get() = null
+
+ /**
+ * Optional content actions (e.g. Save, Run) shown in the title bar. The window is "capability
+ * aware": it renders exactly the actions its content advertises, and none if this is empty.
+ */
+ val actions: List
+ get() = emptyList()
+
+ /** Build the content view for the window, against the supplied window-scoped themed context. */
+ fun onCreateView(
+ context: Context,
+ host: FloatingWindowHost,
+ ): View
+
+ /** Called when the window is being torn down (closed or re-docked). */
+ fun onDestroyView() {}
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingEvent.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingEvent.kt
new file mode 100644
index 0000000000..49703504ea
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingEvent.kt
@@ -0,0 +1,21 @@
+
+package com.itsaky.androidide.floating.model
+
+/**
+ * A one-shot intent emitted when a floating window leaves the floating set, telling the editor
+ * activity what to do with the underlying tab. Removal from [DockingManager.windows] only says the
+ * window is gone; this says *why*.
+ */
+sealed interface DockingEvent {
+ val content: DockableContent
+
+ /** The tab should return to the docked editor strip. */
+ data class Redock(
+ override val content: DockableContent,
+ ) : DockingEvent
+
+ /** The tab should be closed entirely, as if its docked close button were pressed. */
+ data class Close(
+ override val content: DockableContent,
+ ) : DockingEvent
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt
new file mode 100644
index 0000000000..0099de19f2
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt
@@ -0,0 +1,100 @@
+
+
+package com.itsaky.androidide.floating.model
+
+import com.itsaky.androidide.floating.window.FloatingWindowState
+import com.itsaky.androidide.floating.window.WindowBounds
+import com.itsaky.androidide.floating.window.WindowMode
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * The single source of truth for which tabs are currently floating and the state of their windows.
+ *
+ * Both sides of the same process observe [windows]: the floating window service reconciles overlay
+ * windows from it, while the editor activity reconciles its docked tab strip (rendering a
+ * placeholder for any tab that is currently floating). All transitions are plain state edits, so
+ * neither side needs a static reference to the other.
+ */
+object DockingManager {
+ private val _windows = MutableStateFlow>(emptyList())
+ val windows: StateFlow> = _windows.asStateFlow()
+
+ private val _events = MutableSharedFlow(extraBufferCapacity = 16)
+ val events: SharedFlow = _events.asSharedFlow()
+
+ fun isFloating(id: String): Boolean = _windows.value.any { it.id == id }
+
+ fun find(id: String): FloatingTab? = _windows.value.firstOrNull { it.id == id }
+
+ /** Float [content] in a new window. No-op if a window with the same id already exists. */
+ fun undock(
+ content: DockableContent,
+ bounds: WindowBounds,
+ ) {
+ _windows.update { current ->
+ if (current.any { it.id == content.id }) {
+ return@update current
+ }
+ current + FloatingTab(content, FloatingWindowState(bounds = bounds))
+ }
+ }
+
+ /** Remove the floating window for [id] and signal that the tab should return to the dock. */
+ fun dock(id: String): DockableContent? {
+ val tab = find(id) ?: return null
+ _windows.update { current -> current.filterNot { it.id == id } }
+ _events.tryEmit(DockingEvent.Redock(tab.content))
+ return tab.content
+ }
+
+ /** Remove the floating window for [id] and signal that the tab should be closed entirely. */
+ fun close(id: String): DockableContent? {
+ val tab = find(id) ?: return null
+ _windows.update { current -> current.filterNot { it.id == id } }
+ _events.tryEmit(DockingEvent.Close(tab.content))
+ return tab.content
+ }
+
+ fun updateBounds(
+ id: String,
+ bounds: WindowBounds,
+ ) {
+ mutate(id) { it.copy(bounds = bounds) }
+ }
+
+ fun setMode(
+ id: String,
+ mode: WindowMode,
+ ) {
+ mutate(id) { state ->
+ when (mode) {
+ WindowMode.MAXIMIZED, WindowMode.MINIMIZED ->
+ if (state.mode == WindowMode.NORMAL) {
+ state.copy(mode = mode, restoreBounds = state.bounds)
+ } else {
+ state.copy(mode = mode)
+ }
+
+ WindowMode.NORMAL ->
+ state.copy(mode = mode, bounds = state.restoreBounds)
+ }
+ }
+ }
+
+ private fun mutate(
+ id: String,
+ transform: (FloatingWindowState) -> FloatingWindowState,
+ ) {
+ _windows.update { current ->
+ current.map { tab ->
+ if (tab.id == id) tab.copy(state = transform(tab.state)) else tab
+ }
+ }
+ }
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/model/FloatingTab.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/model/FloatingTab.kt
new file mode 100644
index 0000000000..fefc96fdfc
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/model/FloatingTab.kt
@@ -0,0 +1,14 @@
+
+
+package com.itsaky.androidide.floating.model
+
+import com.itsaky.androidide.floating.window.FloatingWindowState
+
+/** A docked-content + window-state pair: one entry per live floating window. */
+data class FloatingTab(
+ val content: DockableContent,
+ val state: FloatingWindowState,
+) {
+ val id: String
+ get() = content.id
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/permission/OverlayPermission.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/permission/OverlayPermission.kt
new file mode 100644
index 0000000000..1364b66345
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/permission/OverlayPermission.kt
@@ -0,0 +1,19 @@
+
+
+package com.itsaky.androidide.floating.permission
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+
+/** Helpers for the "display over other apps" ([Settings.ACTION_MANAGE_OVERLAY_PERMISSION]) grant. */
+object OverlayPermission {
+ fun canDrawOverlays(context: Context): Boolean = Settings.canDrawOverlays(context)
+
+ fun requestIntent(context: Context): Intent =
+ Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:${context.packageName}"),
+ )
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowHost.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowHost.kt
new file mode 100644
index 0000000000..46dd0b6adc
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowHost.kt
@@ -0,0 +1,77 @@
+
+
+package com.itsaky.androidide.floating.window
+
+import android.view.View
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.OnBackPressedDispatcherOwner
+import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+
+/**
+ * Owns the lifecycle, view-model store, saved-state registry and back-press dispatcher for a single
+ * floating overlay window.
+ *
+ * A view added through [android.view.WindowManager.addView] is not part of any activity's view
+ * hierarchy, so it inherits none of the view-tree owners that Jetpack Compose and the
+ * [androidx.fragment.app.FragmentController] require. This host supplies them per window: call
+ * [attach] on the window's root view once it is added to the [android.view.WindowManager], and
+ * [destroy] when the window is removed.
+ */
+class FloatingWindowHost :
+ LifecycleOwner,
+ ViewModelStoreOwner,
+ SavedStateRegistryOwner,
+ OnBackPressedDispatcherOwner {
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
+ override val viewModelStore: ViewModelStore = ViewModelStore()
+
+ override val savedStateRegistry: SavedStateRegistry
+ get() = savedStateRegistryController.savedStateRegistry
+
+ override val onBackPressedDispatcher: OnBackPressedDispatcher = OnBackPressedDispatcher()
+
+ init {
+ savedStateRegistryController.performRestore(null)
+ }
+
+ fun attach(root: View) {
+ root.setViewTreeLifecycleOwner(this)
+ root.setViewTreeViewModelStoreOwner(this)
+ root.setViewTreeSavedStateRegistryOwner(this)
+ root.setViewTreeOnBackPressedDispatcherOwner(this)
+ lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+ }
+
+ fun pause() {
+ if (lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ lifecycleRegistry.currentState = Lifecycle.State.STARTED
+ }
+ }
+
+ fun resume() {
+ if (lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.CREATED)) {
+ lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+ }
+ }
+
+ fun destroy() {
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ viewModelStore.clear()
+ }
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowState.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowState.kt
new file mode 100644
index 0000000000..82075edb4f
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowState.kt
@@ -0,0 +1,32 @@
+
+
+package com.itsaky.androidide.floating.window
+
+/** Position and size of a floating window, in absolute screen pixels. */
+data class WindowBounds(
+ val x: Int,
+ val y: Int,
+ val width: Int,
+ val height: Int,
+)
+
+/** The visual mode of a floating window. */
+enum class WindowMode {
+ NORMAL,
+ MINIMIZED,
+ MAXIMIZED,
+}
+
+/**
+ * Immutable state of a single floating window, owned by
+ * [com.itsaky.androidide.floating.model.DockingManager] and reconciled into a live overlay by the
+ * floating window service.
+ *
+ * [restoreBounds] remembers the [NORMAL]-mode bounds to return to after leaving [MAXIMIZED] or
+ * [MINIMIZED].
+ */
+data class FloatingWindowState(
+ val bounds: WindowBounds,
+ val mode: WindowMode = WindowMode.NORMAL,
+ val restoreBounds: WindowBounds = bounds,
+)
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/window/InitialBounds.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/window/InitialBounds.kt
new file mode 100644
index 0000000000..3c522a689e
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/window/InitialBounds.kt
@@ -0,0 +1,32 @@
+
+
+package com.itsaky.androidide.floating.window
+
+import android.content.Context
+import kotlin.math.roundToInt
+
+/** Computes sensible initial bounds for a newly undocked window, cascading multiple windows. */
+object InitialBounds {
+ private const val WIDTH_FRACTION = 0.72f
+ private const val HEIGHT_FRACTION = 0.6f
+ private const val CASCADE_STEP_DP = 28f
+ private const val CASCADE_WRAP = 6
+
+ fun cascaded(
+ context: Context,
+ index: Int,
+ ): WindowBounds {
+ val metrics = context.resources.displayMetrics
+ val width = (metrics.widthPixels * WIDTH_FRACTION).roundToInt()
+ val height = (metrics.heightPixels * HEIGHT_FRACTION).roundToInt()
+ val step = (CASCADE_STEP_DP * metrics.density).roundToInt() * (index % CASCADE_WRAP)
+ val baseX = (metrics.widthPixels - width) / 2
+ val baseY = (metrics.heightPixels - height) / 2
+ return WindowBounds(
+ x = (baseX + step).coerceAtLeast(0),
+ y = (baseY + step).coerceAtLeast(0),
+ width = width,
+ height = height,
+ )
+ }
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/window/OverlayLayoutParams.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/window/OverlayLayoutParams.kt
new file mode 100644
index 0000000000..213cdea83d
--- /dev/null
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/window/OverlayLayoutParams.kt
@@ -0,0 +1,53 @@
+
+
+package com.itsaky.androidide.floating.window
+
+import android.graphics.PixelFormat
+import android.os.Build
+import android.view.Gravity
+import android.view.WindowManager
+
+/**
+ * Builds the [WindowManager.LayoutParams] for a floating overlay window.
+ *
+ * The [focusable] flag is the crux of in-overlay text editing: a soft keyboard can only target a
+ * window that does NOT carry [WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE]. The window is kept
+ * non-focusable (touch passes through to apps behind) until the editor is tapped, then flipped to
+ * focusable so the IME can attach.
+ */
+object OverlayLayoutParams {
+
+ private val overlayType: Int =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ } else {
+ @Suppress("DEPRECATION")
+ WindowManager.LayoutParams.TYPE_PHONE
+ }
+
+ fun create(state: FloatingWindowState, focusable: Boolean): WindowManager.LayoutParams =
+ WindowManager.LayoutParams(
+ state.bounds.width,
+ state.bounds.height,
+ overlayType,
+ flagsFor(focusable),
+ PixelFormat.TRANSLUCENT,
+ ).apply {
+ gravity = Gravity.TOP or Gravity.START
+ x = state.bounds.x
+ y = state.bounds.y
+ softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
+ }
+
+ fun flagsFor(focusable: Boolean): Int {
+ val common =
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
+ WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
+ return if (focusable) {
+ common
+ } else {
+ common or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+ }
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1b75673afd..bc2aca8c0a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -123,6 +123,7 @@ include(
":eventbus",
":eventbus-android",
":eventbus-events",
+ ":floating-window",
":git-core",
":gradle-plugin",
":gradle-plugin-config",