From 96465e6dc1ecdc501fc69f6811bed5fbc8e122e3 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 23 Jun 2026 22:39:38 +0100 Subject: [PATCH 1/3] feat(ADFA-4400): add floating-window module with docking model and overlay foundation --- app/build.gradle.kts | 1 + floating-window/build.gradle.kts | 41 +++++++ floating-window/src/main/AndroidManifest.xml | 12 +++ .../androidide/floating/model/DockAction.kt | 26 +++++ .../floating/model/DockableContent.kt | 47 ++++++++ .../androidide/floating/model/DockingEvent.kt | 21 ++++ .../floating/model/DockingManager.kt | 100 ++++++++++++++++++ .../androidide/floating/model/FloatingTab.kt | 14 +++ .../floating/permission/OverlayPermission.kt | 19 ++++ .../floating/window/FloatingWindowHost.kt | 77 ++++++++++++++ .../floating/window/FloatingWindowState.kt | 32 ++++++ .../floating/window/InitialBounds.kt | 32 ++++++ .../floating/window/OverlayLayoutParams.kt | 53 ++++++++++ settings.gradle.kts | 1 + 14 files changed, 476 insertions(+) create mode 100644 floating-window/build.gradle.kts create mode 100644 floating-window/src/main/AndroidManifest.xml create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/model/DockAction.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/model/DockableContent.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingEvent.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/model/FloatingTab.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/permission/OverlayPermission.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowHost.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindowState.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/window/InitialBounds.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/window/OverlayLayoutParams.kt 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", From f99e440f243c1dc771222ce56869de812caf5eb7 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 23 Jun 2026 22:39:39 +0100 Subject: [PATCH 2/3] feat(ADFA-4401): add floating window chrome --- .../androidide/floating/ui/FloatingTheme.kt | 87 +++++ .../floating/ui/FloatingWindowChrome.kt | 363 ++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingWindowChrome.kt diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt new file mode 100644 index 0000000000..c3401c9da3 --- /dev/null +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt @@ -0,0 +1,87 @@ + + +package com.itsaky.androidide.floating.ui + +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import com.google.android.material.color.MaterialColors +import com.google.android.material.R as MatR +import com.itsaky.androidide.resources.R as ResR + +private const val UNRESOLVED_COLOR = Int.MIN_VALUE + +private val AtkinsonHyperlegible: FontFamily = + FontFamily( + Font(ResR.font.atkinson_hyperlegible_regular, FontWeight.Normal), + Font(ResR.font.atkinson_hyperlegible_bold, FontWeight.Bold), + Font(ResR.font.atkinson_hyperlegible_italic, FontWeight.Normal, FontStyle.Italic), + Font(ResR.font.atkinson_hyperlegible_bold_italic, FontWeight.Bold, FontStyle.Italic), + ) + +/** + * Wraps floating-window content in a [MaterialTheme] whose colors are read live from the IDE's XML + * `Theme.AndroidIDE` (via the supplied window context) and whose type uses the IDE's Atkinson + * Hyperlegible face. This keeps overlay windows visually identical to the docked editor, including + * light/dark. + */ +@Composable +fun FloatingTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val dark = isSystemInDarkTheme() + val colorScheme = remember(context, dark) { context.toComposeColorScheme(dark) } + val typography = remember { brandedTypography() } + MaterialTheme(colorScheme = colorScheme, typography = typography, content = content) +} + +private fun brandedTypography(): Typography { + val base = Typography() + fun TextStyle.branded(): TextStyle = copy(fontFamily = AtkinsonHyperlegible) + return base.copy( + titleMedium = base.titleMedium.branded(), + titleSmall = base.titleSmall.branded(), + bodyMedium = base.bodyMedium.branded(), + labelLarge = base.labelLarge.branded(), + labelMedium = base.labelMedium.branded(), + labelSmall = base.labelSmall.branded(), + ) +} + +private fun Context.toComposeColorScheme(dark: Boolean): ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + + fun color(attr: Int, fallback: Color): Color { + val resolved = MaterialColors.getColor(this, attr, UNRESOLVED_COLOR) + return if (resolved == UNRESOLVED_COLOR) fallback else Color(resolved) + } + + return base.copy( + primary = color(MatR.attr.colorPrimary, base.primary), + onPrimary = color(MatR.attr.colorOnPrimary, base.onPrimary), + primaryContainer = color(MatR.attr.colorPrimaryContainer, base.primaryContainer), + onPrimaryContainer = color(MatR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), + secondary = color(MatR.attr.colorSecondary, base.secondary), + onSecondary = color(MatR.attr.colorOnSecondary, base.onSecondary), + surface = color(MatR.attr.colorSurface, base.surface), + onSurface = color(MatR.attr.colorOnSurface, base.onSurface), + surfaceVariant = color(MatR.attr.colorSurfaceVariant, base.surfaceVariant), + onSurfaceVariant = color(MatR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), + outline = color(MatR.attr.colorOutline, base.outline), + error = color(MatR.attr.colorError, base.error), + onError = color(MatR.attr.colorOnError, base.onError), + background = color(android.R.attr.colorBackground, base.background), + ) +} diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingWindowChrome.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingWindowChrome.kt new file mode 100644 index 0000000000..26b5ab46dc --- /dev/null +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingWindowChrome.kt @@ -0,0 +1,363 @@ + + +package com.itsaky.androidide.floating.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGestures +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.fillMaxSize +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.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.floating.model.DockAction +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * The chrome for a normal (non-minimized) floating editor window: a slim draggable title bar with + * window controls, a resize grip, and the editor [content] below. The border tints to the theme + * accent while [focused] (i.e. the window currently holds keyboard focus). + */ +@Composable +fun FloatingWindowChrome( + title: String, + focused: Boolean, + maximized: Boolean, + onDrag: (Float, Float) -> Unit, + onDragStopped: () -> Unit, + onResize: (Float, Float) -> Unit, + onResizeStopped: () -> Unit, + onMinimize: () -> Unit, + onToggleMaximize: () -> Unit, + onDock: () -> Unit, + onClose: () -> Unit, + actions: List, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + val borderColor = if (focused) scheme.primary else scheme.outline + Surface( + modifier = modifier.fillMaxSize(), + shape = RoundedCornerShape(12.dp), + color = scheme.surface, + contentColor = scheme.onSurface, + border = BorderStroke(1.dp, borderColor), + tonalElevation = 3.dp, + shadowElevation = 6.dp, + ) { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + TitleBar( + title = title, + maximized = maximized, + actions = actions, + onDrag = onDrag, + onDragStopped = onDragStopped, + onMinimize = onMinimize, + onToggleMaximize = onToggleMaximize, + onDock = onDock, + onClose = onClose, + ) + Box( + Modifier + .fillMaxWidth() + .weight(1f), + ) { + content() + } + } + ResizeHandle( + onResize = onResize, + onResizeStopped = onResizeStopped, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } + } +} + +/** Collapsed representation of a floating window: a draggable pill; tapping it restores the window. */ +@Composable +fun MinimizedBubble( + title: String, + onRestore: () -> Unit, + onDrag: (Float, Float) -> Unit, + onDragStopped: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + val monogram = title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•" + Surface( + modifier = modifier.pointerInput(Unit) { + detectDragGestures( + onDragEnd = onDragStopped, + onDragCancel = onDragStopped, + ) { change, dragAmount -> + change.consume() + onDrag(dragAmount.x, dragAmount.y) + } + }, + shape = RoundedCornerShape(percent = 50), + color = scheme.primaryContainer, + contentColor = scheme.onPrimaryContainer, + border = BorderStroke(1.dp, scheme.outline), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier + .clickable(onClick = onRestore) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = monogram, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 120.dp), + ) + } + } +} + +@Composable +private fun TitleBar( + title: String, + maximized: Boolean, + actions: List, + onDrag: (Float, Float) -> Unit, + onDragStopped: () -> Unit, + onMinimize: () -> Unit, + onToggleMaximize: () -> Unit, + onDock: () -> Unit, + onClose: () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + Row( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .background(scheme.surfaceVariant) + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = onDragStopped, + onDragCancel = onDragStopped, + ) { change, dragAmount -> + change.consume() + onDrag(dragAmount.x, dragAmount.y) + } + } + .padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas(Modifier.size(18.dp)) { drawGrip(scheme.onSurfaceVariant) } + Spacer(Modifier.width(6.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = scheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + actions.forEach { action -> ActionButton(action) } + ChromeButton(onClick = onMinimize, description = "Minimize") { drawMinimize(it) } + ChromeButton( + onClick = onToggleMaximize, + description = if (maximized) "Restore" else "Maximize", + ) { if (maximized) drawRestore(it) else drawMaximize(it) } + ChromeButton(onClick = onDock, description = "Dock to editor") { drawDock(it) } + ChromeButton(onClick = onClose, description = "Close") { drawClose(it) } + } +} + +@Composable +private fun ChromeButton( + onClick: () -> Unit, + description: String, + draw: DrawScope.(Color) -> Unit, +) { + val tint = MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.size(18.dp)) { draw(tint) } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionButton(action: DockAction) { + val scope = rememberCoroutineScope() + var confirmed by remember { mutableStateOf(false) } + val scheme = MaterialTheme.colorScheme + val view = LocalView.current + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .combinedClickable( + onLongClick = { action.onLongPress?.invoke(view) }, + onClick = { + scope.launch { + val confirm = runCatching { action.onInvoke() }.getOrDefault(false) + if (confirm && action.confirmIconRes != null) { + confirmed = true + delay(ACTION_CONFIRM_MS) + confirmed = false + } + } + }, + ) + .semantics { contentDescription = action.label }, + contentAlignment = Alignment.Center, + ) { + val iconRes = if (confirmed) action.confirmIconRes ?: action.iconRes else action.iconRes + Crossfade( + targetState = iconRes, + animationSpec = tween(180), + label = "floating-action-icon", + ) { res -> + Icon( + painter = painterResource(res), + contentDescription = null, + tint = if (confirmed) scheme.primary else scheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private const val ACTION_CONFIRM_MS = 1100L + +@Composable +private fun ResizeHandle( + onResize: (Float, Float) -> Unit, + onResizeStopped: () -> Unit, + modifier: Modifier = Modifier, +) { + val tint = MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = modifier + .size(28.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = onResizeStopped, + onDragCancel = onResizeStopped, + ) { change, dragAmount -> + change.consume() + onResize(dragAmount.x, dragAmount.y) + } + }, + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.size(16.dp)) { drawResizeGrip(tint) } + } +} + +private fun DrawScope.strokePx(): Float = 2.dp.toPx() + +private fun DrawScope.drawGrip(color: Color) { + val radius = 1.3.dp.toPx() + val cols = floatArrayOf(size.width * 0.38f, size.width * 0.62f) + val rows = floatArrayOf(size.height * 0.32f, size.height * 0.5f, size.height * 0.68f) + for (x in cols) { + for (y in rows) { + drawCircle(color = color, radius = radius, center = Offset(x, y)) + } + } +} + +private fun DrawScope.drawMinimize(color: Color) { + val y = size.height * 0.66f + drawLine(color, Offset(size.width * 0.26f, y), Offset(size.width * 0.74f, y), strokePx(), StrokeCap.Round) +} + +private fun DrawScope.drawMaximize(color: Color) { + val pad = size.minDimension * 0.24f + drawRect( + color = color, + topLeft = Offset(pad, pad), + size = Size(size.width - 2 * pad, size.height - 2 * pad), + style = Stroke(strokePx()), + ) +} + +private fun DrawScope.drawRestore(color: Color) { + val pad = size.minDimension * 0.2f + val square = size.minDimension * 0.46f + drawRect(color, Offset(size.width - pad - square, pad), Size(square, square), style = Stroke(strokePx())) + drawRect(color, Offset(pad, size.height - pad - square), Size(square, square), style = Stroke(strokePx())) +} + +private fun DrawScope.drawDock(color: Color) { + val w = size.width + val h = size.height + val stroke = strokePx() + drawLine(color, Offset(w * 0.26f, h * 0.78f), Offset(w * 0.74f, h * 0.78f), stroke, StrokeCap.Round) + drawLine(color, Offset(w * 0.5f, h * 0.22f), Offset(w * 0.5f, h * 0.6f), stroke, StrokeCap.Round) + drawLine(color, Offset(w * 0.5f, h * 0.6f), Offset(w * 0.37f, h * 0.46f), stroke, StrokeCap.Round) + drawLine(color, Offset(w * 0.5f, h * 0.6f), Offset(w * 0.63f, h * 0.46f), stroke, StrokeCap.Round) +} + +private fun DrawScope.drawClose(color: Color) { + val pad = size.minDimension * 0.28f + val stroke = strokePx() + drawLine(color, Offset(pad, pad), Offset(size.width - pad, size.height - pad), stroke, StrokeCap.Round) + drawLine(color, Offset(size.width - pad, pad), Offset(pad, size.height - pad), stroke, StrokeCap.Round) +} + +private fun DrawScope.drawResizeGrip(color: Color) { + val w = size.width + val h = size.height + val stroke = 1.5.dp.toPx() + drawLine(color, Offset(w * 0.95f, h * 0.45f), Offset(w * 0.45f, h * 0.95f), stroke, StrokeCap.Round) + drawLine(color, Offset(w * 0.95f, h * 0.7f), Offset(w * 0.7f, h * 0.95f), stroke, StrokeCap.Round) +} From de85d32303ab62b3a1d0820ee1341a07c4e6f7ba Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 23 Jun 2026 22:39:39 +0100 Subject: [PATCH 3/3] feat(ADFA-4402): add per-window controller and foreground service --- .../floating/fragment/OverlayFragmentHost.kt | 115 ++++++ .../floating/service/FloatingTabService.kt | 160 ++++++++ .../floating/window/FloatingWindow.kt | 378 ++++++++++++++++++ 3 files changed, 653 insertions(+) create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/service/FloatingTabService.kt create mode 100644 floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindow.kt diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt new file mode 100644 index 0000000000..ef805c8daf --- /dev/null +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt @@ -0,0 +1,115 @@ + + +package com.itsaky.androidide.floating.fragment + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.FrameLayout +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentController +import androidx.fragment.app.FragmentFactory +import androidx.fragment.app.FragmentHostCallback +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryOwner +import com.itsaky.androidide.floating.window.FloatingWindowHost + +/** + * Hosts a single [Fragment] with no Activity, inside a floating overlay window. + * + * A [FragmentManager] normally requires a [androidx.fragment.app.FragmentActivity]. This drives one + * directly via a [FragmentController] over a [FragmentHostCallback], delegating the view-model + * store, lifecycle, saved-state registry and back-press dispatcher to the window's + * [FloatingWindowHost]. That is what lets plugin tab Fragments float over other apps and survive the + * editor activity being destroyed. + * + * The hosted Fragment is re-instantiated here (not moved across FragmentManagers, which Android does + * not support); callers supply a [FragmentFactory] so plugin classloaders still resolve. + */ +class OverlayFragmentHost( + private val context: Context, + private val owner: FloatingWindowHost, + fragmentFactory: FragmentFactory? = null, +) { + private val handler = Handler(Looper.getMainLooper()) + + private val container: FrameLayout = + FrameLayout(context).apply { id = View.generateViewId() } + + private val hostCallback = + object : + FragmentHostCallback(context, handler, 0), + ViewModelStoreOwner, + LifecycleOwner, + SavedStateRegistryOwner, + OnBackPressedDispatcherOwner { + override fun onGetHost(): Context = context + + override fun onFindViewById(id: Int): View? = container.findViewById(id) + + override fun onHasView(): Boolean = true + + override val viewModelStore: ViewModelStore + get() = owner.viewModelStore + + override val lifecycle: Lifecycle + get() = owner.lifecycle + + override val savedStateRegistry: SavedStateRegistry + get() = owner.savedStateRegistry + + override val onBackPressedDispatcher: OnBackPressedDispatcher + get() = owner.onBackPressedDispatcher + } + + private val controller: FragmentController = FragmentController.createController(hostCallback) + + private var started = false + + val view: View + get() = container + + val fragmentManager: FragmentManager + get() = controller.supportFragmentManager + + init { + if (fragmentFactory != null) { + controller.supportFragmentManager.fragmentFactory = fragmentFactory + } + } + + fun start() { + if (started) return + controller.attachHost(null) + controller.dispatchCreate() + controller.dispatchActivityCreated() + controller.dispatchStart() + controller.dispatchResume() + started = true + } + + fun setFragment(fragment: Fragment) { + if (!started) start() + controller.supportFragmentManager + .beginTransaction() + .replace(container.id, fragment) + .commitNowAllowingStateLoss() + } + + fun destroy() { + if (!started) return + controller.dispatchPause() + controller.dispatchStop() + controller.dispatchDestroyView() + controller.dispatchDestroy() + started = false + } +} diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/service/FloatingTabService.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/service/FloatingTabService.kt new file mode 100644 index 0000000000..e3ab84bba3 --- /dev/null +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/service/FloatingTabService.kt @@ -0,0 +1,160 @@ + + +package com.itsaky.androidide.floating.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.IBinder +import android.view.ContextThemeWrapper +import android.view.Display +import android.view.WindowManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.itsaky.androidide.floating.model.DockingManager +import com.itsaky.androidide.floating.model.FloatingTab +import com.itsaky.androidide.floating.window.FloatingWindow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import com.itsaky.androidide.resources.R as ResR + +/** + * Foreground service that owns the live floating overlay windows. It reconciles its set of + * [FloatingWindow]s from [DockingManager.windows]: a tab appearing in the flow gets a window added, + * a tab disappearing gets its window removed. When no windows remain, the service stops itself. + * + * Each window is an independent overlay so touches in the gaps between windows pass straight through + * to the apps behind. It lives in the same process as the editor activity, so it shares + * [DockingManager] directly with no IPC; the foreground status is only what keeps the windows alive + * over other apps. + */ +class FloatingTabService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val windows = LinkedHashMap() + private val failed = HashSet() + private var collectJob: Job? = null + + override fun onCreate() { + super.onCreate() + startForeground(NOTIFICATION_ID, buildNotification()) + collectJob = + scope.launch { + DockingManager.windows.collect { tabs -> + runCatching { reconcile(tabs) } + .onFailure { log.error("Floating window reconcile failed", it) } + } + } + log.debug("FloatingTabService created") + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int = START_NOT_STICKY + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + collectJob?.cancel() + windows.values.forEach(FloatingWindow::dismiss) + windows.clear() + scope.cancel() + log.debug("FloatingTabService destroyed") + super.onDestroy() + } + + private fun reconcile(tabs: List) { + val ids = tabs.mapTo(HashSet()) { it.id } + failed.retainAll(ids) + + windows.keys.filter { it !in ids }.forEach { id -> + windows.remove(id)?.let { runCatching { it.dismiss() } } + } + + tabs.forEach { tab -> + if (tab.id in failed || windows.containsKey(tab.id)) { + return@forEach + } + try { + val window = FloatingWindow(newWindowContext(), tab) + windows[tab.id] = window + window.show() + } catch (t: Throwable) { + log.error("Failed to create floating window for {}; dropping it", tab.id, t) + failed.add(tab.id) + windows.remove(tab.id)?.let { runCatching { it.dismiss() } } + DockingManager.close(tab.id) + } + } + + if (windows.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun newWindowContext(): Context { + val base: Context = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val display = + (getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) + .getDisplay(Display.DEFAULT_DISPLAY) + createWindowContext(display, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, null) + } else { + this + } + return ContextThemeWrapper(base, ResR.style.Theme_AndroidIDE) + } + + private fun buildNotification(): Notification { + createChannel() + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle("Floating editor windows") + .setContentText("Editor windows are floating over other apps") + .setSmallIcon(android.R.drawable.ic_menu_view) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Floating windows", + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + } + } + + companion object { + private val log = LoggerFactory.getLogger(FloatingTabService::class.java) + private const val CHANNEL_ID = "floating_windows" + private const val NOTIFICATION_ID = 0x0F10A7 + + /** Start (or no-op if already running) the foreground service that hosts floating windows. */ + fun ensureRunning(context: Context) { + ContextCompat.startForegroundService( + context, + Intent(context, FloatingTabService::class.java), + ) + } + } +} diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindow.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindow.kt new file mode 100644 index 0000000000..d878af930e --- /dev/null +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/window/FloatingWindow.kt @@ -0,0 +1,378 @@ + + +package com.itsaky.androidide.floating.window + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.view.animation.DecelerateInterpolator +import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.itsaky.androidide.floating.model.DockingManager +import com.itsaky.androidide.floating.model.FloatingTab +import com.itsaky.androidide.floating.ui.FloatingTheme +import com.itsaky.androidide.floating.ui.FloatingWindowChrome +import com.itsaky.androidide.floating.ui.MinimizedBubble +import org.slf4j.LoggerFactory +import kotlin.math.roundToInt + +/** + * A single live floating overlay window: it binds the Compose chrome to its [tab] content, owns the + * window's [WindowManager.LayoutParams], and translates chrome gestures into live window updates. + * + * Keyboard focus follows interaction: the window is non-focusable (touch passes through to apps + * behind) until tapped, then becomes focusable so the editor's IME attaches. Minimize/maximize/dock + * transitions are animated. + */ +class FloatingWindow( + private val windowContext: Context, + private val tab: FloatingTab, +) { + + private val windowManager: WindowManager = + windowContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val host = FloatingWindowHost() + private val density = windowContext.resources.displayMetrics.density + private val minWidthPx = (MIN_WIDTH_DP * density).roundToInt() + private val minHeightPx = (MIN_HEIGHT_DP * density).roundToInt() + private val screenWidth = windowContext.resources.displayMetrics.widthPixels + private val screenHeight = windowContext.resources.displayMetrics.heightPixels + private val bubbleWidthPx = (BUBBLE_WIDTH_DP * density).roundToInt() + private val bubbleHeightPx = (BUBBLE_HEIGHT_DP * density).roundToInt() + + private var bounds: WindowBounds = tab.state.bounds + private var restoreBounds: WindowBounds = tab.state.restoreBounds + private var focusable: Boolean = false + private var added: Boolean = false + private var transition: ValueAnimator? = null + + private val modeState = mutableStateOf(tab.state.mode) + private val focusedState = mutableStateOf(false) + + private val params: WindowManager.LayoutParams = + OverlayLayoutParams.create(tab.state, focusable = false) + + private val contentView: View = tab.content.onCreateView(windowContext, host) + + private val composeView: ComposeView = ComposeView(windowContext) + + private val rootView: OverlayRootView = + OverlayRootView(windowContext).apply { + addView( + composeView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + } + + init { + // Wire callbacks here (not in the OverlayRootView.apply block above): inside that block the + // receiver is the View, so `setFocusable`/`id` would resolve to View members, not these. + rootView.onInsideTouch = { setFocusable(true) } + rootView.isContentTouch = { rawX, rawY -> isWithinContent(rawX, rawY) } + rootView.onContentTap = { rootView.post { focusContentAndShowIme() } } + composeView.setContent { this@FloatingWindow.Content() } + } + + val id: String + get() = tab.id + + fun show() { + if (added) return + host.attach(rootView) + runCatching { windowManager.addView(rootView, params) } + .onSuccess { added = true } + .onFailure { log.error("Failed to add floating window {}", id, it) } + } + + fun dismiss() { + transition?.cancel() + if (added) { + runCatching { windowManager.removeView(rootView) } + added = false + } + (contentView.parent as? ViewGroup)?.removeView(contentView) + host.destroy() + tab.content.onDestroyView() + } + + private fun moveBy(dx: Float, dy: Float) { + if (modeState.value == WindowMode.MAXIMIZED) return + val topInset = + ViewCompat.getRootWindowInsets(rootView) + ?.getInsets(WindowInsetsCompat.Type.statusBars()) + ?.top ?: 0 + val maxX = (screenWidth - params.width).coerceAtLeast(0) + val maxY = (screenHeight - params.height).coerceAtLeast(topInset) + params.x = (params.x + dx.roundToInt()).coerceIn(0, maxX) + params.y = (params.y + dy.roundToInt()).coerceIn(topInset, maxY) + bounds = bounds.copy(x = params.x, y = params.y) + if (modeState.value == WindowMode.MINIMIZED) { + restoreBounds = restoreBounds.copy(x = params.x, y = params.y) + } + safeUpdate() + } + + private fun resizeBy(dw: Float, dh: Float) { + if (modeState.value != WindowMode.NORMAL) return + params.width = (params.width + dw.roundToInt()).coerceAtLeast(minWidthPx) + params.height = (params.height + dh.roundToInt()).coerceAtLeast(minHeightPx) + bounds = bounds.copy(width = params.width, height = params.height) + safeUpdate() + } + + private fun commitBounds() { + if (modeState.value == WindowMode.NORMAL) { + DockingManager.updateBounds(id, bounds) + } + } + + private fun minimize() { + if (modeState.value == WindowMode.NORMAL) { + restoreBounds = bounds + } + setFocusable(false) + modeState.value = WindowMode.MINIMIZED + DockingManager.setMode(id, WindowMode.MINIMIZED) + animateBoundsTo(WindowBounds(params.x, params.y, bubbleWidthPx, bubbleHeightPx)) + } + + private fun restore() { + modeState.value = WindowMode.NORMAL + DockingManager.setMode(id, WindowMode.NORMAL) + animateBoundsTo(restoreBounds) { bounds = restoreBounds } + } + + private fun toggleMaximize() { + if (modeState.value == WindowMode.MAXIMIZED) { + restore() + return + } + if (modeState.value == WindowMode.NORMAL) { + restoreBounds = bounds + } + modeState.value = WindowMode.MAXIMIZED + DockingManager.setMode(id, WindowMode.MAXIMIZED) + animateBoundsTo(WindowBounds(0, 0, screenWidth, screenHeight)) + } + + private fun applyBounds(target: WindowBounds) { + bounds = target + params.x = target.x + params.y = target.y + params.width = target.width + params.height = target.height + safeUpdate() + } + + private fun currentResolvedBounds(): WindowBounds { + val w = if (params.width >= 0) params.width else screenWidth + val h = if (params.height >= 0) params.height else screenHeight + return WindowBounds(params.x, params.y, w, h) + } + + private fun animateBoundsTo(target: WindowBounds, onEnd: () -> Unit = {}) { + if (!added) { + applyBounds(target) + onEnd() + return + } + transition?.cancel() + val start = currentResolvedBounds() + transition = + ValueAnimator.ofFloat(0f, 1f).apply { + duration = TRANSITION_MS + interpolator = DecelerateInterpolator() + addUpdateListener { animator -> + val fraction = animator.animatedValue as Float + params.x = lerp(start.x, target.x, fraction) + params.y = lerp(start.y, target.y, fraction) + params.width = lerp(start.width, target.width, fraction) + params.height = lerp(start.height, target.height, fraction) + safeUpdate() + } + addListener(endListener(onEnd)) + start() + } + } + + private fun animateExit(onEnd: () -> Unit) { + if (!added) { + onEnd() + return + } + transition?.cancel() + transition = + ValueAnimator.ofFloat(params.alpha, 0f).apply { + duration = EXIT_MS + addUpdateListener { animator -> + params.alpha = animator.animatedValue as Float + safeUpdate() + } + addListener(endListener(onEnd)) + start() + } + } + + private fun lerp(from: Int, to: Int, fraction: Float): Int = + (from + (to - from) * fraction).roundToInt() + + private fun endListener(onEnd: () -> Unit): AnimatorListenerAdapter = + object : AnimatorListenerAdapter() { + private var cancelled = false + + override fun onAnimationCancel(animation: Animator) { + cancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (!cancelled) onEnd() + } + } + + private fun setFocusable(value: Boolean) { + if (focusable == value) return + focusable = value + focusedState.value = value + params.flags = OverlayLayoutParams.flagsFor(value) + safeUpdate() + if (!value) { + hideIme() + rootView.clearFocus() + } + } + + private fun hideIme() { + val imm = windowContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(rootView.windowToken, 0) + } + + private fun isWithinContent(rawX: Float, rawY: Float): Boolean { + val location = IntArray(2) + contentView.getLocationOnScreen(location) + val inEditor = rawX >= location[0] && rawX <= location[0] + contentView.width && + rawY >= location[1] && rawY <= location[1] + contentView.height + if (!inEditor) return false + + // Exclude the resize-handle corner (bottom-right of the window): it overlays the editor, so + // without this, grabbing it to resize registers as a tap on the editor and pops the keyboard. + val handlePx = RESIZE_HANDLE_DP * density + val rootLocation = IntArray(2) + rootView.getLocationOnScreen(rootLocation) + val handleLeft = rootLocation[0] + rootView.width - handlePx + val handleTop = rootLocation[1] + rootView.height - handlePx + return rawX < handleLeft || rawY < handleTop + } + + private fun focusContentAndShowIme() { + contentView.requestFocus() + val target = contentView.findFocus() ?: contentView + val imm = windowContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(target, 0) + } + + private fun safeUpdate() { + if (!added) return + runCatching { windowManager.updateViewLayout(rootView, params) } + .onFailure { log.error("Failed to update floating window {}", id, it) } + } + + @Composable + private fun Content() { + FloatingTheme { + Crossfade( + targetState = modeState.value == WindowMode.MINIMIZED, + animationSpec = tween(TRANSITION_MS.toInt()), + label = "floating-window-mode", + ) { minimized -> + if (minimized) { + MinimizedBubble( + title = tab.content.title, + onRestore = ::restore, + onDrag = ::moveBy, + onDragStopped = ::commitBounds, + ) + } else { + FloatingWindowChrome( + title = tab.content.title, + focused = focusedState.value, + maximized = modeState.value == WindowMode.MAXIMIZED, + onDrag = ::moveBy, + onDragStopped = ::commitBounds, + onResize = ::resizeBy, + onResizeStopped = ::commitBounds, + onMinimize = ::minimize, + onToggleMaximize = ::toggleMaximize, + onDock = { animateExit { DockingManager.dock(id) } }, + onClose = { animateExit { DockingManager.close(id) } }, + actions = tab.content.actions, + content = { EditorContent() }, + ) + } + } + } + } + + @Composable + private fun EditorContent() { + AndroidView( + factory = { + (contentView.parent as? ViewGroup)?.removeView(contentView) + contentView + }, + modifier = Modifier.fillMaxSize(), + ) + } + + companion object { + + private val log = LoggerFactory.getLogger(FloatingWindow::class.java) + private const val MIN_WIDTH_DP = 200f + private const val MIN_HEIGHT_DP = 140f + private const val RESIZE_HANDLE_DP = 32f + private const val BUBBLE_WIDTH_DP = 200f + private const val BUBBLE_HEIGHT_DP = 52f + private const val TRANSITION_MS = 200L + private const val EXIT_MS = 150L + } +} + +/** + * Root view of a floating window. Reports the first touch inside so the host can enable keyboard + * focus, and whether a touch landed within the content (vs the chrome). + */ +@SuppressLint("ViewConstructor") +internal class OverlayRootView(context: Context) : FrameLayout(context) { + + var onInsideTouch: (() -> Unit)? = null + var isContentTouch: ((Float, Float) -> Boolean)? = null + var onContentTap: (() -> Unit)? = null + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + onInsideTouch?.invoke() + if (isContentTouch?.invoke(event.rawX, event.rawY) == true) { + onContentTap?.invoke() + } + } + return super.dispatchTouchEvent(event) + } +}