diff --git a/CONTRIBUTORS b/CONTRIBUTORS index cb51b07e..3e9d3707 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -6,3 +6,4 @@ Philip Emery Christian Derr Antti Aro Kim T +Ramiro Montes De Oca diff --git a/owlplug-client/src/main/java/com/owlplug/core/components/ApplicationDefaults.java b/owlplug-client/src/main/java/com/owlplug/core/components/ApplicationDefaults.java index af63f955..e7fb28bc 100644 --- a/owlplug-client/src/main/java/com/owlplug/core/components/ApplicationDefaults.java +++ b/owlplug-client/src/main/java/com/owlplug/core/components/ApplicationDefaults.java @@ -20,7 +20,6 @@ import com.owlplug.core.model.OperatingSystem; import com.owlplug.core.model.RuntimePlatform; -import com.owlplug.explore.model.RemotePackage; import com.owlplug.plugin.model.PluginFormat; import com.owlplug.plugin.model.PluginType; import com.owlplug.project.model.DawApplication; @@ -32,7 +31,6 @@ import java.util.List; import java.util.stream.Collectors; import javafx.scene.image.Image; -import org.kordamp.ikonli.javafx.FontIcon; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -64,7 +62,7 @@ public class ApplicationDefaults { public final Image vst3Image = new Image(getClass().getResourceAsStream("/icons/vst3-green-16.png")); public final Image auImage = new Image(getClass().getResourceAsStream("/icons/au-purple-16.png")); public final Image lv2Image = new Image(getClass().getResourceAsStream("/icons/lv2-orange-16.png")); - public final Image pluginComponentImage = new Image(getClass().getResourceAsStream("/icons/cube-white-16.png")); + public final Image defaultFormat = new Image(getClass().getResourceAsStream("/icons/format-white-16.png")); public final Image verifiedSourceImage = new Image(getClass().getResourceAsStream("/icons/doublecheck-grey-16.png")); public final Image suggestedSourceImage = new Image( ApplicationDefaults.class.getResourceAsStream("/icons/check-grey-16.png")); @@ -160,14 +158,14 @@ public RuntimePlatform getRuntimePlatform() { */ public Image getPluginFormatIcon(PluginFormat format) { if (format == null) { - return pluginComponentImage; + return defaultFormat; } return switch (format) { case VST2 -> vst2Image; case VST3 -> vst3Image; case AU -> auImage; case LV2 -> lv2Image; - default -> pluginComponentImage; + default -> defaultFormat; }; } diff --git a/owlplug-client/src/main/java/com/owlplug/core/utils/StringUtils.java b/owlplug-client/src/main/java/com/owlplug/core/utils/StringUtils.java index 80f80c1d..985c912e 100644 --- a/owlplug-client/src/main/java/com/owlplug/core/utils/StringUtils.java +++ b/owlplug-client/src/main/java/com/owlplug/core/utils/StringUtils.java @@ -43,6 +43,13 @@ public static String ellipsis(String input, int maxLength, int clearEndLength) { } } + public static String capitalize(String str) { + if (str == null || str.isEmpty()) { + return str; + } + return Character.toUpperCase(str.charAt(0)) + str.substring(1); + } + public static String getStackTraceAsString(Throwable throwable) { if (throwable == null) { return "null throwable"; diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/components/PluginFilterState.java b/owlplug-client/src/main/java/com/owlplug/plugin/components/PluginFilterState.java new file mode 100644 index 00000000..fab92c83 --- /dev/null +++ b/owlplug-client/src/main/java/com/owlplug/plugin/components/PluginFilterState.java @@ -0,0 +1,114 @@ +/* OwlPlug + * Copyright (C) 2021 Arthur + * + * This file is part of OwlPlug. + * + * OwlPlug is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 + * as published by the Free Software Foundation. + * + * OwlPlug is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OwlPlug. If not, see . + */ + +package com.owlplug.plugin.components; + +import com.owlplug.plugin.model.IPlugin; +import com.owlplug.plugin.model.Plugin; +import com.owlplug.plugin.model.PluginComponent; +import com.owlplug.plugin.model.PluginFormat; +import com.owlplug.plugin.model.PluginType; +import java.util.function.Predicate; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableSet; +import org.springframework.stereotype.Component; + +@Component +public class PluginFilterState { + + private final ObservableSet selectedFormats = FXCollections.observableSet(); + private final ObservableSet selectedTypes = FXCollections.observableSet(); + private final ObservableSet selectedManufacturers = FXCollections.observableSet(); + private final ObservableSet selectedCategories = FXCollections.observableSet(); + private final ObjectProperty> predicate = new SimpleObjectProperty<>(); + + public PluginFilterState() { + predicate.bind(Bindings.createObjectBinding(this::buildPredicate, + selectedFormats, selectedTypes, selectedManufacturers, selectedCategories)); + } + + private Predicate buildPredicate() { + if (selectedFormats.isEmpty() && selectedTypes.isEmpty() + && selectedManufacturers.isEmpty() && selectedCategories.isEmpty()) { + return null; + } + return plugin -> { + if (plugin instanceof PluginComponent component) { + return matchesFormat(component.asPlugin().getFormat()) + && matchesType(component.getType()) + && matchesManufacturer(component.getManufacturerName()) + && matchesCategory(component.getCategory()); + } else { + Plugin p = (Plugin) plugin; + return matchesFormat(p.getFormat()) + && (matchesType(p.getType()) + || p.getComponents().stream().anyMatch(c -> matchesType(c.getType()))) + && (matchesManufacturer(p.getManufacturerName()) + || p.getComponents().stream().anyMatch(c -> matchesManufacturer(c.getManufacturerName()))) + && (matchesCategory(p.getCategory()) + || p.getComponents().stream().anyMatch(c -> matchesCategory(c.getCategory()))); + } + }; + } + + private boolean matchesFormat(PluginFormat format) { + return selectedFormats.isEmpty() || selectedFormats.contains(format); + } + + private boolean matchesType(PluginType type) { + return selectedTypes.isEmpty() || selectedTypes.contains(type); + } + + private boolean matchesManufacturer(String manufacturer) { + return selectedManufacturers.isEmpty() || selectedManufacturers.contains(manufacturer); + } + + private boolean matchesCategory(String category) { + return selectedCategories.isEmpty() || selectedCategories.contains(category); + } + + public ObservableSet getSelectedFormats() { + return selectedFormats; + } + + public ObservableSet getSelectedTypes() { + return selectedTypes; + } + + public ObservableSet getSelectedManufacturers() { + return selectedManufacturers; + } + + public ObservableSet getSelectedCategories() { + return selectedCategories; + } + + public ObjectProperty> predicateProperty() { + return predicate; + } + + public void clearAll() { + selectedFormats.clear(); + selectedTypes.clear(); + selectedManufacturers.clear(); + selectedCategories.clear(); + } +} diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/ComponentInfoController.java b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/ComponentInfoController.java index 4d778f94..3939b160 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/ComponentInfoController.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/ComponentInfoController.java @@ -24,6 +24,7 @@ import com.owlplug.plugin.model.PluginComponent; import com.owlplug.plugin.services.PluginService; import com.owlplug.plugin.ui.PluginFormatBadgeView; +import com.owlplug.plugin.ui.PluginKindBadgeView; import com.owlplug.plugin.ui.PluginStateView; import java.util.ArrayList; import java.util.Optional; @@ -82,13 +83,15 @@ public class ComponentInfoController extends BaseController { public void initialize() { componentProperty.addListener(e -> refresh()); pluginScreenshotPane.setEffect(new ColorAdjust(0, 0, -0.6, 0)); + pluginFormatBadgeContainer.setSpacing(5); } public void refresh() { PluginComponent component = componentProperty.get(); pluginFormatBadgeContainer.getChildren().setAll( - new PluginFormatBadgeView(component.getPlugin().getFormat(), this.getApplicationDefaults())); + new PluginKindBadgeView(component, this.getApplicationDefaults()), + new Label(component.asPlugin().getFormat().getName() + " Component")); pluginTitleLabel.setText(component.getName()); pluginNameLabel.setText(Optional.ofNullable(component.getDescriptiveName()).orElse(component.getName())); pluginVersionLabel.setText(Optional.ofNullable(component.getVersion()).orElse("Unknown")); diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginFilterController.java b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginFilterController.java new file mode 100644 index 00000000..ef8a7cc2 --- /dev/null +++ b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginFilterController.java @@ -0,0 +1,309 @@ +/* OwlPlug + * Copyright (C) 2021 Arthur + * + * This file is part of OwlPlug. + * + * OwlPlug is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 + * as published by the Free Software Foundation. + * + * OwlPlug is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OwlPlug. If not, see . + */ + +package com.owlplug.plugin.controllers; + +import com.owlplug.core.components.ApplicationDefaults; +import com.owlplug.core.ui.SideBar; +import com.owlplug.core.utils.StringUtils; +import com.owlplug.plugin.components.PluginFilterState; +import com.owlplug.plugin.model.PluginFormat; +import com.owlplug.plugin.model.PluginType; +import com.owlplug.plugin.repositories.PluginComponentRepository; +import com.owlplug.plugin.ui.PluginFormatBadgeView; +import jakarta.annotation.PostConstruct; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import javafx.collections.ObservableSet; +import javafx.collections.SetChangeListener; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Separator; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; + +@Controller +public class PluginFilterController { + + @Autowired + private PluginFilterState filterState; + @Autowired + private ApplicationDefaults applicationDefaults; + @Autowired + private PluginComponentRepository pluginComponentRepository; + + private static final int PREVIEW_COUNT = 3; + + private SideBar sideBar; + private VBox formatSection; + private VBox typeSection; + private VBox manufacturerSection; + private VBox categorySection; + private final Map formatCheckBoxes = new EnumMap<>(PluginFormat.class); + private final Map typeCheckBoxes = new EnumMap<>(PluginType.class); + private final Map manufacturerCheckBoxes = new HashMap<>(); + private final Map categoryCheckBoxes = new HashMap<>(); + + @PostConstruct + public void setup() { + formatSection = new VBox(4); + typeSection = new VBox(4); + manufacturerSection = new VBox(4); + categorySection = new VBox(4); + + // Format and Type checkboxes are fixed (one per enum value) so their listeners are + // wired directly: checkbox → filterState (selectedProperty) and filterState → checkbox + // (SetChangeListener). Both directions are safe here because the checkboxes are never + // discarded — they live as long as the sidebar. + for (PluginType type : PluginType.values()) { + CheckBox cb = new CheckBox(StringUtils.capitalize(type.getText())); + // checkbox → filterState: toggling the checkbox adds/removes the type from the active filter + cb.selectedProperty().addListener((obs, old, selected) -> { + if (selected != filterState.getSelectedTypes().contains(type)) { + if (selected) { + filterState.getSelectedTypes().add(type); + } else { + filterState.getSelectedTypes().remove(type); + } + } + }); + // filterState → checkbox: external changes (e.g. "Clear all") keep the checkbox in sync + filterState.getSelectedTypes().addListener( + (SetChangeListener) change -> cb.setSelected(filterState.getSelectedTypes().contains(type))); + typeCheckBoxes.put(type, cb); + typeSection.getChildren().add(cb); + } + + for (PluginFormat format : PluginFormat.values()) { + CheckBox cb = new CheckBox(format.getName()); + cb.setGraphic(new PluginFormatBadgeView(format, applicationDefaults, PluginFormatBadgeView.DisplayMode.ICON_ONLY)); + cb.selectedProperty().addListener((obs, old, selected) -> { + if (selected != filterState.getSelectedFormats().contains(format)) { + if (selected) { + filterState.getSelectedFormats().add(format); + } else { + filterState.getSelectedFormats().remove(format); + } + } + }); + filterState.getSelectedFormats().addListener( + (SetChangeListener) change -> cb.setSelected(filterState.getSelectedFormats().contains(format))); + formatCheckBoxes.put(format, cb); + formatSection.getChildren().add(cb); + } + + // Shared listeners keep expandable-section checkboxes in sync with the filter state. + // Each listener holds a reference only to the value-to-checkbox map, not to individual + // CheckBox instances, so rebuilt checkboxes are not retained past their section lifetime. + filterState.getSelectedManufacturers().addListener((SetChangeListener) change -> { + String v = change.wasAdded() ? change.getElementAdded() : change.getElementRemoved(); + CheckBox cb = manufacturerCheckBoxes.get(v); + if (cb != null) cb.setSelected(change.wasAdded()); + }); + filterState.getSelectedCategories().addListener((SetChangeListener) change -> { + String v = change.wasAdded() ? change.getElementAdded() : change.getElementRemoved(); + CheckBox cb = categoryCheckBoxes.get(v); + if (cb != null) cb.setSelected(change.wasAdded()); + }); + + ScrollPane scrollPane = new ScrollPane(buildContent()); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.getStyleClass().add("plugin-filter-scroll"); + + sideBar = new SideBar(220, scrollPane); + sideBar.getStyleClass().add("plugin-filter-sidebar"); + sideBar.setVisible(false); + sideBar.setPrefWidth(0); + } + + private VBox buildContent() { + VBox content = new VBox(8); + content.setPadding(new Insets(12)); + + HBox header = new HBox(); + header.setAlignment(Pos.CENTER_LEFT); + Label titleLabel = new Label("Filters"); + titleLabel.getStyleClass().add("plugin-filter-title"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Hyperlink clearLink = new Hyperlink("Clear all"); + clearLink.getStyleClass().add("plugin-filter-clear"); + clearLink.setOnAction(e -> filterState.clearAll()); + header.getChildren().addAll(titleLabel, spacer, clearLink); + + Label formatLabel = new Label("Format"); + formatLabel.getStyleClass().add("plugin-filter-section-title"); + Label typeLabel = new Label("Type"); + typeLabel.getStyleClass().add("plugin-filter-section-title"); + Label manufacturerLabel = new Label("Manufacturer"); + manufacturerLabel.getStyleClass().add("plugin-filter-section-title"); + Label categoryLabel = new Label("Category"); + categoryLabel.getStyleClass().add("plugin-filter-section-title"); + + content.getChildren().addAll( + header, new Separator(), + formatLabel, formatSection, + new Separator(), typeLabel, typeSection, + new Separator(), manufacturerLabel, manufacturerSection, + new Separator(), categoryLabel, categorySection + ); + return content; + } + + /** + * Refreshes filter section counts from the database and rebuilds expandable sections. + * Called after each plugin scan or data reload. Format and Type labels are updated + * in-place; Manufacturer and Category sections are fully rebuilt because their values + * are dynamic (any string from component metadata). + */ + public void refresh() { + // Format counts: keyed by enum name() because the DB stores EnumType.STRING values + Map formatCounts = pluginComponentRepository.countFormatsFromComponents() + .stream().collect(Collectors.toMap(e -> e.getLabel(), e -> e.getCnt())); + formatCheckBoxes.forEach((format, cb) -> { + long count = formatCounts.getOrDefault(format.name(), 0L); + cb.setText(format.getName() + " (" + count + ")"); + }); + + typeCheckBoxes.forEach((type, cb) -> + cb.setText(StringUtils.capitalize(type.getText()) + " (" + pluginComponentRepository.countByType(type) + ")")); + + // Manufacturer and Category sections are rebuilt on each refresh: values are dynamic + // and the set of visible checkboxes changes with each scan result. + Map manufacturerCounts = pluginComponentRepository.countManufacturerNamesFromComponents() + .stream().collect(Collectors.toMap(e -> e.getLabel(), e -> e.getCnt())); + buildExpandableSection( + manufacturerSection, manufacturerCheckBoxes, manufacturerCounts, + value -> toggle(filterState.getSelectedManufacturers(), value), + filterState.getSelectedManufacturers() + ); + + Map categoryCounts = pluginComponentRepository.countCategoriesFromComponents() + .stream().collect(Collectors.toMap(e -> e.getLabel(), e -> e.getCnt())); + buildExpandableSection( + categorySection, categoryCheckBoxes, categoryCounts, + value -> toggle(filterState.getSelectedCategories(), value), + filterState.getSelectedCategories() + ); + } + + private void toggle(ObservableSet set, String value) { + if (set.contains(value)) { + set.remove(value); + } else { + set.add(value); + } + } + + /** + * Rebuilds a dynamic filter section. The first PREVIEW_COUNT values are always visible; + * the rest are hidden behind a "View more" toggle. Entries are sorted by count desc, + * then alphabetically so the most common values appear first. + * + * checkBoxMap is cleared and repopulated on every call so the shared SetChangeListeners + * registered in setup() always resolve to a live checkbox, never a stale one. + */ + private void buildExpandableSection(VBox container, Map checkBoxMap, + Map countMap, Consumer onToggle, ObservableSet selectedSet) { + container.getChildren().clear(); + // Clearing the map releases references to the previous generation of CheckBox instances + checkBoxMap.clear(); + if (countMap.isEmpty()) { + return; + } + + List values = countMap.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()) + .thenComparing(Map.Entry.comparingByKey())) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + List preview = values.subList(0, Math.min(PREVIEW_COUNT, values.size())); + List remaining = values.size() > PREVIEW_COUNT + ? values.subList(PREVIEW_COUNT, values.size()) + : List.of(); + + VBox previewBox = new VBox(4); + for (String value : preview) { + CheckBox cb = createCheckBox(value, countMap.get(value), onToggle, selectedSet); + checkBoxMap.put(value, cb); + previewBox.getChildren().add(cb); + } + container.getChildren().add(previewBox); + + if (!remaining.isEmpty()) { + // Overflow values are rendered in a hidden VBox toggled by the "View more" hyperlink. + // Both visible and hidden checkboxes are registered in checkBoxMap so the shared + // listener can reach them regardless of the expanded/collapsed state. + VBox fullBox = new VBox(4); + for (String value : remaining) { + CheckBox cb = createCheckBox(value, countMap.get(value), onToggle, selectedSet); + checkBoxMap.put(value, cb); + fullBox.getChildren().add(cb); + } + fullBox.setVisible(false); + fullBox.setManaged(false); + + Hyperlink viewMore = new Hyperlink("View more (" + remaining.size() + ")"); + viewMore.getStyleClass().add("plugin-filter-view-more"); + viewMore.setOnAction(e -> { + boolean expanded = fullBox.isVisible(); + fullBox.setVisible(!expanded); + fullBox.setManaged(!expanded); + viewMore.setText(expanded ? "View more (" + remaining.size() + ")" : "View less"); + }); + container.getChildren().addAll(fullBox, viewMore); + } + } + + /** + * Creates a filter checkbox for a single string value. The checkbox→filterState direction + * is wired here via selectedProperty. The reverse direction (filterState→checkbox) is + * handled by the shared SetChangeListeners in setup() to avoid leaking stale checkboxes. + */ + private CheckBox createCheckBox(String value, long count, Consumer onToggle, + ObservableSet selectedSet) { + CheckBox cb = new CheckBox(value + " (" + count + ")"); + cb.setSelected(selectedSet.contains(value)); + // Guard against feedback loops: only propagate when the UI state diverges from the model + cb.selectedProperty().addListener((obs, old, selected) -> { + if (selected != selectedSet.contains(value)) { + onToggle.accept(value); + } + }); + return cb; + } + + public SideBar getView() { + return sideBar; + } +} diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginInfoController.java b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginInfoController.java index 828f203a..548b9bf8 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginInfoController.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginInfoController.java @@ -157,7 +157,7 @@ public void initialize() { Async.run(() -> pluginService.enablePlugin(plugin)); }); - pluginComponentListView.setCellFactory(new PluginComponentCellFactory(this.getApplicationDefaults())); + pluginComponentListView.setCellFactory(new PluginComponentCellFactory()); lastScanErrorLabel.managedProperty().bind(lastScanErrorLabel.visibleProperty()); lastScanErrorLabel.setVisible(false); diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTableController.java b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTableController.java index 9cda0e51..a8f074bb 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTableController.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTableController.java @@ -18,19 +18,20 @@ package com.owlplug.plugin.controllers; -import com.owlplug.core.components.ApplicationDefaults; import com.owlplug.core.components.ApplicationDefaults.Prefs; import com.owlplug.core.controllers.BaseController; import com.owlplug.core.utils.FileUtils; import com.owlplug.core.utils.PlatformUtils; +import com.owlplug.plugin.components.PluginFilterState; import com.owlplug.plugin.controllers.dialogs.DisablePluginDialogController; import com.owlplug.plugin.model.IPlugin; import com.owlplug.plugin.model.Plugin; import com.owlplug.plugin.model.PluginFormat; import com.owlplug.plugin.model.PluginState; import com.owlplug.plugin.services.PluginService; -import com.owlplug.plugin.ui.PluginFormatBadgeView; +import com.owlplug.plugin.ui.PluginKindBadgeView; import com.owlplug.plugin.ui.PluginStateView; +import java.util.function.Predicate; import java.io.File; import com.owlplug.core.utils.Async; import javafx.beans.binding.Bindings; @@ -47,8 +48,6 @@ import javafx.scene.control.TableColumn; import javafx.scene.control.TableRow; import javafx.scene.control.TableView; -import javafx.scene.image.ImageView; -import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.kordamp.ikonli.javafx.FontIcon; @@ -68,6 +67,7 @@ public class PluginTableController extends BaseController { private PluginService pluginService; private final SimpleStringProperty search = new SimpleStringProperty(); + private final SimpleObjectProperty> filterPredicate = new SimpleObjectProperty<>(); private final TableView tableView; private final ObservableList pluginList; @@ -96,13 +96,20 @@ public PluginTableController() { FilteredList filteredPluginList = new FilteredList<>(pluginList); filteredPluginList.predicateProperty().bind(Bindings.createObjectBinding(() -> { - if (search.getValue() == null || search.getValue().isEmpty()) { + String searchVal = search.getValue(); + boolean hasSearch = searchVal != null && !searchVal.isEmpty(); + Predicate fp = filterPredicate.get(); + if (!hasSearch && fp == null) { return null; } - return (plugin) -> plugin.getName().toLowerCase().contains(search.getValue().toLowerCase()) - || (plugin.getCategory() != null && plugin.getCategory().toLowerCase().contains( - search.getValue().toLowerCase())); - }, search)); + return plugin -> { + boolean searchMatch = !hasSearch + || plugin.getName().toLowerCase().contains(searchVal.toLowerCase()) + || (plugin.getCategory() != null + && plugin.getCategory().toLowerCase().contains(searchVal.toLowerCase())); + return searchMatch && (fp == null || fp.test(plugin)); + }; + }, search, filterPredicate)); SortedList sortedPluginList = new SortedList<>(filteredPluginList); tableView.setItems(sortedPluginList); @@ -114,17 +121,16 @@ private void createColumns() { TableColumn nameColumn = new TableColumn<>("Name"); nameColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getName())); TableColumn formatColumn = new TableColumn<>("Format"); - formatColumn.setCellValueFactory(cellData -> new SimpleObjectProperty<>(cellData.getValue().asPlugin().getFormat())); + formatColumn.setCellValueFactory( + cellData -> new SimpleObjectProperty<>(cellData.getValue().asPlugin().getFormat())); formatColumn.setCellFactory(e -> new TableCell<>() { @Override public void updateItem(PluginFormat item, boolean empty) { super.updateItem(item, empty); if (item == null || empty) { - setText(null); setGraphic(null); } else { - setText(null); - setGraphic(new HBox(new PluginFormatBadgeView(item, getApplicationDefaults(), PluginFormatBadgeView.DisplayMode.ICON_ONLY))); + setGraphic(new PluginKindBadgeView(getTableRow().getItem(), getApplicationDefaults())); } } }); @@ -191,6 +197,10 @@ public void updateItem(PluginState item, boolean empty) { } + public void bindFilterState(PluginFilterState filterState) { + filterPredicate.bind(filterState.predicateProperty()); + } + public void setPlugins(Iterable plugins) { pluginList.clear(); plugins.forEach(p -> { diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTreeViewController.java b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTreeViewController.java index 9890f4a1..327e8577 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTreeViewController.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginTreeViewController.java @@ -20,6 +20,7 @@ import com.owlplug.core.controllers.BaseController; import com.owlplug.core.ui.FilterableTreeItem; +import com.owlplug.plugin.components.PluginFilterState; import com.owlplug.plugin.model.IDirectory; import com.owlplug.plugin.model.IPlugin; import com.owlplug.plugin.model.Plugin; @@ -33,7 +34,9 @@ import java.util.HashMap; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; @@ -51,6 +54,7 @@ public class PluginTreeViewController extends BaseController { private SymlinkRepository symlinkRepository; private final SimpleStringProperty search = new SimpleStringProperty(); + private final SimpleObjectProperty> filterPredicate = new SimpleObjectProperty<>(); private final TreeView pluginTreeView; private final FilterableTreeItem treePluginNode; private final FilterableTreeItem treeFileRootNode; @@ -68,36 +72,13 @@ public PluginTreeViewController() { pluginTreeView.setRoot(treePluginNode); - // Binds search property to plugin tree filter - treePluginNode.predicateProperty().bind(Bindings.createObjectBinding(() -> { - if (search.getValue() == null || search.getValue().isEmpty()) { - return null; - } - return (item) -> { - if (item instanceof IPlugin plugin) { - return plugin.getName().toLowerCase().contains(search.getValue().toLowerCase()) - || (plugin.getCategory() != null && plugin.getCategory().toLowerCase().contains( - search.getValue().toLowerCase())); - } else { - return item.toString().toLowerCase().contains(search.getValue().toLowerCase()); - } - }; - }, search)); + treePluginNode.predicateProperty().bind( + Bindings.createObjectBinding(() -> buildTreePredicate(search.getValue(), filterPredicate.get()), + search, filterPredicate)); - // Binds search property to file tree filter - treeFileRootNode.predicateProperty().bind(Bindings.createObjectBinding(() -> { - if (search.getValue() == null || search.getValue().isEmpty()) { - return null; - } - return (item) -> { - if (item instanceof IPlugin plugin) { - return plugin.getName().toLowerCase().contains(search.getValue().toLowerCase()) - || (plugin.getCategory() != null && plugin.getCategory().toLowerCase().contains(search.getValue().toLowerCase())); - } else { - return item.toString().toLowerCase().contains(search.getValue().toLowerCase()); - } - }; - }, search)); + treeFileRootNode.predicateProperty().bind( + Bindings.createObjectBinding(() -> buildTreePredicate(search.getValue(), filterPredicate.get()), + search, filterPredicate)); } @@ -105,6 +86,28 @@ public SimpleStringProperty searchProperty() { return this.search; } + public void bindFilterState(PluginFilterState filterState) { + filterPredicate.bind(filterState.predicateProperty()); + } + + private Predicate buildTreePredicate(String searchVal, Predicate fp) { + boolean hasSearch = searchVal != null && !searchVal.isEmpty(); + if (!hasSearch && fp == null) { + return null; + } + return item -> { + if (item instanceof IPlugin plugin) { + boolean searchMatch = !hasSearch + || plugin.getName().toLowerCase().contains(searchVal.toLowerCase()) + || (plugin.getCategory() != null + && plugin.getCategory().toLowerCase().contains(searchVal.toLowerCase())); + return searchMatch && (fp == null || fp.test(plugin)); + } else { + return !hasSearch || item.toString().toLowerCase().contains(searchVal.toLowerCase()); + } + }; + } + public TreeView getTreeView() { return pluginTreeView; } diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginsController.java b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginsController.java index 99e41363..b0664d45 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginsController.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/controllers/PluginsController.java @@ -18,10 +18,10 @@ package com.owlplug.plugin.controllers; -import com.owlplug.core.components.ApplicationDefaults; import com.owlplug.core.components.ApplicationDefaults.Prefs; import com.owlplug.core.controllers.BaseController; import com.owlplug.core.utils.FX; +import com.owlplug.plugin.components.PluginFilterState; import com.owlplug.plugin.components.PluginTaskFactory; import com.owlplug.plugin.controllers.dialogs.ExportDialogController; import com.owlplug.plugin.controllers.dialogs.NewLinkController; @@ -38,6 +38,7 @@ import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; @@ -62,6 +63,10 @@ public class PluginsController extends BaseController { protected PluginTreeViewController treeViewController; @Autowired protected PluginTableController tableController; + @Autowired + protected PluginFilterController filterController; + @Autowired + protected PluginFilterState filterState; @FXML private SplitMenuButton scanMenuButton; @@ -85,9 +90,13 @@ public class PluginsController extends BaseController { @FXML private Button newLinkButton; @FXML + private Button filterToggleButton; + @FXML private VBox pluginInfoPane; @FXML private VBox pluginsContainer; + @FXML + private HBox filterContainer; /** * FXML initialize method. @@ -99,6 +108,11 @@ public void initialize() { newLinkController.show(); }); + // Wire filter sidebar and predicate into sub-controllers + filterContainer.getChildren().add(0, filterController.getView()); + tableController.bindFilterState(filterState); + treeViewController.bindFilterState(filterState); + // Add Plugin Table and TreeView to the scene graph pluginsContainer.getChildren().add(treeViewController.getTreeView()); pluginsContainer.getChildren().add(tableController.getTableView()); @@ -215,6 +229,7 @@ public void displayPlugins() { .thenAccept(plugins -> FX.run(() -> { treeViewController.setPlugins(plugins); tableController.setPlugins(plugins); + filterController.refresh(); })); } @@ -231,6 +246,11 @@ public void refresh() { tableController.refresh(); } + @FXML + public void toggleFilter() { + filterController.getView().toggle(); + } + public void setSearch(String query) { searchTextField.setText(query); } diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/model/PluginComponent.java b/owlplug-client/src/main/java/com/owlplug/plugin/model/PluginComponent.java index d41ada15..44896175 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/model/PluginComponent.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/model/PluginComponent.java @@ -49,11 +49,22 @@ public class PluginComponent implements IPlugin { @Enumerated(EnumType.STRING) protected PluginType type; - @ManyToOne @JsonIgnore private Plugin plugin; + + // Computed during scan based on parent plugin + @Enumerated(EnumType.STRING) + protected PluginFormat pluginFormat; + + /* Active if the component has been successfully scanned. + Otherwise, component is a ghost placeholder representing + a fallback reference of an expected component. */ + @Column(columnDefinition = "boolean default true") + protected boolean active = true; + + public Long getId() { return id; } @@ -142,6 +153,22 @@ public void setPlugin(Plugin plugin) { this.plugin = plugin; } + public PluginFormat getPluginFormat() { + return pluginFormat; + } + + public void setPluginFormat(PluginFormat pluginFormat) { + this.pluginFormat = pluginFormat; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + @Override public Plugin asPlugin() { return this.plugin; diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginComponentRepository.java b/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginComponentRepository.java new file mode 100644 index 00000000..fa8e2a7c --- /dev/null +++ b/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginComponentRepository.java @@ -0,0 +1,45 @@ +/* OwlPlug + * Copyright (C) 2021 Arthur + * + * This file is part of OwlPlug. + * + * OwlPlug is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 + * as published by the Free Software Foundation. + * + * OwlPlug is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OwlPlug. If not, see . + */ + +package com.owlplug.plugin.repositories; + +import com.owlplug.plugin.model.PluginComponent; +import com.owlplug.plugin.model.PluginType; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; + +public interface PluginComponentRepository extends JpaRepository, JpaSpecificationExecutor { + + @Query("SELECT pc.manufacturerName AS label, COUNT(pc) AS cnt " + + "FROM PluginComponent pc WHERE pc.manufacturerName IS NOT NULL AND pc.manufacturerName <> '' " + + "GROUP BY pc.manufacturerName") + List countManufacturerNamesFromComponents(); + + @Query("SELECT pc.category AS label, COUNT(pc) AS cnt " + + "FROM PluginComponent pc WHERE pc.category IS NOT NULL AND pc.category <> '' " + + "GROUP BY pc.category") + List countCategoriesFromComponents(); + + @Query("SELECT p.format AS label, COUNT(pc) AS cnt FROM PluginComponent pc " + + "JOIN pc.plugin p WHERE p.format IS NOT NULL GROUP BY p.format") + List countFormatsFromComponents(); + + long countByType(PluginType type); +} \ No newline at end of file diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginRepository.java b/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginRepository.java index f6c3b8e3..98f589ba 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginRepository.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/repositories/PluginRepository.java @@ -26,6 +26,7 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; public interface PluginRepository extends JpaRepository, JpaSpecificationExecutor { @@ -47,7 +48,6 @@ static Specification hasComponentName(String name) { } - Plugin findByPath(String path); List findBySyncComplete(boolean syncComplete); diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/repositories/StringCountEntry.java b/owlplug-client/src/main/java/com/owlplug/plugin/repositories/StringCountEntry.java new file mode 100644 index 00000000..adbbc9b7 --- /dev/null +++ b/owlplug-client/src/main/java/com/owlplug/plugin/repositories/StringCountEntry.java @@ -0,0 +1,24 @@ +/* OwlPlug + * Copyright (C) 2021 Arthur + * + * This file is part of OwlPlug. + * + * OwlPlug is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 + * as published by the Free Software Foundation. + * + * OwlPlug is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OwlPlug. If not, see . + */ + +package com.owlplug.plugin.repositories; + +public interface StringCountEntry { + String getLabel(); + Long getCnt(); +} \ No newline at end of file diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/tasks/PluginScanTask.java b/owlplug-client/src/main/java/com/owlplug/plugin/tasks/PluginScanTask.java index d665515a..f46ac6df 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/tasks/PluginScanTask.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/tasks/PluginScanTask.java @@ -185,6 +185,7 @@ protected void collect() throws Exception { for (NativePlugin nativePlugin : nativePlugins) { PluginComponent component = createComponentFromNative(nativePlugin); + component.setPluginFormat(plugin.getFormat()); component.setPlugin(plugin); plugin.getComponents().add(component); log.debug("Created component {} for plugin {}", component.getName(), plugin.getName()); @@ -201,6 +202,17 @@ protected void collect() throws Exception { } } + // If no component has been added from native discovery (disabled, unscannable, ...) + // A fallback inactive component reference is added. + if (plugin.getComponents().isEmpty()) { + PluginComponent inactiveComponent = new PluginComponent(); + inactiveComponent.setActive(false); + inactiveComponent.setName(plugin.getName()); + inactiveComponent.setPluginFormat(plugin.getFormat()); + inactiveComponent.setPlugin(plugin); + plugin.getComponents().add(inactiveComponent); + } + plugin.setScanComplete(true); pluginFootprintRepository.save(pluginFootprint); pluginRepository.save(plugin); @@ -230,7 +242,9 @@ private PluginComponent createComponentFromNative(NativePlugin nativePlugin) { pluginComponent.setType(PluginType.EFFECT); } + pluginComponent.setActive(true); return pluginComponent; + } private void mapPluginPropertiesFromNative(Plugin plugin, NativePlugin nativePlugin) { diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginComponentCellFactory.java b/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginComponentCellFactory.java index 562d88a5..7821179a 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginComponentCellFactory.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginComponentCellFactory.java @@ -18,38 +18,38 @@ package com.owlplug.plugin.ui; -import com.owlplug.core.components.ApplicationDefaults; import com.owlplug.plugin.model.PluginComponent; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; -import javafx.scene.image.ImageView; import javafx.util.Callback; +import org.kordamp.ikonli.javafx.FontIcon; public class PluginComponentCellFactory implements Callback, ListCell> { - private ApplicationDefaults applicationDefaults; - public PluginComponentCellFactory(ApplicationDefaults applicationDefaults) { - - this.applicationDefaults = applicationDefaults; + public PluginComponentCellFactory() { } @Override public ListCell call(ListView arg0) { - return new ListCell<>() { @Override - public void updateItem(PluginComponent plugin, boolean empty) { - super.updateItem(plugin, empty); + public void updateItem(PluginComponent component, boolean empty) { + super.updateItem(component, empty); if (empty) { setText(null); setGraphic(null); } else { - ImageView imageView = new ImageView(); - imageView.setImage(applicationDefaults.pluginComponentImage); - setText(plugin.getName()); - setGraphic(imageView); + FontIcon icon; + if (component.isActive()) { + icon = new FontIcon("mdi2t-toy-brick-outline"); + setText(component.getName()); + } else { + icon = new FontIcon("mdi2t-toy-brick-remove-outline"); + setText(component.getName() + " (Unscannable)"); + } + setGraphic(icon); } } }; diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginKindBadgeView.java b/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginKindBadgeView.java new file mode 100644 index 00000000..f39011ca --- /dev/null +++ b/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginKindBadgeView.java @@ -0,0 +1,26 @@ +package com.owlplug.plugin.ui; + +import com.owlplug.core.components.ApplicationDefaults; +import com.owlplug.plugin.model.IPlugin; +import com.owlplug.plugin.model.PluginComponent; +import javafx.geometry.Pos; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import org.kordamp.ikonli.javafx.FontIcon; + +public class PluginKindBadgeView extends HBox { + + public PluginKindBadgeView(IPlugin plugin, ApplicationDefaults applicationDefaults) { + super(4); + setAlignment(Pos.CENTER_LEFT); + + if (plugin instanceof PluginComponent component) { + getChildren().add(new PluginFormatBadgeView(component.asPlugin().getFormat(), applicationDefaults)); + FontIcon icon = new FontIcon("mdi2t-toy-brick-outline"); + Tooltip.install(icon, new Tooltip("Component")); + getChildren().add(icon); + } else { + getChildren().add(new PluginFormatBadgeView(plugin.asPlugin().getFormat(), applicationDefaults)); + } + } +} \ No newline at end of file diff --git a/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginTreeCell.java b/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginTreeCell.java index 09dd3e93..8064f9a0 100644 --- a/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginTreeCell.java +++ b/owlplug-client/src/main/java/com/owlplug/plugin/ui/PluginTreeCell.java @@ -30,7 +30,6 @@ import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.TreeCell; -import javafx.scene.image.ImageView; import javafx.scene.layout.HBox; import javafx.scene.shape.Circle; import javafx.scene.text.Text; @@ -39,8 +38,8 @@ public class PluginTreeCell extends TreeCell { - private PluginService pluginService; - private ApplicationDefaults applicationDefaults; + private final PluginService pluginService; + private final ApplicationDefaults applicationDefaults; public PluginTreeCell(ApplicationDefaults applicationDefaults, PluginService pluginService) { this.applicationDefaults = applicationDefaults; @@ -106,7 +105,7 @@ private void renderPlugin(Plugin plugin) { private void renderComponent(PluginComponent pluginComponent) { Label label = new Label(pluginComponent.getName()); - label.setGraphic(new ImageView(applicationDefaults.pluginComponentImage)); + label.setGraphic(new FontIcon("mdi2t-toy-brick-outline")); setGraphic(label); setText(null); } diff --git a/owlplug-client/src/main/resources/fxml/plugins/PluginsView.fxml b/owlplug-client/src/main/resources/fxml/plugins/PluginsView.fxml index ea80ac0e..9c7eaf72 100644 --- a/owlplug-client/src/main/resources/fxml/plugins/PluginsView.fxml +++ b/owlplug-client/src/main/resources/fxml/plugins/PluginsView.fxml @@ -17,15 +17,20 @@ - - - + + +