diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTable.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTable.java index d7c1e6e21..2e3c57586 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTable.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTable.java @@ -44,23 +44,23 @@ public class SourceTreeTable extends TreeTableView private final ObjectProperty sourceType = new SimpleObjectProperty<>(this, "sourceType", null); private final TreeTableColumn keyColumn = new TreeTableColumn<>("Name"); - private final TreeTableColumn valueColumn = new TreeTableColumn<>("Value"); + private final TreeTableColumn infoColumn = new TreeTableColumn<>("Info"); /** * Creates a new source tree table. It comes pre-populated with a key and a value column. */ public SourceTreeTable() { keyColumn.prefWidthProperty().bind(widthProperty().divide(2).subtract(2)); - valueColumn.prefWidthProperty().bind(widthProperty().divide(2).subtract(2)); + infoColumn.prefWidthProperty().bind(widthProperty().divide(2).subtract(2)); keyColumn.setCellValueFactory( f -> new ReadOnlyStringWrapper(getEntryForCellData(f).getViewName())); - valueColumn.setCellValueFactory( - f -> new ReadOnlyObjectWrapper(getEntryForCellData(f).getValueView())); + infoColumn.setCellValueFactory( + f -> new ReadOnlyObjectWrapper(getEntryForCellData(f).getInfo())); Label placeholder = new Label("No data available"); setPlaceholder(placeholder); - getColumns().addAll(keyColumn, valueColumn); + getColumns().addAll(keyColumn, infoColumn); } /** diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/dnd/DataFormats.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/dnd/DataFormats.java index beaa7840b..e73ab4b34 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/dnd/DataFormats.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/dnd/DataFormats.java @@ -33,11 +33,6 @@ public final class DataFormats { */ public static final DataFormat source = new DataFormat(APP_PREFIX + "/data-source"); - /** - * The data format for widget type names (string). - */ - public static final DataFormat widgetType = new DataFormat(APP_PREFIX + "/widget-type"); - /** * The data format for components that do not exist inside a tile. */ diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceEntry.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceEntry.java index 61fd8e655..6365331df 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceEntry.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceEntry.java @@ -30,9 +30,9 @@ default String getViewName() { Object getValue(); /** - * Gets an object used to display the value of the source this entry represents. Implementers are encouraged to + * Gets an object used to display information about the source. Implementers are encouraged to * sharpen the return type */ - Object getValueView(); + Object getInfo(); } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceTypes.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceTypes.java index edc687ebd..0ca0f3875 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceTypes.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/SourceTypes.java @@ -3,15 +3,13 @@ import edu.wpi.first.shuffleboard.api.data.DataType; import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.shuffleboard.api.sources.recording.TimestampedData; -import edu.wpi.first.shuffleboard.api.util.PropertyUtils; import edu.wpi.first.shuffleboard.api.util.Registry; -import org.fxmisc.easybind.EasyBind; +import javafx.collections.ListChangeListener; import java.util.HashMap; import java.util.Map; import java.util.Objects; -import java.util.Optional; import javafx.beans.InvalidationListener; import javafx.collections.FXCollections; @@ -49,11 +47,25 @@ public SourceTypes() { register(Static); typeNames.addListener((InvalidationListener) __ -> { - Optional> names = typeNames.stream() + typeNames + .stream() .map(this::forName) .map(SourceType::getAvailableSourceUris) - .reduce(PropertyUtils::combineLists); - names.ifPresent(l -> EasyBind.listBind(allUris, l)); + .forEach(observableUriList -> { + observableUriList.addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasAdded()) { + for (String uri : c.getAddedSubList()) { + if (!allUris.contains(uri)) { + allUris.add(uri); + } + } + } else if (c.wasRemoved()) { + allUris.removeAll(c.getRemoved()); + } + } + }); + }); }); } diff --git a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/recording/Recorder.java b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/recording/Recorder.java index 5ee80a404..b7fdcdfea 100644 --- a/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/recording/Recorder.java +++ b/api/src/main/java/edu/wpi/first/shuffleboard/api/sources/recording/Recorder.java @@ -2,11 +2,8 @@ import edu.wpi.first.shuffleboard.api.DashboardMode; import edu.wpi.first.shuffleboard.api.data.DataType; -import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.shuffleboard.api.properties.AtomicBooleanProperty; 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.sources.recording.serialization.Serializer; import edu.wpi.first.shuffleboard.api.sources.recording.serialization.Serializers; import edu.wpi.first.shuffleboard.api.util.ShutdownHooks; @@ -135,14 +132,6 @@ public void start() { startTime = Instant.now(); firstSave = true; recording = new Recording(); - // Record initial conditions - SourceTypes.getDefault().getItems().stream() - .map(SourceType::getAvailableSources) - .forEach(sources -> sources.forEach((id, value) -> { - DataTypes.getDefault().forJavaType(value.getClass()) - .map(t -> new TimestampedData(id, t, value, 0L)) - .ifPresent(recording::append); - })); } setRunning(true); } 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 9bb259b2c..2a033a30d 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 @@ -2,10 +2,10 @@ import edu.wpi.first.shuffleboard.api.data.IncompatibleSourceException; import edu.wpi.first.shuffleboard.api.sources.DataSource; - import javafx.beans.property.ObjectProperty; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.ListChangeListener; /** * A partial implementation of {@code Widget} that only has a single source. @@ -14,6 +14,36 @@ public abstract class SingleSourceWidget extends AbstractWidget { protected final ObjectProperty source = new SimpleObjectProperty<>(this, "source", DataSource.none()); + /** + * Instantiates a new single source widget. This automatically registers listeners to make the + * {@link #source} and {@link #sources} properties stay in sync such that both properties will + * have the same, single data source object. + */ + public SingleSourceWidget() { + // Bidirectional binding to make the sources list act like a single-element wrapper around + // the source property + source.addListener((__, oldSource, newSource) -> sources.setAll(newSource)); + + sources.addListener(new ListChangeListener() { + @Override + public void onChanged(Change c) { + while (c.next()) { + if (c.wasAdded()) { + var added = c.getAddedSubList(); + if (!added.isEmpty()) { + var addedSource = added.get(0); + if (addedSource != source.get()) { + source.set(addedSource); + } + } + } else if (c.wasRemoved()) { + source.set(DataSource.none()); + } + } + } + }); + } + @Override public final void addSource(DataSource source) throws IncompatibleSourceException { if (getDataTypes().contains(source.getDataType())) { diff --git a/api/src/main/resources/edu/wpi/first/shuffleboard/api/base.css b/api/src/main/resources/edu/wpi/first/shuffleboard/api/base.css index c98135fe6..f2f2ccf46 100644 --- a/api/src/main/resources/edu/wpi/first/shuffleboard/api/base.css +++ b/api/src/main/resources/edu/wpi/first/shuffleboard/api/base.css @@ -261,20 +261,6 @@ -fx-stroke-width: 0.25em; } -/******************************************************************************* - * * - * Widget Gallery * - * * - ******************************************************************************/ -.widget-gallery .item { - -fx-border-width: 2; - -fx-border-color: -swatch-200; - -fx-border-insets: 5; - -fx-background-insets: 5; - -fx-alignment: center; - -fx-cursor: hand; -} - /******************************************************************************* * * * Property sheet * diff --git a/api/src/test/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTableTest.java b/api/src/test/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTableTest.java index 2f972d502..b21c5f45f 100644 --- a/api/src/test/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTableTest.java +++ b/api/src/test/java/edu/wpi/first/shuffleboard/api/components/SourceTreeTableTest.java @@ -86,7 +86,7 @@ public Object getValue() { } @Override - public Object getValueView() { + public Object getInfo() { return uri; } diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/LeftDrawerController.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/LeftDrawerController.java index 5db215dcd..412649732 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/LeftDrawerController.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/LeftDrawerController.java @@ -6,14 +6,13 @@ import edu.wpi.first.shuffleboard.api.util.FxUtils; import edu.wpi.first.shuffleboard.api.util.StringUtils; import edu.wpi.first.shuffleboard.api.widget.Component; -import edu.wpi.first.shuffleboard.api.widget.Components; import edu.wpi.first.shuffleboard.app.components.InteractiveSourceTree; -import edu.wpi.first.shuffleboard.app.components.WidgetGallery; import edu.wpi.first.shuffleboard.app.plugin.PluginLoader; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; +import javafx.scene.control.ScrollPane; import org.controlsfx.glyphfont.FontAwesome; import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.GlyphFont; @@ -22,7 +21,6 @@ import java.util.Comparator; import java.util.function.Consumer; -import java.util.stream.Collectors; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; @@ -36,7 +34,6 @@ import javafx.geometry.Pos; import javafx.scene.control.Accordion; import javafx.scene.control.Labeled; -import javafx.scene.control.TabPane; import javafx.scene.control.TextField; import javafx.scene.control.TitledPane; import javafx.scene.control.Tooltip; @@ -58,12 +55,10 @@ public final class LeftDrawerController { @FXML private Pane root; @FXML - private TabPane tabs; + private ScrollPane sourceContainer; @FXML private Accordion sourcesAccordion; @FXML - private WidgetGallery widgetGallery; - @FXML private Pane handle; @FXML private Labeled expandContractButton; @@ -81,7 +76,8 @@ private void initialize() { listenToPluginChanges(plugin); setup(plugin); }); - tabs.maxWidthProperty().bind(root.widthProperty().subtract(handle.widthProperty())); + sourceContainer.maxWidthProperty().bind(root.widthProperty().subtract(handle.widthProperty())); + sourceContainer.minWidthProperty().bind(root.widthProperty().subtract(handle.widthProperty())); sourcesAccordion.getPanes().sort(Comparator.comparing(TitledPane::getText)); PluginLoader.getDefault().getKnownPlugins().addListener((ListChangeListener) c -> { while (c.next()) { @@ -173,23 +169,16 @@ private void setup(Plugin plugin) { sourcesAccordion.setExpandedPane(titledPane); } }); - - // Add widgets to the gallery as well - widgetGallery.setWidgets(Components.getDefault().allWidgets().collect(Collectors.toList())); }); } /** - * Removes all traces from a plugin from the left drawer. Source trees will be removed and all widgets - * defined by the plugin will be removed from the gallery. + * Removes all traces from a plugin from the left drawer. */ private void tearDown(Plugin plugin) { // Remove the source panes sourcesAccordion.getPanes().removeAll(sourcePanes.removeAll(plugin)); FXCollections.sort(sourcesAccordion.getPanes(), Comparator.comparing(TitledPane::getText)); - - // Remove widgets from the gallery - widgetGallery.setWidgets(Components.getDefault().allWidgets().collect(Collectors.toList())); } @FXML 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 b56f1ac54..58c41065b 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 @@ -1,5 +1,6 @@ package edu.wpi.first.shuffleboard.app; +import com.google.common.base.Stopwatch; import edu.wpi.first.shuffleboard.api.plugin.Plugin; import edu.wpi.first.shuffleboard.api.prefs.Category; import edu.wpi.first.shuffleboard.api.sources.recording.Recorder; @@ -21,6 +22,7 @@ import edu.wpi.first.shuffleboard.app.sources.recording.Playback; import edu.wpi.first.shuffleboard.app.tab.TabInfoRegistry; +import java.util.concurrent.TimeUnit; import org.fxmisc.easybind.EasyBind; import java.awt.Desktop; @@ -251,7 +253,15 @@ public void load() throws IOException { * @throws IOException if the file could not be read from */ public void load(File saveFile) throws IOException { + var timer = Stopwatch.createStarted(); setDashboard(saveFileHandler.load(saveFile)); + log.info( + "Loaded save file " + + saveFile.getAbsolutePath() + + " in " + + timer.elapsed(TimeUnit.MILLISECONDS) + + " milliseconds" + ); } @FXML diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/TileDropHandler.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/TileDropHandler.java index 25658a4f4..a07dadeca 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/TileDropHandler.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/TileDropHandler.java @@ -71,16 +71,6 @@ public void handle(DragEvent event) { event.consume(); } - // Dragging a widget from the gallery - if (dragboard.hasContent(DataFormats.widgetType) && tile instanceof LayoutTile) { - String widgetType = (String) dragboard.getContent(DataFormats.widgetType); - - dropGalleryWidgetOntoLayout(widgetType, eventPos); - event.consume(); - - return; - } - // Dragging a source from the sources tree if (dragboard.hasContent(DataFormats.source) && tile instanceof LayoutTile) { SourceEntry entry = DeserializationHelper.sourceFromDrag(dragboard.getContent(DataFormats.source)); @@ -136,18 +126,6 @@ private void dropSourceOntoLayout(Layout layout, SourceEntry entry, Point2D scre .ifPresent(w -> add(layout, w, screenPos)); } - /** - * Drops a widget from the gallery onto the tile, if it contains a layout. - * - * @param widgetType the type of the widget that is being dragged - * @param screenPos the screen coordinates where the widget was dropped - */ - private void dropGalleryWidgetOntoLayout(String widgetType, Point2D screenPos) { - Components.getDefault().createWidget(widgetType).ifPresent(widget -> { - add((Layout) tile.getContent(), widget, screenPos); - }); - } - /** * Drops multiple tiles onto the tile, if it contains a layout. * diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneDragHandler.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneDragHandler.java index f8cf19d0e..156ca2ecf 100644 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneDragHandler.java +++ b/app/src/main/java/edu/wpi/first/shuffleboard/app/WidgetPaneDragHandler.java @@ -90,9 +90,6 @@ private void handleDragOver(DragEvent event) { if (dragboard.hasContent(DataFormats.source) && !previewSource(point, dragboard)) { return; } - if (dragboard.hasContent(DataFormats.widgetType) && !previewGalleryWidget(point, dragboard)) { - return; - } if (dragboard.hasContent(DataFormats.tilelessComponent) && !previewTilelessComponent(point, dragboard)) { return; } @@ -116,22 +113,6 @@ private boolean previewTilelessComponent(GridPoint point, Dragboard dragboard) { return true; } - private boolean previewGalleryWidget(GridPoint point, Dragboard dragboard) { - if (!pane.isOpen(point, new TileSize(1, 1), n -> false)) { - // Dragged a widget onto a tile, can't drop - pane.setHighlight(false); - return false; - } - String componentType = (String) dragboard.getContent(DataFormats.widgetType); - if (tilePreviewSize == null) { - Components.getDefault().createComponent(componentType) - .map(pane::sizeOfWidget) - .ifPresent(size -> tilePreviewSize = size); - } - highlightPoint(point); - return true; - } - private boolean previewSource(GridPoint point, Dragboard dragboard) { if (!pane.isOpen(point, new TileSize(1, 1), n -> false)) { // Dragged a source onto a tile, let the tile handle the drag and drop @@ -267,11 +248,6 @@ private void handleDragDropped(DragEvent event) { dropManyTiles((DataFormats.MultipleTileData) dragboard.getContent(DataFormats.multipleTiles), point); } - // Dropping a widget from the gallery - if (dragboard.hasContent(DataFormats.widgetType)) { - dropGalleryWidget((String) dragboard.getContent(DataFormats.widgetType), point); - } - // Dragging a component out of a layout if (dragboard.hasContent(DataFormats.tilelessComponent)) { dropTilelessComponent((TilelessComponentData) dragboard.getContent(DataFormats.tilelessComponent), point); @@ -333,17 +309,6 @@ private boolean canMove(TileLayout layout, int dx, int dy) { && pane.isOpen(origin.add(dx, dy), size, this::ignoreIfDragArtifact); } - private void dropGalleryWidget(String componentType, GridPoint point) { - Components.getDefault().createComponent(componentType).ifPresent(c -> { - TileSize size = pane.sizeOfWidget(c); - if (pane.isOpen(point, size, __ -> false)) { - c.setTitle(componentType); - Tile tile = pane.addComponentToTile(c); - moveTile(tile, point); - } - }); - } - private void dropTilelessComponent(TilelessComponentData data, GridPoint point) { Optional component = Components.getDefault().getByUuid(data.getComponentId()); Optional parent = Components.getDefault().getByUuid(data.getParentId()) diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetGallery.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetGallery.java deleted file mode 100644 index 4d908f77a..000000000 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetGallery.java +++ /dev/null @@ -1,96 +0,0 @@ -package edu.wpi.first.shuffleboard.app.components; - -import edu.wpi.first.shuffleboard.api.sources.DummySource; -import edu.wpi.first.shuffleboard.api.util.TypeUtils; -import edu.wpi.first.shuffleboard.api.widget.Widget; -import edu.wpi.first.shuffleboard.api.widget.WidgetType; - -import java.io.IOException; -import java.util.Collection; - -import javafx.beans.property.Property; -import javafx.beans.property.SimpleObjectProperty; -import javafx.fxml.FXMLLoader; -import javafx.scene.control.Label; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.TilePane; -import javafx.scene.layout.VBox; - -public class WidgetGallery extends TilePane { - /** - * Creates a new WidgetGallery. This loads WidgetGallery.fxml and set up the constructor - */ - public WidgetGallery() { - FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("WidgetGallery.fxml")); - fxmlLoader.setRoot(this); - - try { - fxmlLoader.load(); - } catch (IOException e) { - throw new IllegalStateException("Can't load FXML : " + getClass().getSimpleName(), e); - } - } - - /** - * Add the given widget types to the gallery. - */ - public void setWidgets(Collection widgets) { - clear(); - widgets.stream() - .map(WidgetType::get) - .flatMap(TypeUtils.castStream(Widget.class)) - .peek(widget -> - DummySource.forTypes(widget.getDataTypes()) - .ifPresent(widget::addSource) - ) - .forEach(this::addWidget); - } - - private void addWidget(Widget widget) { - WidgetGalleryItem item = new WidgetGalleryItem(); - item.setWidget(widget); - this.getChildren().add(item); - } - - public void clear() { - getChildren().clear(); - } - - public static class WidgetGalleryItem extends VBox { - - private final Property widget = new SimpleObjectProperty<>(this, "widget", null); - - private WidgetGalleryItem() { - this.getStyleClass().add("item"); - this.widget.addListener((property, oldValue, newWidget) -> { - this.getChildren().clear(); - if (newWidget != null) { - StackPane dragTarget = new StackPane(); - dragTarget.getStyleClass().add("tile"); - dragTarget.getChildren().add(newWidget.getView()); - dragTarget.getChildren().add(new Pane()); - dragTarget.setMaxSize(128, 128); - - this.getChildren().add(dragTarget); - setVgrow(dragTarget, Priority.ALWAYS); - this.getChildren().add(new Label(newWidget.getName())); - } - }); - } - - public void setWidget(Widget widget) { - this.widget.setValue(widget); - } - - public Property getWidgetProperty() { - return widget; - } - - public Widget getWidget() { - return this.widget.getValue(); - } - - } -} diff --git a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetGalleryController.java b/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetGalleryController.java deleted file mode 100644 index 3f4563bad..000000000 --- a/app/src/main/java/edu/wpi/first/shuffleboard/app/components/WidgetGalleryController.java +++ /dev/null @@ -1,39 +0,0 @@ -package edu.wpi.first.shuffleboard.app.components; - -import edu.wpi.first.shuffleboard.api.dnd.DataFormats; - -import java.io.IOException; - -import javafx.collections.ListChangeListener; -import javafx.fxml.FXML; -import javafx.scene.Node; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.Dragboard; -import javafx.scene.input.TransferMode; - -public class WidgetGalleryController { - @FXML - private WidgetGallery root; - - @FXML - private void initialize() throws IOException { - root.getChildren().addListener((ListChangeListener) change -> { - while (change.next()) { - for (Node node : change.getAddedSubList()) { - if (node instanceof WidgetGallery.WidgetGalleryItem) { - WidgetGallery.WidgetGalleryItem galleryItem = (WidgetGallery.WidgetGalleryItem) node; - galleryItem.setOnDragDetected(event -> { - Dragboard dragboard = galleryItem.startDragAndDrop(TransferMode.COPY); - - // TODO type safety - ClipboardContent clipboard = new ClipboardContent(); - clipboard.put(DataFormats.widgetType, galleryItem.getWidget().getName()); - dragboard.setContent(clipboard); - event.consume(); - }); - } - } - } - }); - } -} diff --git a/app/src/main/resources/edu/wpi/first/shuffleboard/app/LeftDrawer.fxml b/app/src/main/resources/edu/wpi/first/shuffleboard/app/LeftDrawer.fxml index 7eaa17919..d387a6621 100644 --- a/app/src/main/resources/edu/wpi/first/shuffleboard/app/LeftDrawer.fxml +++ b/app/src/main/resources/edu/wpi/first/shuffleboard/app/LeftDrawer.fxml @@ -1,10 +1,7 @@ - - - @@ -13,18 +10,9 @@ fx:controller="edu.wpi.first.shuffleboard.app.LeftDrawerController" maxWidth="800" fx:id="root" styleClass="drawer"> - - - - - - - - - - - - + + + diff --git a/app/src/main/resources/edu/wpi/first/shuffleboard/app/MainWindow.fxml b/app/src/main/resources/edu/wpi/first/shuffleboard/app/MainWindow.fxml index 08a6763ed..206624509 100644 --- a/app/src/main/resources/edu/wpi/first/shuffleboard/app/MainWindow.fxml +++ b/app/src/main/resources/edu/wpi/first/shuffleboard/app/MainWindow.fxml @@ -42,7 +42,8 @@ - + + diff --git a/app/src/main/resources/edu/wpi/first/shuffleboard/app/components/WidgetGallery.fxml b/app/src/main/resources/edu/wpi/first/shuffleboard/app/components/WidgetGallery.fxml deleted file mode 100644 index 5dd72bf98..000000000 --- a/app/src/main/resources/edu/wpi/first/shuffleboard/app/components/WidgetGallery.fxml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/plugins/cameraserver/src/main/java/edu/wpi/first/shuffleboard/plugin/cameraserver/source/CameraServerSourceEntry.java b/plugins/cameraserver/src/main/java/edu/wpi/first/shuffleboard/plugin/cameraserver/source/CameraServerSourceEntry.java index ea53690f0..057e15df6 100644 --- a/plugins/cameraserver/src/main/java/edu/wpi/first/shuffleboard/plugin/cameraserver/source/CameraServerSourceEntry.java +++ b/plugins/cameraserver/src/main/java/edu/wpi/first/shuffleboard/plugin/cameraserver/source/CameraServerSourceEntry.java @@ -26,7 +26,7 @@ public Object getValue() { } @Override - public Object getValueView() { + public Object getInfo() { return null; } diff --git a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/NetworkTablesPlugin.java b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/NetworkTablesPlugin.java index 001e9c4f5..96c311d3b 100644 --- a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/NetworkTablesPlugin.java +++ b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/NetworkTablesPlugin.java @@ -42,7 +42,7 @@ @Description( group = "edu.wpi.first.shuffleboard", name = "NetworkTables", - version = "2.3.2", + version = "2.4.0", summary = "Provides sources and widgets for NetworkTables" ) public class NetworkTablesPlugin extends Plugin { @@ -59,6 +59,7 @@ public class NetworkTablesPlugin extends Plugin { private final RecorderController recorderController; private final ChangeListener dashboardModeChangeListener; + private final ChangeListener recorderChangeListener; private final HostParser hostParser = new HostParser(); @@ -104,6 +105,34 @@ public NetworkTablesPlugin(NetworkTableInstance inst) { } }; + recorderChangeListener = (__, was, isRecording) -> { + if (isRecording) { + // Automatically capture and record changes in network tables + // This is done here because each key under N subtables would have N+1 copies + // in the recording (eg "/a/b/c" has 2 tables and 3 copies: "/a", "/a/b", and "/a/b/c") + // This significantly reduces the size of recording files. + // We only run this listener while recording to avoid unnecessary network traffic + recorderUid = inst.addListener( + new String[]{""}, + EnumSet.of(NetworkTableEvent.Kind.kImmediate, NetworkTableEvent.Kind.kValueAll), + event -> { + Object value = event.valueData.value.getValue(); + String name = NetworkTableUtils.topicNameForEvent(event); + DataTypes.getDefault().forJavaType(value.getClass()) + .ifPresent(type -> { + Recorder.getInstance().record( + NetworkTableSourceType.getInstance().toUri(name), + type, + value + ); + }); + }); + } else { + // No longer running, remove the NT listener + inst.removeListener(recorderUid); + } + }; + serverChangeListener = (observable, oldValue, newValue) -> { var hostInfoOpt = hostParser.parse(newValue); @@ -131,25 +160,7 @@ public void onLoad() { PreferencesUtils.read(serverId, preferences); serverId.addListener(serverSaver); - // Automatically capture and record changes in network tables - // This is done here because each key under N subtables would have N+1 copies - // in the recording (eg "/a/b/c" has 2 tables and 3 copies: "/a", "/a/b", and "/a/b/c") - // This significantly reduces the size of recording files. - recorderUid = inst.addListener( - new String[] {""}, - EnumSet.of(NetworkTableEvent.Kind.kImmediate, NetworkTableEvent.Kind.kValueAll), - event -> { - Object value = event.valueData.value.getValue(); - String name = NetworkTableUtils.topicNameForEvent(event); - DataTypes.getDefault().forJavaType(value.getClass()) - .ifPresent(type -> { - Recorder.getInstance().record( - NetworkTableSourceType.getInstance().toUri(name), - type, - value - ); - }); - }); + Recorder.getInstance().runningProperty().addListener(recorderChangeListener); DashboardMode.currentModeProperty().addListener(dashboardModeChangeListener); recorderController.start(); @@ -161,6 +172,7 @@ public void onLoad() { @Override public void onUnload() { + Recorder.getInstance().runningProperty().removeListener(recorderChangeListener); DashboardMode.currentModeProperty().removeListener(dashboardModeChangeListener); recorderController.stop(); tabGenerator.stop(); diff --git a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSource.java b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSource.java index 4e958827c..8a006278f 100644 --- a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSource.java +++ b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSource.java @@ -2,6 +2,7 @@ import edu.wpi.first.shuffleboard.api.data.ComplexDataType; import edu.wpi.first.shuffleboard.api.data.DataType; +import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.shuffleboard.api.sources.AbstractDataSource; import edu.wpi.first.shuffleboard.api.sources.DataSource; import edu.wpi.first.shuffleboard.api.sources.SourceType; @@ -71,7 +72,7 @@ protected final void setTableListener(TableListener listener) { } setConnected(true); if (isSingular()) { - singleSub = inst.getTopic(fullTableKey).genericSubscribe(PubSubOption.hidden(true)); + singleSub = inst.getTopic(fullTableKey).genericSubscribe(PubSubOption.hidden(false), PubSubOption.sendAll(true)); listenerUid = inst.addListener( singleSub, EnumSet.of( @@ -91,7 +92,12 @@ protected final void setTableListener(TableListener listener) { } }); } else { - multiSub = new MultiSubscriber(inst, new String[] {fullTableKey}, PubSubOption.hidden(true)); + multiSub = new MultiSubscriber( + inst, + new String[] {fullTableKey}, + PubSubOption.hidden(false), + PubSubOption.sendAll(true) + ); listenerUid = inst.addListener( multiSub, EnumSet.of( @@ -200,8 +206,14 @@ public static DataSource forKey(String fullTableKey) { } if (NetworkTableUtils.rootTable.containsSubTable(key) || key.isEmpty()) { // Composite - return sources.computeIfAbsent(uri, __ -> - new CompositeNetworkTableSource(key, (ComplexDataType) NetworkTableUtils.dataTypeForEntry(key))); + return sources.computeIfAbsent(uri, __ -> { + DataType lookup = NetworkTableUtils.dataTypeForEntry(key); + if (lookup == DataTypes.Unknown) { + // No known data type, fall back to generic map data + lookup = DataTypes.Map; + } + return new CompositeNetworkTableSource<>(key, (ComplexDataType) lookup); + }); } return DataSource.none(); } diff --git a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceEntry.java b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceEntry.java index 72a00caf4..a425f8253 100644 --- a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceEntry.java +++ b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceEntry.java @@ -84,7 +84,7 @@ public String getViewName() { } @Override - public String getValueView() { + public String getInfo() { return displayString; } diff --git a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceType.java b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceType.java index bc7a3d7b8..5bd9a5078 100644 --- a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceType.java +++ b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/sources/NetworkTableSourceType.java @@ -1,5 +1,7 @@ package edu.wpi.first.shuffleboard.plugin.networktables.sources; +import edu.wpi.first.networktables.Topic; +import edu.wpi.first.networktables.TopicInfo; import edu.wpi.first.shuffleboard.api.data.DataType; import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.shuffleboard.api.sources.ConnectionStatus; @@ -8,6 +10,7 @@ import edu.wpi.first.shuffleboard.api.sources.Sources; import edu.wpi.first.shuffleboard.api.sources.recording.TimestampedData; import edu.wpi.first.shuffleboard.api.util.AsyncUtils; +import edu.wpi.first.shuffleboard.api.util.FxUtils; import edu.wpi.first.shuffleboard.plugin.networktables.NetworkTablesPlugin; import edu.wpi.first.shuffleboard.plugin.networktables.util.NetworkTableUtils; import edu.wpi.first.networktables.MultiSubscriber; @@ -17,9 +20,13 @@ import edu.wpi.first.networktables.NetworkTableInstance; import edu.wpi.first.networktables.PubSubOption; +import com.google.gson.Gson; + import java.util.EnumSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -31,9 +38,11 @@ public final class NetworkTableSourceType extends SourceType implements AutoClos private final ObservableList availableSourceIds = FXCollections.observableArrayList(); private final ObservableMap availableSources = FXCollections.observableHashMap(); + /** Maps source URIs to the last known data type. */ + private final Map availableDataTypes = new HashMap<>(); private final NetworkTablesPlugin plugin; private final MultiSubscriber subscriber; - private final int listener; + private final int topicListener; @SuppressWarnings("JavadocMethod") public NetworkTableSourceType(NetworkTablesPlugin plugin) { @@ -44,44 +53,56 @@ public NetworkTableSourceType(NetworkTablesPlugin plugin) { plugin.serverIdProperty().addListener((__, old, serverId) -> setConnectionStatus(serverId, false)); inst.addConnectionListener(true, event -> setConnectionStatus(plugin.getServerId(), event.is(NetworkTableEvent.Kind.kConnected))); - subscriber = new MultiSubscriber(inst, new String[] {""}, PubSubOption.hidden(true)); - listener = inst.addListener( + + inst.addConnectionListener(true, event -> { + AsyncUtils.runAsync(() -> { + if (event.is(NetworkTableEvent.Kind.kDisconnected)) { + // Explicitly clear everything on disconnect. + // Topics that are written to by the dashboard are cached locally and do not receive an + // "unpublish" event from the server, so the topic listener would never fire to remove + // their associated data sources and widgets would still be controllable when the server + // shuts down. + availableSourceIds.clear(); + availableSources.clear(); + availableDataTypes.clear(); + } else if (event.is(NetworkTableEvent.Kind.kConnected)) { + // For every topic that's still retained by the client, create a fake event to regenerate + // the sources. We only subscribe to the available topics, not their values, so because + // the server will not send a "publish" event for a topic that is already cached in + // shuffleboard, the topic-only subscribe is never triggered for these topics and we would + // incorrectly assume they've gone away. + for (Topic topic : inst.getTopics()) { + handleEvent( + new NetworkTableEvent( + inst, + 0, + 0x0080 | 0x0008, // local, publish + null, + topic.getInfo(), + null, + null, + null)); + } + } + }); + }); + + subscriber = new MultiSubscriber(inst, new String[] {""}, PubSubOption.topicsOnly(true)); + topicListener = inst.addListener( subscriber, EnumSet.of( NetworkTableEvent.Kind.kImmediate, - NetworkTableEvent.Kind.kTopic, - NetworkTableEvent.Kind.kValueAll), - event -> { - AsyncUtils.runAsync(() -> { - final boolean delete = event.is(NetworkTableEvent.Kind.kUnpublish); - final String name = NetworkTableUtils.topicNameForEvent(event); - List hierarchy = NetworkTable.getHierarchy(name); - for (int i = 0; i < hierarchy.size(); i++) { - String uri = toUri(hierarchy.get(i)); - if (i == hierarchy.size() - 1) { - if (delete) { - availableSources.remove(uri); - Sources sources = Sources.getDefault(); - sources.get(uri).ifPresent(sources::unregister); - NetworkTableSource.removeCachedSource(uri); - } else if (event.valueData != null) { - availableSources.put(uri, event.valueData.value.getValue()); - } - } - if (delete) { - availableSourceIds.remove(uri); - } else if (!availableSourceIds.contains(uri)) { - availableSourceIds.add(uri); - } - } - }); - }); + NetworkTableEvent.Kind.kTopic), + event -> AsyncUtils.runAsync(() -> handleEvent(event))); } @Override public void close() { subscriber.close(); - NetworkTableInstance.getDefault().removeListener(listener); + NetworkTableInstance.getDefault().removeListener(topicListener); + availableSources.clear(); + availableDataTypes.clear(); + availableSourceIds.clear(); } private void setConnectionStatus(String serverId, boolean connected) { @@ -110,13 +131,21 @@ public static NetworkTableSourceType getInstance() { @Override public void read(TimestampedData recordedData) { - super.read(recordedData); - final String fullKey = removeProtocol(recordedData.getSourceId()); - NetworkTableEntry entry = NetworkTableInstance.getDefault().getEntry(fullKey); - entry.setValue(recordedData.getData()); - if (!entry.getTopic().isRetained()) { - entry.getTopic().setRetained(true); - } + FxUtils.runOnFxThread(() -> { + // Add the data point to the set of available sources + // Note: recorded data is the individual topics, not complex data + if (!availableSourceIds.contains(recordedData.getSourceId())) { + availableSourceIds.add(recordedData.getSourceId()); + availableSources.put(recordedData.getSourceId(), recordedData.getDataType().getName()); + } + + final String fullKey = removeProtocol(recordedData.getSourceId()); + NetworkTableEntry entry = NetworkTableInstance.getDefault().getEntry(fullKey); + entry.setValue(recordedData.getData()); + if (!entry.getTopic().isRetained()) { + entry.getTopic().setRetained(true); + } + }); } @Override @@ -154,7 +183,7 @@ public SourceEntry createSourceEntryForUri(String uri) { @Override public DataType dataTypeForSource(DataTypes registry, String sourceUri) { - return NetworkTableUtils.dataTypeForEntry(removeProtocol(sourceUri)); + return availableDataTypes.getOrDefault(sourceUri, DataTypes.Unknown); } @Override @@ -162,4 +191,49 @@ public String toUri(String sourceName) { return super.toUri(NetworkTable.normalizeKey(sourceName)); } + private void handleEvent(NetworkTableEvent event) { + final boolean delete = event.is(NetworkTableEvent.Kind.kUnpublish); + final TopicInfo topicInfo = event.topicInfo; + if (topicInfo.name.endsWith("/.type") && !delete) { + // Got type metadata for composite data + // Remove trailing "/.type" + var compositeSourceId = toUri(topicInfo.name.substring(0, topicInfo.name.length() - 6)); + var topic = topicInfo.getTopic(); + var typeNameJson = topic.getProperty("SmartDashboard"); + if ("null".equals(typeNameJson)) { + // Metadata property hasn't been set, fall back to use the generic map data type + availableDataTypes.put(compositeSourceId, DataTypes.Map); + } else { + var typeName = new Gson().fromJson(typeNameJson, String.class); + var dataType = DataTypes.getDefault().forName(typeName).orElse(DataTypes.Map); + availableDataTypes.put(compositeSourceId, dataType); + } + } + + final String name = NetworkTableUtils.topicNameForEvent(event); + List hierarchy = NetworkTable.getHierarchy(name); + for (int i = 0; i < hierarchy.size(); i++) { + String uri = toUri(hierarchy.get(i)); + if (i == hierarchy.size() - 1) { + // The full key + if (delete) { + availableSources.remove(uri); + availableDataTypes.remove(uri); + Sources sources = Sources.getDefault(); + sources.get(uri).ifPresent(sources::unregister); + NetworkTableSource.removeCachedSource(uri); + } else { + var dataType = NetworkTableUtils.dataTypeForTypeString(topicInfo.typeStr); + availableSources.put(uri, dataType.getName()); + availableDataTypes.put(uri, dataType); + } + } + if (delete) { + availableSourceIds.remove(uri); + availableDataTypes.remove(uri); + } else if (!availableSourceIds.contains(uri)) { + availableSourceIds.add(uri); + } + } + } } diff --git a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/util/NetworkTableUtils.java b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/util/NetworkTableUtils.java index 6cc613f08..714af0527 100644 --- a/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/util/NetworkTableUtils.java +++ b/plugins/networktables/src/main/java/edu/wpi/first/shuffleboard/plugin/networktables/util/NetworkTableUtils.java @@ -1,11 +1,15 @@ package edu.wpi.first.shuffleboard.plugin.networktables.util; +import edu.wpi.first.networktables.GenericEntry; +import edu.wpi.first.networktables.PubSubOption; import edu.wpi.first.shuffleboard.api.data.DataType; import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.networktables.NetworkTable; import edu.wpi.first.networktables.NetworkTableEvent; import edu.wpi.first.networktables.NetworkTableInstance; +import edu.wpi.first.shuffleboard.api.util.StringUtils; +import edu.wpi.first.shuffleboard.plugin.networktables.sources.NetworkTableSourceType; /** * Utility class for working with network tables. @@ -74,31 +78,8 @@ public static DataType dataTypeForTypeString(String typeString) { * @return the data type most closely associated with the given key */ public static DataType dataTypeForEntry(String key) { - String normalKey = NetworkTable.normalizeKey(key, false); - if (normalKey.isEmpty() || "/".equals(normalKey)) { - return DataTypes.Map; - } - if (rootTable.containsKey(normalKey)) { - return dataTypeForTypeString(rootTable.getTopic(normalKey).getTypeString()); - } - if (rootTable.containsSubTable(normalKey)) { - NetworkTable table = rootTable.getSubTable(normalKey); - String type; - if (table.containsKey("~TYPE~")) { - type = table.getEntry("~TYPE~").getString(null); - } else if (table.containsKey(".type")) { - type = table.getEntry(".type").getString(null); - } else { - return DataTypes.Map; - } - if (type == null) { - return DataTypes.Map; - } else { - return DataTypes.getDefault().forName(type) - .orElse(DataTypes.Map); - } - } - return null; + var networkTableSourceType = NetworkTableSourceType.getInstance(); + return networkTableSourceType.dataTypeForSource(DataTypes.getDefault(), networkTableSourceType.toUri(key)); } /**