From 5066e90f353458de2e84e190bea0d5e1f168a43d Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 5 Mar 2024 07:24:37 -0500 Subject: [PATCH] Add quick-nav window --- .../coley/recaf/RecafApplication.java | 18 +- .../recaf/services/window/WindowManager.java | 9 + .../recaf/ui/config/KeybindingConfig.java | 8 + .../recaf/ui/control/AbstractSearchBar.java | 14 +- .../ui/control/richtext/search/SearchBar.java | 12 +- .../coley/recaf/ui/window/QuickNavWindow.java | 407 ++++++++++++++++++ recaf-ui/src/main/resources/style/tweaks.css | 7 + .../main/resources/translations/en_US.lang | 4 + 8 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/window/QuickNavWindow.java 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 a23841df6..e016d689b 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java +++ b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java @@ -6,12 +6,14 @@ 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.ui.RecafTheme; +import software.coley.recaf.ui.config.KeybindingConfig; import software.coley.recaf.ui.control.FontIconView; import software.coley.recaf.ui.docking.DockingManager; import software.coley.recaf.ui.docking.DockingRegion; @@ -51,6 +53,8 @@ public void start(Stage stage) { Node logging = createLoggingWrapper(); workspaceRootPane = recaf.get(WorkspaceRootPane.class); welcomePane = recaf.get(WelcomePane.class); + KeybindingConfig keybindingConfig = recaf.get(KeybindingConfig.class); + WindowManager windowManager = recaf.get(WindowManager.class); // Layout SplitPane splitPane = new SplitPane(root, logging); @@ -69,23 +73,31 @@ public void start(Stage stage) { workspaceManager.addWorkspaceCloseListener(this); // Display + Scene scene = new RecafScene(wrapper); + scene.addEventFilter(KeyEvent.KEY_PRESSED, (KeyEvent event) -> { + // Global keybind handling + if (keybindingConfig.getQuickNav().match(event)) { + Stage quickNav = windowManager.getQuickNav(); + quickNav.show(); + quickNav.requestFocus(); + } + }); stage.setMinWidth(900); stage.setMinHeight(600); - Scene value = new RecafScene(wrapper); - stage.setScene(value); + stage.setScene(scene); stage.getIcons().add(Icons.getImage(Icons.LOGO)); stage.setTitle("Recaf"); stage.setOnCloseRequest(e -> System.exit(0)); stage.show(); // Register main window - WindowManager windowManager = recaf.get(WindowManager.class); windowManager.register(WindowManager.WIN_MAIN, stage); // Publish UI init event recaf.getContainer().getBeanContainer().getEvent().fire(new UiInitializationEvent()); } + @Nonnull private Node createLoggingWrapper() { LoggingPane logging = recaf.get(LoggingPane.class); DockingRegion dockingPane = recaf.get(DockingManager.class).newRegion(); diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java b/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java index 5f8d78cd6..e462e1d1d 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java @@ -34,6 +34,7 @@ public class WindowManager implements Service { public static final String WIN_INFO = "system-information"; public static final String WIN_SCRIPTS = "script-manager"; public static final String WIN_MAP_PROGRESS = "mapping-progress"; + public static final String WIN_QUICK_NAV = "quick-nav"; // Manager instance data private final WindowManagerConfig config; private final ObservableList activeWindows = new ObservableList<>(); @@ -182,6 +183,14 @@ public Stage getMappingPreviewWindow() { return Objects.requireNonNull(getWindow(WIN_MAP_PROGRESS)); } + /** + * @return Window for quick navigation display. + */ + @Nonnull + public Stage getQuickNav() { + return Objects.requireNonNull(getWindow(WIN_QUICK_NAV)); + } + @Nonnull @Override public String getServiceId() { diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/config/KeybindingConfig.java b/recaf-ui/src/main/java/software/coley/recaf/ui/config/KeybindingConfig.java index 03708084e..05c51ad6d 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/config/KeybindingConfig.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/config/KeybindingConfig.java @@ -36,6 +36,7 @@ @ApplicationScoped public class KeybindingConfig extends BasicConfigContainer { public static final String ID = "bind"; + private static final String ID_QUICK_NAV = "quicknav"; private static final String ID_FIND = "editor.find"; private static final String ID_REPLACE = "editor.replace"; private static final String ID_SAVE = "editor.save"; @@ -48,6 +49,7 @@ public KeybindingConfig(@Nonnull GsonProvider gsonProvider) { // We will only be storing one 'value' so that the UI can treat it as a singular element. bundle = new BindingBundle(Arrays.asList( + createBindForPlatform(ID_QUICK_NAV, CONTROL, G), createBindForPlatform(ID_FIND, CONTROL, F), createBindForPlatform(ID_REPLACE, CONTROL, R), createBindForPlatform(ID_SAVE, CONTROL, S), @@ -75,6 +77,12 @@ public KeybindingConfig(@Nonnull GsonProvider gsonProvider) { }); } + /** + * @return Keybinding for opening the quick-nav stage. + */ + @Nonnull + public Binding getQuickNav() { + return Objects.requireNonNull(bundle.get(ID_QUICK_NAV)); } /** diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/AbstractSearchBar.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/AbstractSearchBar.java index 935508260..930a4c66b 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/control/AbstractSearchBar.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/AbstractSearchBar.java @@ -16,6 +16,7 @@ import javafx.scene.control.TextField; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.scene.text.TextAlignment; import org.kordamp.ikonli.carbonicons.CarbonIcons; @@ -50,6 +51,14 @@ public ObservableList getPastSearches() { return pastSearches; } + /** + * @return Property of the current search text. + */ + @Nonnull + public StringProperty getSearchTextProperty() { + return searchInput.textProperty(); + } + /** * Note: The base {@link AbstractSearchBar} does not tie into any search systems, * so its up to implementors to ensure this is wired up properly. @@ -116,8 +125,8 @@ public void setup() { resultCount.setAlignment(Pos.CENTER); resultCount.setTextAlignment(TextAlignment.CENTER); bindResultCountDisplay(resultCount.textProperty()); - resultCount.styleProperty().bind(hasResults.map(b -> { - if (b) { + resultCount.styleProperty().bind(hasResults.map(has -> { + if (has) { return "-fx-text-fill: -color-fg-default;"; } else { return "-fx-text-fill: red;"; @@ -141,6 +150,7 @@ public void setup() { * Called at the end of {@link #setup()}. */ protected void setupLayout() { + HBox.setHgrow(searchInput, Priority.ALWAYS); HBox searchLine = new HBox(searchInput, resultCount); searchLine.setAlignment(Pos.CENTER_LEFT); searchLine.setSpacing(10); diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/search/SearchBar.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/search/SearchBar.java index 64504b1b0..aeaa128ca 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/search/SearchBar.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/search/SearchBar.java @@ -11,7 +11,6 @@ import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; -import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.event.EventHandler; import javafx.geometry.Insets; @@ -23,6 +22,7 @@ import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import org.fxmisc.richtext.CodeArea; import org.kordamp.ikonli.carbonicons.CarbonIcons; import regexodus.Matcher; @@ -45,7 +45,7 @@ @Dependent public class SearchBar implements EditorComponent, EventHandler { private final KeybindingConfig keys; - private Bar bar; + private FindAndReplaceSearchBar bar; @Inject public SearchBar(@Nonnull KeybindingConfig keys) { @@ -54,7 +54,7 @@ public SearchBar(@Nonnull KeybindingConfig keys) { @Override public void install(@Nonnull Editor editor) { - bar = new Bar(editor); + bar = new FindAndReplaceSearchBar(editor); NodeEvents.addKeyPressHandler(editor, this); } @@ -90,7 +90,7 @@ public void handle(KeyEvent event) { /** * The actual search bar. */ - private static class Bar extends AbstractSearchBar { + private static class FindAndReplaceSearchBar extends AbstractSearchBar { private final SimpleIntegerProperty lastResultIndex = new SimpleIntegerProperty(-1); private final ObservableList pastReplaces = FXCollections.observableArrayList(); private final ObservableList resultRanges = FXCollections.observableArrayList(); @@ -104,7 +104,7 @@ private static class Bar extends AbstractSearchBar { private Button replace; private Button replaceAll; - private Bar(@Nonnull Editor editor) { + private FindAndReplaceSearchBar(@Nonnull Editor editor) { this.editor = editor; setup(); @@ -160,10 +160,12 @@ protected void setupLayout() { HBox prevAndNext = new HBox(prev, next); prevAndNext.setAlignment(Pos.CENTER); prevAndNext.setFillHeight(false); + HBox.setHgrow(searchInput, Priority.ALWAYS); HBox searchLine = new HBox(searchInput, resultCount, prevAndNext, new Spacer(), close); searchLine.setAlignment(Pos.CENTER_LEFT); searchLine.setSpacing(10); searchLine.setPadding(new Insets(0, 5, 0, 0)); + HBox.setHgrow(replaceInput, Priority.ALWAYS); replaceLine.getChildren().addAll(replaceInput, new Spacer(0), replace, replaceAll); replaceLine.setAlignment(Pos.CENTER_LEFT); replaceLine.setSpacing(10); diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/window/QuickNavWindow.java b/recaf-ui/src/main/java/software/coley/recaf/ui/window/QuickNavWindow.java new file mode 100644 index 000000000..559874802 --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/window/QuickNavWindow.java @@ -0,0 +1,407 @@ +package software.coley.recaf.ui.window; + +import atlantafx.base.controls.Popover; +import atlantafx.base.controls.Spacer; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import javafx.beans.InvalidationListener; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.PseudoClass; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.OverrunStyle; +import javafx.scene.control.TabPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.stage.Stage; +import org.fxmisc.flowless.Cell; +import org.fxmisc.flowless.VirtualFlow; +import org.fxmisc.flowless.VirtualizedScrollPane; +import org.kordamp.ikonli.carbonicons.CarbonIcons; +import org.slf4j.Logger; +import regexodus.Matcher; +import regexodus.Pattern; +import software.coley.recaf.analytics.logging.Logging; +import software.coley.recaf.info.ClassInfo; +import software.coley.recaf.path.*; +import software.coley.recaf.services.cell.CellConfigurationService; +import software.coley.recaf.services.cell.context.ContextSource; +import software.coley.recaf.services.navigation.Actions; +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.ui.config.TextFormatConfig; +import software.coley.recaf.ui.control.AbstractSearchBar; +import software.coley.recaf.ui.control.BoundTab; +import software.coley.recaf.ui.control.FontIconView; +import software.coley.recaf.util.Icons; +import software.coley.recaf.util.Lang; +import software.coley.recaf.util.RegexUtil; +import software.coley.recaf.workspace.model.Workspace; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Window for quickly opening classes, fields, methods, files, and other supported content. + * + * @author Matt Coley + */ +@Dependent +public class QuickNavWindow extends AbstractIdentifiableStage { + private static final Logger logger = Logging.get(QuickNavWindow.class); + + @Inject + public QuickNavWindow(@Nonnull WorkspaceManager workspaceManager, + @Nonnull Actions actions, @Nonnull TextFormatConfig formatConfig, + @Nonnull CellConfigurationService configurationService) { + super(WindowManager.WIN_QUICK_NAV); + + ContentPane classContent = new ContentPane<>(actions, this, () -> { + Workspace current = workspaceManager.getCurrent(); + if (current == null) + return Stream.empty(); + return current.classesStream(); + }, path -> path.getValue().getName(), cell -> { + ClassPathNode classPath = cell.getItem(); + DirectoryPathNode packagePath = Objects.requireNonNull(classPath.getParent()); + String packageName = packagePath.getValue(); + packageName = formatConfig.filterEscape(packageName); + packageName = formatConfig.filterMaxLength(packageName); + + Label classDisplay = new Label(); + classDisplay.setText(configurationService.textOf(classPath)); + classDisplay.setGraphic(configurationService.graphicOf(classPath)); + + Label packageDisplay = new Label(); + packageDisplay.setText(packageName); + packageDisplay.setGraphic(Icons.getIconView(Icons.FOLDER_PACKAGE)); + packageDisplay.setOpacity(0.5); + packageDisplay.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS); + + Spacer spacer = new Spacer(); + HBox box = new HBox(classDisplay, spacer, packageDisplay); + HBox.setHgrow(spacer, Priority.ALWAYS); + cell.setText(null); + cell.setGraphic(box); + + cell.setOnMouseClicked(configurationService.contextMenuHandlerOf(cell, classPath, ContextSource.REFERENCE)); + }); + ContentPane memberContent = new ContentPane<>(actions, this, () -> { + Workspace current = workspaceManager.getCurrent(); + if (current == null) + return Stream.empty(); + return current.classesStream().flatMap(p -> { + ClassInfo classInfo = p.getValue(); + Stream fields = classInfo.getFields().stream().map(p::child); + Stream methods = classInfo.getMethods().stream().map(p::child); + return Stream.concat(fields, methods); + }); + }, path -> path.getValue().getName(), cell -> { + ClassMemberPathNode memberPath = cell.getItem(); + ClassPathNode classPath = Objects.requireNonNull(memberPath.getParent()); + + Label memberDisplay = new Label(); + memberDisplay.setText(configurationService.textOf(memberPath)); + memberDisplay.setGraphic(configurationService.graphicOf(memberPath)); + + Label classDisplay = new Label(); + classDisplay.setText(configurationService.textOf(classPath)); + classDisplay.setGraphic(configurationService.graphicOf(classPath)); + classDisplay.setOpacity(0.5); + + Spacer spacer = new Spacer(); + HBox box = new HBox(memberDisplay, spacer, classDisplay); + HBox.setHgrow(spacer, Priority.ALWAYS); + cell.setText(null); + cell.setGraphic(box); + + cell.setOnMouseClicked(configurationService.contextMenuHandlerOf(cell, memberPath, ContextSource.REFERENCE)); + }); + ContentPane fileContent = new ContentPane<>(actions, this, () -> { + Workspace current = workspaceManager.getCurrent(); + if (current == null) + return Stream.empty(); + return current.filesStream(); + }, path -> path.getValue().getName(), cell -> { + FilePathNode filePath = cell.getItem(); + DirectoryPathNode directoryPath = Objects.requireNonNull(filePath.getParent()); + String directoryName = directoryPath.getValue(); + directoryName = formatConfig.filterEscape(directoryName); + directoryName = formatConfig.filterMaxLength(directoryName); + + Label fileDisplay = new Label(); + fileDisplay.setText(configurationService.textOf(filePath)); + fileDisplay.setGraphic(configurationService.graphicOf(filePath)); + + Label packageDisplay = new Label(); + packageDisplay.setText(directoryName); + packageDisplay.setGraphic(Icons.getIconView(Icons.FOLDER_RES)); + packageDisplay.setOpacity(0.5); + packageDisplay.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS); + + Spacer spacer = new Spacer(); + HBox box = new HBox(fileDisplay, spacer, packageDisplay); + HBox.setHgrow(spacer, Priority.ALWAYS); + cell.setText(null); + cell.setGraphic(box); + + cell.setOnMouseClicked(configurationService.contextMenuHandlerOf(cell, filePath, ContextSource.REFERENCE)); + }); + // TODO: Need to rework internals to support differentiating the result type and the type scanned + // - Need to *efficiently* search text line by line, not as one blob + ContentPane textContent = new ContentPane<>(actions, this, () -> { + Workspace current = workspaceManager.getCurrent(); + if (current == null) + return Stream.empty(); + return current.filesStream() + .filter(f -> f.getValue().isTextFile()); + }, path -> path.getValue().asTextFile().getText(), cell -> { + FilePathNode filePath = cell.getItem(); + + Label fileDisplay = new Label(); + fileDisplay.setText(configurationService.textOf(filePath)); + fileDisplay.setGraphic(configurationService.graphicOf(filePath)); + + Label textDisplay = new Label(); + textDisplay.setText("TODO: show multiple lines of matched text + line number"); + textDisplay.setOpacity(0.5); + textDisplay.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS); + + Spacer spacer = new Spacer(); + HBox box = new HBox(fileDisplay, spacer, textDisplay); + HBox.setHgrow(spacer, Priority.ALWAYS); + cell.setText(null); + cell.setGraphic(box); + + cell.setOnMouseClicked(configurationService.contextMenuHandlerOf(cell, filePath, ContextSource.REFERENCE)); + }); + List> contentPanes = List.of(classContent, memberContent, fileContent/*, textContent*/); + contentPanes.forEach(workspaceManager::addWorkspaceCloseListener); + + BoundTab tabClasses = new BoundTab(Lang.getBinding("dialog.quicknav.tab.classes"), Icons.getIconView(Icons.CLASS), classContent); + BoundTab tabMembers = new BoundTab(Lang.getBinding("dialog.quicknav.tab.members"), Icons.getIconView(Icons.FIELD_N_METHOD), memberContent); + BoundTab tabFiles = new BoundTab(Lang.getBinding("dialog.quicknav.tab.files"), new FontIconView(CarbonIcons.DOCUMENT), fileContent); + //BoundTab tabText = new BoundTab(Lang.getBinding("dialog.quicknav.tab.text"), new FontIconView(CarbonIcons.STRING_TEXT), textContent); + + TabPane tabs = new TabPane(); + tabs.getTabs().addAll(tabClasses, tabMembers, tabFiles/*, tabText*/); + tabs.getTabs().forEach(tab -> tab.setClosable(false)); + + // Layout + titleProperty().bind(Lang.getBinding("dialog.quicknav")); + setMinWidth(300); + setMinHeight(300); + setScene(new RecafScene(tabs, 750, 550)); + } + + /** + * Pane for displaying the search bar + results pane for some content type. + * + * @param + * Content / result type. + */ + private static class ContentPane> extends BorderPane implements WorkspaceCloseListener { + private final PathResultsPane results; + + private ContentPane(@Nonnull Actions actions, + @Nonnull Stage stage, + @Nonnull Supplier> valueProvider, + @Nonnull Function valueTextMapper, + @Nonnull Consumer> renderCell) { + results = new PathResultsPane<>(actions, stage, renderCell); + setTop(new NavSearchBar<>(results, valueProvider, valueTextMapper)); + setCenter(results); + } + + @Override + public void onWorkspaceClosed(@Nonnull Workspace workspace) { + results.list.clear(); + } + } + + /** + * Pane for displaying the results of a search. + * + * @param + * Result type. + */ + private static class PathResultsPane> extends BorderPane { + private static final PseudoClass PSEUDO_HOVER = PseudoClass.getPseudoClass("hover"); + private final ObservableList list = FXCollections.observableArrayList(); + + private PathResultsPane(@Nonnull Actions actions, @Nonnull Stage stage, + @Nonnull Consumer> renderCell) { + VirtualFlow> flow = VirtualFlow.createVertical(list, + initial -> new ResultCell(initial, actions, stage, renderCell)); + setCenter(new VirtualizedScrollPane<>(flow)); + } + + private class ResultCell implements Cell { + private final ListCell cell = new ListCell<>(); + private final Consumer> renderCell; + + private ResultCell(@Nullable T initial, @Nonnull Actions actions, @Nonnull Stage stage, + @Nonnull Consumer> renderCell) { + this.renderCell = renderCell; + + updateItem(initial); + cell.getStyleClass().add("search-result-list-cell"); + cell.setOnMouseEntered(e -> { + if (cell.getItem() != null) + cell.pseudoClassStateChanged(PSEUDO_HOVER, true); + }); + cell.setOnMouseExited(e -> cell.pseudoClassStateChanged(PSEUDO_HOVER, false)); + cell.setOnMousePressed(e -> { + if (e.isPrimaryButtonDown() && e.getClickCount() == 2) { + try { + T item = cell.getItem(); + if (item != null) { + actions.gotoDeclaration(item); + stage.hide(); + } + } catch (IncompletePathException ex) { + logger.error("Failed to open path", ex); + } + } + }); + } + + @Override + public void updateItem(T item) { + if (item == null) { + reset(); + } else { + cell.setItem(item); + renderCell.accept(cell); + } + } + + @Override + public void reset() { + cell.setItem(null); + cell.setText(null); + cell.setGraphic(null); + cell.setOnMouseClicked(null); + cell.setOnMouseEntered(null); + cell.setOnMouseExited(null); + } + + @Override + public void dispose() { + reset(); + } + + @Override + public Node getNode() { + return cell; + } + } + } + + /** + * Search bar implementation, tied to a {@link PathResultsPane}. + * + * @param + * Result type. + */ + private static class NavSearchBar> extends AbstractSearchBar { + private final PathResultsPane results; + private final Supplier> valueProvider; + private final Function valueTextMapper; + + private NavSearchBar(@Nonnull PathResultsPane results, + @Nonnull Supplier> valueProvider, + @Nonnull Function valueTextMapper) { + this.results = results; + this.valueProvider = valueProvider; + this.valueTextMapper = valueTextMapper; + + setup(); + } + + @Override + protected void bindResultCountDisplay(@Nonnull StringProperty resultTextProperty) { + results.list.addListener((InvalidationListener) observable -> { + int size = results.list.size(); + if (size > 0) { + hasResults.set(true); + resultTextProperty.set(String.valueOf(size)); + } else { + hasResults.set(false); + resultTextProperty.set(Lang.get("menu.search.noresults")); + } + }); + } + + @Override + protected void refreshResults() { + // Skip when there is nothing + String search = searchInput.getText(); + if (search == null || search.isBlank()) { + results.list.clear(); + hasResults.set(false); + return; + } + + List tempResultsList = new ArrayList<>(); + if (regex.get()) { + // Validate the regex. + RegexUtil.RegexValidation validation = RegexUtil.validate(search); + Popover popoverValidation = null; + if (validation.valid()) { + // It's valid, match against values + Pattern pattern = RegexUtil.pattern(search); + valueProvider.get().forEach(item -> { + String text = valueTextMapper.apply(item); + Matcher matcher = pattern.matcher(text); + if (matcher.find()) + tempResultsList.add(item); + }); + } else { + // It's not valid. Tell the user what went wrong. + popoverValidation = new Popover(new Label(validation.message())); + popoverValidation.setHeaderAlwaysVisible(true); + popoverValidation.titleProperty().bind(Lang.getBinding("find.regexinvalid")); + popoverValidation.show(searchInput); + } + + // Hide the prior popover if any exists. + Object old = searchInput.getProperties().put("regex-popover", popoverValidation); + if (old instanceof Popover oldPopover) + oldPopover.hide(); + } else { + // Modify the text/search for case-insensitive searches. + Function localValueTextMapper; + if (!caseSensitivity.get()) { + search = search.toLowerCase(); + localValueTextMapper = valueTextMapper.andThen(String::toLowerCase); + } else { + localValueTextMapper = valueTextMapper; + } + + // Match against values + String finalSearch = search; + valueProvider.get().forEach(item -> { + String text = localValueTextMapper.apply(item); + if (text.contains(finalSearch)) + tempResultsList.add(item); + }); + } + tempResultsList.sort(Comparator.naturalOrder()); + results.list.setAll(tempResultsList); + } + } +} diff --git a/recaf-ui/src/main/resources/style/tweaks.css b/recaf-ui/src/main/resources/style/tweaks.css index 731e519e8..40780861b 100644 --- a/recaf-ui/src/main/resources/style/tweaks.css +++ b/recaf-ui/src/main/resources/style/tweaks.css @@ -129,6 +129,13 @@ -fx-border-color: -color-base-6; -fx-border-width: 0 1px 0 0; } +.search-result-list-cell { + -fx-text-fill: -color-fg-default; + -fx-padding: 4 4 4 4; +} +.search-result-list-cell:hover { + -fx-background-color: -color-base-7; +} .variable-table .table-row-cell { -fx-cell-size: 24px; diff --git a/recaf-ui/src/main/resources/translations/en_US.lang b/recaf-ui/src/main/resources/translations/en_US.lang index 387dfa0ce..4640cb2cf 100644 --- a/recaf-ui/src/main/resources/translations/en_US.lang +++ b/recaf-ui/src/main/resources/translations/en_US.lang @@ -247,6 +247,10 @@ dialog.conv.title.expression=Number expression ## Quick nav dialog.quicknav=Quick navigation +dialog.quicknav.tab.classes=Classes +dialog.quicknav.tab.members=Members +dialog.quicknav.tab.files=Files +dialog.quicknav.tab.text=Text ## Error dialog dialog.error.exportclass.title=Failed to export class