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",