diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2cf2cf2d..579628942 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +acc-agent = "1.0.4" assertj = "3.27.3" asm = "9.8" atlantafx = "2.0.1" @@ -9,7 +10,7 @@ cdi-impl = "6.0.2.Final" cfr = "0.152" dex-translator = "1.1.1" diffutils = "4.15" -docking = "1.2.3" +docking = "0.7.3" downgrader = "1.1.2" extra-collections = "1.6.0" extra-observables = "1.3.0" @@ -47,6 +48,8 @@ shadow = "8.3.5" peterabeles-gversion = "1.10.3" [libraries] +acc-agent = { module = "software.coley:javafx-access-agent", version.ref = "acc-agent" } + assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" } asm-core = { module = "org.ow2.asm:asm", version.ref = "asm" } @@ -71,7 +74,7 @@ dex-translator = { module = "software.coley:dex-translator", version.ref = "dex- diffutils = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "diffutils" } -docking = { module = "com.github.Col-E:tiwulfx-dock", version.ref = "docking" } +docking = { module = "software.coley:bento-fx", version.ref = "docking" } downgrader = { module = "com.github.RaphiMC.JavaDowngrader:core", version.ref = "downgrader" } diff --git a/recaf-core/src/main/java/software/coley/recaf/services/search/SearchFeedback.java b/recaf-core/src/main/java/software/coley/recaf/services/search/SearchFeedback.java index da2a7d2fd..fadb10566 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/search/SearchFeedback.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/search/SearchFeedback.java @@ -69,4 +69,9 @@ default boolean doVisitFile(@Nonnull FileInfo file) { default boolean doAcceptResult(@Nonnull Result result) { return true; } + + /** + * Called when the search query completes. + */ + default void onCompletion() {} } diff --git a/recaf-core/src/main/java/software/coley/recaf/services/search/SearchService.java b/recaf-core/src/main/java/software/coley/recaf/services/search/SearchService.java index d253a2ee0..16e6fd1ea 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/search/SearchService.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/search/SearchService.java @@ -141,6 +141,10 @@ public Results search(@Nonnull Workspace workspace, @Nonnull List queries searchResource(results, service, feedback, resource, workspaceNode, androidClassVisitor, jvmClassVisitor, fileVisitor); ThreadUtil.blockUntilComplete(service); + + // Notify feedback of search completion + feedback.onCompletion(); + return results; } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/threading/Batch.java b/recaf-core/src/main/java/software/coley/recaf/util/threading/Batch.java new file mode 100644 index 000000000..1f0b19348 --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/util/threading/Batch.java @@ -0,0 +1,26 @@ +package software.coley.recaf.util.threading; + +import jakarta.annotation.Nonnull; + +/** + * Outlines a model to wrap multiple tasks into a single execution. + * + * @author Matt Coley + */ +public interface Batch { + /** + * Run all registered tasks. This will remove all executed tasks once completed. + */ + void execute(); + + /** + * @param runnable + * Task to execute. + */ + void add(@Nonnull Runnable runnable); + + /** + * Clears tasks. + */ + void clear(); +} diff --git a/recaf-ui/build.gradle b/recaf-ui/build.gradle index 54c1b1c04..572d81e27 100644 --- a/recaf-ui/build.gradle +++ b/recaf-ui/build.gradle @@ -16,6 +16,7 @@ dependencies { implementation project(':recaf-core') testImplementation(testFixtures(project(":recaf-core"))) + implementation(libs.acc.agent) implementation(libs.atlantafx) implementation(libs.docking) implementation(libs.bundles.ikonli) diff --git a/recaf-ui/src/main/java/software/coley/recaf/Main.java b/recaf-ui/src/main/java/software/coley/recaf/Main.java index 3012b8faf..1d07a4a2d 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/Main.java +++ b/recaf-ui/src/main/java/software/coley/recaf/Main.java @@ -3,6 +3,7 @@ import jakarta.enterprise.inject.spi.Bean; import org.slf4j.Logger; import picocli.CommandLine; +import software.coley.fxaccess.AccessCheck; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.cdi.EagerInitializationExtension; @@ -120,6 +121,7 @@ private static void initialize() { initPlugins(); fireInitEvent(); } else { + initFxAccessAgent(); initTranslations(); initPlugins(); fireInitEvent(); @@ -140,6 +142,33 @@ private static void initScale() { System.setProperty("glass.gtk.uiScale", String.valueOf(scale)); } + /** + * Configure the JavaFX access logging agent. + * The logging is only active when the agent is passed as a launch argument to Recaf. + *
+ * Example usage: {@code -javaagent:javafx-access-agent.jar=software/;org/;com/;javafx/} + */ + private static void initFxAccessAgent() { + AccessCheck.addAccessCheckListener((className, methodName, lineNumber, threadName, calledMethodSignature) -> { + // Some kinds of operations are safe and can be ignored. + if (calledMethodSignature != null) { + // Skip on constructors + if (calledMethodSignature.contains("<")) + return; + + // Skip on get operations + if (calledMethodSignature.contains("#get")) + return; + + // Skip on things that will be operated on later + if (calledMethodSignature.contains("#setOn") || calledMethodSignature.contains("#addListener")) + return; + } + + System.err.printf("[thread:%s] %s.%s (line %d) - %s\n", threadName, className, methodName, lineNumber, calledMethodSignature); + }); + } + /** * Publishes the {@link InitializationEvent} so that {@link EagerInitialization} annotated services marked to be run * {@link InitializationStage#IMMEDIATE immediately} are initialized. diff --git a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java index 6649795dc..fb4efe94d 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java +++ b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java @@ -1,48 +1,31 @@ package software.coley.recaf; -import jakarta.annotation.Nonnull; import javafx.application.Application; -import javafx.geometry.Orientation; -import javafx.scene.Node; import javafx.scene.Scene; -import javafx.scene.control.SplitPane; import javafx.scene.input.KeyEvent; import javafx.scene.layout.BorderPane; import javafx.stage.Stage; -import org.kordamp.ikonli.carbonicons.CarbonIcons; import software.coley.recaf.cdi.UiInitializationEvent; import software.coley.recaf.services.window.WindowManager; -import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.services.workspace.WorkspaceManager; -import software.coley.recaf.services.workspace.WorkspaceOpenListener; import software.coley.recaf.ui.RecafTheme; import software.coley.recaf.ui.config.KeybindingConfig; import software.coley.recaf.ui.config.WindowScaleConfig; -import software.coley.recaf.ui.control.FontIconView; -import software.coley.recaf.ui.docking.DockingManager; -import software.coley.recaf.ui.docking.DockingRegion; -import software.coley.recaf.ui.docking.DockingTab; import software.coley.recaf.ui.menubar.MainMenu; import software.coley.recaf.ui.pane.LoggingPane; -import software.coley.recaf.ui.pane.WelcomePane; -import software.coley.recaf.ui.pane.WorkspaceRootPane; +import software.coley.recaf.ui.docking.DockingLayoutManager; import software.coley.recaf.ui.window.RecafScene; import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.util.Icons; -import software.coley.recaf.util.Lang; import software.coley.recaf.workspace.PathExportingManager; -import software.coley.recaf.workspace.model.Workspace; /** * JavaFX application entry point. * * @author Matt Coley */ -public class RecafApplication extends Application implements WorkspaceOpenListener, WorkspaceCloseListener { +public class RecafApplication extends Application { private final Recaf recaf = Bootstrap.get(); - private final BorderPane root = new BorderPane(); - private WorkspaceRootPane workspaceRootPane; - private WelcomePane welcomePane; @Override public void start(Stage stage) { @@ -52,30 +35,21 @@ public void start(Stage stage) { // Setup global style setUserAgentStylesheet(new RecafTheme().getUserAgentStylesheet()); - // Get components - MainMenu menu = recaf.get(MainMenu.class); - WelcomePane pane = recaf.get(WelcomePane.class); - Node logging = createLoggingWrapper(); - workspaceRootPane = recaf.get(WorkspaceRootPane.class); - welcomePane = recaf.get(WelcomePane.class); + // Get services + DockingLayoutManager dockingLayoutManager = recaf.get(DockingLayoutManager.class); KeybindingConfig keybindingConfig = recaf.get(KeybindingConfig.class); WindowManager windowManager = recaf.get(WindowManager.class); + WorkspaceManager workspaceManager = recaf.get(WorkspaceManager.class); + + // Get components + MainMenu menu = recaf.get(MainMenu.class); + LoggingPane logging = recaf.get(LoggingPane.class); // Layout - SplitPane splitPane = new SplitPane(root, logging); - SplitPane.setResizableWithParent(logging, false); - splitPane.setOrientation(Orientation.VERTICAL); - splitPane.setDividerPositions(0.21); // Behaves inverse to expectation in these specific circumstances BorderPane wrapper = new BorderPane(); wrapper.setTop(menu); - wrapper.setCenter(splitPane); + wrapper.setCenter(dockingLayoutManager.getRoot().getBackingRegion()); wrapper.getStyleClass().addAll("padded", "bg-inset"); - root.setCenter(pane); - - // Register listener - WorkspaceManager workspaceManager = recaf.get(WorkspaceManager.class); - workspaceManager.addWorkspaceOpenListener(this); - workspaceManager.addWorkspaceCloseListener(this); // Display WindowScaleConfig scaleConfig = recaf.get(WindowScaleConfig.class); @@ -105,23 +79,4 @@ public void start(Stage stage) { recaf.getContainer().getBeanContainer().getEvent().fire(new UiInitializationEvent()); } - @Nonnull - private Node createLoggingWrapper() { - LoggingPane logging = recaf.get(LoggingPane.class); - DockingRegion dockingPane = recaf.get(DockingManager.class).newRegion(); - DockingTab tab = dockingPane.createTab(Lang.getBinding("logging.title"), logging); - tab.setGraphic(new FontIconView(CarbonIcons.TERMINAL)); - tab.setClosable(false); - return dockingPane; - } - - @Override - public void onWorkspaceClosed(@Nonnull Workspace workspace) { - FxThreadUtil.run(() -> root.setCenter(welcomePane)); - } - - @Override - public void onWorkspaceOpened(@Nonnull Workspace workspace) { - FxThreadUtil.run(() -> root.setCenter(workspaceRootPane)); - } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/ResourceSummaryService.java b/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/ResourceSummaryService.java index 06bafb26b..b2b2c26fb 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/ResourceSummaryService.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/ResourceSummaryService.java @@ -9,6 +9,7 @@ import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.Service; import software.coley.recaf.ui.pane.WorkspaceInformationPane; +import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; @@ -68,7 +69,7 @@ public CompletableFuture summarizeTo(@Nonnull Workspace workspace, boolean lastSummarizerAppended = false; for (ResourceSummarizer summarizer : new TreeSet<>(summarizers.values())) { if (lastSummarizerAppended) - consumer.appendSummary(new Separator()); + FxThreadUtil.run(() -> consumer.appendSummary(new Separator())); try { lastSummarizerAppended = summarizer.summarize(workspace, resource, consumer); } catch (Throwable t) { diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/EntryPointSummarizer.java b/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/EntryPointSummarizer.java index 23682af74..b9b4b338e 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/EntryPointSummarizer.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/EntryPointSummarizer.java @@ -8,7 +8,6 @@ import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Label; -import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.cell.icon.IconProviderService; @@ -17,7 +16,9 @@ import software.coley.recaf.services.info.summary.SummaryConsumer; import software.coley.recaf.services.navigation.Actions; import software.coley.recaf.ui.control.BoundLabel; +import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.util.Lang; +import software.coley.recaf.util.threading.Batch; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; @@ -41,8 +42,8 @@ public class EntryPointSummarizer implements ResourceSummarizer { @Inject public EntryPointSummarizer(@Nonnull TextProviderService textService, - @Nonnull IconProviderService iconService, - @Nonnull Actions actions) { + @Nonnull IconProviderService iconService, + @Nonnull Actions actions) { this.textService = textService; this.iconService = iconService; this.actions = actions; @@ -50,11 +51,14 @@ public EntryPointSummarizer(@Nonnull TextProviderService textService, @Override public boolean summarize(@Nonnull Workspace workspace, - @Nonnull WorkspaceResource resource, - @Nonnull SummaryConsumer consumer) { - Label title = new BoundLabel(Lang.getBinding("service.analysis.entry-points")); - title.getStyleClass().addAll(Styles.TITLE_4); - consumer.appendSummary(title); + @Nonnull WorkspaceResource resource, + @Nonnull SummaryConsumer consumer) { + Batch batch = FxThreadUtil.batch(); + batch.add(() -> { + Label title = new BoundLabel(Lang.getBinding("service.analysis.entry-points")); + title.getStyleClass().addAll(Styles.TITLE_4); + consumer.appendSummary(title); + }); // Visit JVM classes int[] found = {0}; @@ -64,42 +68,45 @@ public boolean summarize(@Nonnull Workspace workspace, .filter(this::isJvmEntry) .toList(); if (!entryMethods.isEmpty()) { - Supplier classLookup = () -> Objects.requireNonNullElse(bundle.get(cls.getName()), cls); + found[0]++; + batch.add(() -> { + Supplier classLookup = () -> Objects.requireNonNullElse(bundle.get(cls.getName()), cls); - // Add entry for class - String classDisplay = textService.getJvmClassInfoTextProvider(workspace, resource, bundle, cls).makeText(); - Node classIcon = iconService.getJvmClassInfoIconProvider(workspace, resource, bundle, cls).makeIcon(); - Label classLabel = new Label(classDisplay, classIcon); - classLabel.setCursor(Cursor.HAND); - classLabel.setOnMouseEntered(e -> classLabel.getStyleClass().add(Styles.TEXT_UNDERLINED)); - classLabel.setOnMouseExited(e -> classLabel.getStyleClass().remove(Styles.TEXT_UNDERLINED)); - classLabel.setOnMouseClicked(e -> actions.gotoDeclaration(workspace, resource, bundle, classLookup.get())); - consumer.appendSummary(classLabel); + // Add entry for class + String classDisplay = textService.getJvmClassInfoTextProvider(workspace, resource, bundle, cls).makeText(); + Node classIcon = iconService.getJvmClassInfoIconProvider(workspace, resource, bundle, cls).makeIcon(); + Label classLabel = new Label(classDisplay, classIcon); + classLabel.setCursor(Cursor.HAND); + classLabel.setOnMouseEntered(e -> classLabel.getStyleClass().add(Styles.TEXT_UNDERLINED)); + classLabel.setOnMouseExited(e -> classLabel.getStyleClass().remove(Styles.TEXT_UNDERLINED)); + classLabel.setOnMouseClicked(e -> actions.gotoDeclaration(workspace, resource, bundle, classLookup.get())); + consumer.appendSummary(classLabel); - // Add entries for methods - for (MethodMember method : entryMethods) { - String methodDisplay = textService.getMethodMemberTextProvider(workspace, resource, bundle, cls, method).makeText(); - Node methodIcon = iconService.getClassMemberIconProvider(workspace, resource, bundle, cls, method).makeIcon(); - Label methodLabel = new Label(methodDisplay); - methodLabel.setCursor(Cursor.HAND); - methodLabel.setGraphic(methodIcon); - methodLabel.setPadding(new Insets(2, 2, 2, 15)); - methodLabel.setOnMouseEntered(e -> methodLabel.getStyleClass().add(Styles.TEXT_UNDERLINED)); - methodLabel.setOnMouseExited(e -> methodLabel.getStyleClass().remove(Styles.TEXT_UNDERLINED)); - methodLabel.setOnMouseClicked(e -> { - actions.gotoDeclaration(workspace, resource, bundle, classLookup.get()) - .requestFocus(method); - }); - consumer.appendSummary(methodLabel); - found[0]++; - } + // Add entries for methods + for (MethodMember method : entryMethods) { + String methodDisplay = textService.getMethodMemberTextProvider(workspace, resource, bundle, cls, method).makeText(); + Node methodIcon = iconService.getClassMemberIconProvider(workspace, resource, bundle, cls, method).makeIcon(); + Label methodLabel = new Label(methodDisplay); + methodLabel.setCursor(Cursor.HAND); + methodLabel.setGraphic(methodIcon); + methodLabel.setPadding(new Insets(2, 2, 2, 15)); + methodLabel.setOnMouseEntered(e -> methodLabel.getStyleClass().add(Styles.TEXT_UNDERLINED)); + methodLabel.setOnMouseExited(e -> methodLabel.getStyleClass().remove(Styles.TEXT_UNDERLINED)); + methodLabel.setOnMouseClicked(e -> { + actions.gotoDeclaration(workspace, resource, bundle, classLookup.get()) + .requestFocus(method); + }); + consumer.appendSummary(methodLabel); + } + }); } }); }); - if (found[0] == 0) { - consumer.appendSummary(new BoundLabel(Lang.getBinding("service.analysis.entry-points.none"))); - } + if (found[0] == 0) + batch.add(() -> consumer.appendSummary(new BoundLabel(Lang.getBinding("service.analysis.entry-points.none")))); + + batch.execute(); return true; } diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java index 785fbd295..7ee05e1a0 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java @@ -8,6 +8,7 @@ import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.scene.Node; +import javafx.scene.Scene; import javafx.scene.control.ContextMenu; import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; @@ -19,6 +20,10 @@ import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.slf4j.Logger; +import software.coley.bentofx.dockable.Dockable; +import software.coley.bentofx.dockable.DockableIconFactory; +import software.coley.bentofx.path.DockablePath; +import software.coley.bentofx.space.TabbedDockSpace; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.AndroidClassInfo; @@ -48,6 +53,7 @@ import software.coley.recaf.path.PathNodes; import software.coley.recaf.services.Service; import software.coley.recaf.services.cell.CellConfigurationService; +import software.coley.recaf.services.cell.icon.IconProvider; import software.coley.recaf.services.cell.icon.IconProviderService; import software.coley.recaf.services.cell.text.TextProviderService; import software.coley.recaf.services.inheritance.InheritanceGraph; @@ -67,10 +73,9 @@ import software.coley.recaf.ui.control.popup.NamePopup; import software.coley.recaf.ui.control.popup.OverrideMethodPopup; import software.coley.recaf.ui.docking.DockingManager; -import software.coley.recaf.ui.docking.DockingRegion; -import software.coley.recaf.ui.docking.DockingTab; import software.coley.recaf.ui.pane.CommentEditPane; import software.coley.recaf.ui.pane.DocumentationPane; +import software.coley.recaf.ui.pane.WorkspaceInformationPane; import software.coley.recaf.ui.pane.editing.AbstractContentPane; import software.coley.recaf.ui.pane.editing.android.AndroidClassEditorType; import software.coley.recaf.ui.pane.editing.android.AndroidClassPane; @@ -90,12 +95,10 @@ import software.coley.recaf.ui.pane.search.MemberReferenceSearchPane; import software.coley.recaf.ui.pane.search.NumberSearchPane; import software.coley.recaf.ui.pane.search.StringSearchPane; -import software.coley.recaf.ui.window.RecafScene; import software.coley.recaf.util.ClipboardUtil; import software.coley.recaf.util.EscapeUtil; import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.util.Lang; -import software.coley.recaf.util.SceneUtils; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.visitors.ClassAnnotationRemovingVisitor; import software.coley.recaf.util.visitors.FieldAnnotationRemovingVisitor; @@ -161,6 +164,7 @@ public class Actions implements Service { private final Instance videoPaneProvider; private final Instance hexPaneProvider; private final Instance assemblerPaneProvider; + private final Instance infoPaneProvider; private final Instance documentationPaneProvider; private final Instance callGraphsPaneProvider; private final Instance stringSearchPaneProvider; @@ -194,6 +198,7 @@ public Actions(@Nonnull ActionsConfig config, @Nonnull Instance videoPaneProvider, @Nonnull Instance hexPaneProvider, @Nonnull Instance assemblerPaneProvider, + @Nonnull Instance infoPaneProvider, @Nonnull Instance documentationPaneProvider, @Nonnull Instance stringSearchPaneProvider, @Nonnull Instance numberSearchPaneProvider, @@ -222,6 +227,7 @@ public Actions(@Nonnull ActionsConfig config, this.videoPaneProvider = videoPaneProvider; this.hexPaneProvider = hexPaneProvider; this.assemblerPaneProvider = assemblerPaneProvider; + this.infoPaneProvider = infoPaneProvider; this.documentationPaneProvider = documentationPaneProvider; this.stringSearchPaneProvider = stringSearchPaneProvider; this.numberSearchPaneProvider = numberSearchPaneProvider; @@ -331,28 +337,29 @@ public ClassNavigable gotoDeclaration(@Nonnull Workspace workspace, return (ClassNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getJvmClassInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getJvmClassInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getJvmClassInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. JvmClassPane content = jvmPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); content.addPathUpdateListener(updatedPath -> { // Update tab graphic in case backing class details change. JvmClassInfo updatedInfo = updatedPath.getValue().asJvmClass(); String updatedTitle = textService.getJvmClassInfoTextProvider(workspace, resource, bundle, updatedInfo).makeText(); - Node updatedGraphic = iconService.getJvmClassInfoIconProvider(workspace, resource, bundle, updatedInfo).makeIcon(); + IconProvider updatedIconProvider = iconService.getJvmClassInfoIconProvider(workspace, resource, bundle, updatedInfo); + DockableIconFactory updatedGraphicFactory = d -> Objects.requireNonNull(updatedIconProvider.makeIcon(), "Missing graphic"); FxThreadUtil.run(() -> { - tab.setText(updatedTitle); - tab.setGraphic(updatedGraphic); + dockable.withTitle(updatedTitle); + dockable.withIconFactory(updatedGraphicFactory); }); }); - setupInfoTabContextMenu(info, content, tab); - return tab; + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -380,28 +387,29 @@ public ClassNavigable gotoDeclaration(@Nonnull Workspace workspace, return (ClassNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getAndroidClassInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getAndroidClassInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getAndroidClassInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. AndroidClassPane content = androidPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); content.addPathUpdateListener(updatedPath -> { // Update tab graphic in case backing class details change. AndroidClassInfo updatedInfo = updatedPath.getValue().asAndroidClass(); String updatedTitle = textService.getAndroidClassInfoTextProvider(workspace, resource, bundle, updatedInfo).makeText(); - Node updatedGraphic = iconService.getAndroidClassInfoIconProvider(workspace, resource, bundle, updatedInfo).makeIcon(); + IconProvider updatedIconProvider = iconService.getAndroidClassInfoIconProvider(workspace, resource, bundle, updatedInfo); + DockableIconFactory updatedGraphicFactory = d -> Objects.requireNonNull(updatedIconProvider.makeIcon(), "Missing graphic"); FxThreadUtil.run(() -> { - tab.setText(updatedTitle); - tab.setGraphic(updatedGraphic); + dockable.withTitle(updatedTitle); + dockable.withIconFactory(updatedGraphicFactory); }); }); - setupInfoTabContextMenu(info, content, tab); - return tab; + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -477,18 +485,18 @@ public FileNavigable gotoDeclaration(@Nonnull Workspace workspace, return (FileNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getFileInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getFileInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getFileInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. BinaryXmlFilePane content = binaryXmlPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); - setupInfoTabContextMenu(info, content, tab); - return tab; + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -516,31 +524,32 @@ public FileNavigable gotoDeclaration(@Nonnull Workspace workspace, return (FileNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getFileInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getFileInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getFileInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. TextFilePane content = textPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); - ContextMenu menu = new ContextMenu(); - ObservableList items = menu.getItems(); - Menu mode = menu("menu.mode", CarbonIcons.VIEW); - mode.getItems().addAll( - action("menu.mode.file.text", CarbonIcons.CODE, - () -> content.setEditorType(TextEditorType.TEXT)), - action("menu.mode.file.hex", CarbonIcons.NUMBER_0, - () -> content.setEditorType(TextEditorType.HEX)) - ); - items.add(mode); - addCopyPathAction(menu, info); - addCloseActions(menu, tab); - tab.setContextMenu(menu); - - return tab; + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); + dockable.withCachedContextMenu(true).withContextMenuFactory(d -> { + ContextMenu menu = new ContextMenu(); + ObservableList items = menu.getItems(); + Menu mode = menu("menu.mode", CarbonIcons.VIEW); + mode.getItems().addAll( + action("menu.mode.file.text", CarbonIcons.CODE, + () -> content.setEditorType(TextEditorType.TEXT)), + action("menu.mode.file.hex", CarbonIcons.NUMBER_0, + () -> content.setEditorType(TextEditorType.HEX)) + ); + items.add(mode); + addCopyPathAction(menu, info); + addCloseActions(menu, dockable); + return menu; + }); + return dockable; }); } @@ -568,18 +577,18 @@ public FileNavigable gotoDeclaration(@Nonnull Workspace workspace, return (FileNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getFileInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getFileInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getFileInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. ImageFilePane content = imagePaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); - setupInfoTabContextMenu(info, content, tab); - return tab; + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -607,18 +616,18 @@ public FileNavigable gotoDeclaration(@Nonnull Workspace workspace, return (FileNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getFileInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getFileInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getFileInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. AudioFilePane content = audioPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); - setupInfoTabContextMenu(info, content, tab); - return tab; + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -646,18 +655,18 @@ public FileNavigable gotoDeclaration(@Nonnull Workspace workspace, return (FileNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getFileInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getFileInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getFileInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. VideoFilePane content = videoPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); - setupInfoTabContextMenu(info, content, tab); - return tab; + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -685,18 +694,18 @@ public FileNavigable gotoDeclaration(@Nonnull Workspace workspace, return (FileNavigable) getOrCreatePathContent(path, () -> { // Create text/graphic for the tab to create. String title = textService.getFileInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = iconService.getFileInfoIconProvider(workspace, resource, bundle, info).makeIcon(); + IconProvider iconProvider = iconService.getFileInfoIconProvider(workspace, resource, bundle, info); + DockableIconFactory graphicFactory = d -> Objects.requireNonNull(iconProvider.makeIcon(), "Missing graphic"); if (title == null) throw new IllegalStateException("Missing title"); - if (graphic == null) throw new IllegalStateException("Missing graphic"); // Create content for the tab. HexFilePane content = hexPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - DockingTab tab = createTab(dockingManager.getPrimaryRegion(), title, graphic, content); - setupInfoTabContextMenu(info, content, tab); - return tab; + Dockable dockable = createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); + setupInfoContextMenu(info, content, dockable); + return dockable; }); } @@ -721,9 +730,9 @@ public void openCommentEditing(@Nonnull Workspace workspace, // Create text/graphic for the tab to create. String title = textService.getClassInfoTextProvider(workspace, resource, bundle, info).makeText(); - Node graphic = new FontIconView(CarbonIcons.BOOKMARK_FILLED); + DockableIconFactory graphicFactory = d -> new FontIconView(CarbonIcons.BOOKMARK_FILLED); if (title == null) throw new IllegalStateException("Missing title"); - return createCommentEditTab(path, title, graphic, info); + return createCommentEditDockable(path, title, graphicFactory, info); }); } @@ -752,32 +761,52 @@ public void openCommentEditing(@Nonnull Workspace workspace, // Create text/graphic for the tab to create. ClassInfo classInfo = path.getParent().getValue(); String title = textService.getMemberTextProvider(workspace, resource, bundle, classInfo, member).makeText(); - Node graphic = new FontIconView(CarbonIcons.BOOKMARK_FILLED); + DockableIconFactory graphicFactory = d -> new FontIconView(CarbonIcons.BOOKMARK_FILLED); if (title == null) throw new IllegalStateException("Missing title"); - return createCommentEditTab(path, title, graphic, classInfo); + return createCommentEditDockable(path, title, graphicFactory, classInfo); }); } @Nonnull - private DockingTab createCommentEditTab(@Nonnull PathNode path, @Nonnull String title, - @Nonnull Node graphic, @Nonnull ClassInfo classInfo) { - // Create content for the tab. + private Dockable createCommentEditDockable(@Nonnull PathNode path, @Nonnull String title, + @Nonnull DockableIconFactory graphicFactory, @Nonnull ClassInfo classInfo) { + // Create content for the dockable. CommentEditPane content = documentationPaneProvider.get(); content.onUpdatePath(path); // Place the tab in a region with other comments if possible. - DockingRegion targetRegion = dockingManager.getDockTabs().stream() - .filter(t -> t.getContent() instanceof DocumentationPane) - .map(DockingTab::getRegion) - .findFirst().orElse(dockingManager.getPrimaryRegion()); - - // Build the tab. - DockingTab tab = createTab(targetRegion, title, graphic, content); - ContextMenu menu = new ContextMenu(); - ObservableList items = menu.getItems(); - addCloseActions(menu, tab); - tab.setContextMenu(menu); - return tab; + DockablePath docPanePath = null; + for (DockablePath dockablePath : dockingManager.getBento().getAllDockables()) { + Dockable dockable = dockablePath.dockable(); + Node node = dockable.nodeProperty().get(); + if (node instanceof DocumentationPane) { + docPanePath = dockablePath; + break; + } + } + TabbedDockSpace space = docPanePath != null ? + (TabbedDockSpace) docPanePath.space() : + dockingManager.getPrimaryTabbedSpace(); + + // Build the dockable. + Dockable dockable = createDockable(space, title, graphicFactory, content); + space.addDockable(dockable); + dockable.withCachedContextMenu(true).withContextMenuFactory(d -> { + ContextMenu menu = new ContextMenu(); + ObservableList items = menu.getItems(); + addCloseActions(menu, dockable); + return menu; + }); + return dockable; + } + + /** + * Display the workspace summary / current information. + */ + public void openSummary() { + WorkspaceInformationPane informationPane = infoPaneProvider.get(); + createDockable(dockingManager.getPrimaryTabbedSpace(), getBinding("workspace.info"), + d -> new FontIconView(CarbonIcons.INFORMATION), informationPane); } /** @@ -1630,14 +1659,14 @@ public Navigable openAssembler(@Nonnull PathNode path) throws IncompletePathE else if (path instanceof ClassMemberPathNode classMemberPathNode) name = classMemberPathNode.getValue().getName(); String title = "Assembler: " + EscapeUtil.escapeStandard(StringUtil.cutOff(name, 60)); - Node graphic = new FontIconView(CarbonIcons.CODE); + DockableIconFactory graphicFactory = d -> new FontIconView(CarbonIcons.CODE); // Create content for the tab. AssemblerPane content = assemblerPaneProvider.get(); content.onUpdatePath(path); // Build the tab. - return createTab(dockingManager.getPrimaryRegion(), title, graphic, content); + return createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); }); } @@ -1667,14 +1696,14 @@ public Navigable openMethodCallGraph(@Nonnull Workspace workspace, return createContent(() -> { // Create text/graphic for the tab to create. String title = Lang.get("menu.view.methodcallgraph") + ": " + method.getName(); - Node graphic = new FontIconView(CarbonIcons.FLOW); + DockableIconFactory graphicFactory = d -> new FontIconView(CarbonIcons.FLOW); // Create content for the tab. MethodCallGraphsPane content = callGraphsPaneProvider.get(); content.onUpdatePath(PathNodes.memberPath(workspace, resource, bundle, declaringClass, method)); // Build the tab. - return createTab(dockingManager.getPrimaryRegion(), title, graphic, content); + return createDockable(dockingManager.getPrimaryTabbedSpace(), title, graphicFactory, content); }); } @@ -2318,21 +2347,27 @@ public MemberDeclarationSearchPane openNewMemberDeclarationSearch() { @Nonnull private T openSearchPane(@Nonnull String titleId, @Nonnull Ikon icon, @Nonnull Instance paneProvider) { - DockingRegion region = dockingManager.getRegions().stream() - .filter(r -> r.getDockTabs().stream().anyMatch(t -> t.getContent() instanceof AbstractSearchPane)) - .findFirst().orElse(null); + // Place the tab in a region with other comments if possible. + DockablePath searchPath = null; + for (DockablePath dockablePath : dockingManager.getBento().getAllDockables()) { + Dockable dockable = dockablePath.dockable(); + Node node = dockable.nodeProperty().get(); + if (node instanceof AbstractSearchPane) { + searchPath = dockablePath; + break; + } + } + TabbedDockSpace space = searchPath == null ? null : (TabbedDockSpace) searchPath.space(); + T content = paneProvider.get(); - if (region != null) { - DockingTab tab = createTab(region, getBinding(titleId), new FontIconView(icon), content); - tab.select(); - FxThreadUtil.run(() -> SceneUtils.focus(content)); + if (space != null) { + createDockable(space, getBinding(titleId), d -> new FontIconView(icon), content); } else { - region = dockingManager.newRegion(); - DockingTab tab = createTab(region, getBinding(titleId), new FontIconView(icon), content); - RecafScene scene = new RecafScene(region); - Stage window = windowFactory.createAnonymousStage(scene, getBinding("menu.search"), 800, 400); - window.show(); - window.requestFocus(); + Dockable dockable = createDockable(null, getBinding(titleId), d -> new FontIconView(icon), content); + Scene originScene = dockingManager.getPrimaryTabbedSpace().getBackingRegion().getScene(); + Stage stage = dockingManager.getBento().newStageForDockable(originScene, dockable, 800, 400); + stage.show(); + stage.requestFocus(); } return content; } @@ -2341,13 +2376,13 @@ private T openSearchPane(@Nonnull String titleId, * Looks for the {@link Navigable} component representing the path and returns it if found. * If no such component exists, it should be generated by the passed supplier, which then gets returned. *
- * The tab containing the {@link Navigable} component is selected when returned. + * The dockable containing the {@link Navigable} component is selected when returned. * * @param path * Path to navigate to. * @param factory - * Factory to create a tab for displaying content located at the given path, - * should a tab for the content not already exist. + * Factory to create a dockable for displaying content located at the given path, + * should a dockable for the content not already exist. *
* NOTE: It is required/assumed that the {@link Tab#getContent()} is a * component implementing {@link Navigable}. @@ -2355,149 +2390,158 @@ private T openSearchPane(@Nonnull String titleId, * @return Navigable content representing content of the path. */ @Nonnull - public Navigable getOrCreatePathContent(@Nonnull PathNode path, @Nonnull Supplier factory) { + public Navigable getOrCreatePathContent(@Nonnull PathNode path, @Nonnull Supplier factory) { List children = navigationManager.getNavigableChildrenByPath(path); - if (children.isEmpty()) { - return createContent(factory); - } else { - // Content by path is already open. - Navigable navigable = children.getFirst(); - selectTab(navigable); - navigable.requestFocus(); - return navigable; - } + Navigable navigable = children.isEmpty() ? createContent(factory) : children.getFirst(); + selectTab(navigable); + navigable.requestFocus(); + return navigable; } @Nonnull - private static Navigable createContent(@Nonnull Supplier factory) { - // Create the tab for the content, then display it. - DockingTab tab = factory.get(); - tab.select(); - SceneUtils.focus(tab.getRegion().getScene()); - return (Navigable) tab.getContent(); - } - - private void setupInfoTabContextMenu(@Nonnull Info info, @Nonnull AbstractContentPane contentPane, @Nonnull DockingTab tab) { - ContextMenu menu = new ContextMenu(); - ObservableList items = menu.getItems(); + private Navigable createContent(@Nonnull Supplier factory) { + // Create the dockable for the content, then display it. + Dockable dockable = factory.get(); + Navigable navigable = (Navigable) dockable.getNode(); + selectTab(navigable); + navigable.requestFocus(); + return navigable; + } + + private void setupInfoContextMenu(@Nonnull Info info, + @Nonnull AbstractContentPane contentPane, + @Nonnull Dockable dockable) { + dockable.withCachedContextMenu(true).withContextMenuFactory(d -> { + ContextMenu menu = new ContextMenu(); + ObservableList items = menu.getItems(); - if (info instanceof JvmClassInfo classInfo && contentPane instanceof JvmClassPane content) { - Menu mode = menu("menu.mode", CarbonIcons.VIEW); - mode.getItems().addAll( - action("menu.mode.class.decompile", CarbonIcons.CODE, - () -> content.setEditorType(JvmClassEditorType.DECOMPILE)), - action("menu.mode.file.hex", CarbonIcons.NUMBER_0, - () -> content.setEditorType(JvmClassEditorType.HEX)) - ); - items.add(mode); - } else if (info instanceof AndroidClassInfo classInfo && contentPane instanceof AndroidClassPane content) { - Menu mode = menu("menu.mode", CarbonIcons.VIEW); - mode.getItems().addAll( - action("menu.mode.class.decompile", CarbonIcons.CODE, - () -> content.setEditorType(AndroidClassEditorType.DECOMPILE)), - action("menu.mode.file.smali", CarbonIcons.NUMBER_0, - () -> content.setEditorType(AndroidClassEditorType.SMALI)) - ); - items.add(mode); - } else if (info instanceof ImageFileInfo fileInfo) { - // TODO: We need to copy-paste this a number of times for all the different file info types - // and let each toggle between "automatic" (native editor) and hex. The way that it is done - // here works but isn't exactly pretty and lets users replace the current pane they have open - // with the same kind of pane. Having some abstraction model to alleviate the copy-paste and this - // replacement-of-self problem would be nice. - Menu mode = menu("menu.mode", CarbonIcons.VIEW); - mode.getItems().addAll( - action("menu.mode.file.auto", CarbonIcons.IMAGE, () -> { - ImageFilePane content = imagePaneProvider.get(); - if (tab.getContent() instanceof AbstractContentPane existing && existing.getPath() != null) { - content.onUpdatePath(existing.getPath()); - existing.disable(); - } - tab.setContent(content); - }), - action("menu.mode.file.hex", CarbonIcons.CODE, () -> { - HexFilePane content = hexPaneProvider.get(); - if (tab.getContent() instanceof AbstractContentPane existing && existing.getPath() != null) { - content.onUpdatePath(existing.getPath()); - existing.disable(); - } - tab.setContent(content); - }) - ); - items.add(mode); - } + if (info instanceof JvmClassInfo classInfo && contentPane instanceof JvmClassPane content) { + Menu mode = menu("menu.mode", CarbonIcons.VIEW); + mode.getItems().addAll( + action("menu.mode.class.decompile", CarbonIcons.CODE, + () -> content.setEditorType(JvmClassEditorType.DECOMPILE)), + action("menu.mode.file.hex", CarbonIcons.NUMBER_0, + () -> content.setEditorType(JvmClassEditorType.HEX)) + ); + items.add(mode); + } else if (info instanceof AndroidClassInfo classInfo && contentPane instanceof AndroidClassPane content) { + Menu mode = menu("menu.mode", CarbonIcons.VIEW); + mode.getItems().addAll( + action("menu.mode.class.decompile", CarbonIcons.CODE, + () -> content.setEditorType(AndroidClassEditorType.DECOMPILE)), + action("menu.mode.file.smali", CarbonIcons.NUMBER_0, + () -> content.setEditorType(AndroidClassEditorType.SMALI)) + ); + items.add(mode); + } else if (info instanceof ImageFileInfo fileInfo) { + // TODO: We need to copy-paste this a number of times for all the different file info types + // and let each toggle between "automatic" (native editor) and hex. The way that it is done + // here works but isn't exactly pretty and lets users replace the current pane they have open + // with the same kind of pane. Having some abstraction model to alleviate the copy-paste and this + // replacement-of-self problem would be nice. + Menu mode = menu("menu.mode", CarbonIcons.VIEW); + mode.getItems().addAll( + action("menu.mode.file.auto", CarbonIcons.IMAGE, () -> { + ImageFilePane content = imagePaneProvider.get(); + if (d.getNode() instanceof AbstractContentPane existing && existing.getPath() != null) { + content.onUpdatePath(existing.getPath()); + existing.disable(); + } + d.setNode(content); + }), + action("menu.mode.file.hex", CarbonIcons.CODE, () -> { + HexFilePane content = hexPaneProvider.get(); + if (d.getNode() instanceof AbstractContentPane existing && existing.getPath() != null) { + content.onUpdatePath(existing.getPath()); + existing.disable(); + } + d.setNode(content); + }) + ); + items.add(mode); + } - addCopyPathAction(menu, info); - addCloseActions(menu, tab); - tab.setContextMenu(menu); + addCopyPathAction(menu, info); + addCloseActions(menu, d); + return menu; + }); } /** - * Selects the containing {@link DockingTab} that contains the content. + * Selects the containing {@link Dockable} that contains the navigable content. * * @param navigable - * Navigable content to select in its containing {@link DockingRegion}. + * Navigable content to select in its containing {@link TabbedDockSpace}. */ - private static void selectTab(Navigable navigable) { - if (navigable instanceof Node node) - SceneUtils.focus(node); + private void selectTab(@Nullable Navigable navigable) { + if (navigable == null) + return; + Dockable dockable = navigationManager.lookupDockable(navigable); + if (dockable != null) + dockable.inSpace(s -> s.selectDockable(dockable)); } /** - * Shorthand for tab-creation + graphic setting. + * Shorthand for dockable-creation + graphic setting. * - * @param region - * Parent region to spawn in. + * @param space + * Parent tabbed space to spawn in. * @param title - * Tab title. - * @param graphic - * Tab graphic. - * @param content - * Tab content. + * Dockable title. + * @param graphicFactory + * Dockable graphic factory. + * @param node + * Dockable content. * - * @return Created tab. + * @return Created dockable. */ @Nonnull - private DockingTab createTab(@Nonnull DockingRegion region, - @Nonnull String title, - @Nonnull Node graphic, - @Nonnull Node content) { - DockingTab tab = region.createTab(title, content); - tab.setGraphic(graphic); - content.addEventFilter(KeyEvent.KEY_PRESSED, e -> { - if (tab.isClosable() && keybindingConfig.getCloseTab().match(e)) - tab.close(); + private Dockable createDockable(@Nullable TabbedDockSpace space, + @Nonnull String title, + @Nonnull DockableIconFactory graphicFactory, + @Nonnull Node node) { + Dockable dockable = dockingManager.newDockable(title, graphicFactory, node); + node.addEventFilter(KeyEvent.KEY_PRESSED, e -> { + if (dockable.closableProperty().get() && keybindingConfig.getCloseTab().match(e)) + dockable.inSpace(s -> s.closeDockable(dockable)); }); - return tab; + if (space != null) { + space.addDockable(dockable); + space.selectDockable(dockable); + } + return dockable; } /** - * Shorthand for tab-creation + graphic setting. + * Shorthand for dockable-creation + graphic setting. * - * @param region - * Parent region to spawn in. + * @param space + * Parent tabbed space to spawn in. * @param title - * Tab title. - * @param graphic - * Tab graphic. - * @param content - * Tab content. + * Dockable title. + * @param graphicFactory + * Dockable graphic factory. + * @param node + * Dockable content. * - * @return Created tab. + * @return Created dockable. */ @Nonnull - private DockingTab createTab(@Nonnull DockingRegion region, - @Nonnull ObservableValue title, - @Nonnull Node graphic, - @Nonnull Node content) { - DockingTab tab = region.createTab(title, content); - tab.setGraphic(graphic); - content.addEventFilter(KeyEvent.KEY_PRESSED, e -> { - if (tab.isClosable() && keybindingConfig.getCloseTab().match(e)) - tab.close(); + private Dockable createDockable(@Nullable TabbedDockSpace space, + @Nonnull ObservableValue title, + @Nonnull DockableIconFactory graphicFactory, + @Nonnull Node node) { + Dockable dockable = dockingManager.newTranslatableDockable(title, graphicFactory, node); + node.addEventFilter(KeyEvent.KEY_PRESSED, e -> { + if (dockable.closableProperty().get() && keybindingConfig.getCloseTab().match(e)) + dockable.inSpace(s -> s.closeDockable(dockable)); }); - return tab; + if (space != null) { + space.addDockable(dockable); + space.selectDockable(dockable); + } + return dockable; } @@ -2525,23 +2569,27 @@ private static void addCopyPathAction(@Nonnull ContextMenu menu, @Nonnull Info i * * @param menu * Menu to add to. - * @param tab - * Tab reference. + * @param dockable + * Dockable reference. */ - private static void addCloseActions(@Nonnull ContextMenu menu, @Nonnull DockingTab tab) { + private static void addCloseActions(@Nonnull ContextMenu menu, @Nonnull Dockable dockable) { menu.getItems().addAll( - action("menu.tab.close", CarbonIcons.CLOSE, tab::close), + action("menu.tab.close", CarbonIcons.CLOSE, () -> dockable.inSpace(space -> space.closeDockable(dockable))), action("menu.tab.closeothers", CarbonIcons.CLOSE, () -> { - Unchecked.checkedForEach(tab.getRegion().getDockTabs(), regionTab -> { - if (regionTab != tab) - regionTab.close(); - }, (regionTab, error) -> { - logger.error("Failed to close tab '{}'", regionTab.getText(), error); + dockable.inSpace(space -> { + Unchecked.checkedForEach(space.getDockables(), d -> { + if (d != dockable) + space.closeDockable(d); + }, (d, error) -> { + logger.error("Failed to close tab '{}'", d.getTitle(), error); + }); }); }), action("menu.tab.closeall", CarbonIcons.CLOSE, () -> { - Unchecked.checkedForEach(tab.getRegion().getDockTabs(), DockingTab::close, (regionTab, error) -> { - logger.error("Failed to close tab '{}'", regionTab.getText(), error); + dockable.inSpace(space -> { + Unchecked.checkedForEach(space.getDockables(), space::closeDockable, (d, error) -> { + logger.error("Failed to close tab '{}'", d.getTitle(), error); + }); }); }) ); diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Navigable.java b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Navigable.java index b5a270742..171f3a954 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Navigable.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Navigable.java @@ -9,6 +9,7 @@ import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.path.PathNode; import software.coley.recaf.ui.docking.DockingManager; +import software.coley.recaf.workspace.model.Workspace; import java.util.ArrayList; import java.util.Collection; @@ -16,7 +17,8 @@ import java.util.List; /** - * Outline of navigable content. UI components implement this type can be discovered via path-based look-ups. + * Outline of navigable content (IE, content in the {@link Workspace} such as classes and files). + * UI components implement this type can be discovered via {@link PathNode} look-ups. * * @author Matt Coley * @see NavigationManager Tracker of all open {@link Navigable} content. diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/NavigationManager.java b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/NavigationManager.java index ff26bad26..289224b35 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/NavigationManager.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/NavigationManager.java @@ -1,6 +1,7 @@ package software.coley.recaf.services.navigation; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; @@ -9,6 +10,8 @@ import javafx.beans.value.ObservableValue; import javafx.scene.Node; import org.slf4j.Logger; +import software.coley.bentofx.dockable.Dockable; +import software.coley.bentofx.path.DockablePath; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.cdi.EagerInitialization; @@ -28,7 +31,6 @@ import software.coley.recaf.services.mapping.MappingResults; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.ui.docking.DockingManager; -import software.coley.recaf.ui.docking.DockingTab; import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.WorkspaceModificationListener; @@ -51,23 +53,23 @@ /** * Tracks available {@link Navigable} content currently open in the UI. *
- * This is done by tracking the content of {@link DockingTab} instances when they are {@link Navigable}. + * This is done by tracking the content of {@link Dockable} instances when they are {@link Navigable}. * This component is itself {@link Navigable} which means if we use these tracked instances as our * {@link #getNavigableChildren()} we can do dynamic look-ups with {@link #getNavigableChildrenByPath(PathNode)} * to discover any currently open content. * * @author Matt Coley + * @see DockingManager */ @EagerInitialization(InitializationStage.AFTER_UI_INIT) @ApplicationScoped public class NavigationManager implements Navigable, Service { public static final String ID = "navigation"; private static final Logger logger = Logging.get(NavigationManager.class); - private final List children = new CopyOnWriteArrayList<>(); private final List addListeners = new CopyOnWriteArrayList<>(); private final List removeListeners = new CopyOnWriteArrayList<>(); - private final Map childrenToTab = Collections.synchronizedMap(new IdentityHashMap<>()); - private final Map tabToSpy = Collections.synchronizedMap(new IdentityHashMap<>()); + private final Map childrenToDockable = Collections.synchronizedMap(new IdentityHashMap<>()); + private final Map dockableToSpy = Collections.synchronizedMap(new IdentityHashMap<>()); private final Forwarding forwarding = new Forwarding(); private final NavigationManagerConfig config; private PathNode path = new DummyInitialNode(); @@ -79,12 +81,12 @@ public NavigationManager(@Nonnull NavigationManagerConfig config, this.config = config; // Track what navigable content is available. - dockingManager.addTabCreationListener((parent, tab) -> { - ObjectProperty contentProperty = tab.contentProperty(); + dockingManager.getBento().addDockableOpenListener((path, dockable) -> { + ObjectProperty contentProperty = dockable.nodeProperty(); // Create spy for the tab. - NavigableSpy spy = new NavigableSpy(tab); - tabToSpy.put(tab, spy); + NavigableSpy spy = new NavigableSpy(dockable); + dockableToSpy.put(dockable, spy); // Add listener, so if content changes we are made aware of the changes. contentProperty.addListener(spy); @@ -92,38 +94,46 @@ public NavigationManager(@Nonnull NavigationManagerConfig config, // Record initial value. spy.changed(contentProperty, null, contentProperty.getValue()); }); - dockingManager.addTabClosureListener(((parent, tab) -> { + dockingManager.getBento().addDockableCloseListener((path, dockable) -> { // The tab is closed, remove its spy lookup. - NavigableSpy spy = tabToSpy.remove(tab); + NavigableSpy spy = dockableToSpy.remove(dockable); if (spy == null) { - logger.warn("Tab {} was closed, but had no associated content spy instance", tab.getText()); + logger.warn("Tab {} was closed, but had no associated content spy instance", dockable.getTitle()); return; } // Remove content from navigation tracking. - spy.remove(tab.getContent()); + spy.remove(dockable.nodeProperty().get()); // Remove the listener from the tab. - tab.contentProperty().removeListener(spy); - })); + dockable.nodeProperty().removeListener(spy); + }); // When the workspace closes, close all associated children. workspaceManager.addWorkspaceCloseListener(workspace -> FxThreadUtil.run(() -> { - for (Navigable child : children) { + for (Navigable child : childrenToDockable.keySet()) { child.disable(); - DockingTab dockingTab = childrenToTab.get(child); - if (dockingTab != null) - dockingTab.close(); + + Dockable dockable = childrenToDockable.get(child); + if (dockable == null) { + logger.warn("Navigation manager children-to-dockable map did not have value for key: {}", child.getPath()); + continue; + } + + DockablePath path = dockingManager.getBento().findDockable(dockable); + if (path == null) { + logger.warn("Navigation manager couldn't invoke dockable.onClose() since dockable path could not be resolved for: {}", dockable.getTitle()); + continue; + } + + // Trigger on-close handler + path.space().closeDockable(dockable); } // Validate all child references have been removed. - if (!children.isEmpty()) { - logger.warn("Navigation manager children list was not empty after workspace closure"); - children.clear(); - } - if (!childrenToTab.isEmpty()) { - logger.warn("Navigation manager children-to-tab map was not empty after workspace closure"); - childrenToTab.clear(); + if (!childrenToDockable.isEmpty()) { + logger.warn("Navigation manager children-to-dockable map was not empty after workspace closure"); + childrenToDockable.clear(); } // Remove the path reference to the old workspace. @@ -131,11 +141,11 @@ public NavigationManager(@Nonnull NavigationManagerConfig config, path = new DummyInitialNode(); // Force close any remaining tabs that hold navigable content. - for (DockingTab tab : dockingManager.getDockTabs()) { - if (tab.getContent() instanceof Navigable navigable) { + for (DockablePath path : dockingManager.getBento().getAllDockables()) { + Dockable dockable = path.dockable(); + if (dockable.nodeProperty().get() instanceof Navigable navigable) { navigable.disable(); - tab.setClosable(true); - tab.close(); + path.space().closeDockable(dockable); } } })); @@ -169,6 +179,19 @@ public void onRemoveLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceReso }); } + /** + * Looks up which {@link Dockable} has the given navigable as its {@link Dockable#nodeProperty()}. + * + * @param navigable + * Some navigable UI element. + * + * @return The dockable holding the navigable element, if any exists. + */ + @Nullable + public Dockable lookupDockable(@Nonnull Navigable navigable) { + return childrenToDockable.get(navigable); + } + /** * Add a listener that observes the addition of {@link Navigable} components to the UI. * @@ -218,7 +241,7 @@ public PathNode getPath() { @Nonnull @Override public Collection getNavigableChildren() { - return Collections.unmodifiableCollection(children); + return Collections.unmodifiableCollection(childrenToDockable.keySet()); } @Override @@ -244,13 +267,13 @@ public NavigationManagerConfig getServiceConfig() { } /** - * Listener to update {@link #children}. + * Listener to update {@link #childrenToDockable}. */ private class NavigableSpy implements ChangeListener { - private final DockingTab tab; + private final Dockable dockable; - public NavigableSpy(DockingTab tab) { - this.tab = tab; + public NavigableSpy(@Nonnull Dockable dockable) { + this.dockable = dockable; } @Override @@ -259,19 +282,17 @@ public void changed(ObservableValue observable, Node oldValue, N add(newValue); } - void add(Node value) { + void add(@Nullable Node value) { if (value instanceof Navigable navigable && navigable.isTrackable()) { - children.add(navigable); - childrenToTab.put(navigable, tab); + childrenToDockable.put(navigable, dockable); Unchecked.checkedForEach(addListeners, listener -> listener.onAdd(navigable), (listener, t) -> logger.error("Exception thrown when handling navigable added '{}'", navigable.getClass().getName(), t)); } } - void remove(Node value) { + void remove(@Nullable Node value) { if (value instanceof Navigable navigable) { - children.remove(navigable); - childrenToTab.remove(navigable); + childrenToDockable.remove(navigable); Unchecked.checkedForEach(removeListeners, listener -> listener.onRemove(navigable), (listener, t) -> logger.error("Exception thrown when handling navigable removed '{}'", navigable.getClass().getName(), t)); diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java index fe09effd5..e7f0f8e9a 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java @@ -8,6 +8,7 @@ import javafx.beans.property.SimpleObjectProperty; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; +import org.kordamp.ikonli.carbonicons.CarbonIcons; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassMemberPathNode; @@ -21,6 +22,7 @@ import software.coley.recaf.services.navigation.Navigable; import software.coley.recaf.services.navigation.UpdatableNavigable; import software.coley.recaf.services.text.TextFormatConfig; +import software.coley.recaf.ui.control.FontIconView; import software.coley.recaf.util.Lang; import software.coley.recaf.workspace.model.Workspace; @@ -59,6 +61,7 @@ private Tab creatTab(@Nonnull Workspace workspace, @Nonnull CallGraph callGraph, Tab tab = new Tab(); tab.setContent(new MethodCallGraphPane(workspace, callGraph, configurationService, format, actions, mode, methodInfoObservable)); tab.textProperty().bind(Lang.getBinding("menu.view.methodcallgraph." + mode.name().toLowerCase())); + tab.setGraphic(new FontIconView(mode == MethodCallGraphPane.CallGraphMode.CALLS ? CarbonIcons.LOGOUT : CarbonIcons.LOGIN)); tab.setClosable(false); return tab; } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/source/JavaContextActionSupport.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/source/JavaContextActionSupport.java index 3767f5a4c..23b42c6a8 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/source/JavaContextActionSupport.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/source/JavaContextActionSupport.java @@ -490,23 +490,23 @@ private AstAvailabilityButton() { * Called when AST unit is good to use. */ private void setAvailable() { - setVisible(false); + FxThreadUtil.run(() -> setVisible(false)); } /** * Called when an AST unit exists, but a new one is being made. */ private void setNewParseInProgress() { - setVisible(true); - setOnAction(e -> { - Popover popover = new Popover(); - popover.setContentNode(new BoundLabel(Lang.getBinding("java.parse-state.new-progress-details"))); - popover.setArrowLocation(Popover.ArrowLocation.BOTTOM_RIGHT); - popover.show(this); - }); FxThreadUtil.run(() -> { + setOnAction(e -> { + Popover popover = new Popover(); + popover.setContentNode(new BoundLabel(Lang.getBinding("java.parse-state.new-progress-details"))); + popover.setArrowLocation(Popover.ArrowLocation.BOTTOM_RIGHT); + popover.show(this); + }); textProperty().unbind(); textProperty().bind(Lang.getBinding("java.parse-state.new-progress")); + setVisible(true); }); } @@ -514,11 +514,11 @@ private void setNewParseInProgress() { * Called when a new AST unit was requested, but nothing was returned by the parser. */ private void setUnavailable() { - setVisible(true); - setOnAction(null); FxThreadUtil.run(() -> { + setOnAction(null); textProperty().unbind(); textProperty().bind(Lang.getBinding("java.parse-state.error")); + setVisible(true); }); } @@ -529,25 +529,25 @@ private void setUnavailable() { * The exception result from the parser. */ private void setParserError(@Nonnull Throwable error) { - setVisible(true); - setOnAction(e -> { - BoundLabel title = new BoundLabel(Lang.getBinding("java.parse-state.error-details")); - - String exceptionType = error.getClass().getSimpleName(); - String message = error.getMessage(); - - TextArea errorTextArea = new TextArea(); - errorTextArea.setEditable(false); - errorTextArea.setText(exceptionType + "\n" + "=".repeat(exceptionType.length()) + "\n" + message); - - Popover popover = new Popover(); - popover.setContentNode(new VBox(title, errorTextArea)); - popover.setArrowLocation(Popover.ArrowLocation.BOTTOM_RIGHT); - popover.show(this); - }); FxThreadUtil.run(() -> { textProperty().unbind(); textProperty().bind(Lang.getBinding("java.parse-state.error")); + setOnAction(e -> { + BoundLabel title = new BoundLabel(Lang.getBinding("java.parse-state.error-details")); + + String exceptionType = error.getClass().getSimpleName(); + String message = error.getMessage(); + + TextArea errorTextArea = new TextArea(); + errorTextArea.setEditable(false); + errorTextArea.setText(exceptionType + "\n" + "=".repeat(exceptionType.length()) + "\n" + message); + + Popover popover = new Popover(); + popover.setContentNode(new VBox(title, errorTextArea)); + popover.setArrowLocation(Popover.ArrowLocation.BOTTOM_RIGHT); + popover.show(this); + }); + setVisible(true); }); } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingLayoutManager.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingLayoutManager.java new file mode 100644 index 000000000..35c241b7e --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingLayoutManager.java @@ -0,0 +1,237 @@ +package software.coley.recaf.ui.docking; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import javafx.geometry.Orientation; +import javafx.geometry.Side; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import org.kordamp.ikonli.carbonicons.CarbonIcons; +import org.slf4j.Logger; +import software.coley.bentofx.builder.LayoutBuilder; +import software.coley.bentofx.builder.LeafLayoutArgs; +import software.coley.bentofx.builder.SingleSpaceArgs; +import software.coley.bentofx.builder.SplitLayoutArgs; +import software.coley.bentofx.builder.TabbedSpaceArgs; +import software.coley.bentofx.layout.DockLayout; +import software.coley.bentofx.layout.LeafDockLayout; +import software.coley.bentofx.layout.RootDockLayout; +import software.coley.bentofx.layout.SplitDockLayout; +import software.coley.bentofx.space.DockSpace; +import software.coley.bentofx.space.TabbedDockSpace; +import software.coley.recaf.analytics.logging.Logging; +import software.coley.recaf.services.navigation.NavigationManager; +import software.coley.recaf.services.workspace.WorkspaceCloseListener; +import software.coley.recaf.services.workspace.WorkspaceManager; +import software.coley.recaf.services.workspace.WorkspaceOpenListener; +import software.coley.recaf.ui.control.ActionMenuItem; +import software.coley.recaf.ui.control.FontIconView; +import software.coley.recaf.ui.pane.LoggingPane; +import software.coley.recaf.ui.pane.WelcomePane; +import software.coley.recaf.ui.pane.WorkspaceExplorerPane; +import software.coley.recaf.ui.pane.WorkspaceInformationPane; +import software.coley.recaf.util.FxThreadUtil; +import software.coley.recaf.util.Lang; +import software.coley.recaf.workspace.model.Workspace; + +/** + * Manages updates to the UI layout. + *

+ * This currently covers: + *

    + *
  • Displaying {@link WelcomePane} when no workspace is open
  • + *
  • Displaying {@link WorkspaceExplorerPane} when a workspace is open
  • + *
+ * + * @see DockingManager + * @see NavigationManager + */ +@ApplicationScoped +public class DockingLayoutManager { + private static final Logger logger = Logging.get(DockingLayoutManager.class); + + /** Top half of the main UI at initial layout. */ + public static final String ID_LAYOUT_ROOT_TOP = "layout-root-top"; + /** Bottom half of the main UI at initial layout. */ + public static final String ID_LAYOUT_ROOT_BOTTOM = "layout-root-bottom"; + /** {@link LeafDockLayout} holding the {@link WorkspaceExplorerPane} */ + public static final String ID_LAYOUT_WORKSPACE_TOOLS = "layout-workspace-tools"; + /** {@link LeafDockLayout} holding the primary editor tabs. */ + public static final String ID_LAYOUT_WORKSPACE_PRIMARY = "layout-workspace-primary"; + /** {@link TabbedDockSpace} holding workspace tools like {@link WorkspaceExplorerPane} */ + public static final String ID_SPACE_WORKSPACE_TOOLS = "space-workspace-tools"; + /** {@link TabbedDockSpace} holding the primary editor tabs. */ + public static final String ID_SPACE_WORKSPACE_PRIMARY = "space-workspace-primary"; + /** {@link TabbedDockSpace} holding other tools like {@link LoggingPane} */ + public static final String ID_SPACE_TOOLS_BOTTOM = "space-tools-bottom"; + + private final DockingManager dockingManager; + private final Instance loggingPaneProvider; + private final Instance welcomePaneProvider; + private final Instance workspaceInfoProvider; + private final Instance workspaceExplorerProvider; + private final RootDockLayout root; + + @Inject + public DockingLayoutManager(@Nonnull DockingManager dockingManager, + @Nonnull WorkspaceManager workspaceManager, + @Nonnull Instance loggingPaneProvider, + @Nonnull Instance welcomePaneProvider, + @Nonnull Instance workspaceInfoProvider, + @Nonnull Instance workspaceExplorerProvider) { + this.dockingManager = dockingManager; + this.loggingPaneProvider = loggingPaneProvider; + this.welcomePaneProvider = welcomePaneProvider; + this.workspaceInfoProvider = workspaceInfoProvider; + this.workspaceExplorerProvider = workspaceExplorerProvider; + + // Register listener + ListenerHost host = new ListenerHost(); + workspaceManager.addWorkspaceOpenListener(host); + workspaceManager.addWorkspaceCloseListener(host); + + // Create root + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + DockLayout top = newEmptyTop(); + DockLayout bottom = newBottom(); + root = builder.root(builder.split(new SplitLayoutArgs() + .setOrientation(Orientation.VERTICAL) + .setChildrenSizes(-1, 150) + .addChildren(top, bottom))); + } + + @Nonnull + public RootDockLayout getRoot() { + return root; + } + + @Nonnull + private DockSpace newWelcomeSpace() { + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + return builder.single(new SingleSpaceArgs() + .setDockable(builder.dockable() + .withNode(welcomePaneProvider.get()) + .withDragGroup(-1) + .build()) + .setSide(null) + ); + } + + @Nonnull + private DockLayout newWorkspaceExplorerLayout() { + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + return builder.leaf(new LeafLayoutArgs() + .setResizeWithParent(false) + .setIdentifier(ID_LAYOUT_WORKSPACE_TOOLS) + .setSpace(builder.tabbed( + new TabbedSpaceArgs() + .setIdentifier(ID_SPACE_WORKSPACE_TOOLS) + .setCanSplit(false) + .addDockables(dockingManager.newToolDockable("workspace.title", CarbonIcons.TREE_VIEW, workspaceExplorerProvider.get())) + .setMenuFactory(this::buildMenu) + )) + ); + } + + @Nonnull + private DockLayout newWorkspacePrimaryLayout() { + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + return builder.leaf(new LeafLayoutArgs() + .setIdentifier(ID_LAYOUT_WORKSPACE_PRIMARY) + .setSpace(builder.tabbed( + new TabbedSpaceArgs() + .setIdentifier(ID_SPACE_WORKSPACE_PRIMARY) + .setAutoPruneWhenEmpty(false) + .addDockables(builder.dockable() + .withNode(workspaceInfoProvider.get()) + .withTitle(Lang.getBinding("workspace.info")) + .withIconFactory(d -> new FontIconView(CarbonIcons.INFORMATION)) + .build()) + )) + ); + } + + @Nonnull + private DockLayout newEmptyTop() { + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + return builder.leaf(new LeafLayoutArgs() + .setIdentifier(ID_LAYOUT_ROOT_TOP) + .setSpace(newWelcomeSpace()) + ); + } + + @Nonnull + private SplitDockLayout newWorkspaceTop() { + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + return builder.split(new SplitLayoutArgs() + .setOrientation(Orientation.HORIZONTAL) + .setIdentifier(ID_LAYOUT_ROOT_TOP) + .setChildrenSizes(200) + .addChildren(newWorkspaceExplorerLayout(), newWorkspacePrimaryLayout()) + ); + } + + @Nonnull + private DockLayout newBottom() { + LayoutBuilder builder = dockingManager.getBento().newLayoutBuilder(); + return builder.leaf(new LeafLayoutArgs() + .setIdentifier(ID_LAYOUT_ROOT_BOTTOM) + .setSpace(builder.tabbed(new TabbedSpaceArgs() + .setCanSplit(false) + .setIdentifier(ID_SPACE_TOOLS_BOTTOM) + .setSide(Side.BOTTOM) + .setCanSplit(false) + .setMenuFactory(this::buildMenu) + .addDockables(dockingManager.newToolDockable("logging.title", CarbonIcons.TERMINAL, loggingPaneProvider.get())) + )).setResizeWithParent(false) + ); + } + + @Nonnull + private ContextMenu buildMenu(@Nonnull TabbedDockSpace space) { + // TODO: Reworking bento for tabbed space to be a dockable-destination would + // allow us to inspect the state of the space being collapsed or not. + // - May want to not add the side options while closed + ContextMenu menu = new ContextMenu(); + addSideOptions(menu, space); + return menu; + } + + private static void addSideOptions(@Nonnull ContextMenu menu, @Nonnull TabbedDockSpace space) { + for (Side side : Side.values()) { + FontIconView sideIcon = switch (side) { + case TOP -> new FontIconView(CarbonIcons.OPEN_PANEL_FILLED_TOP); + case BOTTOM -> new FontIconView(CarbonIcons.OPEN_PANEL_FILLED_BOTTOM); + case LEFT -> new FontIconView(CarbonIcons.OPEN_PANEL_FILLED_LEFT); + case RIGHT -> new FontIconView(CarbonIcons.OPEN_PANEL_FILLED_RIGHT); + }; + Label graphic = new Label(side == space.sideProperty().get() ? "✓" : " ", sideIcon); + MenuItem item = new ActionMenuItem(Lang.getBinding("misc.direction." + side.name().toLowerCase()), graphic, + () -> space.sideProperty().set(side)); + menu.getItems().add(item); + } + } + + private class ListenerHost implements WorkspaceOpenListener, WorkspaceCloseListener { + @Override + public void onWorkspaceOpened(@Nonnull Workspace workspace) { + FxThreadUtil.run(() -> { + if (!dockingManager.replace(ID_LAYOUT_ROOT_TOP, DockingLayoutManager.this::newWorkspaceTop)) { + logger.error("Failed replacing root on workspace open"); + } + }); + } + + @Override + public void onWorkspaceClosed(@Nonnull Workspace workspace) { + FxThreadUtil.run(() -> { + if (!dockingManager.replace(ID_LAYOUT_ROOT_TOP, DockingLayoutManager.this::newEmptyTop)) { + logger.error("Failed replacing root on workspace close"); + } + }); + } + } +} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingManager.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingManager.java index 44eff13ac..b39ed38f8 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingManager.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingManager.java @@ -3,251 +3,251 @@ import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import javafx.beans.value.ObservableValue; +import javafx.scene.Node; +import org.kordamp.ikonli.Ikon; import org.slf4j.Logger; -import software.coley.collections.Unchecked; +import software.coley.bentofx.Bento; +import software.coley.bentofx.dockable.Dockable; +import software.coley.bentofx.dockable.DockableIconFactory; +import software.coley.bentofx.layout.DockLayout; +import software.coley.bentofx.path.SpacePath; +import software.coley.bentofx.space.DockSpace; +import software.coley.bentofx.space.TabbedDockSpace; +import software.coley.bentofx.util.DragDropStage; import software.coley.recaf.analytics.logging.Logging; -import software.coley.recaf.ui.docking.listener.TabClosureListener; -import software.coley.recaf.ui.docking.listener.TabCreationListener; -import software.coley.recaf.ui.docking.listener.TabMoveListener; -import software.coley.recaf.ui.docking.listener.TabSelectionListener; +import software.coley.recaf.services.navigation.NavigationManager; +import software.coley.recaf.services.window.WindowManager; +import software.coley.recaf.ui.control.FontIconView; +import software.coley.recaf.ui.window.RecafScene; +import software.coley.recaf.util.Lang; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Supplier; +import java.util.UUID; /** - * Manages open docking regions and tabs. + * Facilitates creation and inspection of dockable UI content. * * @author Matt Coley + * @see DockingLayoutManager + * @see NavigationManager */ @ApplicationScoped public class DockingManager { private static final Logger logger = Logging.get(DockingManager.class); - private final DockingRegionFactory factory = new DockingRegionFactory(this); - private final DockingRegion primaryRegion; - private final List regions = new CopyOnWriteArrayList<>(); - private final List tabSelectionListeners = new CopyOnWriteArrayList<>(); - private final List tabCreationListeners = new CopyOnWriteArrayList<>(); - private final List tabClosureListeners = new CopyOnWriteArrayList<>(); - private final List tabMoveListeners = new CopyOnWriteArrayList<>(); - @Inject - public DockingManager() { - primaryRegion = newRegion(); - primaryRegion.setCloseIfEmpty(false); - } - - /** - * The primary region is where tabs should open by default. - * It is locked to the main window where the initial workspace info is displayed. - * Compared to an IDE, this would occupy the same space where open classes are shown. - * - * @return Primary region. - */ - @Nonnull - public DockingRegion getPrimaryRegion() { - return primaryRegion; - } + private static final int GROUP_TOOLS = 1; - /** - * @return All current docking regions. - */ - @Nonnull - public List getRegions() { - return regions; - } + private final Bento bento = Bento.newBento(); /** - * @return All current docking tabs. + * @param windowManager + * Window manager to notify of docking created windows. */ - @Nonnull - public List getDockTabs() { - return regions.stream().flatMap(r -> r.getDockTabs().stream()).toList(); + @Inject + public DockingManager(@Nonnull WindowManager windowManager) { + // Stages created via our docking framework need to be tracked in the window manager. + bento.setStageFactory(originScene -> { + DragDropStage stage = new DragDropStage(true); + windowManager.register("dnd-" + UUID.randomUUID(), stage); + return stage; + }); + bento.setSceneFactory(RecafScene::new); } /** - * @return New region. + * @return Backing bento docking instance. */ @Nonnull - public DockingRegion newRegion() { - // The docking region constructor should handle registration in the regions list. - DockingRegion region = factory.create(); - factory.init(region); - return region; + public Bento getBento() { + return bento; } /** - * Package-private so {@link DockingRegion#DockingRegion(DockingManager)} has access. + * @param identifier + * The identifier of some {@link DockSpace} to find and replace. + * @param supplier + * Supplier of a {@link DockSpace} to replace the existing space with. * - * @param region - * Region to register. + * @return {@code true} when the existing space was found and replaced. */ - void registerRegion(@Nonnull DockingRegion region) { - if (!regions.contains(region)) - regions.add(region); + public boolean replace(@Nonnull String identifier, @Nonnull SpaceSupplier supplier) { + return bento.replaceSpace(identifier, supplier::get); } /** - * Configured by {@link DockingRegionFactory} this method is called when a {@link DockingRegion} is closed. - * - * @param region - * Region being closed. + * @param identifier + * The identifier of some {@link DockLayout} to find and replace. + * @param supplier + * Supplier of a {@link DockLayout} to replace the existing layout with. * - * @return {@code true} when region closure was a success. - * {@code false} when a region denied closure. + * @return {@code true} when the existing layout was found and replaced. */ - boolean onRegionClose(@Nonnull DockingRegion region) { - // Close any tabs that are closable. - // If there are tabs that cannot be closed, deny region closure. - boolean allowClosure = true; - for (DockingTab tab : new ArrayList<>(region.getDockTabs())) - if (tab.isClosable()) - tab.close(); - else - allowClosure = false; - if (!allowClosure) - return false; - - // Update internal state. - regions.remove(region); - - // Needed in case a window containing the region gets closed. - for (DockingTab tab : new ArrayList<>(region.getDockTabs())) - tab.close(); - - // Tell the region it is closed, removing its reference to this docking manager. - region.onClose(); - - // Closure allowed. - return true; + public boolean replace(@Nonnull String identifier, @Nonnull LayoutSupplier supplier) { + return bento.replaceLayout(identifier, supplier::get); } /** - * Configured by {@link DockingRegion#createTab(Supplier)}, called when a tab is created. + * The primary space is where content in the workspace is displayed when opened. + * Opening classes and files should place content in here. * - * @param parent - * Parent region. - * @param tab - * Tab created. + * @return The primary tabbed space where most content is placed in the UI. */ - void onTabCreate(@Nonnull DockingRegion parent, @Nonnull DockingTab tab) { - Unchecked.checkedForEach(tabCreationListeners, listener -> listener.onCreate(parent, tab), - (listener, t) -> logger.error("Exception thrown when opening tab '{}'", tab.getText(), t)); + @Nonnull + public TabbedDockSpace getPrimaryTabbedSpace() { + SpacePath path = bento.findSpace(DockingLayoutManager.ID_SPACE_WORKSPACE_PRIMARY); + if (path != null && path.space() instanceof TabbedDockSpace tc) + return tc; + throw new IllegalStateException("Primary TabbedContent space could not be found"); } /** - * Configured by {@link DockingRegion#createTab(Supplier)}, called when a tab is closed. + * Creates a {@link Dockable} that is assigned to the {@link #GROUP_TOOLS} group. + * + * @param translationKey + * Dockable title translation key. + * @param icon + * Dockable icon. + * @param content + * Dockable content to display. * - * @param parent - * Parent region. - * @param tab - * Tab created. + * @return Created dockable. */ - void onTabClose(@Nonnull DockingRegion parent, @Nonnull DockingTab tab) { - // TODO: In some cases the listeners need to be called on the FX thread - Unchecked.checkedForEach(tabClosureListeners, listener -> listener.onClose(parent, tab), - (listener, t) -> logger.error("Exception thrown when closing tab '{}'", tab.getText(), t)); + @Nonnull + public Dockable newToolDockable(@Nonnull String translationKey, @Nonnull Ikon icon, @Nonnull Node content) { + return newToolDockable(translationKey, d -> new FontIconView(icon), content); } - /** - * Configured by {@link DockingRegion#createTab(Supplier)}, called when a tab is - * moved between {@link DockingRegion}s. - * - * @param oldRegion - * Prior parent region. - * @param newRegion - * New parent region. - * @param tab - * Tab created. - */ - void onTabMove(@Nonnull DockingRegion oldRegion, @Nonnull DockingRegion newRegion, @Nonnull DockingTab tab) { - Unchecked.checkedForEach(tabMoveListeners, listener -> listener.onMove(oldRegion, newRegion, tab), - (listener, t) -> logger.error("Exception thrown when moving tab '{}'", tab.getText(), t)); - } /** - * Configured by {@link DockingRegion#createTab(Supplier)}, called when a tab is selected. + * Creates a {@link Dockable} that is assigned to the {@link #GROUP_TOOLS} group. * - * @param parent - * Parent region. - * @param tab - * Tab created. - */ - void onTabSelection(@Nonnull DockingRegion parent, @Nonnull DockingTab tab) { - Unchecked.checkedForEach(tabSelectionListeners, listener -> listener.onSelection(parent, tab), - (listener, t) -> logger.error("Exception thrown when selecting tab '{}'", tab.getText(), t)); - } - - /** - * @param listener - * Listener to add. + * @param translationKey + * Dockable title translation key. + * @param iconFactory + * Dockable icon factory. + * @param content + * Dockable content to display. + * + * @return Created dockable. */ - public void addTabSelectionListener(@Nonnull TabSelectionListener listener) { - tabSelectionListeners.add(listener); + @Nonnull + public Dockable newToolDockable(@Nonnull String translationKey, @Nonnull DockableIconFactory iconFactory, @Nonnull Node content) { + return bento.newDockableBuilder() + .withTitle(Lang.getBinding(translationKey)) + .withNode(content) + .withIconFactory(iconFactory) + .withClosable(false) + .withDragGroup(GROUP_TOOLS) + .withIdentifier(translationKey) + .build(); } /** - * @param listener - * Listener to remove. + * Creates a {@link Dockable}. + * + * @param title + * Dockable title. + * @param icon + * Dockable icon. + * @param content + * Dockable content to display. * - * @return {@code true} upon removal. {@code false} when listener wasn't present. + * @return Created dockable. */ - public boolean removeTabSelectionListener(@Nonnull TabSelectionListener listener) { - return tabSelectionListeners.remove(listener); + @Nonnull + public Dockable newDockable(@Nonnull String title, @Nonnull Ikon icon, @Nonnull Node content) { + return newDockable(title, d -> new FontIconView(icon), content); } /** - * @param listener - * Listener to add. + * Creates a {@link Dockable}. + * + * @param title + * Dockable title. + * @param iconFactory + * Dockable icon factory. + * @param content + * Dockable content to display. + * + * @return Created dockable. */ - public void addTabCreationListener(@Nonnull TabCreationListener listener) { - tabCreationListeners.add(listener); + @Nonnull + public Dockable newDockable(@Nonnull String title, @Nonnull DockableIconFactory iconFactory, @Nonnull Node content) { + return bento.newDockableBuilder() + .withTitle(title) + .withNode(content) + .withIconFactory(iconFactory) + .build(); } /** - * @param listener - * Listener to remove. + * Creates a {@link Dockable}. + * + * @param translationKey + * Dockable title translation key. + * @param icon + * Dockable icon. + * @param content + * Dockable content to display. * - * @return {@code true} upon removal. {@code false} when listener wasn't present. + * @return Created dockable. */ - public boolean removeTabCreationListener(@Nonnull TabCreationListener listener) { - return tabCreationListeners.remove(listener); + @Nonnull + public Dockable newTranslatableDockable(@Nonnull String translationKey, @Nonnull Ikon icon, @Nonnull Node content) { + return newTranslatableDockable(translationKey, d -> new FontIconView(icon), content); } /** - * @param listener - * Listener to add. + * Creates a {@link Dockable}. + * + * @param translationKey + * Dockable title translation key. + * @param iconFactory + * Dockable icon factory. + * @param content + * Dockable content to display. + * + * @return Created dockable. */ - public void addTabClosureListener(@Nonnull TabClosureListener listener) { - tabClosureListeners.add(listener); + @Nonnull + public Dockable newTranslatableDockable(@Nonnull String translationKey, @Nonnull DockableIconFactory iconFactory, @Nonnull Node content) { + return newTranslatableDockable(Lang.getBinding(translationKey), iconFactory, content); } /** - * @param listener - * Listener to remove. + * Creates a {@link Dockable}. + * + * @param titleBinding + * Dockable title translation binding. + * @param iconFactory + * Dockable icon factory. + * @param content + * Dockable content to display. * - * @return {@code true} upon removal. {@code false} when listener wasn't present. + * @return Created dockable. */ - public boolean removeTabClosureListener(@Nonnull TabClosureListener listener) { - return tabClosureListeners.remove(listener); + @Nonnull + public Dockable newTranslatableDockable(@Nonnull ObservableValue titleBinding, @Nonnull DockableIconFactory iconFactory, @Nonnull Node content) { + return bento.newDockableBuilder() + .withTitle(titleBinding) + .withNode(content) + .withIconFactory(iconFactory) + .build(); } /** - * @param listener - * Listener to add. + * Supplier of a {@link DockSpace}. */ - public void addTabMoveListener(@Nonnull TabMoveListener listener) { - tabMoveListeners.add(listener); + public interface SpaceSupplier { + @Nonnull + DockSpace get(); } /** - * @param listener - * Listener to remove. - * - * @return {@code true} upon removal. {@code false} when listener wasn't present. + * Supplier of a {@link DockLayout}. */ - public boolean removeTabMoveListener(@Nonnull TabMoveListener listener) { - return tabMoveListeners.remove(listener); + public interface LayoutSupplier { + @Nonnull + DockLayout get(); } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingRegion.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingRegion.java deleted file mode 100644 index 8dab29da7..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingRegion.java +++ /dev/null @@ -1,267 +0,0 @@ -package software.coley.recaf.ui.docking; - -import atlantafx.base.theme.Styles; -import com.panemu.tiwulfx.control.dock.DetachableTabPane; -import com.panemu.tiwulfx.control.dock.TabDropHint; -import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; -import javafx.beans.value.ObservableValue; -import javafx.collections.ObservableList; -import javafx.event.Event; -import javafx.event.EventHandler; -import javafx.scene.Node; -import javafx.scene.control.Tab; -import javafx.scene.control.TabPane; -import javafx.scene.shape.*; -import org.slf4j.Logger; -import software.coley.collections.Unchecked; -import software.coley.recaf.analytics.logging.Logging; -import software.coley.recaf.util.Icons; - -import java.util.List; -import java.util.function.Supplier; - -/** - * Base docking tab-pane. - * - * @author Matt Coley - */ -public class DockingRegion extends DetachableTabPane { - private static final Logger logger = Logging.get(DockingRegion.class); - private static int counter = 0; - private final int id = counter++; - private DockingManager manager; - - DockingRegion(@Nonnull DockingManager manager) { - this.manager = manager; - setDropHint(new RecafDropHint()); - setStageFactory((priorParent, tab) -> { - // Factory must yield a tab-stage, so we can't use 'RecafStage' here. - // Setting an icon is all we really need though. - TabStage stage = new TabStage(priorParent, tab); - stage.getIcons().add(Icons.getImage(Icons.LOGO)); - return stage; - }); - getStyleClass().add(Styles.DENSE); - - // It's easier to hook region creation like this than to try and ensure registration is handled by the caller. - manager.registerRegion(this); - } - - /** - * Add a tab to the region. - * - * @param title - * Tab title. - * @param content - * Tab content. - * - * @return Created tab. - */ - @Nonnull - public DockingTab createTab(@Nonnull String title, @Nonnull Node content) { - return createTab(() -> new DockingTab(title, content)); - } - - /** - * Add a tab to the region. - * - * @param title - * Tab title. - * @param content - * Tab content. - * - * @return Created tab. - */ - @Nonnull - public DockingTab createTab(@Nonnull ObservableValue title, @Nonnull Node content) { - return createTab(() -> new DockingTab(title, content)); - } - - /** - * Add a tab to the region. The tab is constructed by the given factory. - * - * @param factory - * Factory that will provide the tab. - * - * @return Created tab. - */ - @Nonnull - public DockingTab createTab(@Nonnull Supplier factory) { - DockingTab tab = factory.get(); - - // Ensure we record tabs closing (and let them still declare close handlers via the factory) - EventHandler closeHandler = tab.getOnClosed(); - tab.setOnCloseRequest(e -> { - // We use on-close-request so that 'getRegion' still has a reference to the containing tab-pane. - // Using on-close, the region reference is removed. - if (tab.isClosable()) { - DockingRegion region = tab.getRegion(); - if (region != null && region.getDockTabs().contains(tab)) { - if (closeHandler != null) - closeHandler.handle(e); - region.getAndCheckManager().onTabClose(region, tab); - } - } - }); - - // Ensure the manager is aware of which tabs per region are selected. - DockingRegion thisRegion = this; - EventHandler defaultSelectionHandler = tab.getOnSelectionChanged(); - registerSelectionCallbacks(tab, defaultSelectionHandler, thisRegion); - - // Ensure we record tabs moving between regions. - tab.tabPaneProperty().addListener((observable, oldValue, newValue) -> { - // Always record the last non-null parent region. - if (oldValue instanceof DockingRegion oldParent) - tab.setPriorRegion(oldParent); - - // Handle moving to a new region. - // - // Note: Do not mark the tab as 'closed' if the new parent is 'null'. - // It will be null while the tab is being dragged. - if (newValue instanceof DockingRegion newParent) { - // Re-register the selection callbacks with the new parent. - registerSelectionCallbacks(tab, defaultSelectionHandler, newParent); - - // Call tab-move listener if a past region has been recorded. - DockingRegion priorRegion = tab.getPriorRegion(); - if (priorRegion != null) - // Use the new parent to get the manager, as the old region could be closed. - newParent.getAndCheckManager().onTabMove(priorRegion, newParent, tab); - } - }); - - // Add the tab. - ObservableList tabs = getTabs(); - if (!tabs.contains(tab)) - tabs.add(tab); - else - logger.error("Tab was already added to region, prior to 'createTab' call.\n" + - "Please ensure tabs are only added via 'createTab' to ensure consistent behavior.", - new IllegalStateException("Stack dump")); - getAndCheckManager().onTabCreate(this, tab); - return tab; - } - - /** - * Sets the tab-selection listener to fire off {@link DockingManager#onTabSelection(DockingRegion, DockingTab)}. - * Must be called when a tab's parent changes to ensure the parent region it looks up (in fallback cases) - * is not out of date. - * - * @param tab - * Tab to set the selection change handler of. - * @param defaultSelectionHandler - * Default tab selection change handler. - * @param fallbackRegion - * Region context to use if the current tab-pane parent is not found/viable. - */ - private static void registerSelectionCallbacks(@Nonnull DockingTab tab, @Nullable EventHandler defaultSelectionHandler, - @Nonnull DockingRegion fallbackRegion) { - tab.setOnSelectionChanged(e -> { - if (defaultSelectionHandler != null) - defaultSelectionHandler.handle(e); - - if (tab.isSelected()) { - // Use the tab's parent, but if none is set then we are likely spawning the tab in 'this' region. - TabPane parentPane = tab.getTabPane(); - if (parentPane instanceof DockingRegion parentRegion) { - // Update with the tab's most up-to-date parent region isntance if possible - parentRegion.getAndCheckManager().onTabSelection(parentRegion, tab); - } else if (parentPane == null) { - // If the parent is null, we're likely in the initialization of a given tab. - // We'll assume it'll be added to 'this' region. - fallbackRegion.getAndCheckManager().onTabSelection(fallbackRegion, tab); - } else { - logger.error("Could not update tab selection state for tab '{}'" + - ", parent pane was not docking region.", tab.getText()); - } - } - }); - } - - /** - * @return List of all docking tabs in the region. - */ - @Nonnull - public List getDockTabs() { - // We do not want to use a 'stream.map()' operation just to satisfy generic contracts. - // We know the tab implementations are all dock-tabs so this unsafe operation is OK. - return Unchecked.cast(super.getTabs()); - } - - /** - * @return {@code true} when this region has been marked as closed. - */ - public boolean isClosed() { - return manager == null; - } - - /** - * Called when this region is closed. - */ - void onClose() { - // We no longer need a reference to the manager that spawned this region. - manager = null; - } - - /** - * Exists so we don't directly reference the manager, which will NPE after the region is closed. - * - * @return Manager instance. - */ - @Nonnull - private DockingManager getAndCheckManager() { - if (manager == null) - throw new IllegalStateException("Region has been closed, the manager reference has been cleared"); - return manager; - } - - /** - * This is a slightly modified version of the default {@link TabDropHint} which fixes some size constants. - */ - private static class RecafDropHint extends TabDropHint { - @Override - protected void generateAdjacentPath(Path path, double startX, double startY, double width, double height) { - // no-op - } - - @Override - protected void generateInsertionPath(Path path, double tabPos, double width, double height) { - int padding = 5; - int tabHeight = 45; - tabPos = Math.max(0, tabPos); - path.getElements().clear(); - MoveTo moveTo = new MoveTo(); - moveTo.setX(padding); - moveTo.setY(tabHeight); - path.getElements().add(moveTo);//start - - path.getElements().add(new HLineTo(width - padding));//path width - path.getElements().add(new VLineTo(height - padding));//path height - path.getElements().add(new HLineTo(padding));//path bottom left - path.getElements().add(new VLineTo(tabHeight));//back to start - - if (tabPos > 20) { - path.getElements().add(new MoveTo(tabPos, tabHeight + 5)); - path.getElements().add(new LineTo(Math.max(padding, tabPos - 10), tabHeight + 15)); - path.getElements().add(new HLineTo(tabPos + 10)); - path.getElements().add(new LineTo(tabPos, tabHeight + 5)); - } else { - double tip = Math.max(tabPos, padding + 5); - path.getElements().add(new MoveTo(tip, tabHeight + 5)); - path.getElements().add(new LineTo(tip + 10, tabHeight + 5)); - path.getElements().add(new LineTo(tip, tabHeight + 15)); - path.getElements().add(new VLineTo(tabHeight + 5)); - } - } - } - - @Override - public String toString() { - // Incremental ID's makes debugging docking problems way easier. - return "DockingRegion[" + id + "]" + - (manager == null ? "(DEAD)" : "") + - " - Children[" + getTabs().size() + "]"; - } -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingRegionFactory.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingRegionFactory.java deleted file mode 100644 index 99557246a..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingRegionFactory.java +++ /dev/null @@ -1,70 +0,0 @@ -package software.coley.recaf.ui.docking; - -import com.panemu.tiwulfx.control.dock.DetachableTabPane; -import com.panemu.tiwulfx.control.dock.DetachableTabPaneFactory; -import jakarta.annotation.Nonnull; -import javafx.scene.control.TabPane; -import javafx.stage.Stage; -import javafx.stage.Window; -import javafx.stage.WindowEvent; -import javafx.util.Callback; -import software.coley.recaf.ui.window.RecafScene; - -/** - * Factory for {@link DockingRegion}. - * Handles callbacks back to {@link DockingManager}. - * - * @author Matt Coley - */ -public class DockingRegionFactory extends DetachableTabPaneFactory { - private static final int SCENE_WIDTH = 600; - private static final int SCENE_HEIGHT = 400; - private final DockingManager manager; - - /** - * @param manager - * Associated docking manager. - */ - public DockingRegionFactory(@Nonnull DockingManager manager) { - this.manager = manager; - } - - @Override - protected DockingRegion create() { - return new DockingRegion(manager); - } - - @Override - protected void init(DetachableTabPane newTabPane) { - // IMPORTANT: All stages are owned by the primary region's window (the main window) - // Not doing this results in odd behavior when trying to pull tabs out of a region into a blank space. - newTabPane.setStageOwnerFactory(stage -> manager.getPrimaryRegion().getScene().getWindow()); - - // Rest of the pane setup. - newTabPane.setSceneFactory(param -> new RecafScene(param, SCENE_WIDTH, SCENE_HEIGHT)); - newTabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.ALL_TABS); - newTabPane.setCloseIfEmpty(true); - newTabPane.setDetachableTabPaneFactory(this); - - // Register closure callbacks - if (newTabPane instanceof DockingRegion region) { - region.setOnRemove(pane -> { - // We can ignore the return value of 'onRegionClose' since by this point - // the docking region should have no tabs. That is why its being removed. - if (region.isCloseIfEmpty()) - manager.onRegionClose(region); - }); - - // When the containing window is closed, trigger the region closure callback. - region.sceneProperty().addListener((obS, oldScene, newScene) -> { - if (newScene != null) { - newScene.windowProperty().addListener((obW, oldWindow, newWindow) -> - newWindow.addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, e -> { - if (!manager.onRegionClose(region)) - e.consume(); - })); - } - }); - } - } -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingTab.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingTab.java deleted file mode 100644 index 786f0b858..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/DockingTab.java +++ /dev/null @@ -1,134 +0,0 @@ -package software.coley.recaf.ui.docking; - -import com.panemu.tiwulfx.control.dock.DetachableTab; -import jakarta.annotation.Nullable; -import javafx.beans.value.ObservableValue; -import javafx.collections.ObservableList; -import javafx.event.Event; -import javafx.scene.Node; -import javafx.scene.control.Tab; -import javafx.scene.control.TabPane; - -/** - * {@link Tab} extension to track additional information required for {@link DockingManager} operations. - * - * @author Matt Coley - */ -public class DockingTab extends DetachableTab { - private volatile boolean firedClose; - /** Last known associated region */ - private DockingRegion priorRegion; - - /** - * @param title - * Initial tab title. - * Non-observable and effectively the final title text of the tab. - * @param content - * Initial tab content. - */ - DockingTab(String title, Node content) { - textProperty().setValue(title); - setContent(content); - } - - /** - * @param title - * Initial tab title. - * @param content - * Initial tab content. - */ - public DockingTab(ObservableValue title, Node content) { - textProperty().bind(title); - setContent(content); - } - - /** - * @return Parent docking region that contains the tab. - */ - @Nullable - public DockingRegion getRegion() { - return (DockingRegion) getTabPane(); - } - - /** - * @return Prior region. - */ - @Nullable - public DockingRegion getPriorRegion() { - return priorRegion; - } - - /** - * @param priorRegion - * Prior region. - */ - void setPriorRegion(@Nullable DockingRegion priorRegion) { - this.priorRegion = priorRegion; - } - - /** - * Close the current tab. - */ - public void close() { - if (isClosable()) { - // Fire the close event if we've not done so yet. - // The event should only be fired once, even if 'close()' is called multiple times. - // We double-lock this to ensure the event really is only ever called once if 'close()' is ran across threads. - boolean alreadyRequested = false; - if (!firedClose) { - synchronized (this) { - if (!firedClose) { - firedClose = true; - - // It is important that this event is the same as the one that the containing DockingRegion - // registers a listener for. We use close requests instead of direct closes. - Event.fireEvent(this, new Event(Tab.TAB_CLOSE_REQUEST_EVENT)); - } else { - alreadyRequested = true; - } - } - } else { - alreadyRequested = true; - } - - // We already triggered a close. No need to handle anything else below twice. - if (alreadyRequested) - return; - - // Remove from containing tab-pane. - TabPane tabPane = getTabPane(); - if (tabPane != null) { - // In cases where this is handled after the scene has been closed - // we can skip this process as the docking library we use does not - // consider such cases and throws an exception. - ObservableList tabList = tabPane.getTabs(); - if (tabPane.getScene() != null) { - // Select next available tab. - int i = tabList.indexOf(this); - if (i > 0 && tabList.get(i - 1) instanceof DockingTab prevTab) - prevTab.select(); - else if (tabList.size() > 1 && tabList.get(i + 1) instanceof DockingTab nextTab) { - nextTab.select(); - } - - // Remove this tab. - tabList.remove(this); - priorRegion = null; - } - } - } - } - - /** - * Select the current tab. - */ - public void select() { - TabPane parent = getTabPane(); - if (parent != null) - parent.getSelectionModel().select(this); - - Node content = getContent(); - if (content != null) - content.requestFocus(); - } -} \ No newline at end of file diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabClosureListener.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabClosureListener.java deleted file mode 100644 index 84009117d..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabClosureListener.java +++ /dev/null @@ -1,19 +0,0 @@ -package software.coley.recaf.ui.docking.listener; - -import software.coley.recaf.ui.docking.DockingTab; -import software.coley.recaf.ui.docking.DockingRegion; - -/** - * Listener for {@link DockingTab} being closed. - * - * @author Matt Coley - */ -public interface TabClosureListener { - /** - * @param parent - * Parent region the tab belonged to. - * @param tab - * Tab closed. - */ - void onClose(DockingRegion parent, DockingTab tab); -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabCreationListener.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabCreationListener.java deleted file mode 100644 index 1be80f892..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabCreationListener.java +++ /dev/null @@ -1,19 +0,0 @@ -package software.coley.recaf.ui.docking.listener; - -import software.coley.recaf.ui.docking.DockingTab; -import software.coley.recaf.ui.docking.DockingRegion; - -/** - * Listener for {@link DockingTab} being created. - * - * @author Matt Coley - */ -public interface TabCreationListener { - /** - * @param parent - * Parent docking region. - * @param tab - * Created tab. - */ - void onCreate(DockingRegion parent, DockingTab tab); -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabMoveListener.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabMoveListener.java deleted file mode 100644 index 53717da1e..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabMoveListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package software.coley.recaf.ui.docking.listener; - -import software.coley.recaf.ui.docking.DockingTab; -import software.coley.recaf.ui.docking.DockingRegion; - -/** - * Listener for {@link DockingTab} moving between {@link DockingRegion}. - * - * @author Matt Coley - */ -public interface TabMoveListener { - /** - * @param oldParent - * Prior parent region. - * @param newParent - * New parent region. - * @param tab - * Tab moved. - */ - void onMove(DockingRegion oldParent, DockingRegion newParent, DockingTab tab); -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabSelectionListener.java b/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabSelectionListener.java deleted file mode 100644 index 1ae4e08b2..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/docking/listener/TabSelectionListener.java +++ /dev/null @@ -1,19 +0,0 @@ -package software.coley.recaf.ui.docking.listener; - -import software.coley.recaf.ui.docking.DockingTab; -import software.coley.recaf.ui.docking.DockingRegion; - -/** - * Listener for {@link DockingTab} being selected. - * - * @author Matt Coley - */ -public interface TabSelectionListener { - /** - * @param region - * Parent region the tab belongs to. - * @param tab - * Tab selected. - */ - void onSelection(DockingRegion region, DockingTab tab); -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/media/MediaHacker.java b/recaf-ui/src/main/java/software/coley/recaf/ui/media/MediaHacker.java index cc752f4b0..550d10022 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/media/MediaHacker.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/media/MediaHacker.java @@ -39,6 +39,9 @@ public class MediaHacker { */ @Nonnull public static Media create(@Nonnull String path) throws IOException { + // TODO: Maybe hack into JarFileFactory#fileCache and go through the regular URL constructor for Media + // - set the url to be cached to facilitate looking up in that cache + // - clear the file cache when workspace is closed String uriPath = RecafURLStreamHandlerProvider.fileUri(path); try { // We need to bypass the constructor due to how URI is handled normally preventing normal usage of our diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/AnalysisMenu.java b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/AnalysisMenu.java index c7a8feeaf..be7585512 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/AnalysisMenu.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/AnalysisMenu.java @@ -6,20 +6,20 @@ import jakarta.inject.Inject; import javafx.scene.Node; import org.kordamp.ikonli.carbonicons.CarbonIcons; +import software.coley.bentofx.dockable.Dockable; +import software.coley.bentofx.path.DockablePath; +import software.coley.bentofx.space.DockSpace; import software.coley.recaf.services.window.WindowManager; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.ui.control.ActionMenuItem; import software.coley.recaf.ui.control.FontIconView; import software.coley.recaf.ui.docking.DockingManager; -import software.coley.recaf.ui.docking.DockingRegion; -import software.coley.recaf.ui.docking.DockingTab; import software.coley.recaf.ui.pane.CommentListPane; -import software.coley.recaf.ui.window.DeobfuscationWindow; import software.coley.recaf.ui.pane.DocumentationPane; +import software.coley.recaf.ui.window.DeobfuscationWindow; import software.coley.recaf.util.Animations; import software.coley.recaf.util.FxThreadUtil; -import java.util.Optional; import java.util.UUID; import static software.coley.recaf.util.Lang.getBinding; @@ -78,28 +78,25 @@ private void openDeobfuscation() { */ private void openCommentList() { // Check for tabs with the panel already open. - Optional tabWithListPane = dockingManager.getDockTabs().stream() - .filter(tab -> tab.getContent() instanceof CommentListPane) - .findFirst(); - if (tabWithListPane.isPresent()) { - // It already exists, focus the tab. - DockingTab dockingTab = tabWithListPane.get(); - dockingTab.select(); - Node content = dockingTab.getContent(); - FxThreadUtil.run(() -> Animations.animateNotice(content, 1000)); - content.requestFocus(); - } else { - // It does not exist, create a tab and put a new list pane in it. - Optional tabWithDocContent = dockingManager.getDockTabs().stream() - .filter(tab -> tab.getContent() instanceof DocumentationPane) - .findFirst(); - DockingRegion region = tabWithDocContent.isPresent() ? - tabWithDocContent.get().getRegion() : - dockingManager.getPrimaryRegion(); - CommentListPane commentList = commentListPaneProvider.get(); - DockingTab tab = region.createTab(getBinding("menu.analysis.list-comments"), commentList); - tab.setGraphic(new FontIconView(CarbonIcons.CHAT)); - tab.select(); + DockablePath docPanePath = null; + for (DockablePath path : dockingManager.getBento().getAllDockables()) { + Dockable dockable = path.dockable(); + Node node = dockable.nodeProperty().get(); + if (node instanceof CommentListPane) { + path.space().selectDockable(dockable); + FxThreadUtil.run(() -> { + node.requestFocus(); + Animations.animateNotice(node, 1000); + }); + return; + } else if (node instanceof DocumentationPane) { + docPanePath = path; + } } + + // Not already open, gotta open a new one. + DockSpace space = docPanePath != null ? docPanePath.space() : dockingManager.getPrimaryTabbedSpace(); + Dockable dockable = dockingManager.newTranslatableDockable("menu.analysis.list-comments", CarbonIcons.CHAT, commentListPaneProvider.get()); + space.addDockable(dockable); } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/FileMenu.java b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/FileMenu.java index 78087aaa2..5d77561e5 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/FileMenu.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/FileMenu.java @@ -1,6 +1,5 @@ package software.coley.recaf.ui.menubar; -import com.panemu.tiwulfx.control.dock.DetachableTab; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Instance; @@ -14,6 +13,7 @@ import org.kordamp.ikonli.carbonicons.CarbonIcons; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; +import software.coley.recaf.services.navigation.Actions; import software.coley.recaf.services.window.WindowManager; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.ui.config.RecentFilesConfig; @@ -21,7 +21,6 @@ import software.coley.recaf.ui.control.FontIconView; import software.coley.recaf.ui.control.popup.OpenUrlPopup; import software.coley.recaf.ui.docking.DockingManager; -import software.coley.recaf.ui.docking.DockingRegion; import software.coley.recaf.ui.pane.WorkspaceBuilderPane; import software.coley.recaf.ui.pane.WorkspaceInformationPane; import software.coley.recaf.ui.window.RecafScene; @@ -60,6 +59,7 @@ public class FileMenu extends WorkspaceAwareMenu { private final Instance openUrlProvider; private final DockingManager dockingManager; private final WindowManager windowManager; + private final Actions actions; // config private final RecentFilesConfig recentFilesConfig; @@ -71,6 +71,7 @@ public FileMenu(@Nonnull WorkspaceManager workspaceManager, @Nonnull Instance openUrlProvider, @Nonnull DockingManager dockingManager, @Nonnull WindowManager windowManager, + @Nonnull Actions actions, @Nonnull RecentFilesConfig recentFilesConfig) { super(workspaceManager); this.workspaceManager = workspaceManager; @@ -80,6 +81,7 @@ public FileMenu(@Nonnull WorkspaceManager workspaceManager, this.openUrlProvider = openUrlProvider; this.dockingManager = dockingManager; this.windowManager = windowManager; + this.actions = actions; this.recentFilesConfig = recentFilesConfig; textProperty().bind(getBinding("menu.file")); @@ -91,7 +93,7 @@ public FileMenu(@Nonnull WorkspaceManager workspaceManager, MenuItem itemAddToWorkspace = action("menu.file.addtoworkspace", CarbonIcons.WORKSPACE_IMPORT, this::addToWorkspace); MenuItem itemExportPrimary = action("menu.file.exportapp", CarbonIcons.EXPORT, this::exportCurrent); MenuItem itemViewChanges = action("menu.file.modifications", CarbonIcons.COMPARE, this::openChangeViewer); - MenuItem itemViewSummary = action("menu.file.summary", CarbonIcons.INFORMATION, this::openSummary); + MenuItem itemViewSummary = action("menu.file.summary", CarbonIcons.INFORMATION, actions::openSummary); MenuItem itemClose = action("menu.file.close", CarbonIcons.TRASH_CAN, this::closeWorkspace); itemAddToWorkspace.disableProperty().bind(hasWorkspace.not()); itemExportPrimary.disableProperty().bind(hasWorkspace.not().and(hasAgentWorkspace.not())); @@ -227,18 +229,6 @@ private void openUrl() { popup.requestInputFocus(); } - /** - * Display the workspace summary / current information. - */ - private void openSummary() { - // TODO: Move this into 'Actions' class and fill in the tab's context menu - WorkspaceInformationPane informationPane = infoPaneProvider.get(); - DockingRegion dockInfo = dockingManager.getPrimaryRegion(); - DetachableTab infoTab = dockInfo.createTab(Lang.getBinding("workspace.info"), informationPane); - infoTab.getTabPane().getSelectionModel().select(infoTab); - infoTab.setGraphic(new FontIconView(CarbonIcons.INFORMATION)); - } - /** * Display the change viewer window. */ diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceExplorerPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceExplorerPane.java index c6df01bec..5be5db2c4 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceExplorerPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceExplorerPane.java @@ -1,7 +1,6 @@ package software.coley.recaf.ui.pane; import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import javafx.geometry.Pos; @@ -12,6 +11,7 @@ import org.kordamp.ikonli.carbonicons.CarbonIcons; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.cell.context.ContextSource; +import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.ui.control.BoundLabel; import software.coley.recaf.ui.control.FontIconView; import software.coley.recaf.ui.control.tree.TreeFiltering; @@ -19,14 +19,14 @@ import software.coley.recaf.ui.control.tree.WorkspaceTreeFilterPane; import software.coley.recaf.ui.dnd.DragAndDrop; import software.coley.recaf.ui.dnd.WorkspaceLoadingDropListener; +import software.coley.recaf.ui.docking.DockingLayoutManager; import software.coley.recaf.util.Lang; -import software.coley.recaf.workspace.model.Workspace; /** * Pane to display the current workspace in a navigable tree layout. * * @author Matt Coley - * @see WorkspaceRootPane + * @see DockingLayoutManager */ @Dependent public class WorkspaceExplorerPane extends BorderPane { @@ -37,13 +37,13 @@ public class WorkspaceExplorerPane extends BorderPane { * Workspace drag-and-drop listener. * @param workspaceTree * Tree to display workspace with. - * @param workspace - * Current workspace, if any. + * @param workspaceManager + * Manager to pull in current workspace from. */ @Inject public WorkspaceExplorerPane(@Nonnull WorkspaceLoadingDropListener listener, @Nonnull WorkspaceTree workspaceTree, - @Nullable Workspace workspace) { + @Nonnull WorkspaceManager workspaceManager) { this.workspaceTree = workspaceTree; // As we are the explorer pane, these items should be treated as declarations and not references. @@ -63,8 +63,8 @@ public WorkspaceExplorerPane(@Nonnull WorkspaceLoadingDropListener listener, setBottom(workspaceTreeFilterPane); // Populate tree - if (workspace != null) - workspaceTree.createWorkspaceRoot(workspace); + if (workspaceManager.hasCurrentWorkspace()) + workspaceTree.createWorkspaceRoot(workspaceManager.getCurrent()); // Add label to indicate when filter pane input results in the tree being empty. // This should help out users if they forget they have something in the search bar and the tree looks empty. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceInformationPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceInformationPane.java index 022a02b10..77ebe09cf 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceInformationPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceInformationPane.java @@ -23,11 +23,11 @@ import software.coley.recaf.path.WorkspacePathNode; import software.coley.recaf.services.cell.icon.IconProviderService; import software.coley.recaf.services.cell.text.TextProviderService; -import software.coley.recaf.services.info.summary.ResourceSummarizer; import software.coley.recaf.services.info.summary.ResourceSummaryService; import software.coley.recaf.services.info.summary.SummaryConsumer; import software.coley.recaf.services.navigation.Navigable; import software.coley.recaf.ui.control.BoundLabel; +import software.coley.recaf.ui.docking.DockingLayoutManager; import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.util.Lang; import software.coley.recaf.workspace.model.Workspace; @@ -44,8 +44,8 @@ * Pane to display summary data about the loaded {@link Workspace} when opened. * * @author Matt Coley - * @see WorkspaceRootPane Parent display. - * @see ResourceSummaryService Manages content to display here via discovered {@link ResourceSummarizer} types. + * @see DockingLayoutManager + * @see ResourceSummaryService */ @Dependent public class WorkspaceInformationPane extends StackPane implements Navigable { diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceRootPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceRootPane.java deleted file mode 100644 index d9f1ff8be..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/WorkspaceRootPane.java +++ /dev/null @@ -1,112 +0,0 @@ -package software.coley.recaf.ui.pane; - -import com.panemu.tiwulfx.control.dock.DetachableTab; -import jakarta.annotation.Nonnull; -import jakarta.enterprise.context.Dependent; -import jakarta.enterprise.inject.Instance; -import jakarta.inject.Inject; -import javafx.scene.control.SplitPane; -import javafx.scene.layout.BorderPane; -import org.kordamp.ikonli.carbonicons.CarbonIcons; -import software.coley.recaf.RecafApplication; -import software.coley.recaf.services.workspace.WorkspaceManager; -import software.coley.recaf.ui.control.FontIconView; -import software.coley.recaf.ui.docking.DockingManager; -import software.coley.recaf.ui.docking.DockingRegion; -import software.coley.recaf.ui.docking.DockingTab; -import software.coley.recaf.util.FxThreadUtil; -import software.coley.recaf.util.Lang; -import software.coley.recaf.workspace.model.Workspace; - -import java.util.ArrayList; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Root panel for displaying a {@link Workspace}. - *
- * Provides: - *
    - *
  • Searchable tree layout - {@link WorkspaceExplorerPane}
  • - *
- * There should only ever be a single instance of this, managed by {@link RecafApplication}. - * - * @author Matt Coley - */ -@Dependent -public class WorkspaceRootPane extends BorderPane { - @Inject - public WorkspaceRootPane(@Nonnull DockingManager dockingManager, - @Nonnull WorkspaceManager workspaceManager, - @Nonnull Instance explorerPaneProvider, - @Nonnull Instance informationPaneProvider) { - getStyleClass().add("bg-inset"); - - AtomicReference lastTreeRegion = new AtomicReference<>(); - AtomicReference lastPrimaryRegion = new AtomicReference<>(); - - // Register an open listener to fill the pane's regions when a workspace is loaded. - workspaceManager.addWorkspaceOpenListener(workspace -> { - // Most of these actions need to be on the UI thread. - FxThreadUtil.run(() -> { - DockingRegion dockTree = dockingManager.newRegion(); - DockingRegion dockPrimary = dockingManager.getPrimaryRegion(); - createWorkspaceExplorerTab(dockTree, explorerPaneProvider); - createPrimaryTab(dockPrimary, informationPaneProvider); - - // Layout - SplitPane split = new SplitPane(dockTree, dockPrimary); - SplitPane.setResizableWithParent(dockTree, false); - split.setDividerPositions(0.333); - setCenter(split); - - // Record last regions - lastTreeRegion.set(dockTree); - lastPrimaryRegion.set(dockPrimary); - }); - }); - - // Register a close listener to remove the old workspace's content from the pane's regions. - workspaceManager.addWorkspaceCloseListener(workspace -> { - FxThreadUtil.run(() -> { - DockingRegion dockTree = lastTreeRegion.get(); - if (dockTree != null) { - // Close all tabs. - for (DockingTab tab : new ArrayList<>(dockTree.getDockTabs())) { - // Mark as closable so they can be closed. - tab.setClosable(true); - - // When the last tab in the region is closed, - // the close handler should kick in and clean things up for us. - // We will validate this below. - tab.close(); - } - } - DockingRegion dockPrimary = lastPrimaryRegion.get(); - if (dockPrimary != null) { - // Only close closable tabs. - for (DockingTab tab : new ArrayList<>(dockPrimary.getDockTabs())) { - tab.close(); - } - } - }); - }); - } - - private void createPrimaryTab(@Nonnull DockingRegion region, - @Nonnull Instance informationPaneProvider) { - // Add summary of workspace, targeting the primary region. - // In the UI, this region will persist and be the default location for - // opening most 'new' content/tabs. - DetachableTab infoTab = region.createTab(Lang.getBinding("workspace.info"), informationPaneProvider.get()); - infoTab.setGraphic(new FontIconView(CarbonIcons.INFORMATION)); - } - - private void createWorkspaceExplorerTab(@Nonnull DockingRegion region, - @Nonnull Instance explorerPaneProvider) { - // Add workspace explorer tree. - DockingTab workspaceTab = region.createTab(Lang.getBinding("workspace.title"), explorerPaneProvider.get()); - workspaceTab.setGraphic(new FontIconView(CarbonIcons.TREE_VIEW)); - workspaceTab.setClosable(false); - workspaceTab.setDetachable(false); - } -} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/AbstractContentPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/AbstractContentPane.java index 5dba502be..0e5b58f17 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/AbstractContentPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/AbstractContentPane.java @@ -1,16 +1,34 @@ package software.coley.recaf.ui.pane.editing; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import javafx.beans.value.ObservableValue; import javafx.geometry.Orientation; +import javafx.geometry.Side; import javafx.scene.Node; -import javafx.scene.control.Tab; import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Region; +import org.kordamp.ikonli.Ikon; +import software.coley.bentofx.Bento; +import software.coley.bentofx.builder.LayoutBuilder; +import software.coley.bentofx.builder.SingleSpaceArgs; +import software.coley.bentofx.builder.TabbedSpaceArgs; +import software.coley.bentofx.dockable.Dockable; +import software.coley.bentofx.dockable.DockableIconFactory; +import software.coley.bentofx.header.Header; +import software.coley.bentofx.impl.ImplBento; +import software.coley.bentofx.layout.LeafDockLayout; +import software.coley.bentofx.layout.RootDockLayout; +import software.coley.bentofx.layout.SplitDockLayout; +import software.coley.bentofx.path.SpacePath; +import software.coley.bentofx.space.TabbedDockSpace; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.Info; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.navigation.Navigable; import software.coley.recaf.services.navigation.UpdatableNavigable; +import software.coley.recaf.ui.control.FontIconView; import java.util.ArrayList; import java.util.Collection; @@ -28,11 +46,54 @@ * @see ClassPane For {@link ClassInfo} */ public abstract class AbstractContentPane

> extends BorderPane implements UpdatableNavigable { + private static final String TOOL_TABS_ID = "tool-tabs"; + /** We use a separate instance because the side-tabs of this pane are not intended to be tracked by our docking manger. */ + private final ImplBento bento = (ImplBento) Bento.newBento(); + /** Wrapper to hold {@link #displayWrapper} and {@link Dockable} side tabs. Split according to {@link #toolTabSide}. */ + private final SplitDockLayout displaySplit; + /** Side of the UI to place additional tools on. */ + private final Side toolTabSide; + /** Wrapper to hold display for provided content. See {@link #generateDisplay()} */ + private final BorderPane displayWrapper = new BorderPane(); protected final List> pathUpdateListeners = new CopyOnWriteArrayList<>(); protected final List children = new ArrayList<>(); - protected SideTabs sideTabs; protected P path; + /** + * New content pane. Any additional tools registered via {@link #addSideTab(Dockable)} will be placed on the right. + */ + protected AbstractContentPane() { + this(Side.RIGHT); + } + + /** + * New content pane. + * + * @param toolTabSide + * Side to place additional tools registered via {@link #addSideTab(Dockable)}. + */ + protected AbstractContentPane(@Nonnull Side toolTabSide) { + this.toolTabSide = toolTabSide; + + LayoutBuilder builder = bento.newLayoutBuilder(); + Dockable dockable = bento.newDockableBuilder().withNode(displayWrapper).withCanBeDragged(false).withDragGroup(-1).build(); + LeafDockLayout displayLayout = builder.leaf(builder.single(new SingleSpaceArgs().setSide(null).setDockable(dockable))); + + displaySplit = builder.split(toolTabSide.isHorizontal() ? Orientation.VERTICAL : Orientation.HORIZONTAL, displayLayout); + + RootDockLayout root = builder.root(displaySplit); + Region rootRegion = root.getBackingRegion(); + rootRegion.getStyleClass().add("embedded-bento"); + switch (toolTabSide) { + case TOP -> rootRegion.pseudoClassStateChanged(Header.PSEUDO_SIDE_TOP, true); + case BOTTOM -> rootRegion.pseudoClassStateChanged(Header.PSEUDO_SIDE_BOTTOM, true); + case LEFT -> rootRegion.pseudoClassStateChanged(Header.PSEUDO_SIDE_LEFT, true); + case RIGHT -> rootRegion.pseudoClassStateChanged(Header.PSEUDO_SIDE_RIGHT, true); + } + setCenter(rootRegion); + bento.registerRoot(root); + } + /** * @param targetType * Type of children to filter with. @@ -50,23 +111,31 @@ protected void eachChild(@Nonnull Class targetType, @Nonnull Consumer } } + /** + * @return Current display + */ + @Nullable + public Node getDisplay() { + return displayWrapper.getCenter(); + } + /** * Clear the display. */ protected void clearDisplay() { // Remove navigable child. - if (getCenter() instanceof Navigable navigable) + if (displayWrapper.getCenter() instanceof Navigable navigable) children.remove(navigable); // Remove display node. - setCenter(null); + displayWrapper.setCenter(null); } /** * @return {@code true} when there is a current displayed node. */ protected boolean hasDisplay() { - return getCenter() != null; + return getDisplay() != null; } /** @@ -75,7 +144,7 @@ protected boolean hasDisplay() { */ protected void setDisplay(Node node) { // Remove old navigable child. - Node old = getCenter(); + Node old = displayWrapper.getCenter(); if (old instanceof Navigable navigableOld) children.remove(navigableOld); @@ -84,7 +153,7 @@ protected void setDisplay(Node node) { children.add(navigableNode); // Set display node. - setCenter(node); + displayWrapper.setCenter(node); } /** @@ -96,7 +165,7 @@ protected void refreshDisplay() { generateDisplay(); // Refresh UI with path - if (getCenter() instanceof UpdatableNavigable updatable) { + if (displayWrapper.getCenter() instanceof UpdatableNavigable updatable) { PathNode currentPath = getPath(); if (currentPath != null) updatable.onUpdatePath(currentPath); @@ -110,19 +179,86 @@ protected void refreshDisplay() { protected abstract void generateDisplay(); /** - * @param tab - * Tab to add to the side panel. + * Adds a new side tab to this pane. + * + * @param binding + * Side tab title binding. + * @param icon + * Side tab icon. + * @param content + * Side tab content to display. */ - public void addSideTab(@Nonnull Tab tab) { - // Lazily create/add side-tabs to UI. - if (sideTabs == null) { - sideTabs = new SideTabs(Orientation.VERTICAL); - children.add(sideTabs); - setRight(sideTabs); + public void addSideTab(@Nonnull ObservableValue binding, @Nonnull Ikon icon, @Nullable Node content) { + addSideTab(binding, d -> new FontIconView(icon), content); + } + + /** + * Adds a new side tab to this pane. + * + * @param binding + * Side tab title binding. + * @param iconFactory + * Side tab icon factory. + * @param content + * Side tab content to display. + */ + public void addSideTab(@Nonnull ObservableValue binding, @Nonnull DockableIconFactory iconFactory, @Nullable Node content) { + Dockable dockable = bento.newDockableBuilder() + .withDragGroup(-1) // Prevent being used as a drag-drop target + .withCanBeDragged(false) // Prevent being used as a drag-drop initiator + .withClosable(false) + .withTitle(binding) + .withIconFactory(iconFactory) + .withNode(content) + .build(); + addSideTab(dockable); + } + + /** + * Adds a new side tab to this pane. + * + * @param dockable + * Side tab model. + */ + public void addSideTab(@Nonnull Dockable dockable) { + if (displaySplit.getChildLayouts().size() < 2) { + LayoutBuilder builder = bento.newLayoutBuilder(); + TabbedDockSpace space = builder.tabbed(new TabbedSpaceArgs() + .setCanSplit(false) + .setSide(toolTabSide) + .setIdentifier(TOOL_TABS_ID) + .addDockables(dockable)); + LeafDockLayout leaf = builder.fitLeaf(space); + switch (toolTabSide) { + case TOP, LEFT -> displaySplit.addChildLayout(0, leaf); + case BOTTOM, RIGHT -> displaySplit.addChildLayout(leaf); + } + displaySplit.setChildCollapsed(leaf, true); + switch (toolTabSide) { + case TOP, LEFT -> displaySplit.setChildSize(leaf, 232); + case BOTTOM, RIGHT -> displaySplit.setChildSize(leaf, 180); + } + } else { + SpacePath path = bento.findSpace(TOOL_TABS_ID); + if (path != null && path.space() instanceof TabbedDockSpace space) { + space.addDockable(dockable); + space.selectDockable(space.getDockables().getFirst()); + } } + if (dockable.getNode() instanceof Navigable dockableNode) + children.add(dockableNode); + } - // Add the given tab. - sideTabs.getTabs().add(tab); + /** + * Clears all side tabs from this pane. + */ + public void clearSideTabs() { + SpacePath path = bento.findSpace(TOOL_TABS_ID); + if (path != null && path.space() instanceof TabbedDockSpace space) { + for (Dockable dockable : new ArrayList<>(space.getDockables())) { + space.closeDockable(dockable); + } + } } /** diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/ClassPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/ClassPane.java index 161c0b99d..e9669bade 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/ClassPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/ClassPane.java @@ -50,7 +50,7 @@ public void onUpdatePath(@Nonnull PathNode path) { (listener, t) -> logger.error("Exception thrown when handling class-pane path update callback", t)); // Initialize UI if it has not been done yet. - if (getCenter() == null) + if (!hasDisplay()) generateDisplay(); // Notify children of change. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/FilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/FilePane.java index 186678581..46702670f 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/FilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/FilePane.java @@ -31,7 +31,7 @@ public void onUpdatePath(@Nonnull PathNode path) { pathUpdateListeners.forEach(listener -> listener.accept(filePath)); // Initialize UI if it has not been done yet. - if (getCenter() == null) + if (!hasDisplay()) generateDisplay(); // Notify children of change. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabs.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabs.java deleted file mode 100644 index 7c30572d2..000000000 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabs.java +++ /dev/null @@ -1,365 +0,0 @@ -package software.coley.recaf.ui.pane.editing; - -import jakarta.annotation.Nonnull; -import javafx.beans.property.DoubleProperty; -import javafx.beans.property.ReadOnlyDoubleProperty; -import javafx.beans.property.SimpleDoubleProperty; -import javafx.collections.FXCollections; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.css.PseudoClass; -import javafx.geometry.Insets; -import javafx.geometry.Orientation; -import javafx.scene.Cursor; -import javafx.scene.Group; -import javafx.scene.Node; -import javafx.scene.control.Tab; -import javafx.scene.control.TabPane; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; -import javafx.scene.text.Text; -import software.coley.observables.ObservableBoolean; -import software.coley.observables.ObservableObject; -import software.coley.recaf.path.PathNode; -import software.coley.recaf.services.navigation.Navigable; -import software.coley.recaf.services.navigation.UpdatableNavigable; -import software.coley.recaf.util.NodeEvents; - -import java.util.Collection; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; -import java.util.function.DoubleSupplier; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * Alternative to vertical oriented {@link TabPane} that operates similarly to the side-tabs seen in IntelliJ. - * When these tabs are selected their content is "toggled" and expands into the main editor viewport. - * By default {@link TabPane} isn't really suitable for this. Thus, we make our own minimal implementation. - * - * @author Matt Coley - */ -public class SideTabs extends BorderPane implements UpdatableNavigable { - private static final PseudoClass PSEUDO_HORIZONTAL = PseudoClass.getPseudoClass("horizontal"); - private static final PseudoClass PSEUDO_VERTICAL = PseudoClass.getPseudoClass("vertical"); - private final ObservableList tabs = FXCollections.observableArrayList(); - private final ObservableObject selectedTab = new ObservableObject<>(null); - private final DoubleProperty initialSize = new SimpleDoubleProperty(); - private final Pane tabContainer; - private final ResizeGrip grip; - private final DoubleProperty prefSize; - private double lastSize; - private boolean sizeIsBound; - private PathNode path; - - /** - * New side tabs. - * - * @param orientation - * Orientation of tab layout. - */ - public SideTabs(@Nonnull Orientation orientation) { - if (orientation == Orientation.VERTICAL) { - VBox vBox = new VBox(); - vBox.setFillWidth(true); - tabContainer = vBox; - } else { - HBox hBox = new HBox(); - hBox.setFillHeight(true); - tabContainer = hBox; - } - tabContainer.getStyleClass().add("side-tab-pane"); - - if (orientation == Orientation.VERTICAL) { - pseudoClassStateChanged(PSEUDO_VERTICAL, true); - setRight(tabContainer); - } else { - pseudoClassStateChanged(PSEUDO_HORIZONTAL, true); - setBottom(tabContainer); - } - - // When the tabs list is updated, add or remove tabs as necessary. - tabs.addListener((ListChangeListener) change -> { - while ((change.next())) { - // Add new tabs. - for (Tab tab : change.getAddedSubList()) { - // Add new tab display node. - // When it is clicked on, update the selected tab. - TabAdapter adapter = new TabAdapter(orientation, tab); - adapter.setOnMousePressed(e -> { - if (selectedTab.getValue() == tab) - selectedTab.setValue(null); - else - selectedTab.setValue(tab); - }); - tabContainer.getChildren().add(adapter); - } - - // Remove old tabs. - for (Tab tab : change.getRemoved()) - tabContainer.getChildren().removeIf(n -> n instanceof TabAdapter adapter && adapter.tab == tab); - } - }); - - // We only want to allocate ONE grip element, which will be reused. - // Less allocation is good, but we want a single instance so the initial callback to the consumer parameter - // is only run ONCE. Not each time we select a new tab. - DoubleProperty prefSize; - ResizeGrip grip; - if (orientation == Orientation.VERTICAL) { - prefSize = prefWidthProperty(); - grip = new ResizeGrip(initialSize, orientation, this::getWidth, - size -> prefSize.bind(size.map((Function) - number -> lastSize = Math.min(number.doubleValue(), getMaxWidth())))); - } else { - prefSize = prefHeightProperty(); - grip = new ResizeGrip(initialSize, orientation, this::getHeight, - size -> prefSize.bind(size.map((Function) - number -> lastSize = Math.min(number.doubleValue(), getMaxHeight())))); - } - this.grip = grip; - this.prefSize = prefSize; - - - // When the selected tab changes display the selected one's content, clear content if no selection. - selectedTab.addChangeListener((ob, old, cur) -> { - for (Node child : tabContainer.getChildren()) { - if (child instanceof TabAdapter adapter) { - adapter.selected.setValue(cur == adapter.tab); - if (cur == null) { - // We unbind here so that the empty region can collapse. - prefSize.unbind(); - prefSize.setValue(0); - setCenter(null); - - // Mark that we are not bound. - // When we go to bind again, we will know we can call the size setter without a binding conflict. - sizeIsBound = false; - } else { - // Set initial pref size to what it was from the last open tab. - if (!sizeIsBound) { - prefSize.set(lastSize); - sizeIsBound = true; - } - - // We have this wrapper which has a resize bar on the left, and the main content in the center. - // The resize bar will allow the main content size to be modified. - // With the given binding logic, the main content is not allowed to expand beyond the container bounds. - // We also record the bound size to a variable so that when we close a tab and re-open it, we can - // restore the prior size. - BorderPane wrapper = new BorderPane(cur.getContent()); - wrapper.getStyleClass().add("side-tab-content"); - if (orientation == Orientation.VERTICAL) { - wrapper.setLeft(grip); - wrapper.setPrefWidth(lastSize); - } else { - wrapper.setTop(grip); - wrapper.setPrefHeight(lastSize); - } - setCenter(wrapper); - } - } - } - }); - - // When the side-tabs are added to the UI we want to initialize the max-width/height property so that we can reference - // it later in our logic to prevent the resize grip from making content larger than the parent size. - NodeEvents.runOnceOnChange(parentProperty(), parent -> { - if (parent instanceof Region parentRegion) { - if (orientation == Orientation.VERTICAL) { - ReadOnlyDoubleProperty parentWidthProperty = parentRegion.widthProperty(); - maxWidthProperty().bind(parentWidthProperty.subtract(35)); - } else { - ReadOnlyDoubleProperty parentHeightProperty = parentRegion.heightProperty(); - maxHeightProperty().bind(parentHeightProperty.subtract(35)); - } - } - }); - } - - /** - * @return List of tabs. - */ - public ObservableList getTabs() { - return tabs; - } - - - /** - * Sets the size of content that will be shown when a tab is selected. - * By default, the tab content auto-sizes to the content shown. - * In some cases when the auto-size is 0 you may want to use this. - * - * @param initialSize - * Initial size of the tab content. - */ - public void setInitialSize(double initialSize) { - this.initialSize.setValue(initialSize); - } - - @Nonnull - @Override - public PathNode getPath() { - return path; - } - - @Nonnull - @Override - public Collection getNavigableChildren() { - return tabs.stream() - .filter(t -> t.getContent() instanceof Navigable) - .map(t -> (Navigable) t.getContent()) - .collect(Collectors.toList()); - } - - @Override - public void onUpdatePath(@Nonnull PathNode path) { - if (this.path == null || this.path.getClass() == path.getClass()) { - this.path = path; - for (Navigable navigable : getNavigableChildren()) { - if (navigable instanceof UpdatableNavigable updatable) { - updatable.onUpdatePath(path); - } - } - } - } - - @Override - public void disable() { - for (Navigable child : getNavigableChildren()) - child.disable(); - setDisable(true); - } - - /** - * Node adapter for {@link Tab} to display. - */ - private static class TabAdapter extends BorderPane { - private static final PseudoClass STATE_SELECTED = new PseudoClass() { - @Override - public String getPseudoClassName() { - return "selected"; - } - }; - private final ObservableBoolean selected = new ObservableBoolean(false); - private final Tab tab; - - /** - * @param orientation - * Parent side-tabs orientation. - * @param tab - * Tab content to represent. - */ - public TabAdapter(@Nonnull Orientation orientation, @Nonnull Tab tab) { - this.tab = tab; - Pane box; - int padSmall = 5; - int padBig = 10; - if (orientation == Orientation.VERTICAL) { - VBox vBox = new VBox(); - vBox.setSpacing(padSmall); - box = vBox; - } else { - HBox hBox = new HBox(); - hBox.setSpacing(padSmall); - box = hBox; - } - ObservableList styleClasses = getStyleClass(); - styleClasses.add("side-tab"); - setPadding(orientation == Orientation.HORIZONTAL ? - new Insets(padSmall, padBig, padSmall, padBig) : - new Insets(padBig, padSmall, padBig, padSmall)); - - // Layout & map content from tab - BorderPane graphicWrapper = new BorderPane(); - graphicWrapper.centerProperty().bind(tab.graphicProperty()); - Text text = new Text(); - text.textProperty().bind(tab.textProperty()); - if (orientation == Orientation.VERTICAL) { - // must be wrapped in group for this to use proper rotated dimensions - graphicWrapper.setRotate(90); - text.setRotate(90); - } - box.getChildren().addAll(graphicWrapper, new Group(text)); - setCenter(box); - - // Handle visual state change with selection & hover events. - selected.addChangeListener((ob, old, cur) -> { - pseudoClassStateChanged(STATE_SELECTED, cur); - }); - } - } - - /** - * Control for handling resizing the display. - */ - private static class ResizeGrip extends BorderPane { - private final SimpleDoubleProperty targetSize = new SimpleDoubleProperty(); - - /** - * @param initialSize - * Initial desired size of the side-tab content. - * @param orientation - * Parent side-tabs orientation. - * @param sizeLookup - * Parent container size lookup. - * @param consumer - * Consumer to take in the desired new size, wrapped in a {@link DoubleProperty} in order to - * support mapping operations. - */ - private ResizeGrip(@Nonnull DoubleProperty initialSize, - @Nonnull Orientation orientation, - @Nonnull DoubleSupplier sizeLookup, - @Nonnull Consumer consumer) { - AtomicInteger startPos = new AtomicInteger(); - Pane box; - if (orientation == Orientation.VERTICAL) { - VBox vBox = new VBox(); - vBox.setSpacing(5); - vBox.setFillWidth(true); - box = vBox; - } else { - HBox hBox = new HBox(); - hBox.setSpacing(5); - hBox.setFillHeight(true); - box = hBox; - } - setCenter(box); - getStyleClass().add("side-tab-grip"); - if (orientation == Orientation.VERTICAL) { - setCursor(Cursor.H_RESIZE); - setMinWidth(5); - setOnMousePressed(e -> startPos.set((int) e.getX())); - setOnMouseDragged(e -> { - double x = e.getX(); - double diffX = (x - startPos.get()); - double width = sizeLookup.getAsDouble(); - double newWidth = width - diffX; - targetSize.set(newWidth); - consumer.accept(targetSize); - }); - } else { - setCursor(Cursor.V_RESIZE); - setMinHeight(5); - setOnMousePressed(e -> startPos.set((int) e.getY())); - setOnMouseDragged(e -> { - double y = e.getY(); - double diffY = (y - startPos.get()); - double height = sizeLookup.getAsDouble(); - double newHeight = height - diffY; - targetSize.set(newHeight); - consumer.accept(targetSize); - }); - } - - // Trigger call to consumer once with existing parent width once initially added to the UI. - NodeEvents.runOnceOnChange(parentProperty(), parent -> { - targetSize.set(Math.max(sizeLookup.getAsDouble(), initialSize.doubleValue())); - consumer.accept(targetSize); - }); - } - } -} \ No newline at end of file diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabsInjector.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabsInjector.java index 313ab3e88..70e41d007 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabsInjector.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/SideTabsInjector.java @@ -12,7 +12,6 @@ import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.navigation.ClassNavigable; -import software.coley.recaf.ui.control.BoundTab; import software.coley.recaf.ui.pane.editing.tabs.FieldsAndMethodsPane; import software.coley.recaf.ui.pane.editing.tabs.InheritancePane; import software.coley.recaf.ui.pane.editing.tabs.KotlinMetadataPane; @@ -24,7 +23,7 @@ import java.util.function.Consumer; /** - * Injector for adding common {@link SideTabs} content to {@link AbstractContentPane} instances. + * Injector for adding common tool tab content to {@link AbstractContentPane} instances. * * @author Matt Coley */ @@ -93,24 +92,15 @@ private void injectClassTabs(@Nonnull AbstractContentPane pane) { fieldsAndMethodsPane.setupSelectionNavigationListener(classNavigable); // Setup side-tabs - pane.addSideTab(new BoundTab(Lang.getBinding("fieldsandmethods.title"), - Icons.getIconView(Icons.FIELD_N_METHOD), - fieldsAndMethodsPane - )); - pane.addSideTab(new BoundTab(Lang.getBinding("hierarchy.title"), - CarbonIcons.FLOW, - inheritancePane - )); + pane.addSideTab(Lang.getBinding("fieldsandmethods.title"), d -> Icons.getIconView(Icons.FIELD_N_METHOD), fieldsAndMethodsPane); + pane.addSideTab(Lang.getBinding("hierarchy.title"), CarbonIcons.FLOW, inheritancePane); // Add kotlin metadata tab if metadata is found. KtClass metadata = KotlinMetadata.extractKtModel(classNavigable.getClassPath().getValue()); if (metadata != null) { KotlinMetadataPane kotlinMetadataPane = kotlinPaneProvider.get(); kotlinMetadataPane.setMetadata(metadata); - pane.addSideTab(new BoundTab(Lang.getBinding("kotlinmetadata.title"), - Icons.getIconView(Icons.KOTLIN_FLAT), - kotlinMetadataPane - )); + pane.addSideTab(Lang.getBinding("kotlinmetadata.title"), d -> Icons.getIconView(Icons.KOTLIN_FLAT), kotlinMetadataPane); } } else { logger.warn("Called 'injectClassTabs' for non-class navigable content"); diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/android/AndroidClassPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/android/AndroidClassPane.java index 2f4313f18..32a3dbff8 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/android/AndroidClassPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/android/AndroidClassPane.java @@ -52,7 +52,7 @@ public void setEditorType(@Nonnull AndroidClassEditorType editorType) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerPane.java index 18d4f5f76..aca1b7e6a 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerPane.java @@ -3,6 +3,7 @@ import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; +import javafx.geometry.Side; import javafx.scene.Node; import me.darknet.assembler.ast.ASTElement; import me.darknet.assembler.ast.specific.ASTClass; @@ -99,9 +100,13 @@ public AssemblerPane(@Nonnull AssemblerPipelineManager pipelineManager, @Nonnull InheritanceGraphService graphService, @Nonnull CellConfigurationService cellConfigurationService, @Nonnull TabCompletionConfig tabCompletionConfig) { + super(Side.BOTTOM); + this.pipelineManager = pipelineManager; this.assemblerToolTabs = assemblerToolTabs; + assemblerToolTabs.setOwner(this); + int timeToWait = pipelineManager.getServiceConfig().getDisassemblyAstParseDelay().getValue(); InheritanceGraph inheritanceGraph = Objects.requireNonNull(graphService.getCurrentWorkspaceInheritanceGraph(), "Graph not created"); @@ -231,7 +236,6 @@ private void lateInitForMethod(@Nonnull ClassMemberPathNode memberPathNode) { protected void generateDisplay() { if (!hasDisplay()) { setDisplay(editor); - setBottom(assemblerToolTabs.getTabs()); // Trigger a disassembly so the initial text is set in the editor. disassemble().whenComplete((unused, error) -> { diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerToolTabs.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerToolTabs.java index 5c88301f2..2f713767a 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerToolTabs.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/assembler/AssemblerToolTabs.java @@ -4,21 +4,15 @@ import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; -import javafx.collections.ObservableList; -import javafx.geometry.Orientation; -import javafx.scene.control.Tab; import me.darknet.assembler.ast.ASTElement; import me.darknet.assembler.compiler.ClassResult; import org.kordamp.ikonli.carbonicons.CarbonIcons; -import software.coley.recaf.behavior.Closing; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.navigation.Navigable; import software.coley.recaf.services.navigation.UpdatableNavigable; -import software.coley.recaf.ui.control.BoundTab; import software.coley.recaf.ui.control.richtext.Editor; import software.coley.recaf.ui.control.richtext.EditorComponent; -import software.coley.recaf.ui.pane.editing.SideTabs; import software.coley.recaf.util.FxThreadUtil; import software.coley.recaf.util.Lang; @@ -42,33 +36,28 @@ public class AssemblerToolTabs implements AssemblerAstConsumer, AssemblerBuildCo private final Instance snippetPaneProvider; private final Instance controlFlowLineProvider; private final List children = new CopyOnWriteArrayList<>(); - private final SideTabs tabs = new SideTabs(Orientation.HORIZONTAL); + private AssemblerPane owner; private PathNode path; @Inject public AssemblerToolTabs(@Nonnull Instance jvmStackAnalysisPaneProvider, - @Nonnull Instance jvmVariablesPaneProvider, - @Nonnull Instance jvmExpressionCompilerPaneProvider, - @Nonnull Instance snippetPaneProvider, - @Nonnull Instance controlFlowLineProvider) { + @Nonnull Instance jvmVariablesPaneProvider, + @Nonnull Instance jvmExpressionCompilerPaneProvider, + @Nonnull Instance snippetPaneProvider, + @Nonnull Instance controlFlowLineProvider) { this.jvmStackAnalysisPaneProvider = jvmStackAnalysisPaneProvider; this.jvmVariablesPaneProvider = jvmVariablesPaneProvider; this.jvmExpressionCompilerPaneProvider = jvmExpressionCompilerPaneProvider; this.snippetPaneProvider = snippetPaneProvider; this.controlFlowLineProvider = controlFlowLineProvider; - - // Without an initial size, the first frame of a method has nothing in it. So the auto-size to fit content - // has nothing to fit to, which leads to only table headers being visible. Looks really dumb so giving it - // a little bit of default space regardless mostly solves that. - tabs.setInitialSize(200); } /** - * @return Side tabs instance. + * @param owner + * The owning assembler pane to add tabs to. */ - @Nonnull - public SideTabs getTabs() { - return tabs; + public void setOwner(@Nonnull AssemblerPane owner) { + this.owner = owner; } private void createChildren(@Nonnull ClassInfo classInPath) { @@ -83,14 +72,12 @@ private void createChildren(@Nonnull ClassInfo classInPath) { ControlFlowLines controlFlowLines = controlFlowLineProvider.get(); children.addAll(Arrays.asList(stackAnalysisPane, variablesPane, expressionPane, snippetsPane, controlFlowLines)); FxThreadUtil.run(() -> { - ObservableList tabs = this.tabs.getTabs(); - tabs.clear(); - tabs.add(new BoundTab(Lang.getBinding("assembler.analysis.title"), CarbonIcons.VIEW_NEXT, stackAnalysisPane)); - tabs.add(new BoundTab(Lang.getBinding("assembler.variables.title"), CarbonIcons.LIST_BOXES, variablesPane)); - tabs.add(new BoundTab(Lang.getBinding("assembler.playground.title"), CarbonIcons.CODE, expressionPane)); - tabs.add(new BoundTab(Lang.getBinding("assembler.snippets.title"), CarbonIcons.BOOK, snippetsPane)); + owner.clearSideTabs(); + owner.addSideTab(Lang.getBinding("assembler.analysis.title"), CarbonIcons.VIEW_NEXT, stackAnalysisPane); + owner.addSideTab(Lang.getBinding("assembler.variables.title"), CarbonIcons.LIST_BOXES, variablesPane); + owner.addSideTab(Lang.getBinding("assembler.playground.title"), CarbonIcons.CODE, expressionPane); + owner.addSideTab(Lang.getBinding("assembler.snippets.title"), CarbonIcons.BOOK, snippetsPane); // Note: There is intentionally no tab for the jump arrow pane at the moment - tabs.forEach(t -> t.setClosable(false)); }); } else if (classInPath.isAndroidClass()) { // Create contents for Android classes @@ -108,7 +95,7 @@ public void consumeAst(@Nonnull List astElements, @Nonnull AstPhase @Override public void consumeClass(@Nonnull ClassResult result, - @Nonnull ClassInfo classInfo) { + @Nonnull ClassInfo classInfo) { for (Navigable navigableChild : getNavigableChildren()) if (navigableChild instanceof AssemblerBuildConsumer consumer) consumer.consumeClass(result, classInfo); diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/BinaryXmlFilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/BinaryXmlFilePane.java index 1384bb3d3..0eee97fe8 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/BinaryXmlFilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/BinaryXmlFilePane.java @@ -26,7 +26,7 @@ public BinaryXmlFilePane(@Nonnull Instance textProvider) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/HexFilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/HexFilePane.java index 7d95dcf5b..48b0b9d73 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/HexFilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/binary/HexFilePane.java @@ -26,7 +26,7 @@ public HexFilePane(@Nonnull KeybindingConfig keys, @Nonnull HexConfig config) { // Setup keybindings - Using event filter here because the hex-editor otherwise consumes key events. addEventFilter(KeyEvent.KEY_PRESSED, e -> { - if (!(getCenter() instanceof HexAdapter adapter)) + if (!(getDisplay() instanceof HexAdapter adapter)) return; if (keys.getSave().match(e)) ThreadUtil.run(adapter::save); @@ -42,7 +42,7 @@ else if (keys.getUndo().match(e)) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/jvm/JvmClassPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/jvm/JvmClassPane.java index efce8b716..61822f737 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/jvm/JvmClassPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/jvm/JvmClassPane.java @@ -58,7 +58,7 @@ public void setEditorType(@Nonnull JvmClassEditorType editorType) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/AudioFilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/AudioFilePane.java index 52d1ee406..49b9ced4e 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/AudioFilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/AudioFilePane.java @@ -25,7 +25,7 @@ public AudioFilePane(@Nonnull Instance audioProvider) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/ImageFilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/ImageFilePane.java index c452dcbe1..e79b496f3 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/ImageFilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/ImageFilePane.java @@ -28,7 +28,7 @@ public ImageFilePane(@Nonnull Instance imageProvider) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/VideoFilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/VideoFilePane.java index 52a402004..da44754f8 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/VideoFilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/media/VideoFilePane.java @@ -26,7 +26,7 @@ public VideoFilePane(@Nonnull Instance videoProvider) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/InheritancePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/InheritancePane.java index a7f3c0b14..4a2d65b63 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/InheritancePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/InheritancePane.java @@ -76,7 +76,6 @@ public InheritancePane(@Nonnull InheritanceGraphService graphService, // Layout getChildren().addAll(tree, toggle); - setMinWidth(235); } /** @@ -110,12 +109,14 @@ private void regenerateTree() { } // Create the appropriate tree model and assign it. - WorkspaceTreeNode root = new WorkspaceTreeNode(path); - if (contentType.get() == TreeContent.CHILDREN) - createChildren(root, vertex); - else - createParents(root, vertex); - FxThreadUtil.run(() -> tree.setRoot(root)); + FxThreadUtil.run(() -> { + WorkspaceTreeNode root = new WorkspaceTreeNode(path); + if (contentType.get() == TreeContent.CHILDREN) + createChildren(root, vertex); + else + createParents(root, vertex); + tree.setRoot(root); + }); } /** diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/KotlinMetadataPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/KotlinMetadataPane.java index 6236406b6..1318a768e 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/KotlinMetadataPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/tabs/KotlinMetadataPane.java @@ -97,7 +97,6 @@ protected void updateItem(KtElement element, boolean empty) { labelWrapper.getStyleClass().add("workspace-filter-pane"); // Style used for top-border separator setCenter(tree); setBottom(labelWrapper); - setMinWidth(235); } /** diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/text/TextFilePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/text/TextFilePane.java index c07a341dc..93da65e2a 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/text/TextFilePane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/editing/text/TextFilePane.java @@ -50,7 +50,7 @@ public void setEditorType(@Nonnull TextEditorType editorType) { protected void generateDisplay() { // If you want to swap out the display, first clear the existing one. // Clearing is done automatically when changing the editor type. - if (getCenter() != null) + if (hasDisplay()) return; // Update content in pane. diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/search/AbstractSearchPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/search/AbstractSearchPane.java index 2ed4eaa33..e564e6865 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/search/AbstractSearchPane.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/search/AbstractSearchPane.java @@ -2,6 +2,7 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import javafx.animation.AnimationTimer; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.scene.Node; @@ -23,6 +24,7 @@ import software.coley.recaf.ui.control.tree.TreeItems; import software.coley.recaf.ui.control.tree.WorkspaceTreeNode; import software.coley.recaf.util.FxThreadUtil; +import software.coley.recaf.util.threading.Batch; import software.coley.recaf.workspace.model.Workspace; import java.util.Collection; @@ -59,9 +61,9 @@ public abstract class AbstractSearchPane extends BorderPane implements Navigable * Action service to assist stylizing the output tree model. */ public AbstractSearchPane(@Nonnull WorkspaceManager workspaceManager, - @Nonnull SearchService searchService, - @Nonnull CellConfigurationService configurationService, - @Nonnull Actions actions) { + @Nonnull SearchService searchService, + @Nonnull CellConfigurationService configurationService, + @Nonnull Actions actions) { this.workspaceManager = workspaceManager; this.searchService = searchService; this.configurationService = configurationService; @@ -130,7 +132,7 @@ protected PathNodeTree newTree() { tree.setOnMousePressed(e -> { if (e.getClickCount() == 2 && e.isPrimaryButtonDown()) { var item = tree.getSelectionModel().getSelectedItem(); - if (item.isLeaf()) { + if (item != null && item.isLeaf()) { try { actions.gotoDeclaration(item.getValue()); } catch (IncompletePathException ignored) { @@ -180,11 +182,8 @@ protected final void search() { CancellableSearchFeedback feedback; if (liveResults.get()) { feedback = new LiveOnlySearchFeedback(result -> { - // Search is multi-threaded, so we will want to lock on the root to prevent concurrent-modification errors - synchronized (root) { - WorkspaceTreeNode node = WorkspaceTreeNode.getOrInsertIntoTree(root, result.getPath(), false); - TreeItems.expandParents(node); - } + WorkspaceTreeNode node = WorkspaceTreeNode.getOrInsertIntoTree(root, result.getPath(), false); + TreeItems.expandParents(node); }); CompletableFuture.runAsync(() -> searchService.search(workspace, query, feedback)); } else { @@ -218,23 +217,52 @@ private void cancelLastSearch() { } /** - * Feedback that passes results to a consumer. + * Feedback that incrementally updates the search results tree. *
* Disables the collection of results into a single wrapper at the end of a search. * Since this is for live-only feedback, we won't use the resulting collection anyways, so we don't need to do * the extra work. */ private static class LiveOnlySearchFeedback extends CancellableSearchFeedback { + private final Batch batch = FxThreadUtil.batch(); + private final AnimationTimer batchTimer = new AnimationTimer() { + private static final long BATCH_INTERVAL_MS = 1000 / 20; + private long last; + + @Override + public void handle(long now) { + if (now - last > BATCH_INTERVAL_MS) { + publishResults(); + last = now; + } + } + }; private final Consumer> resultConsumer; private LiveOnlySearchFeedback(@Nonnull Consumer> resultConsumer) { this.resultConsumer = resultConsumer; + batchTimer.start(); } @Override public boolean doAcceptResult(@Nonnull Result result) { - resultConsumer.accept(result); + batch.add(() -> resultConsumer.accept(result)); + try { + Thread.sleep(20); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } return false; } + + @Override + public void onCompletion() { + batchTimer.stop(); + publishResults(); + } + + private void publishResults() { + batch.execute(); + } } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafScene.java b/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafScene.java index 16dceae2c..0965a5649 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafScene.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafScene.java @@ -32,6 +32,6 @@ public RecafScene(Parent root, double width, double height) { } private void addStyleSheets() { - getStylesheets().add("/style/tweaks.css"); + getStylesheets().addAll("/style/docking.css", "/style/tweaks.css"); } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafStage.java b/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafStage.java index 59868cff5..ecbdf9191 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafStage.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/window/RecafStage.java @@ -1,6 +1,5 @@ package software.coley.recaf.ui.window; -import com.panemu.tiwulfx.control.dock.TabStageAccessor; import jakarta.annotation.Nonnull; import javafx.stage.Stage; import javafx.stage.StageStyle; @@ -11,7 +10,7 @@ * * @author Matt Coley */ -public class RecafStage extends Stage implements TabStageAccessor { +public class RecafStage extends Stage { /** * Decorated stage. */ @@ -29,9 +28,4 @@ public RecafStage(@Nonnull StageStyle style) { super(style); getIcons().add(Icons.getImage(Icons.LOGO)); } - - @Override - public Stage getStage() { - return this; - } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/util/FxThreadUtil.java b/recaf-ui/src/main/java/software/coley/recaf/util/FxThreadUtil.java index 025a210f2..e6ed10c6e 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/util/FxThreadUtil.java +++ b/recaf-ui/src/main/java/software/coley/recaf/util/FxThreadUtil.java @@ -1,8 +1,13 @@ package software.coley.recaf.util; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import javafx.application.Platform; +import javafx.beans.property.Property; +import javafx.beans.value.ObservableValue; +import javafx.beans.value.WritableValue; import software.coley.recaf.RecafApplication; +import software.coley.recaf.util.threading.Batch; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.util.threading.ThreadUtil; @@ -59,6 +64,36 @@ public static void delayedRun(long delayMs, @Nonnull Runnable action) { ThreadUtil.runDelayed(delayMs, () -> run(action)); } + /** + * Assign a property value on the FX thread. + * + * @param property + * Property to update. + * @param value + * Value to assign to the property. + * @param + * Property value type. + */ + public static void set(@Nonnull WritableValue property, @Nullable T value) { + run(() -> property.setValue(value)); + } + + /** + * Binds a property value on the FX thread. + * + * @param property + * Property to bind. + * @param value + * Value to bind the property to. + * @param + * Property value type. + */ + public static void bind(@Nonnull Property property, @Nullable ObservableValue value) { + // Binding will fire off event handlers if the given value does not match the current property value. + // When this is done off the FX thread it can be unsafe. + run(() -> property.bind(value)); + } + /** * @return JFX threaded executor. */ @@ -76,4 +111,46 @@ public static void onInitialize() { run(runnable); preInitQueue.clear(); } + + /** + * @return New execution chain. + */ + @Nonnull + public static Batch batch() { + return new FxBatch(); + } + + /** + * Batch implementation that executes all tasks on the FX thread. + */ + private static class FxBatch implements Batch { + private final List tasks = new ArrayList<>(); + + @Override + public void add(@Nonnull Runnable runnable) { + synchronized (tasks) { + tasks.add(runnable); + } + } + + @Override + public void clear() { + synchronized (tasks) { + tasks.clear(); + } + } + + @Override + public void execute() { + run(this::fire); + } + + private void fire() { + synchronized (tasks) { + for (Runnable task : tasks) + task.run(); + tasks.clear(); + } + } + } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/util/Lang.java b/recaf-ui/src/main/java/software/coley/recaf/util/Lang.java index d5900a353..f20079bbc 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/util/Lang.java +++ b/recaf-ui/src/main/java/software/coley/recaf/util/Lang.java @@ -61,7 +61,7 @@ public static String getCurrentTranslations() { public static void setCurrentTranslations(String translationsKey) { if (translations.containsKey(translationsKey)) { currentTranslationMap = translations.getOrDefault(translationsKey, Collections.emptyMap()); - currentTranslation.set(translationsKey); + FxThreadUtil.set(currentTranslation, translationsKey); } else { logger.warn("Tried to set translations to '{}', but no entries for the translations were found!", translationsKey); // For case it fails to load, use default. diff --git a/recaf-ui/src/main/java/software/coley/recaf/util/SceneUtils.java b/recaf-ui/src/main/java/software/coley/recaf/util/SceneUtils.java index 0057518fa..9f1629689 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/util/SceneUtils.java +++ b/recaf-ui/src/main/java/software/coley/recaf/util/SceneUtils.java @@ -9,8 +9,6 @@ import javafx.scene.Scene; import javafx.stage.Stage; import javafx.stage.Window; -import software.coley.recaf.ui.docking.DockingRegion; -import software.coley.recaf.ui.docking.DockingTab; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -24,33 +22,6 @@ public class SceneUtils { private SceneUtils() {} - /** - * @param node - * Node within a target {@link Scene} to bring to front/focus. - */ - public static void focus(@Nullable Node node) { - while (node != null) { - // Get the parent of the node, skip the intermediate 'content area' from tab-pane default skin. - Parent parent = node.getParent(); - if (parent != null && parent.getStyleClass().contains("tab-content-area")) - parent = parent.getParent(); - - // If the tab content is the node, select it and return. - if (parent instanceof DockingRegion tabParent) { - Scene scene = parent.getScene(); - for (DockingTab tab : tabParent.getDockTabs()) - if (tab.getContent() == node) { - tab.select(); - SceneUtils.focus(scene); - return; - } - } - - // Next parent. - node = parent; - } - } - /** * @param scene * Scene to bring to front/focus. diff --git a/recaf-ui/src/main/resources/style/docking.css b/recaf-ui/src/main/resources/style/docking.css new file mode 100644 index 000000000..263fa4750 --- /dev/null +++ b/recaf-ui/src/main/resources/style/docking.css @@ -0,0 +1,214 @@ +/* Transparent split-pane except for the grabber node */ +.bento .split-pane { + -fx-background-color: transparent; + -fx-padding: 0; +} +.bento .split-pane > .split-pane-divider { + -fx-background-color: transparent; + -fx-padding: 0 2px 0 2px; +} +.bento .split-pane > .split-pane-divider > .horizontal-grabber { + -fx-background-color: -color-base-6; + -fx-padding: 5px 1px 5px 1px; +} +.bento .split-pane > .split-pane-divider > .vertical-grabber { + -fx-background-color: -color-base-6; + -fx-padding: 1px 5px 1px 5px; +} +.bento .split-pane > .split-pane-divider:hover > .horizontal-grabber, +.bento .split-pane > .split-pane-divider:hover > .vertical-grabber { + -fx-background-color: -color-accent-7; +} +.bento .split-pane > .split-pane-divider:pressed > .horizontal-grabber, +.bento .split-pane > .split-pane-divider:pressed > .vertical-grabber { + -fx-background-color: -color-accent-emphasis; +} + +/* Put a border around any space. */ +.bento .layout-leaf { + -fx-border-width: 1px; + -fx-border-color: -color-border-subtle; +} + +/* HeaderView */ +.header-view { + -fx-border-width: 0px; + -fx-background-color: -color-bg-overlay; +} +/* The "x" button on closable headers */ +.header-view .close-button { + -fx-effect: none; + -fx-opacity: 0.5; + -fx-background-insets: 0; + -fx-background-radius: 50px; + -fx-background-color: transparent; + -fx-border-width: 0px; + -fx-padding: 0 2px 0 2px; +} +.header-view .close-button:hover { + -fx-background-color: -color-border-default; +} +/* The "▼" and "≡" buttons in HeaderView */ +.header-view .corner-button { + -fx-background-insets: 0, 1px; + -fx-background-radius: 0px; + -fx-background-color: -color-border-default, -color-bg-overlay; + -fx-border-width: 0px; +} +.header-view .corner-button:hover { + -fx-background-color: -color-border-default, -color-bg-default; +} +.header-view:top .corner-button { + -fx-background-insets: 0, 0 0 1px 1px; +} +.header-view:bottom .corner-button { + -fx-background-insets: 0, 1px 0 0 1px; +} +.header-view:left .corner-button { + -fx-background-insets: 0, 1px 1px 0 0; +} +.header-view:right .corner-button { + -fx-background-insets: 0, 1px 0 0 1px; +} +.header-view:top .corner-button, +.header-view:bottom .corner-button { + -fx-padding: 0 9px 0 10px; +} +.header-view:left .corner-button, +.header-view:right .corner-button { + /* TODO: We only have this in the explorer pane right now where its correct, but this may need to be generalized */ + -fx-padding: 6px 0 7px 0; +} +.header-view:top .button-bar, +.header-view:bottom .button-bar { + -fx-border-width: 0 1px 0 0; /* Needed since the right 'border' of the parent is otherwise occluded */ + -fx-border-color: -color-border-default; +} +.header-view:left .button-bar, +.header-view:right .button-bar { + -fx-border-width: 0; /* Unlike the top/bottom, bordering is not needed in these cases */ +} +.header-view:top .button-bar, +.header-view:bottom .button-bar { + -fx-translate-x: 1; /* Hack for alignment issue with fake 'border' */ +} + +/* HeaderRegion */ +.header-region { + -fx-background-color: -color-border-default, -color-bg-overlay; +} +.header-region:top { + -fx-background-insets: 0, 0 0 1px 0; +} +.header-region:bottom { + -fx-background-insets: 0, 1px 0 0 0; +} +.header-region:left { + -fx-background-insets: 0, 0 1px 0 0; +} +.header-region:right { + -fx-background-insets: 0, 0 0 0 1px; +} +.header-region .node-wrapper {} + +/* Header */ +.header { + -fx-background-color: -color-border-default, -color-bg-overlay; +} +.header:top { + -fx-padding: 0.3em 0.6em 0.3em 0.6em; + -fx-background-insets: 0, 0 0 1px 0; +} +.header:bottom { + -fx-padding: 0.3em 0.6em 0.3em 0.6em; + -fx-background-insets: 0, 1px 0 0 0; +} +.header:left { + -fx-padding: 0.3em 0.4em 0.3em 0.4em; + -fx-background-insets: 0, 0 1px 0 0; +} +.header:right { + -fx-padding: 0.3em 0.4em 0.3em 0.4em; + -fx-background-insets: 0, 0 0 0 1px; +} +.header:hover { + -fx-background-color: -color-border-subtle, -color-bg-subtle; +} +.header-view .header:selected { + -fx-background-color: -color-bg-inset, -color-bg-default; +} +.header-view:active .header:selected { + -fx-background-color: -color-accent-4, -color-bg-default; +} +.header:selected:top { + -fx-background-insets: 0, 0 0 2px 0; +} +.header:selected:bottom { + -fx-background-insets: 0, 2px 0 0 0; +} +.header:selected:left { + -fx-background-insets: 0, 0 2px 0 0; +} +.header:selected:right { + -fx-background-insets: 0, 0 0 0 2px; +} + +/* Collapsed */ +.layout:collapsed .header { -fx-background-color: -color-bg-overlay; } +.layout:collapsed .header-view { -fx-background-color: -color-bg-overlay; } +.layout:collapsed .header-region { -fx-background-color: -color-bg-overlay; } +.layout:collapsed .corner-button { -fx-background-color: -color-bg-overlay; } +.layout:collapsed .corner-button:hover { -fx-background-color: -color-bg-subtle; } + +/* Embedded context */ +.embedded-bento .layout-leaf { + /* Don't show the standard leaf border as this is embedded inside one of those already */ + -fx-border-width: 0; +} +.embedded-bento .header-region { + -fx-background-color: -color-bg-overlay; +} +.embedded-bento .header:top, +.embedded-bento .header-region:top { + -fx-background-color: -color-border-muted, -color-bg-overlay; + -fx-background-insets: 0, 0 0 1px 0; +} +.embedded-bento .header:bottom, +.embedded-bento .header-region:bottom { + -fx-background-color: -color-border-muted, -color-bg-overlay; + -fx-background-insets: 0, 1px 0 0 0; +} +.embedded-bento .header:left, +.embedded-bento .header-region:left { + -fx-background-color: -color-border-muted, -color-bg-overlay; + -fx-background-insets: 0, 0 1px 0 0; +} +.embedded-bento .header:right, +.embedded-bento .header-region:right { + -fx-background-color: -color-border-muted, -color-bg-overlay; + -fx-background-insets: 0, 0 0 0 1px; +} +.embedded-bento .header:hover { + -fx-background-color: -color-border-muted, -color-bg-subtle; +} +.embedded-bento:top .split-pane > .split-pane-divider { + -fx-background-color: -color-border-default, -color-bg-inset; + -fx-background-insets: 0, 0 0 1px 0; +} +.embedded-bento:bottom .split-pane > .split-pane-divider { + -fx-background-color: -color-border-default, -color-bg-inset; + -fx-background-insets: 0, 1px 0 0 0 ; +} +.embedded-bento:left .split-pane > .split-pane-divider { + -fx-background-color: -color-border-default, -color-bg-inset; + -fx-background-insets: 0, 0 1px 0 0; +} +.embedded-bento:right .split-pane > .split-pane-divider { + -fx-background-color: -color-border-default, -color-bg-inset; + -fx-background-insets: 0, 0 0 0 1px; +} + +/* Misc */ +.dock-ghost-zone { + -fx-opacity: 0.3; +} \ No newline at end of file diff --git a/recaf-ui/src/main/resources/style/recaf.css b/recaf-ui/src/main/resources/style/recaf.css index 72a62da4c..86e1abeed 100644 --- a/recaf-ui/src/main/resources/style/recaf.css +++ b/recaf-ui/src/main/resources/style/recaf.css @@ -432,7 +432,6 @@ Text { -fx-graphic-text-gap: 6px; -fx-text-fill: -color-button-fg; -fx-alignment: CENTER; - -fx-effect: dropshadow(gaussian, -color-button-shadow, 3px, -2, 0, 1); -fx-padding: 6px 12px 6px 12px; } .button .font-icon, .button .ikonli-font-icon { @@ -474,7 +473,6 @@ Text { .button:hover { -fx-background-color: -color-button-border-hover, -color-button-bg-hover; -fx-text-fill: -color-button-fg-hover; - -fx-opacity: 0.9; } .button:hover:focused { -fx-background-color: -color-button-border-focused, -color-button-bg-hover; @@ -1609,7 +1607,7 @@ Add support for NO_HEADER tweak in tables .table-view.no-header > .column-header -fx-background-color: transparent; -fx-background-insets: 0 0 1px 0; -fx-background-radius: 0px; - -fx-padding: 10 14 10 14; + -fx-padding: 6 9 6 9; -fx-effect: none; } .menu-bar > .container > .menu-button > .label { diff --git a/recaf-ui/src/main/resources/style/tweaks.css b/recaf-ui/src/main/resources/style/tweaks.css index d9cfb514b..0d689d811 100644 --- a/recaf-ui/src/main/resources/style/tweaks.css +++ b/recaf-ui/src/main/resources/style/tweaks.css @@ -3,13 +3,6 @@ -fx-padding: 5px; } - -/* Fix for TiwulFX relying on Modena values while we use AtlantaFX */ -.adjacent-drop { - -fx-background-color: -color-accent-2; - -fx-border-color: -color-accent-8; -} - .dark-scroll-pane > .viewport { -fx-background-color: -color-bg-inset; } @@ -19,20 +12,6 @@ -fx-padding: 0; } -/* AtlantaFX tweaks to split-pane default behavior/style to fit the docking UI style more. */ -.split-pane { - -fx-background-color: transparent; - -fx-background-insets: 5px; -} -.split-pane > .split-pane-divider, -.split-pane > .split-pane-divider:pressed, -.split-pane > .split-pane-divider:pressed > .horizontal-grabber, -.split-pane > .split-pane-divider:pressed > .vertical-grabber { - -fx-background-color: transparent; - -fx-background-insets: 6px; - -fx-padding: 2px; -} - /* AtlantaFX tweaks */ .dark-combo-box { -fx-background-color: -color-border-default, -color-bg-inset; @@ -110,59 +89,6 @@ -fx-background-radius: 1em; } -/* Visual tweak to tab-pane separator */ -.tab-pane { - -fx-padding: 0px; - -fx-border-color: -color-border-muted; - -fx-border-width: 1px; - -fx-border-insets: 1px; /* removing this causes some issues with our split-pane changes */ -} -.tab-pane.floating > .tab-header-area { - -fx-background-color: -color-border-muted, -color-bg-inset; - -fx-background-insets: 0, 0 0 1px 0; -} -.tab-pane:focused > .tab-header-area > .headers-region > .tab:selected { - -fx-background-color: -color-accent-4, -color-base-9; - -fx-background-insets: 0, 0 0 2px 0; -} - -/* Custom tab-pane for display in editor views */ -.side-tab-pane { - -fx-background-color: -color-bg-overlay; - -fx-border-color: -color-base-6; -} -.side-tab-pane:vertical { - -fx-border-width: 0 0 0 1px; -} -.side-tab-content { - -fx-background-color: -color-bg-overlay; - -fx-border-color: -color-base-6; -} -.side-tab-pane:vertical .side-tab-content { - -fx-border-width: 0 0 0 1px; -} -.side-tab-pane:horizontal .side-tab-content { - -fx-border-width: 1px 0 0 0; -} -.side-tab-grip { - -fx-background-color: -color-bg-inset; - -fx-border-color: -color-base-6; -} -.side-tab-pane:vertical .side-tab-grip { - -fx-border-width: 0 0 0 1px; -} -.side-tab-pane:horizontal .side-tab-grip { - -fx-border-width: 1px 0 0 0; -} -.side-tab { - -fx-background-color: -color-bg-overlay; -} -.side-tab:hover, -.side-tab:selected, -.side-tab:focused { - -fx-background-color: -color-base-8; -} - /* Used at top of editor control */ .search-bar { -fx-background-color: -color-bg-inset;