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
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,13 @@ import com.itsaky.androidide.models.DiagnosticGroup
import com.itsaky.androidide.models.OpenedFile
import com.itsaky.androidide.models.Range
import com.itsaky.androidide.models.SearchResult
import com.itsaky.androidide.plugins.extensions.FileTabMenuItem
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
import com.itsaky.androidide.preferences.internal.BuildPreferences
import com.itsaky.androidide.preferences.internal.GeneralPreferences
import com.itsaky.androidide.projects.IProjectManager
import com.itsaky.androidide.projects.ProjectManagerImpl
import com.itsaky.androidide.resources.R as ResR
import com.itsaky.androidide.services.debug.DebuggerService
import com.itsaky.androidide.tasks.cancelIfActive
import com.itsaky.androidide.ui.CodeEditorView
Expand Down Expand Up @@ -1038,10 +1040,18 @@ abstract class BaseEditorActivity :
}

val pluginMenuItems = if (this is EditorHandlerActivity) {
val self = this
val fileIndex = getFileIndexForTabPosition(position)
if (fileIndex >= 0) {
val file = editorViewModel.getOpenedFile(fileIndex)
IDEApplication.getPluginManager()?.getFileTabMenuItems(file) ?: emptyList()
val pluginItems =
IDEApplication.getPluginManager()?.getFileTabMenuItems(file) ?: emptyList()
listOf(
FileTabMenuItem(
id = "ide.floating.undock",
title = getString(R.string.undock),
) { self.undockFileTab(fileIndex) },
) + pluginItems
} else {
emptyList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,17 @@ open class EditorHandlerActivity :
pluginEditorProvider = null
}

private val floatingTabController by lazy {
com.itsaky.androidide.editor.floating.IdeFloatingTabController(this)
}

override fun onCreate(savedInstanceState: Bundle?) {
setupPluginFragmentFactory()
mBuildEventListener.setActivity(this)
super.onCreate(savedInstanceState)

supportFragmentManager.registerFragmentLifecycleCallbacks(pluginFontScalingListener, true)
floatingTabController.start()

editorViewModel._displayedFile.observe(
this,
Expand Down Expand Up @@ -587,6 +592,21 @@ open class EditorHandlerActivity :
return if (child is CodeEditorView) child else null
}

/** Undock the file tab at [fileIndex] into a floating window over other apps. */
fun undockFileTab(fileIndex: Int) {
floatingTabController.undock(fileIndex)
}

/** Undock the plugin tab [tabId] (at [position]) into a floating window over other apps. */
fun undockPluginTab(tabId: String, position: Int) {
val title =
com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
.getInstance()
.getPluginTab(tabId)
?.title ?: tabId
floatingTabController.floatPluginTab(tabId, title) { closePluginTab(position) }
}

override fun openFileAndSelect(
file: File,
selection: Range?,
Expand Down Expand Up @@ -1565,6 +1585,27 @@ open class EditorHandlerActivity :
}

binding.root.addView(closeItem)

val undockItem =
FileActionPopupWindowItemBinding
.inflate(
android.view.LayoutInflater.from(this),
null,
false,
).root
undockItem.apply {
text = getString(string.undock)
setOnClickListener {
val pos = tab.position
val tabId = getPluginTabId(pos)
if (tabId != null) {
undockPluginTab(tabId, pos)
}
popupWindow.dismiss()
}
}
binding.root.addView(undockItem)

popupWindow.showAsDropDown(anchorView, 0, 0)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.itsaky.androidide.editor.floating

import android.content.Context
import android.view.View
import com.itsaky.androidide.R
import com.itsaky.androidide.api.IDEApiFacade
import com.itsaky.androidide.floating.model.DockAction
import com.itsaky.androidide.floating.model.DockableContent
import com.itsaky.androidide.floating.window.FloatingWindowHost
import com.itsaky.androidide.idetooltips.TooltipCategory
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.models.Position
import com.itsaky.androidide.models.Range
import com.itsaky.androidide.ui.CodeEditorView
import java.io.File
import com.itsaky.androidide.resources.R as ResR

/**
* Adapts an editor file tab to [DockableContent] for a floating window. Builds a [CodeEditorView]
* against the window's long-lived themed context so it outlives the editor activity. Content
* continuity across dock/undock is preserved by saving to disk on each transition; this view simply
* re-opens the saved file.
*/
class EditorPanelDockableContent(
val file: File,
) : DockableContent {
override val id: String = file.absolutePath
override val title: String = file.name

private var editorView: CodeEditorView? = null
private var dockActions: List<DockAction> = emptyList()

override val actions: List<DockAction>
get() = dockActions

override fun onCreateView(
context: Context,
host: FloatingWindowHost,
): View {
val view = CodeEditorView(context, file, Range(Position(0, 0), Position(0, 0)))
editorView = view
dockActions =
listOf(
DockAction(
id = "ide.floating.save",
label = "Save",
iconRes = ResR.drawable.ic_save,
confirmIconRes = R.drawable.ic_check,
onLongPress = { anchor ->
TooltipManager.showTooltip(context, anchor, TooltipCategory.CATEGORY_IDE, TooltipTag.EDITOR_TOOLBAR_QUICK_SAVE)
},
) {
view.save()
true
},
DockAction(
id = "ide.floating.run",
label = "Run",
iconRes = ResR.drawable.ic_run,
onLongPress = { anchor ->
TooltipManager.showTooltip(context, anchor, TooltipCategory.CATEGORY_IDE, TooltipTag.EDITOR_TOOLBAR_QUICK_RUN)
},
) {
IDEApiFacade.runApp()
false
},
)
view.onEditorSelected()
return view
}

suspend fun save(): Boolean = editorView?.save() ?: false

fun release() {
editorView?.close()
editorView = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@

package com.itsaky.androidide.editor.floating

import android.content.Intent
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.floating.model.DockingEvent
import com.itsaky.androidide.floating.model.DockingManager
import com.itsaky.androidide.floating.permission.OverlayPermission
import com.itsaky.androidide.floating.service.FloatingTabService
import com.itsaky.androidide.floating.window.InitialBounds
import kotlinx.coroutines.launch

/**
* Bridges the editor activity to the floating-window system: turns an editor file tab into a
* floating window and back.
*
* - Undock: persist the docked panel, close its docked tab, then float a fresh panel built against
* the service's window context (so it survives this activity being destroyed).
* - Redock/Close ([DockingManager.events]): persist and release the floating panel, and for redock,
* re-open the file as a docked tab.
*/
class IdeFloatingTabController(
private val activity: EditorHandlerActivity,
) {
private var undockCounter = 0

fun start() {
activity.lifecycleScope.launch {
DockingManager.events.collect(::onEvent)
}
}

fun undock(fileIndex: Int) {
if (!OverlayPermission.canDrawOverlays(activity)) {
activity.startActivity(OverlayPermission.requestIntent(activity))
return
}

val panel = activity.getEditorAtIndex(fileIndex) ?: return
val file = panel.file ?: return

activity.lifecycleScope.launch {
panel.save()
panel.markAsSaved()
activity.closeFile(fileIndex) {}
DockingManager.undock(
EditorPanelDockableContent(file),
InitialBounds.cascaded(activity, undockCounter++),
)
FloatingTabService.ensureRunning(activity.applicationContext)
}
}

fun floatPluginTab(
tabId: String,
title: String,
remove: () -> Unit,
) {
if (!OverlayPermission.canDrawOverlays(activity)) {
activity.startActivity(OverlayPermission.requestIntent(activity))
return
}
remove()
DockingManager.undock(
PluginTabDockableContent(tabId, title),
InitialBounds.cascaded(activity, undockCounter++),
)
FloatingTabService.ensureRunning(activity.applicationContext)
}

private fun onEvent(event: DockingEvent) {
when (val content = event.content) {
is EditorPanelDockableContent ->
activity.lifecycleScope.launch {
content.save()
content.release()
if (event is DockingEvent.Redock) {
bringIdeToFront()
activity.openFile(content.file, null)
}
}

is PluginTabDockableContent ->
if (event is DockingEvent.Redock) {
bringIdeToFront()
activity.selectPluginTabById(content.tabId)
}
}
}

private fun bringIdeToFront() {
if (activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
return
}
activity.startActivity(
Intent(activity, activity.javaClass)
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@


package com.itsaky.androidide.editor.floating

import android.content.Context
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.fragment.app.FragmentFactory
import com.itsaky.androidide.floating.fragment.OverlayFragmentHost
import com.itsaky.androidide.floating.model.DockableContent
import com.itsaky.androidide.floating.window.FloatingWindowHost
import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager

/**
* Adapts a plugin editor tab (an [com.itsaky.androidide.plugins.extensions.EditorTabExtension]
* Fragment) to [DockableContent]. The Fragment is re-instantiated through the plugin's factory and
* hosted in an [OverlayFragmentHost] (a FragmentManager with no Activity), so it can run over other
* apps. A [PluginFragmentFactory] is installed so the plugin's classloader resolves on recreation.
*/
class PluginTabDockableContent(
val tabId: String,
override val title: String,
) : DockableContent {
override val id: String = "plugin:$tabId"

private var fragmentHost: OverlayFragmentHost? = null

override fun onCreateView(
context: Context,
host: FloatingWindowHost,
): View =
try {
val overlayHost = OverlayFragmentHost(context, host, PluginFragmentFactory(FragmentFactory()))
fragmentHost = overlayHost
overlayHost.start()
PluginEditorTabManager.getInstance().newTabFragment(tabId)?.let(overlayHost::setFragment)
overlayHost.view
} catch (t: Throwable) {
Log.e(TAG, "Plugin tab '$tabId' failed to load in a floating window", t)
runCatching { fragmentHost?.destroy() }
fragmentHost = null
errorView(context)
}

override fun onDestroyView() {
runCatching { fragmentHost?.destroy() }
fragmentHost = null
}

private fun errorView(context: Context): View =
TextView(context).apply {
text = "Couldn't open “$title” in a floating window."
val pad = (16 * context.resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, pad)
}

private companion object {
private const val TAG = "PluginTabDockableContent"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,26 @@ class PluginEditorTabManager {
}
}

/**
* Create a fresh, uncached fragment for a plugin tab, registering its classloader so it can be
* recreated. Unlike [getOrCreateTabFragment], the result is not cached, so it is safe to host in
* a separate FragmentManager (e.g. a floating window) without colliding with the docked tab.
*/
fun newTabFragment(tabId: String): Fragment? {
return synchronized(this) {
val tabInfo = pluginTabs[tabId] ?: return null
val fragment = try {
tabInfo.tabItem.fragmentFactory()
} catch (e: Throwable) {
val pluginId = resolvePluginId(tabInfo.extension)
if (pluginId != null) handlePluginCrash(pluginId, "fragmentFactory()", e)
return null
}
registerFragmentClassLoader(tabInfo.extension, fragment)
fragment
}
}

private fun registerFragmentClassLoader(extension: EditorTabExtension, fragment: Fragment) {
val pluginManager = pluginManagerRef ?: run {
logger.warn("PluginManager not available, cannot register fragment classloader")
Expand Down
1 change: 1 addition & 0 deletions resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,7 @@
<string name="template_read_error_template_load">Failed to load template at %1$s error: %2$s</string>
<string name="template_read_error_archive_load">Failed to load template archive %1$s error: %2$s</string>
<string name="navigate_up">Navigate up</string>
<string name="undock">Undock</string>

<!-- Floating window chrome button accessibility labels (announced by screen readers) -->
<string name="floating_window_action_minimize">Minimize</string>
Expand Down
Loading