Skip to content

Commit

Permalink
Merge pull request #4 from petebankhead/tour-refactored
Browse files Browse the repository at this point in the history
Tour refactored
  • Loading branch information
petebankhead authored Oct 5, 2024
2 parents 6df06be + fc3cddf commit d70f9bf
Show file tree
Hide file tree
Showing 13 changed files with 782 additions and 460 deletions.
70 changes: 20 additions & 50 deletions src/main/java/qupath/ext/training/ui/tour/GuiTourCommand.java
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
package qupath.ext.training.ui.tour;

import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.MenuButton;
import javafx.scene.control.Pagination;
import javafx.scene.control.Slider;
import javafx.scene.control.Tab;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.controlsfx.control.action.Action;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import qupath.fx.controls.tour.GuiTour;
import qupath.fx.controls.tour.TourItem;
import qupath.lib.gui.QuPathGUI;
import qupath.lib.gui.viewer.tools.PathTools;

import java.util.List;
import java.util.ResourceBundle;

/**
* A command to run a tour of the QuPath user interface.
Expand All @@ -29,26 +30,31 @@ public class GuiTourCommand implements Runnable {

private static final Logger logger = LoggerFactory.getLogger(GuiTourCommand.class);

private static final ResourceBundle resources = ResourceBundle.getBundle("qupath.ext.training.ui.tour");

private final QuPathGUI qupath;

private ObservableList<TourItem> items;
private Pagination pagination;
private GuiTour tour;
private Stage stage;

private GuiHighlight highlight;

public GuiTourCommand(QuPathGUI qupath) {
this.qupath = qupath;
}

private void initialize() {
this.items = createItems(qupath);
this.pagination = createPagination();
this.tour = new GuiTour();
var items = createItems(qupath);
this.tour.getItems().setAll(items);
this.stage = createStage();
this.highlight = new GuiHighlight(qupath.getStage());
}


/**
* Create all the items for the main tour of the QuPath GUI.
* We have to do a bit of work to find the UI components, since they weren't written with this in mind...
* @param qupath
* @return
*/
private ObservableList<TourItem> createItems(QuPathGUI qupath) {
return FXCollections.observableArrayList(
createItem(
Expand Down Expand Up @@ -191,39 +197,15 @@ private ObservableList<TourItem> createItems(QuPathGUI qupath) {
);
}

private Pagination createPagination() {
var pagination = new Pagination();
pagination.pageCountProperty().bind(Bindings.size(items));
pagination.setPageFactory(this::createPage);
return pagination;
}

private Node createPage(int pageIndex) {
var item = items.get(pageIndex);
// It's important to highlight first, otherwise nodes might not
// be visible, and dynamic screenshots don't work
var nodesToHighlight = item.getNodes();
if (!nodesToHighlight.isEmpty()) {
highlightNodes(nodesToHighlight);
Platform.runLater(() -> {
stage.requestFocus();
});
}
return TourUtils.createPage(item);
}

private Stage createStage() {
var stage = new Stage();
stage.initOwner(qupath.getStage());
stage.initModality(Modality.NONE);
stage.setAlwaysOnTop(true); // It'll also be on top of other applications!
stage.setTitle(TourResources.getString("title"));
var scene = new Scene(pagination);
stage.setTitle(resources.getString("title"));
var scene = new Scene(tour);
stage.setScene(scene);
stage.setOnCloseRequest(e -> {
if (highlight != null)
highlight.hide();
});
return stage;
}

Expand All @@ -250,7 +232,7 @@ TourItem createToolbarItem(String key, Action... actions) {
.stream()
.filter(node -> containsActionProperty(node, actions))
.toList();
return TourItem.create(key, items);
return MarkdownTourItem.create(resources, key, items);
}

/**
Expand All @@ -267,7 +249,7 @@ TourItem createTabPaneItem(String key, String tabName) {
.filter(tab -> tabName.equals(tab.getText()))
.map(Tab::getContent)
.toList();
return TourItem.create(key, items);
return MarkdownTourItem.create(resources, key, items);
}

/**
Expand All @@ -277,7 +259,7 @@ TourItem createTabPaneItem(String key, String tabName) {
* @return
*/
private static TourItem createItem(String key, Node... nodes) {
return TourItem.create(key, List.of(nodes));
return MarkdownTourItem.create(resources, key, List.of(nodes));
}

/**
Expand All @@ -294,16 +276,4 @@ private static boolean containsActionProperty(Node node, Action... actions) {
return false;
}

/**
* Highlight one or more nodes.
* @param nodes
*/
private void highlightNodes(List<? extends Node> nodes) {
if (!Platform.isFxApplicationThread()) {
Platform.runLater(() -> highlightNodes(nodes));
return;
}
highlight.highlightNodes(nodes);
}

}
210 changes: 210 additions & 0 deletions src/main/java/qupath/ext/training/ui/tour/MarkdownTourItem.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package qupath.ext.training.ui.tour;

import javafx.application.Platform;
import javafx.geometry.Bounds;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.image.Image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import qupath.fx.controls.tour.TourItem;
import qupath.fx.controls.tour.TourUtils;
import qupath.fx.utils.FXUtils;
import qupath.lib.gui.tools.WebViews;

import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.ResourceBundle;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
* An implementation of {@link TourItem} that uses resource bundles, markdown and WebViews to display content.
* <p>
* This implementation takes a resource bundle key, and uses it to look up the title and text to display.
* The text is assumed to be markdown, and is rendered as HTML.
* <p>
* The item can also provide an image, which can optionally be generated on demand.
*/
public class MarkdownTourItem implements TourItem {

private static final Logger logger = LoggerFactory.getLogger(MarkdownTourItem.class);

private final ResourceBundle bundle;
private String key;
private Supplier<Image> imageSupplier;
private List<Node> nodes;

private MarkdownTourItem(ResourceBundle bundle, String key, Collection<? extends Node> nodes, Supplier<Image> imageSupplier) {
this.bundle = bundle;
this.key = key;
this.nodes = nodes == null ? Collections.emptyList() : List.copyOf(nodes);
this.imageSupplier = imageSupplier;
}

private MarkdownTourItem(ResourceBundle bundle, String key, Collection<? extends Node> nodes) {
this(bundle, key, nodes, null);
this.imageSupplier = this::createScaledSnapshot;
}

/**
* Create a new tour item.
* If nodes are provided, this will lazily generate a snapshot image of the nodes for display.
* @param bundle the resource bundle to use
* @param key the resource bundle key for the item
* @param nodes the nodes to highlight; may be null, if no nodes should be highlighted
* @return the new tour item
*/
public static TourItem create(ResourceBundle bundle, String key, Collection<? extends Node> nodes) {
return new MarkdownTourItem(bundle, key, nodes);
}

/**
* Create a new tour item with a specific (static) image.
* @param bundle the resource bundle to use
* @param key the resource bundle key for the item
* @param nodes the nodes to highlight; may be null, if no nodes should be highlighted
* @param image the image to display; may be null, if no image should be used
* @return the new tour item
*/
public static TourItem createWithImage(ResourceBundle bundle, String key, Collection<? extends Node> nodes, Image image) {
return createWithImage(bundle, key, nodes, () -> image);
}

/**
* Create a new tour item with a lazily-generated image.
* @param bundle the resource bundle to use
* @param key the resource bundle key for the item
* @param nodes the nodes to highlight; may be null, if no nodes should be highlighted
* @param imageSupplier the supplier that generates the image to display; may be null, if no image should be used
* @return the new tour item
*/
public static TourItem createWithImage(ResourceBundle bundle, String key, Collection<? extends Node> nodes, Supplier<Image> imageSupplier) {
return new MarkdownTourItem(bundle, key, nodes, imageSupplier);
}

/**
* Get the title to display.
* @return
*/
@Override
public String getTitle() {
var titleKey = key + ".title";
return bundle.getString(titleKey);
}

/**
* Get the main text to display, formatted as markdown.
* @return
*/
public String getText() {
var textKey = key + ".text";
// We treat all resources with keys starting key.text as distinct paragraphs,
// sorted by length.
// We also check for keys starting with key.text.tip, key.text.info, key.text.caution,
// and format them as blockquotes.
return bundle.keySet()
.stream()
.filter(k -> k.startsWith(textKey))
.sorted(Comparator.comparingInt(String::length))
.map(this::getUpdatedString)
.collect(Collectors.joining("\n\n"));
}


private String getUpdatedString(String key) {
var s = bundle.getString(key);
if (key.contains(".text.tip"))
return "> **Tip:** " + s.replaceAll("\n", "\n> ");
if (key.contains(".text.info"))
return "> **Info:** " + s.replaceAll("\n", "\n> ");
if (key.contains(".text.caution"))
return "> **Caution:** " + s.replaceAll("\n", "\n> ");
return s;
}


/**
* Get a static image to display, or null if no static image is stored.
* @return
*/
public Image getImage() {
return imageSupplier == null ? null : imageSupplier.get();
}

/**
* Get an unmodifiable list of nodes to display.
* @return
*/
@Override
public List<Node> getHighlightNodes() {
return nodes;
}

@Override
public Node createPage() {
var webview = WebViews.create(true);
Platform.runLater(() -> {
var html = MarkdownUtils.createHtml(getTitle(), getText(), getImage());
webview.getEngine().loadContent(html);
});
return webview;
}

@Override
public String toString() {
return "TourItem[" + getTitle() + "]";
}

private Image createScaledSnapshot() {
return createScaledSnapshot(getHighlightNodes());
}

/**
* Create a snapshot of one or more nodes.
* This may be rescaled, so that a higher resolution image is returned for smaller nodes.
* @param nodes
* @return the snapshot image, or null if no nodes are provided
*/
private static Image createScaledSnapshot(List<? extends Node> nodes) {
if (nodes.isEmpty())
return null;
var firstNode = nodes.getFirst();
if (nodes.size() == 1) {
double scale = computeScaleFromBounds(firstNode.getLayoutBounds());
return TourUtils.createScaledSnapshot(firstNode, scale);
}
var window = FXUtils.getWindow(firstNode);
if (window != null) {
var bounds = TourUtils.computeScreenBounds(nodes);
double scale = computeScaleFromBounds(bounds);
double pad = 1;
var rect = new Rectangle2D(
bounds.getMinX()-pad,
bounds.getMinY()-pad,
bounds.getWidth()+pad*2,
bounds.getHeight()+pad*2);
return TourUtils.createScaledSnapshot(window, rect, scale);
} else {
return null;
}
}

/**
* Compute scale from a bounds object; this is used to have smaller items
* (e.g. buttons) at a higher resolution.
* @param bounds
* @return
*/
private static double computeScaleFromBounds(Bounds bounds) {
if (bounds == null)
return 1.0;
double minDim = Math.min(bounds.getWidth(), bounds.getHeight());
if (minDim < 128)
return 2.0;
return 1.0;
}

}
Loading

0 comments on commit d70f9bf

Please sign in to comment.