diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 9805bb5f5b..f3473df67b 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -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 @@ -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() } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index bf3c77705a..1b70707802 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -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, @@ -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?, @@ -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) } diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt new file mode 100644 index 0000000000..8d3726b09c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt @@ -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 = emptyList() + + override val actions: List + 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 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt new file mode 100644 index 0000000000..223b8c030a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt new file mode 100644 index 0000000000..8825e6660c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt @@ -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" + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginEditorTabManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginEditorTabManager.kt index 21dda804f2..4fe6a7fae6 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginEditorTabManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginEditorTabManager.kt @@ -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") diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 85ceb3a561..36667a551c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1338,6 +1338,7 @@ Failed to load template at %1$s error: %2$s Failed to load template archive %1$s error: %2$s Navigate up + Undock Minimize