package com.megatim.manuel.core.viewer;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.megatim.manuel.core.generate.HelpExporter;
import com.megatim.manuel.core.generate.HelpManifest;
import com.megatim.manuel.core.generate.HelpToc;
import com.megatim.manuel.core.theme.ManuelTheme;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.control.SplitPane;
import javafx.scene.control.TextField;
import javafx.scene.control.ToolBar;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.web.WebHistory;
import javafx.scene.web.WebView;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Viewer du manuel JavaFX : affiche un dossier d'aide HTML (décrit par toc.json) avec
* sommaire arborescent, recherche, navigation et zoom.
*
*
Composant embarquable : {@link #build()} retourne un {@link Parent} à poser dans
* n'importe quel conteneur. La charte (couleurs, logo, CSS) est portée par le
* {@link ManuelTheme} passé au constructeur (défaut : charte Sirius). Les erreurs
* (dossier ou toc.json absent/illisible) remontent en {@link IOException}, à afficher
* par l'application hôte.
*/
public class HelpViewer {
private final Path folder;
private final ManuelTheme theme;
private HelpManifest manifest;
private final WebView web = new WebView();
private final TreeView tree = new TreeView<>();
private final TextField search = new TextField();
private final Label status = new Label();
private double zoom = 1.0;
private final Label zoomLabel = new Label("100 %");
/** Viewer au thème par défaut (charte Sirius). */
public HelpViewer(Path folder) {
this(folder, ManuelTheme.defaultTheme());
}
public HelpViewer(Path folder, ManuelTheme theme) {
this.folder = folder;
this.theme = theme;
}
public String title() {
if (theme.getAppTitle() != null && !theme.getAppTitle().isBlank()) {
StringBuilder t = new StringBuilder(theme.getAppTitle().trim());
if (manifest != null && manifest.version != null && !manifest.version.isBlank()) {
t.append(" — v").append(manifest.version.trim());
}
return t.toString();
}
if (manifest == null) return "Aide";
StringBuilder t = new StringBuilder();
if (manifest.project != null && !manifest.project.isBlank()) t.append(manifest.project.trim());
else if (manifest.title != null) t.append(manifest.title);
else t.append("Aide");
if (manifest.version != null && !manifest.version.isBlank()) t.append(" — v").append(manifest.version.trim());
return t.toString();
}
public Parent build() throws IOException {
Path tocFile = folder.resolve(HelpExporter.TOC_JSON);
if (!Files.exists(tocFile)) {
throw new IOException("Dossier d'aide invalide : " + HelpExporter.TOC_JSON + " introuvable dans " + folder);
}
String json = new String(Files.readAllBytes(tocFile), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
manifest = mapper.readValue(json, HelpManifest.class);
if (manifest == null) throw new IOException("toc.json illisible.");
// --- Sommaire ---
tree.setShowRoot(false);
tree.setCellFactory(tv -> new TreeCell<>() {
@Override protected void updateItem(HelpToc n, boolean empty) {
super.updateItem(n, empty);
setText(empty || n == null ? null : n.title);
}
});
tree.setRoot(buildTreeItems(manifest.nodes));
tree.getSelectionModel().selectedItemProperty().addListener((o, a, b) -> {
if (b != null && b.getValue() != null && b.getValue().href != null) load(b.getValue().href);
});
search.setPromptText("Rechercher dans le sommaire…");
search.textProperty().addListener((o, a, q) -> tree.setRoot(buildTreeItems(filter(manifest.nodes, q))));
// En-tête : logo éventuel + titre (thème ou projet du manifest) + version
VBox header = new VBox(1);
String headTitle = theme.getAppTitle() != null && !theme.getAppTitle().isBlank()
? theme.getAppTitle().trim()
: (manifest.project != null && !manifest.project.isBlank() ? manifest.project.trim() : null);
if (headTitle != null) {
Label proj = new Label(headTitle);
proj.getStyleClass().add("help-project-title");
proj.setWrapText(true);
if (theme.getLogo() != null) {
ImageView logo = new ImageView(new Image(theme.getLogo().toExternalForm(), 24, 24, true, true));
proj.setGraphic(logo);
}
header.getChildren().add(proj);
}
if (manifest.version != null && !manifest.version.isBlank()) {
Label ver = new Label("Version " + manifest.version.trim());
ver.getStyleClass().add("help-project-version");
header.getChildren().add(ver);
}
VBox left = new VBox(6);
left.getStyleClass().add("help-left-panel");
if (!header.getChildren().isEmpty()) {
left.getChildren().addAll(header, new Separator());
}
Label sommaireTitle = new Label("Sommaire");
sommaireTitle.getStyleClass().add("help-panel-title");
left.getChildren().addAll(sommaireTitle, search, tree);
left.setPadding(new Insets(8));
left.setPrefWidth(330);
VBox.setVgrow(tree, Priority.ALWAYS);
// --- Barre d'outils : navigation dans l'historique + accueil + zoom ---
Button back = new Button("Précédent");
Button fwd = new Button("Suivant");
Button home = new Button("Accueil");
home.getStyleClass().add("button-primary");
back.setTooltip(new Tooltip("Revenir à la page consultée précédemment"));
fwd.setTooltip(new Tooltip("Avancer vers la page suivante de l'historique"));
home.setTooltip(new Tooltip("Revenir à la page d'accueil du manuel"));
WebHistory history = web.getEngine().getHistory();
back.setOnAction(e -> history.go(-1));
fwd.setOnAction(e -> history.go(1));
home.setOnAction(e -> { if (manifest.home != null) load(manifest.home); });
// Grises tant qu'il n'y a rien avant/apres dans l'historique
back.disableProperty().bind(history.currentIndexProperty().lessThanOrEqualTo(0));
fwd.disableProperty().bind(Bindings.createBooleanBinding(
() -> history.getCurrentIndex() >= history.getEntries().size() - 1,
history.currentIndexProperty(), history.getEntries()));
Button zMinus = new Button("A-");
Button zPlus = new Button("A+");
Button zReset = new Button("100 %");
zMinus.setTooltip(new Tooltip("Réduire la taille du texte"));
zPlus.setTooltip(new Tooltip("Augmenter la taille du texte"));
zReset.setTooltip(new Tooltip("Rétablir la taille par défaut (100 %)"));
zMinus.setOnAction(e -> setZoom(zoom - 0.1));
zPlus.setOnAction(e -> setZoom(zoom + 0.1));
zReset.setOnAction(e -> setZoom(1.0));
Region sp = new Region();
HBox.setHgrow(sp, Priority.ALWAYS);
ToolBar bar = new ToolBar(back, fwd, home, sp, new Label("Zoom :"), zMinus, zoomLabel, zPlus, zReset);
web.setZoom(zoom);
VBox right = new VBox(bar, web);
VBox.setVgrow(web, Priority.ALWAYS);
SplitPane split = new SplitPane(left, right);
split.setDividerPositions(0.30);
SplitPane.setResizableWithParent(left, false);
BorderPane root = new BorderPane(split);
status.getStyleClass().add("help-status");
status.setPadding(new Insets(3, 8, 3, 8));
root.setBottom(status);
// Charte : looked-up colors + feuilles de style du theme
root.getStyleClass().add("help-viewer");
theme.applyTo(root);
if (manifest.home != null) load(manifest.home);
selectFirst();
return root;
}
private void setZoom(double z) {
zoom = Math.max(0.5, Math.min(3.0, z));
web.setZoom(zoom);
zoomLabel.setText(Math.round(zoom * 100) + " %");
}
private void load(String href) {
if (href == null) return;
String file = href;
String frag = null;
int h = href.indexOf('#');
if (h >= 0) { file = href.substring(0, h); frag = href.substring(h + 1); }
String uri = folder.resolve(file).toUri().toString();
if (frag != null && !frag.isEmpty()) uri += "#" + frag;
web.getEngine().load(uri);
status.setText(href);
}
private void selectFirst() {
if (!tree.getRoot().getChildren().isEmpty()) {
tree.getSelectionModel().select(tree.getRoot().getChildren().get(0));
}
}
private TreeItem buildTreeItems(List nodes) {
TreeItem root = new TreeItem<>(new HelpToc("root", null));
addChildren(root, nodes, 0);
return root;
}
private void addChildren(TreeItem parent, List nodes, int depth) {
if (nodes == null) return;
for (HelpToc n : nodes) {
TreeItem item = new TreeItem<>(n);
// Document déplié ; petits dossiers (Préambule…) dépliés ; gros (Sommaire/Illustrations) repliés
int kids = n.children == null ? 0 : n.children.size();
item.setExpanded(depth == 0 || (depth == 1 && kids > 0 && kids <= 8));
addChildren(item, n.children, depth + 1);
parent.getChildren().add(item);
}
}
/** Filtre l'arbre : garde un nœud s'il correspond OU s'il a un descendant correspondant. */
private List filter(List nodes, String query) {
if (query == null || query.isBlank()) return nodes;
String q = query.toLowerCase();
List out = new ArrayList<>();
for (HelpToc n : nodes) {
List kids = filter(n.children, query);
boolean self = n.title != null && n.title.toLowerCase().contains(q);
if (self || !kids.isEmpty()) {
HelpToc copy = new HelpToc(n.title, n.href);
copy.children = kids;
out.add(copy);
}
}
return out;
}
}