From f66951804eb5655b97e07e721561af0664230948 Mon Sep 17 00:00:00 2001 From: Sam Carlberg Date: Fri, 17 Nov 2017 18:06:43 -0500 Subject: [PATCH] Automatically load the most recently saved dashboard file (#277) * Automatically load the most recently saved dashboard file This behavior can be toggled on or off in the application settings Refactors DashboardTab into its own upper-level class * Automatically restore sources for components loaded from save files Not stable for single-source widgets that support multiple data types (eg TextViewWidget) * Restore sources for all components * Disable widgets, not tiles, when a source is a DestroyedSource * Disable widgets when any source is disconnected * Fix occassionally restoring data of wrong type if > 1 type is possible * Catch DataTypeChangedException when restoring sources from plugin load This is needed since destroyed sources may be present after loading a save file --- .../api/sources/AbstractDataSource.java | 6 +- .../shuffleboard/api/sources/DataSource.java | 2 +- .../shuffleboard/api/sources/DummySource.java | 2 +- .../shuffleboard/api/sources/SourceType.java | 2 +- .../shuffleboard/api/sources/Sources.java | 4 - .../shuffleboard/api/util/Debouncer.java | 7 + .../api/widget/AbstractWidget.java | 1 + .../api/widget/ComponentContainer.java | 14 + .../api/widget/SingleSourceWidget.java | 6 +- .../shuffleboard/api/widget/Sourced.java | 13 + .../first/shuffleboard/api/widget/Widget.java | 6 - .../app/MainWindowController.java | 32 +- .../first/shuffleboard/app/Shuffleboard.java | 12 +- .../app/WidgetPaneController.java | 39 ++- .../app/components/DashboardTab.java | 274 ++++++++++++++++++ .../app/components/DashboardTabPane.java | 266 +---------------- .../app/components/WidgetTile.java | 2 - .../app/json/DashboardTabPaneSaver.java | 25 +- .../shuffleboard/app/json/LayoutSaver.java | 13 +- .../app/json/SourcedRestorer.java | 57 ++++ .../shuffleboard/app/json/WidgetSaver.java | 18 +- .../shuffleboard/app/plugin/PluginLoader.java | 7 +- .../app/prefs/AppPreferences.java | 42 ++- .../app/sources/DestroyedSource.java | 84 +++++- .../app/json/DashboardTabPaneSaverTest.java | 4 +- .../plugin/base/layout/SubsystemLayout.java | 9 + 26 files changed, 627 insertions(+), 320 deletions(-) create mode 100644 app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTab.java create mode 100644 app/src/main/java/edu/wpi/first/shuffleboard/app/json/SourcedRestorer.java diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/AbstractDataSource.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/AbstractDataSource.java index c28173d81..a4dd0bda4 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/AbstractDataSource.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/AbstractDataSource.java @@ -23,9 +23,9 @@ public abstract class AbstractDataSource implements DataSource { protected final BooleanProperty active = new SimpleBooleanProperty(this, "active", false); protected final Property data = new AsyncProperty<>(this, "data", null); protected final BooleanProperty connected = new SimpleBooleanProperty(this, "connected", false); - protected final DataType dataType; + protected final DataType dataType; - protected AbstractDataSource(DataType dataType) { + protected AbstractDataSource(DataType dataType) { this.dataType = requireNonNull(dataType, "dataType"); } @@ -53,7 +53,7 @@ protected void setActive(boolean active) { } @Override - public DataType getDataType() { + public DataType getDataType() { return dataType; } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DataSource.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DataSource.java index 368e0f1c4..a24e76b5e 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DataSource.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DataSource.java @@ -64,7 +64,7 @@ default void setData(T newValue) { /** * Gets the type of data that this source is providing. */ - DataType getDataType(); + DataType getDataType(); /** * Closes this data source and frees any used resources. diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DummySource.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DummySource.java index e8552514e..529cabf77 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DummySource.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/DummySource.java @@ -12,7 +12,7 @@ public class DummySource extends AbstractDataSource { /** * Create a new static, unchanging source for the given data type and value. */ - public DummySource(DataType dataType, T value) { + public DummySource(DataType dataType, T value) { super(dataType); this.setActive(true); this.setName(dataType.getName()); diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceType.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceType.java index f0561ba21..c6b550c1c 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceType.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceType.java @@ -86,7 +86,7 @@ public DataSource forUri(String uri) { if (!uri.startsWith(protocol)) { throw new IllegalArgumentException("URI does not start with the correct protocol: " + uri); } - return Sources.getDefault().computeIfAbsent(uri, () -> sourceSupplier.apply(removeProtocol(uri))); + return Sources.getDefault().get(uri).orElseGet(() -> sourceSupplier.apply(removeProtocol(uri))); } /** diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/Sources.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/Sources.java index 6d6d23274..587140aed 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/Sources.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/Sources.java @@ -53,10 +53,6 @@ public List forType(SourceType type) { .collect(Collectors.toList()); } - public DataSource forUri(String uri) { - return computeIfAbsent(uri, () -> SourceTypes.getDefault().forUri(uri)); - } - @SuppressWarnings("unchecked") //NOPMD multiple occurrences of string literal public Optional> get(String id) { return Optional.ofNullable(sources.get(id)); diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/util/Debouncer.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/util/Debouncer.java index 992538502..b08a59d50 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/util/Debouncer.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/util/Debouncer.java @@ -46,4 +46,11 @@ public Duration getDebounceDelay() { return debounceDelay; } + /** + * Cancels the debouncer. The target will not run unless {@link #run()} is called later. + */ + public void cancel() { + future.cancel(true); + } + } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/AbstractWidget.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/AbstractWidget.java index 2de7700c4..2188d26ef 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/AbstractWidget.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/AbstractWidget.java @@ -29,6 +29,7 @@ protected AbstractWidget() { } else { setTitle(getName() + " (" + sources.size() + " sources)"); } + getView().setDisable(sources.stream().anyMatch(s -> !s.isConnected())); }); } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/ComponentContainer.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/ComponentContainer.java index 6fafd4ff1..abf4de178 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/ComponentContainer.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/ComponentContainer.java @@ -1,5 +1,7 @@ package edu.wpi.first.shuffleboard.api.widget; +import edu.wpi.first.shuffleboard.api.util.TypeUtils; + import java.util.stream.Stream; /** @@ -19,4 +21,16 @@ public interface ComponentContainer { */ Stream components(); + /** + * Gets a stream of all the components in this container. + */ + default Stream allComponents() { + return Stream.concat( + components(), + components() + .flatMap(TypeUtils.castStream(ComponentContainer.class)) + .flatMap(ComponentContainer::allComponents) + ); + } + } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/SingleSourceWidget.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/SingleSourceWidget.java index 960bbe3cd..270d3d1d5 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/SingleSourceWidget.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/SingleSourceWidget.java @@ -14,15 +14,11 @@ public abstract class SingleSourceWidget extends AbstractWidget { protected final ObjectProperty source = new SimpleObjectProperty<>(this, "source", DataSource.none()); - @SuppressWarnings("JavadocMethod") - public SingleSourceWidget() { - source.addListener(__ -> sources.setAll(getSource())); - } - @Override public final void addSource(DataSource source) throws IncompatibleSourceException { if (getDataTypes().contains(source.getDataType())) { this.source.set(source); + this.sources.setAll(source); } else { throw new IncompatibleSourceException(getDataTypes(), source.getDataType()); } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Sourced.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Sourced.java index 41b9ef608..f6674428c 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Sourced.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Sourced.java @@ -1,8 +1,14 @@ package edu.wpi.first.shuffleboard.api.widget; +import edu.wpi.first.shuffleboard.api.data.DataType; +import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.shuffleboard.api.data.IncompatibleSourceException; import edu.wpi.first.shuffleboard.api.sources.DataSource; +import com.google.common.collect.ImmutableSet; + +import java.util.Set; + import javafx.collections.ObservableList; /** @@ -24,4 +30,11 @@ public interface Sourced { */ ObservableList getSources(); + /** + * Gets the allowable data types for sources. Defaults to {@link DataTypes#All}. + */ + default Set getDataTypes() { + return ImmutableSet.of(DataTypes.All); + } + } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Widget.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Widget.java index a8e7bf79b..b249126cc 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Widget.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/widget/Widget.java @@ -4,7 +4,6 @@ import edu.wpi.first.shuffleboard.api.sources.DataSource; import java.util.List; -import java.util.Set; import java.util.stream.Stream; import javafx.beans.property.Property; @@ -51,11 +50,6 @@ */ public interface Widget extends Component, Sourced { - /** - * Gets an unmodifiable copy of this widgets supported data types. - */ - Set getDataTypes(); - /** * Gets the user-configurable properties for this widget. */ diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/MainWindowController.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/MainWindowController.java index 3b4003294..5874ab941 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/MainWindowController.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/MainWindowController.java @@ -16,6 +16,7 @@ import edu.wpi.first.shuffleboard.api.util.Storage; import edu.wpi.first.shuffleboard.api.util.TypeUtils; import edu.wpi.first.shuffleboard.api.widget.Components; +import edu.wpi.first.shuffleboard.app.components.DashboardTab; import edu.wpi.first.shuffleboard.app.components.DashboardTabPane; import edu.wpi.first.shuffleboard.app.components.WidgetGallery; import edu.wpi.first.shuffleboard.api.components.WidgetPropertySheet; @@ -75,6 +76,7 @@ /** * Controller for the main UI window. */ +@SuppressWarnings("PMD.GodClass") // TODO refactor this class public class MainWindowController { private static final Logger log = Logger.getLogger(MainWindowController.class.getName()); @@ -231,9 +233,9 @@ private void tearDown(Plugin plugin) { sourcesAccordion.getPanes().removeAll(sourcePanes.removeAll(plugin)); // Remove widgets dashboard.getTabs().stream() - .filter(tab -> tab instanceof DashboardTabPane.DashboardTab) - .map(tab -> (DashboardTabPane.DashboardTab) tab) - .map(DashboardTabPane.DashboardTab::getWidgetPane) + .filter(tab -> tab instanceof DashboardTab) + .map(tab -> (DashboardTab) tab) + .map(DashboardTab::getWidgetPane) .forEach(pane -> pane.getTiles().stream() .filter(tile -> plugin.getComponents().stream() @@ -285,6 +287,7 @@ private MenuItem createShowAsMenuItem(String componentName, DataSource source public void setDashboard(DashboardTabPane dashboard) { dashboard.setId("dashboard"); centerSplitPane.getItems().remove(this.dashboard); + this.dashboard.getTabs().clear(); // Lets tabs get cleaned up (e.g. cancelling deferred autopopulation calls) this.dashboard = dashboard; centerSplitPane.getItems().add(dashboard); } @@ -342,6 +345,7 @@ private void saveFile(File selected) { } currentFile = selected; + AppPreferences.getInstance().setSaveFile(currentFile); } @@ -356,13 +360,20 @@ public void load() throws IOException { new FileChooser.ExtensionFilter("SmartDashboard Save File (.json)", "*.json")); final File selected = chooser.showOpenDialog(root.getScene().getWindow()); + load(selected); + } - if (selected == null) { + /** + * Loads a saved dashboard layout. + * + * @param saveFile the save file to load + */ + public void load(File saveFile) { + if (saveFile == null) { return; } - try { - Reader reader = Files.newReader(selected, Charset.forName("UTF-8")); + Reader reader = Files.newReader(saveFile, Charset.forName("UTF-8")); DashboardData dashboardData = JsonBuilder.forSaveFile().fromJson(reader, DashboardData.class); setDashboard(dashboardData.getTabPane()); @@ -372,7 +383,8 @@ public void load() throws IOException { return; } - currentFile = selected; + currentFile = saveFile; + AppPreferences.getInstance().setSaveFile(currentFile); } /** @@ -451,14 +463,14 @@ private void closeCurrentTab() { @FXML private void showCurrentTabPrefs() { Tab currentTab = dashboard.getSelectionModel().getSelectedItem(); - if (currentTab instanceof DashboardTabPane.DashboardTab) { - ((DashboardTabPane.DashboardTab) currentTab).showPrefsDialog(); + if (currentTab instanceof DashboardTab) { + ((DashboardTab) currentTab).showPrefsDialog(); } } @FXML private void newTab() { - DashboardTabPane.DashboardTab newTab = dashboard.addNewTab(); + DashboardTab newTab = dashboard.addNewTab(); dashboard.getSelectionModel().select(newTab); } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/Shuffleboard.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/Shuffleboard.java index dee2b335d..769ce47e5 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/Shuffleboard.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/Shuffleboard.java @@ -4,6 +4,7 @@ import edu.wpi.first.shuffleboard.api.util.Storage; import edu.wpi.first.shuffleboard.api.util.Time; import edu.wpi.first.shuffleboard.app.plugin.PluginLoader; +import edu.wpi.first.shuffleboard.app.prefs.AppPreferences; import edu.wpi.first.shuffleboard.plugin.base.BasePlugin; import edu.wpi.first.shuffleboard.plugin.cameraserver.CameraServerPlugin; import edu.wpi.first.shuffleboard.plugin.networktables.NetworkTablesPlugin; @@ -63,8 +64,12 @@ public void start(Stage primaryStage) throws IOException { // Set up the application thread to log exceptions instead of using printStackTrace() // Must be called in start() because init() is run on the main thread, not the FX application thread Thread.currentThread().setUncaughtExceptionHandler(Shuffleboard::uncaughtException); - mainPane = FXMLLoader.load(MainWindowController.class.getResource("MainWindow.fxml")); onOtherAppStart = () -> Platform.runLater(primaryStage::toFront); + + FXMLLoader loader = new FXMLLoader(MainWindowController.class.getResource("MainWindow.fxml")); + mainPane = loader.load(); + final MainWindowController mainWindowController = loader.getController(); + primaryStage.setScene(new Scene(mainPane)); PluginLoader.getDefault().load(new BasePlugin()); @@ -75,6 +80,11 @@ public void start(Stage primaryStage) throws IOException { PluginLoader.getDefault().load(new NetworkTablesPlugin()); PluginLoader.getDefault().loadAllJarsFromDir(Storage.getPluginPath()); + // Load the most recent save file after loading all plugins + if (AppPreferences.getInstance().isAutoLoadLastSaveFile()) { + mainWindowController.load(AppPreferences.getInstance().getSaveFile()); + } + primaryStage.setTitle("Shuffleboard"); primaryStage.setMinWidth(640); primaryStage.setMinHeight(480); diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneController.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneController.java index a2b9732a3..bd25e2b8e 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneController.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneController.java @@ -1,14 +1,17 @@ package edu.wpi.first.shuffleboard.app; +import edu.wpi.first.shuffleboard.api.components.WidgetPropertySheet; import edu.wpi.first.shuffleboard.api.dnd.DataFormats; import edu.wpi.first.shuffleboard.api.sources.DataSource; import edu.wpi.first.shuffleboard.api.sources.DummySource; import edu.wpi.first.shuffleboard.api.sources.SourceEntry; +import edu.wpi.first.shuffleboard.api.sources.SourceTypes; import edu.wpi.first.shuffleboard.api.util.FxUtils; import edu.wpi.first.shuffleboard.api.util.GridPoint; import edu.wpi.first.shuffleboard.api.util.RoundingMode; import edu.wpi.first.shuffleboard.api.util.TypeUtils; import edu.wpi.first.shuffleboard.api.widget.Component; +import edu.wpi.first.shuffleboard.api.widget.ComponentContainer; import edu.wpi.first.shuffleboard.api.widget.Components; import edu.wpi.first.shuffleboard.api.widget.Layout; import edu.wpi.first.shuffleboard.api.widget.LayoutType; @@ -19,10 +22,11 @@ import edu.wpi.first.shuffleboard.app.components.Tile; import edu.wpi.first.shuffleboard.app.components.TileLayout; import edu.wpi.first.shuffleboard.app.components.WidgetPane; -import edu.wpi.first.shuffleboard.api.components.WidgetPropertySheet; import edu.wpi.first.shuffleboard.app.components.WidgetTile; import edu.wpi.first.shuffleboard.app.dnd.TileDragResizer; +import edu.wpi.first.shuffleboard.app.json.SourcedRestorer; import edu.wpi.first.shuffleboard.app.prefs.AppPreferences; +import edu.wpi.first.shuffleboard.app.sources.DestroyedSource; import org.fxmisc.easybind.EasyBind; @@ -31,6 +35,7 @@ import java.util.Optional; import java.util.WeakHashMap; import java.util.function.Function; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -198,6 +203,34 @@ private void initialize() { .forEach(tile -> collapseTile(tile, oldCount - newCount, false)); } }); + + // Handle restoring data sources after a widget is added from a save file before its source(s) are available + SourceTypes.getDefault().allAvailableSourceUris().addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasAdded()) { + SourcedRestorer restorer = new SourcedRestorer(); + // Restore sources for top-level Sourced objects + pane.getTiles().stream() + .map(Tile::getContent) + .flatMap(TypeUtils.castStream(Sourced.class)) + .forEach(sourced -> restorer.restoreSourcesFor( + sourced, + c.getAddedSubList(), + WidgetPaneController::destroyedSourceCouldNotBeRestored)); + + // Restore sources for all nested Sourced objects + pane.getTiles().stream() + .map(Tile::getContent) + .flatMap(TypeUtils.castStream(ComponentContainer.class)) + .flatMap(ComponentContainer::allComponents) + .flatMap(TypeUtils.castStream(Sourced.class)) + .forEach(sourced -> restorer.restoreSourcesFor( + sourced, + c.getAddedSubList(), + WidgetPaneController::destroyedSourceCouldNotBeRestored)); + } + } + }); } /** @@ -523,4 +556,8 @@ private Optional collapseTile(Tile tile, } } + private static void destroyedSourceCouldNotBeRestored(DestroyedSource source, Throwable error) { + log.log(Level.WARNING, "Could not restore source: " + source.getId(), error); + } + } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTab.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTab.java new file mode 100644 index 000000000..e83c3cef4 --- /dev/null +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTab.java @@ -0,0 +1,274 @@ +package edu.wpi.first.shuffleboard.app.components; + +import edu.wpi.first.shuffleboard.api.Populatable; +import edu.wpi.first.shuffleboard.api.components.WidgetPropertySheet; +import edu.wpi.first.shuffleboard.api.data.DataTypes; +import edu.wpi.first.shuffleboard.api.sources.DataSource; +import edu.wpi.first.shuffleboard.api.sources.SourceType; +import edu.wpi.first.shuffleboard.api.sources.SourceTypes; +import edu.wpi.first.shuffleboard.api.util.Debouncer; +import edu.wpi.first.shuffleboard.api.util.FxUtils; +import edu.wpi.first.shuffleboard.api.util.NetworkTableUtils; +import edu.wpi.first.shuffleboard.api.util.TypeUtils; +import edu.wpi.first.shuffleboard.api.widget.ComponentContainer; +import edu.wpi.first.shuffleboard.api.widget.Components; +import edu.wpi.first.shuffleboard.api.widget.Sourced; +import edu.wpi.first.shuffleboard.app.Autopopulator; +import edu.wpi.first.shuffleboard.app.prefs.AppPreferences; + +import org.fxmisc.easybind.EasyBind; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.ListChangeListener; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Dialog; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Tab; +import javafx.scene.layout.BorderPane; + +public class DashboardTab extends Tab implements HandledTab, Populatable { + + private final ObjectProperty widgetPane = new SimpleObjectProperty<>(this, "widgetPane"); + private final StringProperty title = new SimpleStringProperty(this, "title", ""); + private final BooleanProperty autoPopulate = new SimpleBooleanProperty(this, "autoPopulate", false); + private final StringProperty sourcePrefix = new SimpleStringProperty(this, "sourcePrefix", ""); + + /** + * Debounces populate() calls so we don't freeze the app while a source type is doing its initial discovery of + * available source URIs. Not debouncing makes typical application startup take at least 5 seconds on an i7-6700HQ + * where the user sees nothing but a blank screen - no UI elements or anything! + * + *

Note that this is only used when we manually force a population call, which is only required because the + * filtering criteria for compatible sources changes based on the {@code sourcePrefix} property. + */ + private final Debouncer populateDebouncer = + new Debouncer(() -> FxUtils.runOnFxThread(this::populate), Duration.ofMillis(50)); + + private final ListChangeListener tileListChangeListener = c -> { + while (c.next()) { + if (c.wasAdded()) { + c.getAddedSubList().stream() + .map(Tile::getContent) + .flatMap(TypeUtils.castStream(Populatable.class)) + .collect(Collectors.toList()) + .forEach(Autopopulator.getDefault()::addTarget); + } else if (c.wasRemoved()) { + c.getRemoved().stream() + .map(Tile::getContent) + .flatMap(TypeUtils.castStream(Populatable.class)) + .collect(Collectors.toList()) + .forEach(Autopopulator.getDefault()::removeTarget); + } + } + }; + + private boolean deferPopulation = true; + + /** + * Creates a single dashboard tab with the given title. + */ + public DashboardTab(String title) { + super(); + this.title.set(title); + setGraphic(new TabHandle(this)); + + widgetPane.addListener((__, prev, cur) -> { + if (prev != null) { + prev.getTiles().removeListener(tileListChangeListener); + } + if (cur != null) { + cur.getTiles().addListener(tileListChangeListener); + } + }); + + setWidgetPane(new WidgetPane()); + + this.contentProperty().bind(widgetPane); + + autoPopulate.addListener((__, was, is) -> { + if (is) { + Autopopulator.getDefault().addTarget(this); + } else { + Autopopulator.getDefault().removeTarget(this); + } + }); + autoPopulate.addListener(__ -> populateDebouncer.run()); + sourcePrefix.addListener(__ -> populateDebouncer.run()); + + MenuItem prefItem = FxUtils.menuItem("Preferences", __ -> showPrefsDialog()); + prefItem.setStyle("-fx-text-fill: black;"); + setContextMenu(new ContextMenu(prefItem)); + } + + /** + * Shows a dialog for editing the properties of this tab. + */ + public void showPrefsDialog() { + // Use a dummy property here to prevent a call to populate() on every keystroke in the editor (!) + StringProperty dummySourcePrefix + = new SimpleStringProperty(sourcePrefix.getBean(), sourcePrefix.getName(), sourcePrefix.getValue()); + WidgetPropertySheet propertySheet = new WidgetPropertySheet( + Arrays.asList( + this.title, + this.autoPopulate, + dummySourcePrefix, + getWidgetPane().tileSizeProperty(), + getWidgetPane().showGridProperty() + )); + propertySheet.getItems().addAll( + new WidgetPropertySheet.PropertyItem<>(getWidgetPane().hgapProperty(), "Horizontal spacing"), + new WidgetPropertySheet.PropertyItem<>(getWidgetPane().vgapProperty(), "Vertical spacing") + ); + Dialog dialog = new Dialog<>(); + dialog.getDialogPane().getStylesheets().setAll(AppPreferences.getInstance().getTheme().getStyleSheets()); + dialog.setResizable(true); + dialog.titleProperty().bind(EasyBind.map(this.title, t -> t + " Preferences")); + dialog.getDialogPane().setContent(new BorderPane(propertySheet)); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CLOSE); + dialog.setOnCloseRequest(__ -> { + this.sourcePrefix.setValue(dummySourcePrefix.getValue()); + }); + dialog.showAndWait(); + } + + /** + * Populates this tab with all available sources that begin with the set source prefix and don't already have a + * widget to display it or any higher-level source. + */ + private void populate() { + if (getTabPane() == null) { + // No longer in the scene; bail + return; + } + if (getWidgetPane().getScene() == null || getWidgetPane().getParent() == null) { + // Defer until the pane is visible and is laid out in the scene + deferPopulation = true; + populateDebouncer.run(); + return; + } + if (deferPopulation) { + // Defer one last time; this method tends to trigger before row/column bindings on the widget pane + // This makes sure the pane is properly sized before populating it + deferPopulation = false; + populateDebouncer.run(); + return; + } + if (isAutoPopulate()) { + Autopopulator.getDefault().populate(this); + } + } + + public WidgetPane getWidgetPane() { + return widgetPane.get(); + } + + public ObjectProperty widgetPaneProperty() { + return widgetPane; + } + + public void setWidgetPane(WidgetPane widgetPane) { + this.widgetPane.set(widgetPane); + } + + Debouncer getPopulateDebouncer() { + return populateDebouncer; + } + + @Override + public Tab getTab() { + return this; + } + + @Override + public StringProperty titleProperty() { + return title; + } + + public String getTitle() { + return title.get(); + } + + public void setTitle(String title) { + this.title.set(title); + } + + public boolean isAutoPopulate() { + return autoPopulate.get(); + } + + public BooleanProperty autoPopulateProperty() { + return autoPopulate; + } + + public void setAutoPopulate(boolean autoPopulate) { + this.autoPopulate.set(autoPopulate); + } + + public String getSourcePrefix() { + return sourcePrefix.get(); + } + + public StringProperty sourcePrefixProperty() { + return sourcePrefix; + } + + public void setSourcePrefix(String sourceRegex) { + this.sourcePrefix.set(sourceRegex); + } + + @Override + public boolean supports(String sourceId) { + SourceType type = SourceTypes.getDefault().typeForUri(sourceId); + String name = NetworkTableUtils.normalizeKey(type.removeProtocol(sourceId), false); + return !deferPopulation + && isAutoPopulate() + && type.dataTypeForSource(DataTypes.getDefault(), sourceId) != DataTypes.Map + && !NetworkTableUtils.isMetadata(sourceId) + && (name.startsWith(getSourcePrefix()) || sourceId.startsWith(getSourcePrefix())); + } + + @Override + public boolean hasComponentFor(String sourceId) { + return getWidgetPane().getTiles().stream() + .map(Tile::getContent) + .flatMap(TypeUtils.castStream(Sourced.class)) + .anyMatch(s -> s.getSources().stream().map(DataSource::getId).anyMatch(sourceId::equals) + || (s.getSources().stream().map(DataSource::getId).anyMatch(sourceId::startsWith) + && !(s instanceof ComponentContainer))); + } + + @Override + public void addComponentFor(DataSource source) { + List targets = getWidgetPane().components() + .flatMap(TypeUtils.castStream(Populatable.class)) + .filter(p -> p.supports(source.getId())) + .collect(Collectors.toList()); + + if (targets.isEmpty()) { + // No nested components capable of adding a component for the source, add it to the root widget pane + Components.getDefault().defaultComponentNameFor(source.getDataType()) + .flatMap(s -> Components.getDefault().createComponent(s, source)) + .ifPresent(c -> { + c.setTitle(source.getName()); + getWidgetPane().addComponent(c); + if (c instanceof Populatable) { + Autopopulator.getDefault().addTarget((Populatable) c); + } + }); + } else { + // Add a component everywhere possible + targets.forEach(t -> t.addComponentIfPossible(source)); + } + } +} diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTabPane.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTabPane.java index e3f93db13..3ee213205 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTabPane.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/DashboardTabPane.java @@ -1,46 +1,15 @@ package edu.wpi.first.shuffleboard.app.components; -import edu.wpi.first.shuffleboard.api.Populatable; -import edu.wpi.first.shuffleboard.api.components.WidgetPropertySheet; -import edu.wpi.first.shuffleboard.api.data.DataTypes; -import edu.wpi.first.shuffleboard.api.sources.DataSource; -import edu.wpi.first.shuffleboard.api.sources.SourceType; -import edu.wpi.first.shuffleboard.api.sources.SourceTypes; import edu.wpi.first.shuffleboard.api.util.Debouncer; -import edu.wpi.first.shuffleboard.api.util.FxUtils; -import edu.wpi.first.shuffleboard.api.util.NetworkTableUtils; import edu.wpi.first.shuffleboard.api.util.TypeUtils; import edu.wpi.first.shuffleboard.api.widget.Component; -import edu.wpi.first.shuffleboard.api.widget.ComponentContainer; -import edu.wpi.first.shuffleboard.api.widget.Components; -import edu.wpi.first.shuffleboard.api.widget.Sourced; import edu.wpi.first.shuffleboard.api.widget.Widget; -import edu.wpi.first.shuffleboard.app.Autopopulator; -import edu.wpi.first.shuffleboard.app.prefs.AppPreferences; -import org.fxmisc.easybind.EasyBind; - -import java.time.Duration; -import java.util.Arrays; -import java.util.List; import java.util.function.Predicate; -import java.util.stream.Collectors; -import javafx.application.Platform; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; import javafx.collections.ListChangeListener; -import javafx.scene.control.ButtonType; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Dialog; -import javafx.scene.control.MenuItem; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; -import javafx.scene.layout.BorderPane; import static edu.wpi.first.shuffleboard.api.util.TypeUtils.optionalCast; @@ -69,12 +38,24 @@ private static DashboardTab createAutoPopulateTab(String name, String sourcePref */ public DashboardTabPane(Tab... tabs) { super(tabs); + getTabs().addListener(DashboardTabPane::onTabsChanged); getStyleClass().add("dashboard-tabs"); AdderTab adder = new AdderTab(); adder.setAddTabCallback(this::addNewTab); getTabs().add(adder); } + private static void onTabsChanged(ListChangeListener.Change change) { + while (change.next()) { + if (change.wasRemoved()) { + change.getRemoved().stream() + .flatMap(TypeUtils.castStream(DashboardTab.class)) + .map(DashboardTab::getPopulateDebouncer) + .forEach(Debouncer::cancel); + } + } + } + /** * Adds a new tab at the end of the tab list. The default name is "Tab n", where n is the number * of dashboard tabs in the pane. @@ -129,227 +110,4 @@ public void selectWidgets(Predicate selector) { .ifPresent(pane -> pane.selectWidgets(selector))); } - public static class DashboardTab extends Tab implements HandledTab, Populatable { - private final ObjectProperty widgetPane = new SimpleObjectProperty<>(this, "widgetPane"); - private final StringProperty title = new SimpleStringProperty(this, "title", ""); - private final BooleanProperty autoPopulate = new SimpleBooleanProperty(this, "autoPopulate", false); - private final StringProperty sourcePrefix = new SimpleStringProperty(this, "sourcePrefix", ""); - - /** - * Debounces populate() calls so we don't freeze the app while a source type is doing its initial discovery of - * available source URIs. Not debouncing makes typical application startup take at least 5 seconds on an i7-6700HQ - * where the user sees nothing but a blank screen - no UI elements or anything! - * - *

Note that this is only used when we manually force a population call, which is only required because the - * filtering criteria for compatible sources changes based on the {@code sourcePrefix} property. - */ - private final Debouncer populateDebouncer = - new Debouncer(() -> FxUtils.runOnFxThread(this::populate), Duration.ofMillis(50)); - - private final ListChangeListener tileListChangeListener = c -> { - while (c.next()) { - if (c.wasAdded()) { - c.getAddedSubList().stream() - .map(Tile::getContent) - .flatMap(TypeUtils.castStream(Populatable.class)) - .collect(Collectors.toList()) - .forEach(Autopopulator.getDefault()::addTarget); - } else if (c.wasRemoved()) { - c.getRemoved().stream() - .map(Tile::getContent) - .flatMap(TypeUtils.castStream(Populatable.class)) - .collect(Collectors.toList()) - .forEach(Autopopulator.getDefault()::removeTarget); - } - } - }; - - private boolean deferPopulation = true; - - /** - * Creates a single dashboard tab with the given title. - */ - public DashboardTab(String title) { - super(); - this.title.set(title); - setGraphic(new TabHandle(this)); - - widgetPane.addListener((__, prev, cur) -> { - if (prev != null) { - prev.getTiles().removeListener(tileListChangeListener); - } - if (cur != null) { - cur.getTiles().addListener(tileListChangeListener); - } - }); - - setWidgetPane(new WidgetPane()); - - this.contentProperty().bind(widgetPane); - - autoPopulate.addListener((__, was, is) -> { - if (is) { - Autopopulator.getDefault().addTarget(this); - } else { - Autopopulator.getDefault().removeTarget(this); - } - }); - autoPopulate.addListener(__ -> populateDebouncer.run()); - sourcePrefix.addListener(__ -> populateDebouncer.run()); - - MenuItem prefItem = FxUtils.menuItem("Preferences", __ -> showPrefsDialog()); - prefItem.setStyle("-fx-text-fill: black;"); - setContextMenu(new ContextMenu(prefItem)); - } - - /** - * Shows a dialog for editing the properties of this tab. - */ - public void showPrefsDialog() { - // Use a dummy property here to prevent a call to populate() on every keystroke in the editor (!) - StringProperty dummySourcePrefix - = new SimpleStringProperty(sourcePrefix.getBean(), sourcePrefix.getName(), sourcePrefix.getValue()); - WidgetPropertySheet propertySheet = new WidgetPropertySheet( - Arrays.asList( - this.title, - this.autoPopulate, - dummySourcePrefix, - getWidgetPane().tileSizeProperty(), - getWidgetPane().showGridProperty() - )); - propertySheet.getItems().addAll( - new WidgetPropertySheet.PropertyItem<>(getWidgetPane().hgapProperty(), "Horizontal spacing"), - new WidgetPropertySheet.PropertyItem<>(getWidgetPane().vgapProperty(), "Vertical spacing") - ); - Dialog dialog = new Dialog<>(); - dialog.getDialogPane().getStylesheets().setAll(AppPreferences.getInstance().getTheme().getStyleSheets()); - dialog.setResizable(true); - dialog.titleProperty().bind(EasyBind.map(this.title, t -> t + " Preferences")); - dialog.getDialogPane().setContent(new BorderPane(propertySheet)); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CLOSE); - dialog.setOnCloseRequest(__ -> { - this.sourcePrefix.setValue(dummySourcePrefix.getValue()); - }); - dialog.showAndWait(); - } - - /** - * Populates this tab with all available sources that begin with the set source prefix and don't already have a - * widget to display it or any higher-level source. - */ - private void populate() { - if (getWidgetPane().getScene() == null || getWidgetPane().getParent() == null) { - // Defer until the pane is visible and is laid out in the scene - deferPopulation = true; - Platform.runLater(this::populate); - return; - } - if (deferPopulation) { - // Defer one last time; this method tends to trigger before row/column bindings on the widget pane - // This makes sure the pane is properly sized before populating it - deferPopulation = false; - Platform.runLater(this::populate); - } - Autopopulator.getDefault().populate(this); - } - - public WidgetPane getWidgetPane() { - return widgetPane.get(); - } - - public ObjectProperty widgetPaneProperty() { - return widgetPane; - } - - public void setWidgetPane(WidgetPane widgetPane) { - this.widgetPane.set(widgetPane); - } - - @Override - public Tab getTab() { - return this; - } - - @Override - public StringProperty titleProperty() { - return title; - } - - public String getTitle() { - return title.get(); - } - - public void setTitle(String title) { - this.title.set(title); - } - - public boolean isAutoPopulate() { - return autoPopulate.get(); - } - - public BooleanProperty autoPopulateProperty() { - return autoPopulate; - } - - public void setAutoPopulate(boolean autoPopulate) { - this.autoPopulate.set(autoPopulate); - } - - public String getSourcePrefix() { - return sourcePrefix.get(); - } - - public StringProperty sourcePrefixProperty() { - return sourcePrefix; - } - - public void setSourcePrefix(String sourceRegex) { - this.sourcePrefix.set(sourceRegex); - } - - @Override - public boolean supports(String sourceId) { - SourceType type = SourceTypes.getDefault().typeForUri(sourceId); - String name = NetworkTableUtils.normalizeKey(type.removeProtocol(sourceId), false); - return !deferPopulation - && isAutoPopulate() - && type.dataTypeForSource(DataTypes.getDefault(), sourceId) != DataTypes.Map - && !NetworkTableUtils.isMetadata(sourceId) - && (name.startsWith(getSourcePrefix()) || sourceId.startsWith(getSourcePrefix())); - } - - @Override - public boolean hasComponentFor(String sourceId) { - return getWidgetPane().getTiles().stream() - .map(Tile::getContent) - .flatMap(TypeUtils.castStream(Sourced.class)) - .anyMatch(s -> s.getSources().stream().map(DataSource::getId).anyMatch(sourceId::equals) - || (s.getSources().stream().map(DataSource::getId).anyMatch(sourceId::startsWith) - && !(s instanceof ComponentContainer))); - } - - @Override - public void addComponentFor(DataSource source) { - List targets = getWidgetPane().components() - .flatMap(TypeUtils.castStream(Populatable.class)) - .filter(p -> p.supports(source.getId())) - .collect(Collectors.toList()); - - if (targets.isEmpty()) { - // No nested components capable of adding a component for the source, add it to the root widget pane - Components.getDefault().defaultComponentNameFor(source.getDataType()) - .flatMap(s -> Components.getDefault().createComponent(s, source)) - .ifPresent(c -> { - c.setTitle(source.getName()); - getWidgetPane().addComponent(c); - if (c instanceof Populatable) { - Autopopulator.getDefault().addTarget((Populatable) c); - } - }); - } else { - // Add a component everywhere possible - targets.forEach(t -> t.addComponentIfPossible(source)); - } - } - } } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetTile.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetTile.java index 442080405..181030a4c 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetTile.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetTile.java @@ -29,10 +29,8 @@ public class WidgetTile extends Tile { private final InvalidationListener dataSourceListChangeListener = __ -> { if (retained.get() != null && retained.get().stream().anyMatch(s -> s instanceof DestroyedSource)) { pseudoClassStateChanged(NO_SOURCE, true); - setDisable(true); } else { pseudoClassStateChanged(NO_SOURCE, false); - setDisable(false); } }; diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaver.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaver.java index b5c203426..8e023745b 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaver.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaver.java @@ -1,30 +1,36 @@ package edu.wpi.first.shuffleboard.app.json; +import edu.wpi.first.shuffleboard.app.components.DashboardTab; +import edu.wpi.first.shuffleboard.app.components.DashboardTabPane; +import edu.wpi.first.shuffleboard.app.components.WidgetPane; + import com.google.gson.JsonArray; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonSerializationContext; -import edu.wpi.first.shuffleboard.app.components.DashboardTabPane; -import edu.wpi.first.shuffleboard.app.components.WidgetPane; -import javafx.scene.control.Tab; import java.util.ArrayList; import java.util.List; +import javafx.scene.control.Tab; + @AnnotatedTypeAdapter(forType = DashboardTabPane.class) public class DashboardTabPaneSaver implements ElementTypeAdapter { + @Override public JsonElement serialize(DashboardTabPane src, JsonSerializationContext context) { JsonArray tabs = new JsonArray(); for (Tab t : src.getTabs()) { - if (t instanceof DashboardTabPane.DashboardTab) { - DashboardTabPane.DashboardTab tab = (DashboardTabPane.DashboardTab) t; + if (t instanceof DashboardTab) { + DashboardTab tab = (DashboardTab) t; JsonObject object = new JsonObject(); object.addProperty("title", tab.getTitle()); + object.addProperty("autoPopulate", tab.isAutoPopulate()); + object.addProperty("autoPopulatePrefix", tab.getSourcePrefix()); object.add("widgetPane", context.serialize(tab.getWidgetPane())); tabs.add(object); @@ -40,9 +46,12 @@ public DashboardTabPane deserialize(JsonElement json, JsonDeserializationContext List tabs = new ArrayList<>(jsonTabs.size()); for (JsonElement i : json.getAsJsonArray()) { - String title = i.getAsJsonObject().get("title").getAsString(); - DashboardTabPane.DashboardTab tab = new DashboardTabPane.DashboardTab(title); - tab.setWidgetPane(context.deserialize(i.getAsJsonObject().get("widgetPane"), WidgetPane.class)); + JsonObject obj = i.getAsJsonObject(); + String title = obj.get("title").getAsString(); + DashboardTab tab = new DashboardTab(title); + tab.setWidgetPane(context.deserialize(obj.get("widgetPane"), WidgetPane.class)); + tab.setSourcePrefix(obj.get("autoPopulatePrefix").getAsString()); + tab.setAutoPopulate(obj.get("autoPopulate").getAsBoolean()); tabs.add(tab); } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/LayoutSaver.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/LayoutSaver.java index b510704cd..9d44c408a 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/LayoutSaver.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/LayoutSaver.java @@ -26,6 +26,9 @@ @AnnotatedTypeAdapter(forType = Layout.class) public class LayoutSaver implements ElementTypeAdapter { + + private final SourcedRestorer sourcedRestorer = new SourcedRestorer(); + @Override public JsonElement serialize(Layout src, JsonSerializationContext context) { JsonObject object = new JsonObject(); @@ -56,13 +59,21 @@ public Layout deserialize(JsonElement json, JsonDeserializationContext context) .orElseThrow(() -> new JsonParseException("Can't find layout name " + name)); if (layout instanceof Sourced) { + Sourced sourcedLayout = (Sourced) layout; for (int i = 0; i > Integer.MIN_VALUE; i++) { String prop = "_source" + i; if (obj.has(prop)) { + String uri = obj.get(prop).getAsString(); + Optional> source = Sources.getDefault().get(uri); try { - ((Sourced) layout).addSource(Sources.getDefault().forUri(obj.get(prop).getAsString())); + if (source.isPresent()) { + sourcedLayout.addSource(source.get()); + } else { + sourcedRestorer.addDestroyedSourcesForAllDataTypes(sourcedLayout, uri); + } } catch (IncompatibleSourceException e) { Logger.getLogger(getClass().getName()).log(Level.WARNING, "Couldn't load source", e); + sourcedRestorer.addDestroyedSourcesForAllDataTypes(sourcedLayout, uri); } } else { break; diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/SourcedRestorer.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/SourcedRestorer.java new file mode 100644 index 000000000..11262c131 --- /dev/null +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/SourcedRestorer.java @@ -0,0 +1,57 @@ +package edu.wpi.first.shuffleboard.app.json; + +import edu.wpi.first.shuffleboard.api.util.TypeUtils; +import edu.wpi.first.shuffleboard.api.widget.Sourced; +import edu.wpi.first.shuffleboard.app.sources.DestroyedSource; + +import java.util.Collection; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +/** + * Deals with destroyed sources and restoring those sources. + */ +public class SourcedRestorer { + + /** + * Adds a {@link DestroyedSource} for each supported data type of a sourced object. The application will handle + * restoring the sources when possible. + * + * @param sourced the sourced object to add destroyed sources to + * @param sourceUri the URI of the source + */ + public void addDestroyedSourcesForAllDataTypes(Sourced sourced, String sourceUri) { + sourced.addSource(DestroyedSource.forUnknownData(sourced.getDataTypes(), sourceUri)); + } + + /** + * Attempts to restore all destroyed sources in a sourced object for the given URIs. + * + * @param sourced the sourced to restore sources for + * @param urisToRestore the URIs of the sources to restore + * @param errorHandler a function to call when a source could not be restored, or when a restored source could not + * be added + */ + public void restoreSourcesFor(Sourced sourced, + Collection urisToRestore, + BiConsumer errorHandler) { + // The destroyed sources that correspond to URIs to restore + List toRestore = sourced.getSources().stream() + .flatMap(TypeUtils.castStream(DestroyedSource.class)) + .filter(s -> urisToRestore.contains(s.getId())) + .collect(Collectors.toList()); + for (DestroyedSource source : toRestore) { + try { + sourced.addSource(source.restore()); + // Remove all destroyed sources with the same ID; they were only present to allow us to restore the source + // with the correct data type. Since restoring this source was successful, the correct data type is known and + // the remaining destroyed sources are no longer necessary + sourced.getSources().removeIf(s -> s instanceof DestroyedSource && s.getId().equals(source.getId())); + } catch (Throwable e) { + errorHandler.accept(source, e); + } + } + } + +} diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/WidgetSaver.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/WidgetSaver.java index 0c432ad54..49267570c 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/json/WidgetSaver.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/json/WidgetSaver.java @@ -1,6 +1,7 @@ package edu.wpi.first.shuffleboard.app.json; import edu.wpi.first.shuffleboard.api.data.IncompatibleSourceException; +import edu.wpi.first.shuffleboard.api.sources.DataSource; import edu.wpi.first.shuffleboard.api.sources.Sources; import edu.wpi.first.shuffleboard.api.widget.Components; import edu.wpi.first.shuffleboard.api.widget.Widget; @@ -11,6 +12,7 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonSerializationContext; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; @@ -18,6 +20,11 @@ @AnnotatedTypeAdapter(forType = Widget.class) public class WidgetSaver implements ElementTypeAdapter { + + private static final Logger log = Logger.getLogger(WidgetSaver.class.getName()); + + private final SourcedRestorer sourcedRestorer = new SourcedRestorer(); + @Override public JsonElement serialize(Widget src, JsonSerializationContext context) { JsonObject object = new JsonObject(); @@ -41,10 +48,17 @@ public Widget deserialize(JsonElement json, JsonDeserializationContext context) for (int i = 0; i > Integer.MIN_VALUE; i++) { String prop = "_source" + i; if (obj.has(prop)) { + String uri = obj.get(prop).getAsString(); + Optional> source = Sources.getDefault().get(uri); try { - widget.addSource(Sources.getDefault().forUri(obj.get(prop).getAsString())); + if (source.isPresent()) { + widget.addSource(source.get()); + } else { + sourcedRestorer.addDestroyedSourcesForAllDataTypes(widget, uri); + } } catch (IncompatibleSourceException e) { - Logger.getLogger(getClass().getName()).log(Level.WARNING, "Couldn't load source", e); + log.log(Level.WARNING, "Couldn't load source, adding destroyed source(s) instead", e); + sourcedRestorer.addDestroyedSourcesForAllDataTypes(widget, uri); } } else { break; diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/plugin/PluginLoader.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/plugin/PluginLoader.java index 10022b3fd..12dfc4635 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/plugin/PluginLoader.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/plugin/PluginLoader.java @@ -9,6 +9,7 @@ import edu.wpi.first.shuffleboard.api.widget.Components; import edu.wpi.first.shuffleboard.api.widget.SingleSourceWidget; import edu.wpi.first.shuffleboard.api.widget.Widget; +import edu.wpi.first.shuffleboard.app.sources.DataTypeChangedException; import edu.wpi.first.shuffleboard.app.sources.DestroyedSource; import com.google.common.collect.ImmutableSet; @@ -186,9 +187,9 @@ public void load(Plugin plugin) { private void tryRestoreSource(Widget widget, DestroyedSource destroyedSource) { try { widget.addSource(destroyedSource.restore()); - } catch (IncompatibleSourceException e) { - log.fine("Could not set the restored source of " + widget - + ". The plugin defining its data type was probably unloaded."); + } catch (IncompatibleSourceException | DataTypeChangedException e) { + log.log(Level.WARNING, "Could not set the restored source of " + widget + + ". The plugin defining its data type was probably unloaded.", e); } } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/prefs/AppPreferences.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/prefs/AppPreferences.java index 4d9c129f7..5d3162289 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/prefs/AppPreferences.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/prefs/AppPreferences.java @@ -1,16 +1,19 @@ package edu.wpi.first.shuffleboard.app.prefs; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableList; - import edu.wpi.first.shuffleboard.api.theme.Theme; import edu.wpi.first.shuffleboard.api.theme.Themes; import edu.wpi.first.shuffleboard.api.util.PreferencesUtils; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; + +import java.io.File; import java.util.prefs.Preferences; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; @@ -22,6 +25,9 @@ public final class AppPreferences { private final Property theme = new SimpleObjectProperty<>(this, "Theme", Themes.INITIAL_THEME); private final DoubleProperty defaultTileSize = new SimpleDoubleProperty(this, "defaultTileSize", 128); + private final Property saveFile = new SimpleObjectProperty<>(this, "saveFile", null); + private final BooleanProperty autoLoadLastSaveFile = + new SimpleBooleanProperty(this, "automaticallyLoadLastSaveFile", true); @VisibleForTesting static AppPreferences instance = new AppPreferences(); @@ -33,9 +39,13 @@ public AppPreferences() { Preferences preferences = Preferences.userNodeForPackage(getClass()); PreferencesUtils.read(theme, preferences, Themes.getDefault()::forName); PreferencesUtils.read(defaultTileSize, preferences); + PreferencesUtils.read(saveFile, preferences, File::new); + PreferencesUtils.read(autoLoadLastSaveFile, preferences); theme.addListener(__ -> PreferencesUtils.save(theme, preferences, Theme::getName)); defaultTileSize.addListener(__ -> PreferencesUtils.save(defaultTileSize, preferences)); + saveFile.addListener(__ -> PreferencesUtils.save(saveFile, preferences, File::getAbsolutePath)); + autoLoadLastSaveFile.addListener(__ -> PreferencesUtils.save(autoLoadLastSaveFile, preferences)); } public static AppPreferences getInstance() { @@ -48,7 +58,8 @@ public static AppPreferences getInstance() { public ImmutableList> getProperties() { return ImmutableList.of( theme, - defaultTileSize + defaultTileSize, + autoLoadLastSaveFile ); } @@ -76,4 +87,27 @@ public void setDefaultTileSize(double defaultTileSize) { this.defaultTileSize.set(defaultTileSize); } + public File getSaveFile() { + return saveFile.getValue(); + } + + public Property saveFileProperty() { + return saveFile; + } + + public void setSaveFile(File saveFile) { + this.saveFile.setValue(saveFile); + } + + public boolean isAutoLoadLastSaveFile() { + return autoLoadLastSaveFile.get(); + } + + public BooleanProperty autoLoadLastSaveFileProperty() { + return autoLoadLastSaveFile; + } + + public void setAutoLoadLastSaveFile(boolean autoLoadLastSaveFile) { + this.autoLoadLastSaveFile.set(autoLoadLastSaveFile); + } } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/sources/DestroyedSource.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/sources/DestroyedSource.java index e97d62728..fd1b8cd5b 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/sources/DestroyedSource.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/sources/DestroyedSource.java @@ -5,6 +5,13 @@ import edu.wpi.first.shuffleboard.api.sources.SourceType; import edu.wpi.first.shuffleboard.api.sources.SourceTypes; +import com.google.common.collect.Iterables; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.Property; @@ -20,24 +27,65 @@ */ public class DestroyedSource implements DataSource { - private final DataType dataType; + private final Set possibleTypes; private final String oldId; - private final SourceType sourceType; private final StringProperty name = new SimpleStringProperty(this, "name", null); private final ObjectProperty data = new SimpleObjectProperty<>(this, "data", null); private final BooleanProperty active = new SimpleBooleanProperty(this, "active", false); + /** + * Creates a new destroyed source for the given data types and URI. This should be used to represent a saved data + * source whose data or type is unknown at the time it is loaded. + * + * @param allowableTypes the possible data types that a restored source may be able to provide + * @param uri the URI of the real source corresponding to the created one + */ + public static DestroyedSource forUnknownData(Collection allowableTypes, String uri) { + return new DestroyedSource<>(allowableTypes, uri, null); + } + + /** + * Creates a new instance that can restore a data source. + * + * @param possibleTypes the possible data types that can be restored + * @param id the ID of the destroyed source + * @param data the data of the source when it was destroyed, or {@code null} if no data was present or known + * + * @throws IllegalArgumentException if no possible types are specified + */ + public DestroyedSource(Collection possibleTypes, String id, T data) { + if (possibleTypes.isEmpty()) { + throw new IllegalArgumentException("There must be at least one possible data type"); + } + this.possibleTypes = new LinkedHashSet<>(possibleTypes); // preserve order, when possible + this.oldId = id; + this.name.set(SourceTypes.getDefault().stripProtocol(id)); + this.data.set(data); + } + + /** + * Creates a new instance that can restore a data source. + * + * @param dataType the type of the data the destroyed source provides + * @param id the ID of the destroyed source + * @param data the data of the source when it was destroyed, or {@code null} if no data was present or known + */ + public DestroyedSource(DataType dataType, String id, T data) { + this(Collections.singleton(dataType), id, data); + } + /** * Creates a new instance that can restore the given data source. * * @param destroyed the destroyed source that the new instance should be able to restore. */ public DestroyedSource(DataSource destroyed) { - dataType = destroyed.getDataType(); - sourceType = destroyed.getType(); - oldId = destroyed.getId(); - name.set(destroyed.getName()); - data.set(destroyed.getData()); + this(destroyed.getDataType(), destroyed.getId(), destroyed.getData()); + this.name.set(destroyed.getName()); + } + + private SourceType getSourceType() { + return SourceTypes.getDefault().typeForUri(oldId); } /** @@ -48,15 +96,22 @@ public DestroyedSource(DataSource destroyed) { */ @SuppressWarnings("unchecked") public DataSource restore() throws DataTypeChangedException, IllegalStateException { + SourceType sourceType = getSourceType(); if (SourceTypes.getDefault().isRegistered(sourceType)) { DataSource restored = (DataSource) sourceType.forUri(oldId); - if (!restored.getDataType().equals(dataType)) { + if (!possibleTypes.contains(restored.getDataType())) { throw new DataTypeChangedException( - "The new data type is " + restored.getDataType() + ", was expecting " + dataType); + "The new data type is " + restored.getDataType() + ", was expecting one of: " + + Iterables.toString(possibleTypes)); } restored.nameProperty().set(name.get()); restored.activeProperty().set(true); - restored.setData(getData()); + if (getData() == null) { + // No data was saved, set it to the default value for its type + restored.setData(restored.getDataType().getDefaultValue()); + } else { + restored.setData(getData()); + } return restored; } else { throw new IllegalStateException("The source type " + sourceType.getName() + " is not registered"); @@ -85,12 +140,12 @@ public void setData(T newValue) { @Override public DataType getDataType() { - return dataType; + return Iterables.get(possibleTypes, 0); } @Override public SourceType getType() { - return sourceType; + return getSourceType(); } @Override @@ -108,4 +163,9 @@ public boolean isConnected() { return false; } + @Override + public String toString() { + return "DestroyedSource(id=" + oldId + ", possibleTypes=" + possibleTypes + ")"; + } + } diff --git a/app/src/test/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaverTest.java b/app/src/test/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaverTest.java index 07023fd4d..5c485d51e 100644 --- a/app/src/test/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaverTest.java +++ b/app/src/test/java/edu/wpi/first/shuffleboard/app/json/DashboardTabPaneSaverTest.java @@ -1,6 +1,8 @@ package edu.wpi.first.shuffleboard.app.json; import edu.wpi.first.networktables.NetworkTableInstance; + +import edu.wpi.first.shuffleboard.app.components.DashboardTab; import edu.wpi.first.shuffleboard.app.components.DashboardTabPane; import edu.wpi.first.shuffleboard.api.util.AsyncUtils; import edu.wpi.first.shuffleboard.api.util.FxUtils; @@ -57,7 +59,7 @@ public void testDeserialize() throws Exception { assertEquals(2 + 1, dashboard.getTabs().size()); // 1 for the adder tab - DashboardTabPane.DashboardTab firstTab = (DashboardTabPane.DashboardTab) dashboard.getTabs().get(0); + DashboardTab firstTab = (DashboardTab) dashboard.getTabs().get(0); assertEquals("First Tab", firstTab.getTitle()); assertEquals(6, firstTab.getWidgetPane().getTiles().size()); } diff --git a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/layout/SubsystemLayout.java b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/layout/SubsystemLayout.java index 31f8b479c..79ec4fccc 100644 --- a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/layout/SubsystemLayout.java +++ b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/layout/SubsystemLayout.java @@ -2,6 +2,7 @@ import edu.wpi.first.shuffleboard.api.Populatable; import edu.wpi.first.shuffleboard.api.components.EditableLabel; +import edu.wpi.first.shuffleboard.api.data.DataType; import edu.wpi.first.shuffleboard.api.data.IncompatibleSourceException; import edu.wpi.first.shuffleboard.api.sources.DataSource; import edu.wpi.first.shuffleboard.api.util.AlphanumComparator; @@ -23,6 +24,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.Set; import javafx.beans.property.Property; import javafx.beans.property.SimpleStringProperty; @@ -149,4 +151,11 @@ public void addSource(DataSource source) throws IncompatibleSourceException { } } + @Override + public Set getDataTypes() { + return ImmutableSet.of( + new SubsystemType() + ); + } + }