Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions floating-window/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions floating-window/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>

<service
android:name=".service.FloatingTabService"
android:exported="false" />

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<DockAction>
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() {}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<List<FloatingTab>>(emptyList())
val windows: StateFlow<List<FloatingTab>> = _windows.asStateFlow()

private val _events = MutableSharedFlow<DockingEvent>(extraBufferCapacity = 16)
val events: SharedFlow<DockingEvent> = _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
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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}"),
)
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading