diff --git a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaHtmlPreviewEditor.kt b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaHtmlPreviewEditor.kt index aa335421..f9475f62 100644 --- a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaHtmlPreviewEditor.kt +++ b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaHtmlPreviewEditor.kt @@ -1,22 +1,29 @@ package me.fornever.avaloniarider.idea.editor +import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.rider.xaml.splitEditor.XamlSplitEditor import me.fornever.avaloniarider.idea.editor.actions.OpenBrowserAction import javax.swing.JComponent class AvaloniaHtmlPreviewEditor( project: Project, - currentFile: VirtualFile -) : AvaloniaPreviewEditorBase(project, currentFile) { + currentFile: VirtualFile, + parentEditor: XamlSplitEditor? = null +) : AvaloniaPreviewEditorBase(project, currentFile, parentEditor) { private val panel = lazy { HtmlPreviewEditorComponent(lifetime, sessionController) } + private val openBrowserAction = OpenBrowserAction(lifetime, sessionController) + override val editorComponent = panel.value + override fun getExtraActions(): Array = arrayOf(openBrowserAction) override fun createToolbar(targetComponent: JComponent) = createToolbarComponent( targetComponent, - OpenBrowserAction(lifetime, sessionController) + false, + openBrowserAction ) } diff --git a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewEditorBase.kt b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewEditorBase.kt index d5d07d21..cd17341c 100644 --- a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewEditorBase.kt +++ b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewEditorBase.kt @@ -8,6 +8,9 @@ import com.intellij.openapi.fileEditor.FileEditorLocation import com.intellij.openapi.fileEditor.FileEditorState import com.intellij.openapi.observable.properties.AtomicProperty import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.WindowWrapper +import com.intellij.openapi.ui.WindowWrapperBuilder +import com.intellij.openapi.util.BooleanGetter import com.intellij.openapi.ui.Splitter import com.intellij.openapi.util.DimensionService import com.intellij.openapi.util.UserDataHolderBase @@ -39,27 +42,24 @@ import me.fornever.avaloniarider.ui.bindVisible import java.awt.BorderLayout import java.awt.Dimension import java.awt.GridBagLayout -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent +import java.awt.Window import java.beans.PropertyChangeListener import javax.swing.JComponent -import javax.swing.JFrame import javax.swing.JPanel -import javax.swing.WindowConstants abstract class AvaloniaPreviewEditorBase( final override val project: Project, private val currentFile: VirtualFile, - private val buildTaskThrottler: Lazy + private val buildTaskThrottler: Lazy, + override val parentEditor: XamlSplitEditor? = null ) : UserDataHolderBase(), XamlPreviewEditor { - constructor(project: Project, currentFile: VirtualFile) : this( + constructor(project: Project, currentFile: VirtualFile, parentEditor: XamlSplitEditor? = null) : this( project, currentFile, - lazy { BuildTaskThrottler.getInstance(project) } + lazy { BuildTaskThrottler.getInstance(project) }, + parentEditor ) - - override val parentEditor: XamlSplitEditor? = null final override val toolbar: PreviewEditorToolbar? = null override val virtualFilePath: String = currentFile.path override val zoomFactorLive: IPropertyView = Property(1.0) @@ -94,8 +94,9 @@ abstract class AvaloniaPreviewEditorBase( } } - private var detachedWindow: JFrame? = null + private var detachedWindow: WindowWrapper? = null private val isPreviewDetached = Property(false) + private val detachedWindowDimensionKey = "AvaloniaPreviewer.DetachedWindow.${currentFile.path}" private val detachedPlaceholderPanel = lazy { JPanel().apply { @@ -152,6 +153,7 @@ abstract class AvaloniaPreviewEditorBase( lifetime.onTermination { UIUtil.invokeLaterIfNeeded { + saveDetachedWindowState(detachedWindow?.window) detachedWindow?.dispose() detachedWindow = null } @@ -202,76 +204,84 @@ abstract class AvaloniaPreviewEditorBase( if (isPreviewDetached.value) return UIUtil.invokeLaterIfNeeded { - val dimensionKey = "AvaloniaPreviewer.DetachedWindow.${currentFile.path}" - val frame = JFrame(AvaloniaRiderBundle.message("previewer.detached.window-title", currentFile.name)) - - frame.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE - frame.layout = BorderLayout() - frame.isResizable = true - frame.minimumSize = Dimension(320, 240) - - frame.addWindowListener(object : WindowAdapter() { - override fun windowClosing(e: WindowEvent) { - val dimensionService = DimensionService.getInstance() - dimensionService.setSize(dimensionKey, frame.size, project) - dimensionService.setLocation(dimensionKey, frame.location, project) - - attachPreviewToEditor() - } - }) + val windowTitle = AvaloniaRiderBundle.message("previewer.detached.window-title", currentFile.name) // Remove editor component from current parent editorComponent.parent?.remove(editorComponent) - // Move the content to the detached window: - frame.contentPane.add(editorComponent, BorderLayout.CENTER) - - // Restore size and location - val dimensionService = DimensionService.getInstance() - val savedSize = dimensionService.getSize(dimensionKey, project) - val savedLocation = dimensionService.getLocation(dimensionKey, project) - - if (savedSize != null) { - frame.size = savedSize - } else { - frame.size = Dimension(800, 600) - } + // Create a content panel that contains both toolbar and preview + val contentPanel = JPanel().apply { + layout = BorderLayout() - if (savedLocation != null) { - frame.location = savedLocation - } else { - val parentWindow = WindowManager.getInstance().suggestParentWindow(project) - frame.setLocationRelativeTo(parentWindow) + // Create toolbar for the detached window (without detach action) + val toolbarPanel = JPanel().apply { + layout = BorderLayout() + add(createDetachedWindowToolbar(editorComponent), BorderLayout.LINE_END) + } + add(toolbarPanel, BorderLayout.PAGE_START) + add(editorComponent, BorderLayout.CENTER) } - detachedWindow = frame + var windowWrapper: WindowWrapper? = null + val wrapper = WindowWrapperBuilder(WindowWrapper.Mode.FRAME, contentPanel) + .setProject(project) + .setTitle(windowTitle) + .setPreferredFocusedComponent(editorComponent) + .setOnCloseHandler(BooleanGetter { + saveDetachedWindowState(windowWrapper?.window) + attachPreviewToEditor(closeWindow = false) + true + }) + .build() + windowWrapper = wrapper + detachedWindow = wrapper isPreviewDetached.value = true - frame.isVisible = true - frame.toFront() + // Switch to "Editor only" mode to maximize useful space + parentEditor?.triggerLayoutChange(XamlSplitEditorSplitLayout.EDITOR_ONLY, requestFocus = false) + + wrapper.show() + + val window = wrapper.window + window.minimumSize = Dimension(320, 240) + + // Ensure the window has decorations (title bar) - fixes regression on Linux + if (window is javax.swing.JFrame) { + window.isUndecorated = false + window.type = Window.Type.NORMAL + } + + restoreDetachedWindowState(window) + window.toFront() } } - internal fun attachPreviewToEditor() { + internal fun attachPreviewToEditor(closeWindow: Boolean = true) { if (!isPreviewDetached.value) return UIUtil.invokeLaterIfNeeded { - detachedWindow?.let { frame -> - // Remove from detached window - frame.contentPane.remove(editorComponent) - frame.dispose() - detachedWindow = null - } + val wrapper = detachedWindow + saveDetachedWindowState(wrapper?.window) + detachedWindow = null + editorComponent.parent?.remove(editorComponent) isPreviewDetached.value = false // The mainComponent property observer will handle re-adding to mainComponentWrapper mainComponent.value = editorComponent + + // Restore split mode to show the preview in editor + parentEditor?.triggerLayoutChange(XamlSplitEditorSplitLayout.SPLIT, requestFocus = false) + + if (closeWindow) { + wrapper?.close() + } } } protected abstract fun createToolbar(targetComponent: JComponent): JComponent protected abstract val editorComponent: JComponent + protected abstract fun getExtraActions(): Array private val component = lazy { JPanel().apply { @@ -298,7 +308,7 @@ abstract class AvaloniaPreviewEditorBase( final override fun getComponent() = component.value override fun getPreferredFocusedComponent() = editorComponent - protected fun createToolbarComponent(targetComponent: JComponent, vararg actions: AnAction): JComponent { + protected fun createToolbarComponent(targetComponent: JComponent, includeDetachAction: Boolean, vararg actions: AnAction): JComponent { val actionGroup = DefaultActionGroup() val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.EDITOR_TOOLBAR, actionGroup, true).apply { this.targetComponent = targetComponent @@ -308,7 +318,9 @@ abstract class AvaloniaPreviewEditorBase( add(getShowErrorAction(toolbar)) add(assemblySelectorAction) add(RestartPreviewerAction(lifetime, sessionController, selectedProjectPath)) - add(ToggleDetachedPreviewAction(this@AvaloniaPreviewEditorBase)) + if (includeDetachAction) { + add(ToggleDetachedPreviewAction(this@AvaloniaPreviewEditorBase)) + } addAll(*actions) add(TogglePreviewerLogAction(isLogManuallyVisible)) add(DebugPreviewerAction(lifetime, sessionController, selectedProjectPath)) @@ -317,6 +329,12 @@ abstract class AvaloniaPreviewEditorBase( return toolbar.component } + protected fun createToolbarComponent(targetComponent: JComponent, vararg actions: AnAction): JComponent = + createToolbarComponent(targetComponent, true, *actions) + + private fun createDetachedWindowToolbar(targetComponent: JComponent): JComponent = + createToolbarComponent(targetComponent, false, *getExtraActions()) + override fun isModified() = false override fun addPropertyChangeListener(listener: PropertyChangeListener) {} override fun removePropertyChangeListener(listener: PropertyChangeListener) {} @@ -326,6 +344,7 @@ abstract class AvaloniaPreviewEditorBase( override fun getCurrentLocation(): FileEditorLocation? = null override fun getBackgroundHighlighter(): BackgroundEditorHighlighter? = null override fun dispose() { + saveDetachedWindowState(detachedWindow?.window) detachedWindow?.dispose() detachedWindow = null lifetimeDefinition.terminate() @@ -338,4 +357,30 @@ abstract class AvaloniaPreviewEditorBase( ) { toolbar.updateActionsAsync() } + + private fun restoreDetachedWindowState(window: Window) { + val dimensionService = DimensionService.getInstance() + val savedSize = dimensionService.getSize(detachedWindowDimensionKey, project) + val savedLocation = dimensionService.getLocation(detachedWindowDimensionKey, project) + + if (savedSize != null) { + window.size = savedSize + } else { + window.size = Dimension(800, 600) + } + + if (savedLocation != null) { + window.location = savedLocation + } else { + val parentWindow = WindowManager.getInstance().suggestParentWindow(project) + window.setLocationRelativeTo(parentWindow) + } + } + + private fun saveDetachedWindowState(window: Window?) { + if (window == null) return + val dimensionService = DimensionService.getInstance() + dimensionService.setSize(detachedWindowDimensionKey, window.size, project) + dimensionService.setLocation(detachedWindowDimensionKey, window.location, project) + } } diff --git a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewerXamlEditorExtension.kt b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewerXamlEditorExtension.kt index ec2e9ef5..2a37d650 100644 --- a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewerXamlEditorExtension.kt +++ b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaPreviewerXamlEditorExtension.kt @@ -19,7 +19,7 @@ class AvaloniaPreviewerXamlEditorExtension : XamlPreviewEditorExtension { parent: XamlSplitEditor, platform: PreviewPlatformKind ): XamlPreviewEditor = when (AvaloniaProjectSettings.getInstance(project).previewerTransportType) { - AvaloniaPreviewerMethod.AvaloniaRemote -> AvaloniaRemotePreviewEditor(project, file) - AvaloniaPreviewerMethod.Html -> AvaloniaHtmlPreviewEditor(project, file) + AvaloniaPreviewerMethod.AvaloniaRemote -> AvaloniaRemotePreviewEditor(project, file, parent) + AvaloniaPreviewerMethod.Html -> AvaloniaHtmlPreviewEditor(project, file, parent) } } diff --git a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaRemotePreviewEditor.kt b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaRemotePreviewEditor.kt index 2e8c6368..2ab5b0e0 100644 --- a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaRemotePreviewEditor.kt +++ b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/AvaloniaRemotePreviewEditor.kt @@ -1,21 +1,27 @@ package me.fornever.avaloniarider.idea.editor +import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.rider.xaml.splitEditor.XamlSplitEditor import me.fornever.avaloniarider.idea.editor.actions.ZoomLevelSelectorAction import me.fornever.avaloniarider.idea.settings.AvaloniaProjectSettings import javax.swing.JComponent class AvaloniaRemotePreviewEditor( project: Project, - currentFile: VirtualFile -) : AvaloniaPreviewEditorBase(project, currentFile) { + currentFile: VirtualFile, + parentEditor: XamlSplitEditor? = null +) : AvaloniaPreviewEditorBase(project, currentFile, parentEditor) { private val panel = lazy { BitmapPreviewEditorComponent(lifetime, sessionController, AvaloniaProjectSettings.getInstance(project)) } + private val zoomAction = ZoomLevelSelectorAction(sessionController.zoomFactor) + override val editorComponent = panel.value + override fun getExtraActions(): Array = arrayOf(zoomAction) override fun createToolbar(targetComponent: JComponent) = - createToolbarComponent(targetComponent, ZoomLevelSelectorAction(sessionController.zoomFactor)) + createToolbarComponent(targetComponent, false, zoomAction) } diff --git a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/actions/SwapPreviewAndDetachGroup.kt b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/actions/SwapPreviewAndDetachGroup.kt new file mode 100644 index 00000000..3ce73c24 --- /dev/null +++ b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/actions/SwapPreviewAndDetachGroup.kt @@ -0,0 +1,35 @@ +package me.fornever.avaloniarider.idea.editor.actions + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DumbAware +import com.jetbrains.rider.xaml.splitEditor.editorActions.SwapPreviewAndTextEditor +import com.jetbrains.rider.xaml.splitEditor.editorActions.XamlSplitEditorActionsUtils + +class SwapPreviewAndDetachGroup : DefaultActionGroup(), DumbAware { + private val swapAction = SwapPreviewAndTextEditor() + private val detachAction = ToggleDetachedPreviewInToolbarAction() + + init { + setPopup(false) + templatePresentation.apply { + text = swapAction.templatePresentation.text + description = swapAction.templatePresentation.description + icon = swapAction.templatePresentation.icon + } + add(swapAction) + add(detachAction) + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + swapAction.actionPerformed(e) + } + + override fun update(e: AnActionEvent) { + val splitEditor = XamlSplitEditorActionsUtils.getSplitEditorFromEvent(e) + e.presentation.isEnabledAndVisible = splitEditor != null + } +} diff --git a/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/actions/ToggleDetachedPreviewInToolbarAction.kt b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/actions/ToggleDetachedPreviewInToolbarAction.kt new file mode 100644 index 00000000..c8f7b49d --- /dev/null +++ b/src/rider/main/kotlin/me/fornever/avaloniarider/idea/editor/actions/ToggleDetachedPreviewInToolbarAction.kt @@ -0,0 +1,56 @@ +package me.fornever.avaloniarider.idea.editor.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.project.DumbAware +import com.jetbrains.rider.xaml.splitEditor.XamlSplitEditorSplitLayout +import com.jetbrains.rider.xaml.splitEditor.editorActions.XamlSplitEditorActionsUtils +import me.fornever.avaloniarider.AvaloniaRiderBundle +import me.fornever.avaloniarider.idea.editor.AvaloniaPreviewEditorBase + +class ToggleDetachedPreviewInToolbarAction : ToggleAction( + AvaloniaRiderBundle.messagePointer("action.previewer.detach"), + AllIcons.Actions.MoveToWindow +), DumbAware { + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun isSelected(e: AnActionEvent): Boolean = + getPreviewEditor(e)?.isPreviewDetached() == true + + override fun setSelected(e: AnActionEvent, state: Boolean) { + val previewEditor = getPreviewEditor(e) ?: return + if (state) { + previewEditor.detachPreviewToWindow() + } else { + previewEditor.attachPreviewToEditor() + } + } + + override fun update(e: AnActionEvent) { + super.update(e) + val splitEditor = XamlSplitEditorActionsUtils.getSplitEditorFromEvent(e) + if (splitEditor == null) { + e.presentation.isEnabledAndVisible = false + return + } + val previewEditor = splitEditor.previewEditor as? AvaloniaPreviewEditorBase + val isDetached = previewEditor?.isPreviewDetached() == true + val isVisible = !isDetached + + val textKey = if (isDetached) "action.previewer.attach" else "action.previewer.detach" + val descriptionKey = if (isDetached) "action.previewer.attach.description" else "action.previewer.detach.description" + + e.presentation.text = AvaloniaRiderBundle.message(textKey) + e.presentation.description = AvaloniaRiderBundle.message(descriptionKey) + e.presentation.isVisible = isVisible + e.presentation.isEnabled = previewEditor != null + } + + private fun getPreviewEditor(e: AnActionEvent): AvaloniaPreviewEditorBase? { + val splitEditor = XamlSplitEditorActionsUtils.getSplitEditorFromEvent(e) ?: return null + return splitEditor.previewEditor as? AvaloniaPreviewEditorBase + } +} diff --git a/src/rider/main/resources/META-INF/plugin.xml b/src/rider/main/resources/META-INF/plugin.xml index 6afab5cd..18652a38 100644 --- a/src/rider/main/resources/META-INF/plugin.xml +++ b/src/rider/main/resources/META-INF/plugin.xml @@ -19,4 +19,10 @@ + + + +