diff --git a/.jvn/announcements.md b/.jvn/announcements.md deleted file mode 100644 index cfaa2b6a..00000000 --- a/.jvn/announcements.md +++ /dev/null @@ -1,87 +0,0 @@ -# JVN Engine Hub Announcements - -Format: - - ## YYYY-MM-DD — Short title - Body text describing what changed. Multiple paragraphs are fine; blank - lines separate them. The block ends at the next "## " header or end of file. - ----- - -## 2026-06-09 — Fix about layered image parsing -The Layered Image Visualizer now infers group and variant labels regardless of token order in the filename. Files named like `normal_mouth.png` and `mouth_normal.png` should now both correctly map to the `mouth` group with the `normal` variant, preventing issues where groups are mistakenly parsed as variants when file naming conventions are flipped. - -## 2026-06-03 -The storyboard overlay is now out of maintenance. Over the span of the next few days, there will be more updates on it. - -## 2026-05-28 — Version control graph view -The Version Control utility now has a Changes/Graph view switch. Changes keeps the existing changed-file workflow, while Graph shows a VS Code-style visual history with commit dots, branch lanes, commit messages, authors, and branch chips. - -The version-control loading state now consumes the full utility area instead of appearing as a separate rounded window, making repository initialization and first status reads feel like one blocking editor workflow. - -## 2026-05-24 — Guided version control -The Version Control panel now highlights the next useful action and shows persistent arrow callouts based on the project’s Git state. It guides users through setup, missing Git, repository initialization, remote setup, authentication/network failures, conflicts, incoming changes, local edits, unpushed commits, and clean up-to-date states. Callouts stay visible until dismissed, and first-time repository checks now show an initializing overlay while Git status is loading. - -## 2026-05-22 — Changes to expression transition - -Expression swaps in VNS now use a short renderer-side crossfade by default, preserving timeline offsets, mirrored poses, detached character placement, and layer order while the new expression appears. Use `[char hero expression neutral dur=0]` when an instant swap is preferred. - - -## 2026-05-22 — Puppeteer layout controls -Puppeteer's top toolbar can now be resized, collapsed, and restored from the workspace or View menu, and the focused preview/keyframe split remembers its vertical layout between sessions. - -## 2026-05-21 — VNS editor navigation polish -Large pasted `timeline { ... }` blocks are easier to work with in the VNS editor. Timeline folding now has a compact in-editor navigator while scrolling long blocks, showing the current timeline number, line range, progress, and target summary with quick Top/Bottom jumps. - -The VNS minimap is now JVN-specific instead of a tiny text clone: it highlights labels, timeline blocks, dialogue beats, choices, commands, diagnostics, bookmarks, and the current viewport as a script map. - -## 2026-05-13 — Puppeteer persistence and charpreset exports -Puppeteer now preserves more editor-side animation state when saving and reopening timelines, including group locks, constraints, named anchors, and orbit-anchor tooling data. Parent-child constraints also respect grouped parent transforms more reliably. - -The Layered Image Visualizer export panel now puts the runtime-ready `@charlayer` + `@charpreset` workflow up front, with direct copy and `.vns` snippet export actions alongside PNG and editor-only `.layersetup` export. - -Developer Mode from the Engine Hub now gives the editor and launcher a collapsed top `Logs` panel for quick log-file viewing, plus launcher-to-editor Developer Mode handoff and child-process log capture. - -Developer Mode also adds a `DevTools` menu to the editor and launcher with runtime diagnostics, manual GC, log refresh, editor heap configuration for launcher-started editor sessions, and a capture-output toggle. - -The Version Control sidebar now uses clearer snapshot/sync language, checks for incoming remote work, highlights the next recommended action, adds more helpful tooltips and colored buttons, and refreshes status reliably after operations. - -The Version Control changes list now supports multi-select and shift-select ranges, so staging, unstaging, discarding, and diff inspection can be applied to several files at once. - -The runtime console toolbar now uses clearer colored JavaFX icons for run, stop, clear, and copy actions. - -The build wrapper now exposes faster daily workflow commands: `./jvnw compile`, `./jvnw quick`, and `./jvnw build-info`, with matching Gradle tasks for compile-only checks, a focused verification slice, and build environment diagnostics. - -The Build & Publish quick-mode presets now use the same animated orange selected arrow from the launcher project list, making the active release flow easier to spot at a glance. - -The editor workspace welcome chip now has a more professional editor-dashboard layout with a status pill, workspace/project context, entry readiness, script/asset counts, runtime version, and last-modified context. - -## 2026-05-10 — Puppeteer group anchors and constraint removal -The Puppeteer Anchors window now supports group entities. When a group is selected in the Entities tab, it appears in the Anchors window with a "[Group]" prefix and orange color indicator. Groups display a placeholder bounding box instead of a sprite image, allowing anchors to be placed on the group's normalized coordinate space. - -## 2026-05-06 — VNS/Java interop improvements -Inline Java error reporting now has accurate line numbers when using `@jimport` or `@bind` directives, and runtime exceptions (NPE, ClassCast, etc.) are also remapped back to the original VNS source line. The error overlay now distinguishes between compilation errors and runtime errors with the correct source location. - -## 2026-05-04 — Video and GIF support for Character Sprites -The JVN engine now officially supports animated `.gif`, `.mp4`, and `.mov` formats for character sprites. -Video playback is fully hardware-accelerated and uses a dynamic texture snapshotting system to ensure that all layer blending modes, color matrices, and z-ordering work nicely. You can freely mix and match these formats within the same `@charpreset`—for example, pairing a looping 3D `.mp4` character body with static `.png` facial expressions. Check out `docs/scripting/vns/presentation/vns-layered-charpresets.md` for more details. - -## 2026-04-27 — Expanded VNS particle ambience presets -The VNS particle preset library now has tuned implementations for `sakura`, `fireflies`, `dust`, and `leaves` instead of routing those preset names through the neutral fallback. - -Use `[particles preset=sakura]`, `[pfx fireflies intensity=0.4]`, `[weather dust opacity=0.5]`, or `[fx leaves wind=-20 tint=#ccdd7722]` to add scene-wide petals, night glows, floating motes, and autumn leaves. The existing shaping options still apply: `intensity`, `layer`, `opacity`, `speed`, `wind`, `duration`, and `tint`. - -Focused preset tests cover the new tuning so these ambience effects keep their scene-sized spawn areas, alpha behavior, wind handling, and blend modes stable. - -## 2026-04-27 — Documentation update covering the engine hub -New documentation has been added regarding the engine hub. You can read engine-hub.md for more information. - -## 2026-04-27 — Particle FX presets for VNS -VNS particle effects now support richer weather controls and render in game through the JavaFX VN renderer. Use `[particles]`, `[weather]`, `[pfx]`, or `[fx]` with presets like `snow` and `rain`, plus shaping options for `intensity`, `layer`, `opacity`, `speed`, `wind`, `duration`, and `tint`. - -Snow and rain now use tuned scene-wide spawn areas, wind drift, opacity/tint scaling, and layer-aware rendering alongside characters and the audio visualizer. Timed effects automatically expire, while stop commands let existing particles fade out instead of disappearing abruptly. - -Editor autocomplete and hover docs were updated for the expanded command surface, and focused parser/runtime tests cover the new options, presets, stop behavior, and duration expiry. - -## 2026-04-27 — Puppeteer GIF export: Reveal in Folder -The "Recording Complete" dialog now has a "Reveal in Folder" action next to Close. One click opens the export directory in your OS file manager. Falls back gracefully on headless or sandboxed environments where desktop integration isn't available. diff --git a/docs/editor/core/engine-hub.md b/docs/editor/core/engine-hub.md index 28c69c09..d6b8a6e2 100644 --- a/docs/editor/core/engine-hub.md +++ b/docs/editor/core/engine-hub.md @@ -165,16 +165,13 @@ The hub exposes the main workspace actions as buttons: The hub shows a compact status strip instead of a terminal-style output panel. Long task output is reduced to simple progress and completion messages. -## Maintenance And Announcements +## Maintenance State -The hub reads dynamic workspace state from committed files under `.jvn/`: +The hub reads launcher maintenance state from the committed `.jvn/maintenance.properties` +file. It currently supports `launcher.maintenance` and `launcher.message`. -| File | Purpose | -|------|---------| -| `.jvn/maintenance.properties` | Feature-level maintenance flags, currently including `launcher.maintenance` and `launcher.message` | -| `.jvn/announcements.md` | Hub announcement cards shown from the bell button | - -The running hub re-reads both files after a successful **Update Engine** action. That means a launcher maintenance badge or announcement can appear or disappear after updating the engine without closing and reopening the hub. +The running hub re-reads this file after a successful **Update Engine** action, so the +launcher maintenance state can change without closing and reopening the hub. When `launcher.maintenance=true`, the **Run Launcher** button stays visible but displays a striped maintenance state. Clicking it shows the configured message instead of launching the standalone launcher. The default committed state is `launcher.maintenance=false`, so the launcher opens normally. diff --git a/modules/editor/src/main/java/com/jvn/editor/EditorApp.java b/modules/editor/src/main/java/com/jvn/editor/EditorApp.java index 074cbe47..8926ef9a 100644 --- a/modules/editor/src/main/java/com/jvn/editor/EditorApp.java +++ b/modules/editor/src/main/java/com/jvn/editor/EditorApp.java @@ -90,6 +90,8 @@ import com.jvn.editor.ui.VnsDiagnosticsView; import com.jvn.editor.ui.VnsFlowMapView; import com.jvn.editor.ui.VnsScriptAnalyzer; +import com.jvn.editor.ui.WhatsNewCatalog; +import com.jvn.editor.ui.WhatsNewDialog; import com.jvn.editor.ui.actioneditor.AnimationProject; import com.jvn.editor.ui.actioneditor.CodeImporter; import com.jvn.editor.ui.actioneditor.EntityTrack; @@ -2201,6 +2203,8 @@ private void initializeEditorStage(Stage primaryStage) { miOpenProjectDocs.setOnAction(e -> openProjectDocsFolder()); MenuItem miOpenWorkspaceDocs = new MenuItem("Open Workspace Docs Folder"); miOpenWorkspaceDocs.setOnAction(e -> openWorkspaceDocsFolder()); + MenuItem miWhatsNew = new MenuItem("What's New in " + buildInfo.versionLabel()); + miWhatsNew.setOnAction(e -> showWhatsNew(primaryStage, buildInfo)); MenuItem miAbout = new MenuItem("About JVN Editor"); miAbout.setOnAction(e -> { EditorDialogs.show(primaryStage, @@ -2214,6 +2218,7 @@ private void initializeEditorStage(Stage primaryStage) { new SeparatorMenuItem(), miOpenProjectDocs, miOpenWorkspaceDocs, new SeparatorMenuItem(), + miWhatsNew, miAbout); mb.getMenus().addAll( @@ -2541,6 +2546,7 @@ private void initializeEditorStage(Stage primaryStage) { }); applyLinuxDefaultWindowState(primaryStage); primaryStage.show(); + Platform.runLater(() -> maybeShowWhatsNew(primaryStage, buildInfo)); installSidebarDividerHoverHints(); scene.setOnDragOver((DragEvent e) -> { Dragboard db = e.getDragboard(); @@ -2583,6 +2589,25 @@ private void initializeEditorStage(Stage primaryStage) { timer.start(); } + private void maybeShowWhatsNew(Window owner, AppBuildInfo.BuildInfo buildInfo) { + if (SAFE_MODE || editorPreferences == null || buildInfo == null) return; + String currentVersion = buildInfo.versionLabel(); + if (!WhatsNewCatalog.shouldShow( + currentVersion, + editorPreferences.getLastSeenWhatsNewVersion())) { + return; + } + + showWhatsNew(owner, buildInfo); + editorPreferences.setLastSeenWhatsNewVersion(currentVersion); + persistEditorPreferences(); + } + + private void showWhatsNew(Window owner, AppBuildInfo.BuildInfo buildInfo) { + if (buildInfo == null) return; + WhatsNewDialog.show(owner, WhatsNewCatalog.forVersion(buildInfo.versionLabel())); + } + private void doOpen(Stage stage) { try { FileChooser fc = new FileChooser(); diff --git a/modules/editor/src/main/java/com/jvn/editor/JvnLauncherApp.java b/modules/editor/src/main/java/com/jvn/editor/JvnLauncherApp.java index 6e65ba7b..79209570 100644 --- a/modules/editor/src/main/java/com/jvn/editor/JvnLauncherApp.java +++ b/modules/editor/src/main/java/com/jvn/editor/JvnLauncherApp.java @@ -30,6 +30,8 @@ import com.jvn.editor.ui.StartupSplashOverlay; import com.jvn.editor.ui.ShutdownSplashOverlay; import com.jvn.editor.ui.WelcomeCenterView; +import com.jvn.editor.ui.WhatsNewCatalog; +import com.jvn.editor.ui.WhatsNewDialog; import javafx.animation.PauseTransition; import javafx.application.Application; @@ -290,7 +292,7 @@ private void initializeLauncherStage(Stage stage) { statusBar.setOnOpenSettings(this::showLauncherSettings); statusLabel.textProperty().bindBidirectional(statusBar.messageLabel().textProperty()); - MenuBar menuBar = buildMenuBar(); + MenuBar menuBar = buildMenuBar(buildInfo); if (DEVELOPER_MODE) { developerLogPanel = new DeveloperLogPanel("Logs", this::developerLogRoots); root.setTop(new VBox(menuBar, developerLogPanel)); @@ -325,6 +327,7 @@ private void initializeLauncherStage(Stage stage) { setCurrentProject(resolveStartupProject(), false); refreshButtonState(); statusLabel.setText("Workspace: " + displayPath(workspaceRoot)); + Platform.runLater(() -> maybeShowWhatsNew(buildInfo)); } private static String startupLogLine(String level, String category, String detail) { @@ -379,7 +382,7 @@ private String detail() { } } - private MenuBar buildMenuBar() { + private MenuBar buildMenuBar(AppBuildInfo.BuildInfo buildInfo) { MenuBar menuBar = new MenuBar(); menuBar.getStyleClass().add("jvn-launcher-menubar"); @@ -447,11 +450,14 @@ private MenuBar buildMenuBar() { menuView.setOnShowing(e -> syncTheme.run()); Menu menuHelp = new Menu("Help"); + MenuItem miWhatsNew = new MenuItem("What's New in " + buildInfo.versionLabel()); + miWhatsNew.setOnAction(e -> + WhatsNewDialog.show(primaryStage, WhatsNewCatalog.forVersion(buildInfo.versionLabel()))); MenuItem miAbout = new MenuItem("About JVN Launcher"); miAbout.setOnAction(e -> EditorDialogs.info(primaryStage, "About JVN Launcher", - "JVN Launcher " + AppBuildInfo.resolve(JvnLauncherApp.class).fullLabel())); - menuHelp.getItems().add(miAbout); + "JVN Launcher " + buildInfo.fullLabel())); + menuHelp.getItems().addAll(miWhatsNew, miAbout); menuBar.getMenus().addAll(menuFile, menuEdit, menuProject, menuView); if (DEVELOPER_MODE) { @@ -465,6 +471,24 @@ private MenuBar buildMenuBar() { return menuBar; } + private void maybeShowWhatsNew(AppBuildInfo.BuildInfo buildInfo) { + if (buildInfo == null || editorPreferences == null) return; + String currentVersion = buildInfo.versionLabel(); + if (!WhatsNewCatalog.shouldShow( + currentVersion, + editorPreferences.getLastSeenWhatsNewVersion())) { + return; + } + + WhatsNewDialog.show(primaryStage, WhatsNewCatalog.forVersion(currentVersion)); + editorPreferences.setLastSeenWhatsNewVersion(currentVersion); + try { + editorPreferencesStore.save(editorPreferences); + } catch (Exception ex) { + statusLabel.setText("Could not remember the displayed release notes: " + ex.getMessage()); + } + } + private void chooseProjectDirectory() { DirectoryChooser chooser = new DirectoryChooser(); chooser.setTitle("Choose JVN Project Directory"); diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/AeroIcon.java b/modules/editor/src/main/java/com/jvn/editor/ui/AeroIcon.java index 5b0cef7b..4aeeaec5 100644 --- a/modules/editor/src/main/java/com/jvn/editor/ui/AeroIcon.java +++ b/modules/editor/src/main/java/com/jvn/editor/ui/AeroIcon.java @@ -37,7 +37,7 @@ public enum Kind { LAYOUT, STORYBOARD, LAYERS, IMAGE_ATTRIBUTES, LIGHTING, VERSION_CONTROL, PUPPETEER, SCRIPT_EDITOR, SETTINGS, NEW_PROJECT, OPEN_PROJECT, RUN, BUILD, REFRESH, ENTRY_SCRIPT, MANIFEST, - README, DOCUMENTATION, REVEAL, ARROW_BACK, HELP, NO_PROJECT, + README, DOCUMENTATION, REVEAL, ARROW_BACK, HELP, WHATS_NEW, NO_PROJECT, VNS_RUN_LABEL, VNS_RUN_ENTRY, VNS_SYMBOLS, VNS_SNIPPET, VNS_FIND, VNS_COMMANDS, VNS_WORD_WRAP, VNS_DIFF, VNS_DIAGNOSTICS, VNS_PREVIEW } @@ -143,6 +143,7 @@ private static Region glyphFor(Kind kind, double size) { case MANIFEST, README -> sized(CssIcon.document(color), size); case ARROW_BACK -> sized(CssIcon.arrowLeft(color), size); case HELP -> helpGlyph(size); + case WHATS_NEW -> sized(CssIcon.sparkles(color), size); case NO_PROJECT -> noProjectGlyph(size); }; if (kind != Kind.HELP && kind != Kind.NO_PROJECT && !isVnsCommand(kind)) { @@ -731,6 +732,7 @@ private static Badge badgeFor(Kind kind, double size) { case REFRESH -> new Badge(sized(CssIcon.check("#ffffff"), glyphSize), Color.web("#2789c5")); case ARROW_BACK -> new Badge(sized(CssIcon.home("#ffffff"), glyphSize), Color.web("#c76328")); case HELP -> new Badge(sized(CssIcon.questionMark("#ffffff"), glyphSize), Color.web("#397cc0")); + case WHATS_NEW -> new Badge(sized(CssIcon.check("#ffffff"), glyphSize), Color.web("#7654bd")); case NO_PROJECT -> new Badge(sized(CssIcon.minus("#ffffff"), glyphSize), Color.web("#c97b2d")); }; } @@ -946,6 +948,7 @@ private static Palette paletteFor(Kind kind) { case REFRESH -> palette("#7edbff", "#2471b3", "#d7f5ff"); case ARROW_BACK -> palette("#ffb55d", "#b74c15", "#ffe0b0"); case HELP -> palette("#a9e5fb", "#235b82", "#f1fbff"); + case WHATS_NEW -> palette("#c5b6ff", "#5f449e", "#f0ebff"); case NO_PROJECT -> palette("#d8e7f0", "#344b5c", "#edf8ff"); }; } diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferences.java b/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferences.java index 1edd86c6..1ab2437a 100644 --- a/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferences.java +++ b/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferences.java @@ -34,6 +34,7 @@ public final class EditorPreferences { private boolean launcherConfirmRunProject; private boolean launcherRuntimePerfHud; private boolean gradleSkipTestsOnRun; + private String lastSeenWhatsNewVersion = ""; private final EnumMap panelPlacements = new EnumMap<>(EditorSidebarPanel.class); private final EnumMap chooserVisibility = @@ -279,6 +280,14 @@ public void setGradleSkipTestsOnRun(boolean gradleSkipTestsOnRun) { this.gradleSkipTestsOnRun = gradleSkipTestsOnRun; } + public String getLastSeenWhatsNewVersion() { + return lastSeenWhatsNewVersion; + } + + public void setLastSeenWhatsNewVersion(String lastSeenWhatsNewVersion) { + this.lastSeenWhatsNewVersion = cleanText(lastSeenWhatsNewVersion); + } + public EditorPanelPlacement getPlacement(EditorSidebarPanel panel) { if (panel == null) return EditorPanelPlacement.HIDDEN; EditorPanelPlacement placement = panelPlacements.getOrDefault(panel, panel.defaultPlacement()); @@ -386,6 +395,7 @@ public EditorPreferences copy() { c.centerDividerRight = this.centerDividerRight; c.activeLeftTab = this.activeLeftTab; c.activeRightTab = this.activeRightTab; + c.lastSeenWhatsNewVersion = this.lastSeenWhatsNewVersion; c.statusBarVisibility.clear(); c.statusBarVisibility.putAll(this.statusBarVisibility); return c; diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferencesStore.java b/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferencesStore.java index 5f22d6be..03d84cf0 100644 --- a/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferencesStore.java +++ b/modules/editor/src/main/java/com/jvn/editor/ui/EditorPreferencesStore.java @@ -27,6 +27,7 @@ public final class EditorPreferencesStore { static final String KEY_LAUNCHER_CONFIRM_RUN_PROJECT = "launcher.confirmRunProject"; static final String KEY_LAUNCHER_RUNTIME_PERF_HUD = "launcher.runtimePerfHud"; static final String KEY_GRADLE_SKIP_TESTS_ON_RUN = "gradle.skipTestsOnRun"; + static final String KEY_LAST_SEEN_WHATS_NEW_VERSION = "whatsNew.lastSeenVersion"; static final String KEY_PANEL_PREFIX = "panel."; static final String KEY_PANEL_SUFFIX = ".placement"; static final String KEY_CHOOSER_SUFFIX = ".chooserVisible"; @@ -137,6 +138,9 @@ static Properties toProperties(EditorPreferences preferences) { props.setProperty( KEY_GRADLE_SKIP_TESTS_ON_RUN, Boolean.toString(preferences.isGradleSkipTestsOnRun())); + props.setProperty( + KEY_LAST_SEEN_WHATS_NEW_VERSION, + preferences.getLastSeenWhatsNewVersion()); for (EditorSidebarPanel panel : EditorSidebarPanel.values()) { props.setProperty( KEY_PANEL_PREFIX + panel.key() + KEY_PANEL_SUFFIX, @@ -203,6 +207,9 @@ static EditorPreferences fromProperties(Properties props) { props.getProperty(KEY_LAUNCHER_RUNTIME_PERF_HUD), true)); preferences.setGradleSkipTestsOnRun(parseBoolean( props.getProperty(KEY_GRADLE_SKIP_TESTS_ON_RUN), true)); + preferences.setLastSeenWhatsNewVersion(props.getProperty( + KEY_LAST_SEEN_WHATS_NEW_VERSION, + "")); for (EditorSidebarPanel panel : EditorSidebarPanel.values()) { String key = KEY_PANEL_PREFIX + panel.key() + KEY_PANEL_SUFFIX; EditorPanelPlacement placement = parsePlacement(props.getProperty(key), panel.defaultPlacement()); diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewCatalog.java b/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewCatalog.java new file mode 100644 index 00000000..718b9901 --- /dev/null +++ b/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewCatalog.java @@ -0,0 +1,131 @@ +package com.jvn.editor.ui; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Versioned release notes shown by the editor after an application update. + * + *

Each shipped version should add a curated entry here. The display decision deliberately uses + * the full version label, so moving from an alpha to a beta or stable build is treated as a version + * change even when the numeric version is unchanged. + */ +public final class WhatsNewCatalog { + private static final Map RELEASES = createReleases(); + + private WhatsNewCatalog() { + } + + public static boolean shouldShow(String currentVersion, String lastSeenVersion) { + String current = clean(currentVersion); + if (current.isBlank()) return false; + return !current.equalsIgnoreCase(clean(lastSeenVersion)); + } + + public static Release forVersion(String versionLabel) { + String displayVersion = clean(versionLabel); + if (displayVersion.isBlank()) displayVersion = "Current version"; + + Release exact = RELEASES.get(key(displayVersion)); + if (exact != null) return exact.withVersionLabel(displayVersion); + + Release base = RELEASES.get(key(baseVersion(displayVersion))); + if (base != null) return base.withVersionLabel(displayVersion); + + return new Release( + displayVersion, + "A new version of JVN is ready.", + List.of(new Section( + "This release", + "This build does not include a detailed release-note entry.", + List.of( + "Your projects and editor preferences remain available.", + "You can reopen this screen at any time from Help > What's New."))), + false); + } + + private static Map createReleases() { + Map releases = new LinkedHashMap<>(); + add(releases, new Release( + "v0.4.2", + "A more immediate authoring loop, calmer runtime feedback, and sharper editor tools.", + List.of( + new Section( + "Richer Puppeteer previews", + "The canvas now communicates useful timeline state while you author.", + List.of( + "See playback mode, precise time, selected target, and selected property directly in the preview.", + "Track the active expression, layered-expression count, and next keyframe or event at a glance.", + "Use the same live readout in compact and focused preview layouts without obscuring anchor placement.")), + new Section( + "Improved runtime feedback", + "Startup and failure states are clearer without interrupting fast runs.", + List.of( + "A themed loading card appears only when preparation takes long enough to notice, then fades after the first frame.", + "Runtime errors now emphasize source context, likely cause, recovery actions, and readable technical details.", + "Reload, continue, and copy-details shortcuts work from the error screen while gameplay input stays blocked.")), + new Section( + "Diagnostics and tool consistency", + "Sidebar utilities now share a more coherent visual and interaction language.", + List.of( + "VNS diagnostics handle malformed and incomplete source more gracefully and report a broader set of issues.", + "Diagnostic actions use clearer icons and more consistent control styling.", + "Sidebar tool buttons now follow the same dimensional JVN button treatment used by primary editor actions.")), + new Section( + "Version-aware release notes", + "Update information now appears when it is relevant and remains easy to revisit.", + List.of( + "A dedicated What's New screen opens once whenever the installed JVN version changes.", + "Launcher and Editor share the last-seen version, preventing the same release summary from opening twice.", + "The Engine Hub announcement bell and screen have been retired in favor of version-specific release notes."))), + true)); + return Map.copyOf(releases); + } + + private static void add(Map releases, Release release) { + releases.put(key(release.versionLabel()), release); + } + + private static String baseVersion(String versionLabel) { + String clean = clean(versionLabel); + int qualifier = clean.indexOf(' '); + return qualifier < 0 ? clean : clean.substring(0, qualifier); + } + + private static String key(String versionLabel) { + return clean(versionLabel).toLowerCase(Locale.ROOT); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + public record Release( + String versionLabel, + String summary, + List

sections, + boolean curated) { + + public Release { + versionLabel = clean(versionLabel); + summary = clean(summary); + sections = sections == null ? List.of() : List.copyOf(sections); + } + + private Release withVersionLabel(String displayVersion) { + return new Release(displayVersion, summary, sections, curated); + } + } + + public record Section(String title, String summary, List changes) { + public Section { + title = clean(title); + summary = clean(summary); + changes = changes == null + ? List.of() + : changes.stream().map(WhatsNewCatalog::clean).filter(text -> !text.isBlank()).toList(); + } + } +} diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java b/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java new file mode 100644 index 00000000..6fdaf421 --- /dev/null +++ b/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java @@ -0,0 +1,107 @@ +package com.jvn.editor.ui; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.Window; + +/** Builds the themed, scrollable release summary shown after an editor update. */ +public final class WhatsNewDialog { + private WhatsNewDialog() { + } + + public static void show(Window owner, WhatsNewCatalog.Release release) { + if (release == null) return; + + VBox content = new VBox(12); + content.getStyleClass().add("whats-new-content"); + + HBox hero = new HBox(14); + hero.setAlignment(Pos.CENTER_LEFT); + hero.getStyleClass().add("whats-new-hero"); + + Region icon = AeroIcon.of(AeroIcon.Kind.WHATS_NEW, 48); + VBox heading = new VBox(3); + HBox badges = new HBox(7); + badges.setAlignment(Pos.CENTER_LEFT); + Label newBadge = new Label("NEW RELEASE"); + newBadge.getStyleClass().add("whats-new-badge"); + Label versionBadge = new Label(release.versionLabel()); + versionBadge.getStyleClass().add("whats-new-version"); + badges.getChildren().addAll(newBadge, versionBadge); + + Label summary = new Label(release.summary()); + summary.getStyleClass().add("whats-new-summary"); + summary.setWrapText(true); + summary.setMaxWidth(420); + heading.getChildren().addAll(badges, summary); + HBox.setHgrow(heading, Priority.ALWAYS); + hero.getChildren().addAll(icon, heading); + content.getChildren().add(hero); + + for (WhatsNewCatalog.Section section : release.sections()) { + content.getChildren().add(sectionCard(section)); + } + + Label footer = new Label("Reopen this summary any time from Help > What's New."); + footer.getStyleClass().add("whats-new-footer"); + footer.setWrapText(true); + content.getChildren().add(footer); + + ScrollPane scroll = new ScrollPane(content); + scroll.getStyleClass().add("whats-new-scroll"); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroll.setPrefViewportWidth(510); + scroll.setPrefViewportHeight(440); + scroll.setMaxHeight(480); + + EditorDialogs.show( + owner, + "What's New in " + release.versionLabel(), + release.curated() + ? "Here are the highlights included in this version." + : "This version has changed since your last JVN session.", + scroll, + EditorDialogs.ActionSpec.accent("close", "Start Creating", () -> {})); + } + + private static VBox sectionCard(WhatsNewCatalog.Section section) { + VBox card = new VBox(7); + card.getStyleClass().add("whats-new-section"); + card.setPadding(new Insets(12)); + + Label title = new Label(section.title()); + title.getStyleClass().add("whats-new-section-title"); + + Label summary = new Label(section.summary()); + summary.getStyleClass().add("whats-new-section-summary"); + summary.setWrapText(true); + + card.getChildren().addAll(title, summary); + for (String change : section.changes()) { + HBox row = new HBox(8); + row.setAlignment(Pos.TOP_LEFT); + row.getStyleClass().add("whats-new-change-row"); + + Label marker = new Label("✓"); + marker.getStyleClass().add("whats-new-change-marker"); + marker.setMinWidth(18); + + Label text = new Label(change); + text.getStyleClass().add("whats-new-change-text"); + text.setWrapText(true); + text.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(text, Priority.ALWAYS); + row.getChildren().addAll(marker, text); + card.getChildren().add(row); + } + return card; + } +} diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/AnimationPreview.java b/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/AnimationPreview.java index e58ffc72..ae08fb28 100644 --- a/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/AnimationPreview.java +++ b/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/AnimationPreview.java @@ -11,6 +11,8 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + import com.jvn.core.graphics.Camera2D; import com.jvn.core.graphics.ViewportScaler2D; import com.jvn.core.input.Input; @@ -441,6 +443,7 @@ public void endViewportStabilization() { private String matrixOverlayText = null; private double matrixOverlayScreenX = 0.0; private double matrixOverlayScreenY = 0.0; + private PuppeteerLivePreviewReadout.@Nullable Snapshot livePreviewReadout; public void setProjectRoot(java.io.File root) { projectRoot = root; @@ -485,6 +488,10 @@ public AnimationPreview() { render(); } + public void setLivePreviewReadout(PuppeteerLivePreviewReadout.@Nullable Snapshot readout) { + livePreviewReadout = readout; + } + public void setScene(JesScene2D scene) { this.scene = scene; if (scene != null) { @@ -618,6 +625,7 @@ public void render() { drawCameraHud(); drawAssetDropOverlay(); drawAnchorPlacementOverlay(w, h); + drawLivePreviewReadout(w, h); if (pivotOverlayText != null) { gc.setFont(javafx.scene.text.Font.font("Monospaced", 11)); @@ -654,6 +662,71 @@ public void render() { } } + private void drawLivePreviewReadout(double canvasW, double canvasH) { + if (scene == null || livePreviewReadout == null || canvasW < 220 || canvasH < 120) return; + + double margin = 10.0; + double panelHeight = canvasW < 620 ? 66.0 : 52.0; + double panelWidth = Math.max(200.0, canvasW - margin * 2.0); + double panelX = margin; + double panelY = canvasH - panelHeight - margin; + if (anchorPlacementMode) panelY -= 28.0; + panelY = Math.max(34.0, panelY); + + gc.save(); + gc.setFill(Color.web("#080b10", 0.88)); + gc.fillRoundRect(panelX, panelY, panelWidth, panelHeight, 10.0, 10.0); + gc.setStroke(Color.web("#91a0b5", 0.38)); + gc.setLineWidth(1.0); + gc.strokeRoundRect(panelX + 0.5, panelY + 0.5, panelWidth - 1.0, panelHeight - 1.0, 10.0, 10.0); + + double progress = Math.max(0.0, Math.min(1.0, livePreviewReadout.progress())); + gc.setFill(Color.web("#273241", 0.92)); + gc.fillRoundRect(panelX + 1.0, panelY + 1.0, panelWidth - 2.0, 3.0, 8.0, 8.0); + if (progress > 0.0) { + gc.setFill(Color.web("#68b7f0", 0.96)); + gc.fillRoundRect(panelX + 1.0, panelY + 1.0, (panelWidth - 2.0) * progress, 3.0, 8.0, 8.0); + } + + String mode = livePreviewReadout.mode(); + gc.setFont(javafx.scene.text.Font.font("Monospaced", javafx.scene.text.FontWeight.BOLD, 10)); + double modeWidth = Math.max(78.0, mode.length() * 6.3 + 16.0); + gc.setFill(Color.web(mode.contains("RUNTIME") ? "#6d4829" : "#234b66", 0.96)); + gc.fillRoundRect(panelX + 9.0, panelY + 10.0, modeWidth, 18.0, 9.0, 9.0); + gc.setFill(Color.web(mode.contains("RUNTIME") ? "#ffd4a8" : "#ccecff", 0.98)); + gc.fillText(mode, panelX + 17.0, panelY + 23.0); + + gc.setFont(javafx.scene.text.Font.font("Monospaced", javafx.scene.text.FontWeight.BOLD, 11)); + gc.setFill(Color.web("#f2f5f8", 0.98)); + gc.fillText(livePreviewReadout.clock(), panelX + modeWidth + 18.0, panelY + 23.0); + + gc.setFont(javafx.scene.text.Font.font("System", javafx.scene.text.FontWeight.BOLD, 11)); + gc.setFill(Color.web("#d9e1ea", 0.96)); + if (canvasW < 620) { + gc.fillText(ellipsize(livePreviewReadout.target(), 48), panelX + 10.0, panelY + 42.0); + gc.setFont(javafx.scene.text.Font.font("System", 10)); + gc.setFill(Color.web("#aeb9c7", 0.94)); + gc.fillText( + ellipsize(livePreviewReadout.state() + " | " + livePreviewReadout.upcoming(), 76), + panelX + 10.0, + panelY + 57.0); + } else { + gc.fillText(ellipsize(livePreviewReadout.target(), 42), panelX + 10.0, panelY + 43.0); + gc.setFont(javafx.scene.text.Font.font("System", 10)); + gc.setFill(Color.web("#b7c1cd", 0.94)); + gc.fillText(ellipsize(livePreviewReadout.state(), 46), panelX + panelWidth * 0.40, panelY + 43.0); + gc.setFill(Color.web("#8ecbf4", 0.96)); + gc.fillText(ellipsize(livePreviewReadout.upcoming(), 42), panelX + panelWidth * 0.70, panelY + 43.0); + } + gc.restore(); + } + + private static String ellipsize(String text, int maxChars) { + if (text == null) return ""; + if (text.length() <= maxChars) return text; + return text.substring(0, Math.max(1, maxChars - 1)) + "…"; + } + private void drawAnchorPlacementOverlay(double canvasW, double canvasH) { if (!anchorPlacementMode) return; diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerLivePreviewReadout.java b/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerLivePreviewReadout.java new file mode 100644 index 00000000..f9ab47a0 --- /dev/null +++ b/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerLivePreviewReadout.java @@ -0,0 +1,300 @@ +package com.jvn.editor.ui.actioneditor; + +import java.util.Locale; + +import org.jspecify.annotations.Nullable; + +/** Builds the compact playhead-aware readout shown over Puppeteer's preview. */ +final class PuppeteerLivePreviewReadout { + private static final double TIME_EPSILON_MS = 0.001; + + record Snapshot( + String mode, + String clock, + String target, + String state, + String upcoming, + double progress + ) {} + + private record Upcoming(double timeMs, String label) {} + + private PuppeteerLivePreviewReadout() {} + + static Snapshot build( + @Nullable AnimationProject project, + @Nullable String selectedTarget, + boolean selectedGroup, + boolean runtimeCamera, + @Nullable PropertyType selectedProperty, + boolean runtimeParity + ) { + if (project == null) { + return new Snapshot("PREVIEW", "00:00.000 / 00:00.000", "Full scene", + "No timeline loaded", "Add a keyframe to begin", 0.0); + } + + double timeMs = sanitizeTime(project.getPlayheadMs()); + double durationMs = Math.max(0.0, sanitizeTime(project.getTotalDurationMs())); + String mode = runtimeParity + ? "RUNTIME PARITY" + : (project.isPlaying() ? "LIVE PLAYBACK" : "LIVE PREVIEW"); + String clock = formatTime(timeMs) + " / " + formatTime(durationMs); + String target = describeTarget(project, selectedTarget, selectedGroup, runtimeCamera, selectedProperty); + String state = describeState(project, selectedTarget, selectedGroup, runtimeCamera, timeMs); + Upcoming upcoming = findUpcoming(project, selectedTarget, selectedGroup, runtimeCamera, timeMs); + String upcomingText = upcoming == null + ? (!hasAuthoredMotion(project) ? "Add a keyframe to begin" : "End of authored motion") + : "Next • " + upcoming.label() + " " + formatDelta(upcoming.timeMs() - timeMs); + double progress = durationMs <= TIME_EPSILON_MS + ? 0.0 + : Math.max(0.0, Math.min(1.0, timeMs / durationMs)); + + return new Snapshot(mode, clock, target, state, upcomingText, progress); + } + + private static boolean hasAuthoredMotion(AnimationProject project) { + if (!project.getEditorEventCues().isEmpty()) return true; + for (EntityTrack track : project.getTracks()) { + if (animatedChannelCount(track) > 0) return true; + } + return false; + } + + private static String describeTarget( + AnimationProject project, + @Nullable String selectedTarget, + boolean selectedGroup, + boolean runtimeCamera, + @Nullable PropertyType selectedProperty + ) { + if (runtimeCamera) { + return selectedProperty == null + ? "Runtime Camera" + : "Runtime Camera • " + selectedProperty.getDisplayName(); + } + if (selectedTarget != null && !selectedTarget.isBlank()) { + String kind = selectedGroup ? "Group" : "Entity"; + return selectedProperty == null + ? kind + " • " + selectedTarget.trim() + : kind + " • " + selectedTarget.trim() + " • " + selectedProperty.getDisplayName(); + } + int count = project.getTrackCount(); + return count == 1 ? "Full scene • 1 track" : "Full scene • " + count + " tracks"; + } + + private static String describeState( + AnimationProject project, + @Nullable String selectedTarget, + boolean selectedGroup, + boolean runtimeCamera, + double timeMs + ) { + if (!selectedGroup && !runtimeCamera && selectedTarget != null && !selectedTarget.isBlank()) { + EditorEventCue activeExpression = latestExpressionCue(project, selectedTarget, timeMs); + if (activeExpression != null) { + String expression = firstNonBlank( + activeExpression.getPayloadValue("value"), + activeExpression.getPayloadValue("expression"), + "unnamed"); + int layerCount = countLayerPaths(activeExpression); + return layerCount > 0 + ? "Expression • " + expression + " • " + layerCount + + (layerCount == 1 ? " layer" : " layers") + : "Expression • " + expression; + } + } + + EntityTrack track = resolveTrack(project, selectedTarget, selectedGroup); + if (track != null) { + int channelCount = animatedChannelCount(track); + int keyedNow = keyframesAt(track, timeMs); + if (keyedNow > 0) { + return keyedNow + (keyedNow == 1 ? " key" : " keys") + " at playhead • " + + channelCount + (channelCount == 1 ? " channel" : " channels"); + } + return channelCount == 0 + ? "No authored channels on selection" + : channelCount + (channelCount == 1 ? " animated channel" : " animated channels"); + } + + int eventCount = project.getEditorEventCues().size(); + return eventCount == 0 + ? "Scene preview • no event cues" + : "Scene preview • " + eventCount + (eventCount == 1 ? " event cue" : " event cues"); + } + + private static @Nullable Upcoming findUpcoming( + AnimationProject project, + @Nullable String selectedTarget, + boolean selectedGroup, + boolean runtimeCamera, + double timeMs + ) { + Upcoming best = null; + EntityTrack selectedTrack = resolveTrack(project, selectedTarget, selectedGroup); + if (runtimeCamera) { + for (EntityTrack track : project.getTracks()) { + if (TimelinePanel.isRuntimeCameraTarget(track.getEntityName())) { + best = earlier(best, upcomingForTrack(track, timeMs)); + } + } + } else if (selectedTrack != null) { + best = upcomingForTrack(selectedTrack, timeMs); + } else { + for (EntityTrack track : project.getTracks()) { + best = earlier(best, upcomingForTrack(track, timeMs)); + } + } + + for (EditorEventCue cue : project.getEditorEventCues()) { + if (cue == null || cue.getTimeMs() <= timeMs + TIME_EPSILON_MS) continue; + String target = cue.getPayloadValue("target"); + if (selectedTarget != null && !selectedTarget.isBlank() + && !target.isBlank() + && !TimelinePanel.expressionTargetMatchesEntity(target, selectedTarget)) { + continue; + } + best = earlier(best, new Upcoming(cue.getTimeMs(), eventLabel(cue))); + } + return best; + } + + private static @Nullable Upcoming upcomingForTrack(@Nullable EntityTrack track, double timeMs) { + if (track == null) return null; + Upcoming best = null; + for (PropertyType property : PropertyType.values()) { + for (Keyframe keyframe : track.getKeyframes(property)) { + if (keyframe.getTimeMs() > timeMs + TIME_EPSILON_MS) { + best = earlier(best, new Upcoming(keyframe.getTimeMs(), property.getDisplayName())); + break; + } + } + } + for (String key : track.getAnimatedCustomProperties()) { + for (Keyframe keyframe : track.getCustomKeyframes(key)) { + if (keyframe.getTimeMs() > timeMs + TIME_EPSILON_MS) { + best = earlier(best, new Upcoming(keyframe.getTimeMs(), humanizeKey(key))); + break; + } + } + } + return best; + } + + private static @Nullable Upcoming earlier( + @Nullable Upcoming current, + @Nullable Upcoming candidate + ) { + if (candidate == null) return current; + return current == null || candidate.timeMs() < current.timeMs() ? candidate : current; + } + + private static @Nullable EntityTrack resolveTrack( + AnimationProject project, + @Nullable String selectedTarget, + boolean selectedGroup + ) { + if (project == null || selectedTarget == null || selectedTarget.isBlank()) return null; + if (!selectedGroup) return project.getTrack(selectedTarget); + EntityGroup group = project.getGroup(selectedTarget); + return group == null ? null : group.getGroupTrack(); + } + + private static @Nullable EditorEventCue latestExpressionCue( + AnimationProject project, + String target, + double timeMs + ) { + EditorEventCue latest = null; + for (EditorEventCue cue : project.getEditorEventCues()) { + if (cue == null || cue.getTimeMs() > timeMs + TIME_EPSILON_MS) break; + if (!"expression".equalsIgnoreCase(cue.getType())) continue; + if (TimelinePanel.expressionTargetMatchesEntity(cue.getPayloadValue("target"), target)) { + latest = cue; + } + } + return latest; + } + + private static int animatedChannelCount(EntityTrack track) { + int count = 0; + for (PropertyType property : PropertyType.values()) { + if (track.hasKeyframes(property)) count++; + } + for (String ignored : track.getAnimatedCustomProperties()) count++; + return count; + } + + private static int keyframesAt(EntityTrack track, double timeMs) { + int count = 0; + for (PropertyType property : PropertyType.values()) { + if (track.findKeyframeAt(property, timeMs) != null) count++; + } + for (String key : track.getAnimatedCustomProperties()) { + for (Keyframe keyframe : track.getCustomKeyframes(key)) { + if (Math.abs(keyframe.getTimeMs() - timeMs) <= TIME_EPSILON_MS) { + count++; + break; + } + } + } + return count; + } + + private static int countLayerPaths(EditorEventCue cue) { + String layers = cue.getPayloadValue("layers"); + if (layers.isBlank()) return 0; + int count = 0; + for (String path : layers.split("\\|")) { + if (!path.isBlank()) count++; + } + return count; + } + + private static String eventLabel(EditorEventCue cue) { + if ("expression".equalsIgnoreCase(cue.getType())) { + String expression = firstNonBlank( + cue.getPayloadValue("value"), + cue.getPayloadValue("expression"), + ""); + return expression.isBlank() ? "Expression" : "Expression • " + expression; + } + return cue.getType() == null || cue.getType().isBlank() ? "Event" : humanizeKey(cue.getType()); + } + + private static String humanizeKey(String value) { + if (value == null || value.isBlank()) return "Custom channel"; + String normalized = value.trim().replace('.', ' ').replace('_', ' '); + return Character.toUpperCase(normalized.charAt(0)) + normalized.substring(1); + } + + private static String formatTime(double millis) { + long totalMillis = Math.max(0L, Math.round(sanitizeTime(millis))); + long minutes = totalMillis / 60_000L; + long seconds = (totalMillis / 1_000L) % 60L; + long ms = totalMillis % 1_000L; + return String.format(Locale.ROOT, "%02d:%02d.%03d", minutes, seconds, ms); + } + + private static String formatDelta(double deltaMs) { + double safe = Math.max(0.0, sanitizeTime(deltaMs)); + if (safe < 1_000.0) { + return String.format(Locale.ROOT, "in %.0f ms", safe); + } + return String.format(Locale.ROOT, "in %.2f s", safe / 1_000.0); + } + + private static double sanitizeTime(double value) { + return Double.isFinite(value) ? Math.max(0.0, value) : 0.0; + } + + private static String firstNonBlank(String... values) { + if (values != null) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + } + return ""; + } +} diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerWindow.java b/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerWindow.java index 5f349475..1443a60c 100644 --- a/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerWindow.java +++ b/modules/editor/src/main/java/com/jvn/editor/ui/actioneditor/PuppeteerWindow.java @@ -583,6 +583,8 @@ public PuppeteerWindow(AnimationProject project) { refreshPropertyPickerChoices(); refreshSidebarTabs(); updateStatusBar(); + updateLivePreviewReadout(); + animationPreview.render(); }); entitySelector.setOnSelectionChanged((name, isGroup) -> { @@ -1060,6 +1062,8 @@ public PuppeteerWindow(AnimationProject project) { if (effective != null && cbProperty.getValue() != effective) { cbProperty.setValue(effective); } + updateLivePreviewReadout(); + animationPreview.render(); }); timelinePanel.setSelectedProperty(PropertyType.X); refreshPropertyPickerChoices(); @@ -8422,6 +8426,7 @@ public void handle(long now) { public void updateTimeLabel() { lblTime.setText(String.format("%.0f ms", project.getPlayheadMs())); + updateLivePreviewReadout(); refreshSidebarTabs(); updateStatusBar(); } @@ -8468,6 +8473,7 @@ public void updatePreview() { if (scene == null) return; double time = project.getPlayheadMs(); + updateLivePreviewReadout(); restorePreviewBaselineState(); if (runtimeParityPreview) { applyRuntimeParityPreview(time); @@ -8676,6 +8682,18 @@ public void updatePreview() { refreshSidebarTabs(); } + private void updateLivePreviewReadout() { + if (animationPreview == null || timelinePanel == null) return; + animationPreview.setLivePreviewReadout(PuppeteerLivePreviewReadout.build( + project, + timelinePanel.getSelectedEntity(), + timelinePanel.isSelectedGroup(), + timelinePanel.isRuntimeCameraSelected(), + timelinePanel.getSelectedProperty(), + runtimeParityPreview + )); + } + private boolean trackHasAuthoredValues(EntityTrack track) { if (track == null) return false; for (PropertyType property : PropertyType.values()) { diff --git a/modules/editor/src/main/resources/com/jvn/editor/editor-light.css b/modules/editor/src/main/resources/com/jvn/editor/editor-light.css index c42d183d..177efaa3 100644 --- a/modules/editor/src/main/resources/com/jvn/editor/editor-light.css +++ b/modules/editor/src/main/resources/com/jvn/editor/editor-light.css @@ -3208,6 +3208,101 @@ -fx-border-color: #b77284; } +/* What's New */ +.whats-new-scroll { + -fx-background-color: transparent; + -fx-background: transparent; + -fx-border-color: transparent; + -fx-padding: 0; +} + +.whats-new-scroll > .viewport { + -fx-background-color: transparent; +} + +.whats-new-content { + -fx-padding: 2 8 4 0; +} + +.whats-new-hero { + -fx-background-color: + radial-gradient(center 12% 20%, radius 85%, rgba(57, 132, 188, 0.16), transparent), + linear-gradient(to bottom, #edf5fb, #e2ebf2); + -fx-background-radius: 11; + -fx-border-color: #afc7d9; + -fx-border-radius: 11; + -fx-padding: 14; +} + +.whats-new-badge { + -fx-background-color: linear-gradient(to bottom, #4e9bcf, #337ba9); + -fx-background-radius: 8; + -fx-border-color: #23648f; + -fx-border-radius: 8; + -fx-text-fill: #ffffff; + -fx-font-size: 9px; + -fx-font-weight: 900; + -fx-padding: 3 7 3 7; +} + +.whats-new-version { + -fx-text-fill: #245f87; + -fx-font-size: 11px; + -fx-font-weight: 900; +} + +.whats-new-summary { + -fx-text-fill: #1c3445; + -fx-font-size: 12px; + -fx-font-weight: 700; +} + +.whats-new-section { + -fx-background-color: linear-gradient(to bottom, #f4f5f6, #eceeef); + -fx-background-radius: 10; + -fx-border-color: #cdd2d6; + -fx-border-radius: 10; +} + +.whats-new-section-title { + -fx-text-fill: #161a1e; + -fx-font-size: 12px; + -fx-font-weight: 900; +} + +.whats-new-section-summary { + -fx-text-fill: #68727c; + -fx-font-size: 10px; + -fx-font-weight: 700; +} + +.whats-new-change-row { + -fx-padding: 2 0 0 0; +} + +.whats-new-change-marker { + -fx-background-color: #d8f0e3; + -fx-background-radius: 8; + -fx-text-fill: #286646; + -fx-font-size: 9px; + -fx-font-weight: 900; + -fx-alignment: center; + -fx-padding: 2 4 2 4; +} + +.whats-new-change-text { + -fx-text-fill: #38414a; + -fx-font-size: 11px; + -fx-font-weight: 600; +} + +.whats-new-footer { + -fx-text-fill: #737d88; + -fx-font-size: 10px; + -fx-font-weight: 700; + -fx-padding: 1 4 0 4; +} + /* Expression keyframe editor */ .expression-keyframe-dialog { -fx-padding: 2 4 4 4; diff --git a/modules/editor/src/main/resources/com/jvn/editor/editor.css b/modules/editor/src/main/resources/com/jvn/editor/editor.css index 0efe9a69..6ec780ca 100644 --- a/modules/editor/src/main/resources/com/jvn/editor/editor.css +++ b/modules/editor/src/main/resources/com/jvn/editor/editor.css @@ -3293,6 +3293,101 @@ The editor.css file defines the visual styling for the editor module, including -fx-border-color: #b77284; } +/* What's New */ +.whats-new-scroll { + -fx-background-color: transparent; + -fx-background: transparent; + -fx-border-color: transparent; + -fx-padding: 0; +} + +.whats-new-scroll > .viewport { + -fx-background-color: transparent; +} + +.whats-new-content { + -fx-padding: 2 8 4 0; +} + +.whats-new-hero { + -fx-background-color: + radial-gradient(center 12% 20%, radius 85%, rgba(76, 153, 214, 0.18), transparent), + linear-gradient(to bottom, #1b222a, #15191e); + -fx-background-radius: 11; + -fx-border-color: #344454; + -fx-border-radius: 11; + -fx-padding: 14; +} + +.whats-new-badge { + -fx-background-color: linear-gradient(to bottom, #347caf, #215878); + -fx-background-radius: 8; + -fx-border-color: #6db5e6; + -fx-border-radius: 8; + -fx-text-fill: #edf8ff; + -fx-font-size: 9px; + -fx-font-weight: 900; + -fx-padding: 3 7 3 7; +} + +.whats-new-version { + -fx-text-fill: #a9d8f7; + -fx-font-size: 11px; + -fx-font-weight: 900; +} + +.whats-new-summary { + -fx-text-fill: #e6ebf0; + -fx-font-size: 12px; + -fx-font-weight: 700; +} + +.whats-new-section { + -fx-background-color: linear-gradient(to bottom, #181a1d, #131416); + -fx-background-radius: 10; + -fx-border-color: #30343a; + -fx-border-radius: 10; +} + +.whats-new-section-title { + -fx-text-fill: #f0f3f6; + -fx-font-size: 12px; + -fx-font-weight: 900; +} + +.whats-new-section-summary { + -fx-text-fill: #929ba6; + -fx-font-size: 10px; + -fx-font-weight: 700; +} + +.whats-new-change-row { + -fx-padding: 2 0 0 0; +} + +.whats-new-change-marker { + -fx-background-color: #214c3a; + -fx-background-radius: 8; + -fx-text-fill: #a9edc9; + -fx-font-size: 9px; + -fx-font-weight: 900; + -fx-alignment: center; + -fx-padding: 2 4 2 4; +} + +.whats-new-change-text { + -fx-text-fill: #c7cdd4; + -fx-font-size: 11px; + -fx-font-weight: 600; +} + +.whats-new-footer { + -fx-text-fill: #737d88; + -fx-font-size: 10px; + -fx-font-weight: 700; + -fx-padding: 1 4 0 4; +} + /* Expression keyframe editor */ .expression-keyframe-dialog { -fx-padding: 2 4 4 4; diff --git a/modules/editor/src/test/java/com/jvn/editor/AppBuildInfoTest.java b/modules/editor/src/test/java/com/jvn/editor/AppBuildInfoTest.java index be249f91..a9c32f92 100644 --- a/modules/editor/src/test/java/com/jvn/editor/AppBuildInfoTest.java +++ b/modules/editor/src/test/java/com/jvn/editor/AppBuildInfoTest.java @@ -3,6 +3,9 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jvn.editor.ui.WhatsNewCatalog; class AppBuildInfoTest { @@ -22,4 +25,13 @@ void displayVersionLabelMapsSnapshotToAlpha() { void displayVersionLabelKeepsStableVersionPlain() { assertEquals("v1.2.3", AppBuildInfo.displayVersionLabel("1.2.3")); } + + @Test + void currentVersionAlwaysShipsCuratedWhatsNewNotes() { + String currentVersion = AppBuildInfo.displayVersionLabel("dev"); + + assertTrue( + WhatsNewCatalog.forVersion(currentVersion).curated(), + "Add a curated What's New entry whenever the application version changes."); + } } diff --git a/modules/editor/src/test/java/com/jvn/editor/ui/EditorPreferencesStoreTest.java b/modules/editor/src/test/java/com/jvn/editor/ui/EditorPreferencesStoreTest.java index 596ff3a8..194c77c5 100644 --- a/modules/editor/src/test/java/com/jvn/editor/ui/EditorPreferencesStoreTest.java +++ b/modules/editor/src/test/java/com/jvn/editor/ui/EditorPreferencesStoreTest.java @@ -41,6 +41,7 @@ void loadDefaultsWhenPreferencesFileDoesNotExist() { assertFalse(preferences.isLauncherConfirmRunProject()); assertTrue(preferences.isLauncherRuntimePerfHud()); assertTrue(preferences.isGradleSkipTestsOnRun()); + assertEquals("", preferences.getLastSeenWhatsNewVersion()); assertEquals(EditorPanelPlacement.LEFT, preferences.getPlacement(EditorSidebarPanel.PROJECT)); assertTrue(preferences.isVisibleInChooser(EditorSidebarPanel.PROJECT)); assertFalse(preferences.isVisibleInChooser(EditorSidebarPanel.INSPECTOR)); @@ -71,6 +72,7 @@ void saveAndLoadRoundTripsSidebarPlacements() throws Exception { preferences.setLauncherConfirmRunProject(true); preferences.setLauncherRuntimePerfHud(false); preferences.setGradleSkipTestsOnRun(false); + preferences.setLastSeenWhatsNewVersion("v0.4.2"); preferences.setPlacement(EditorSidebarPanel.TIMELINE, EditorPanelPlacement.LEFT); store.save(preferences); @@ -93,6 +95,7 @@ void saveAndLoadRoundTripsSidebarPlacements() throws Exception { assertTrue(loaded.isLauncherConfirmRunProject()); assertFalse(loaded.isLauncherRuntimePerfHud()); assertFalse(loaded.isGradleSkipTestsOnRun()); + assertEquals("v0.4.2", loaded.getLastSeenWhatsNewVersion()); assertEquals(EditorPanelPlacement.LEFT, loaded.getPlacement(EditorSidebarPanel.TIMELINE)); } @@ -145,6 +148,7 @@ void invalidValuesFallBackToSafeDefaults() throws Exception { assertFalse(loaded.isLauncherConfirmRunProject()); assertTrue(loaded.isLauncherRuntimePerfHud()); assertTrue(loaded.isGradleSkipTestsOnRun()); + assertEquals("", loaded.getLastSeenWhatsNewVersion()); assertEquals(EditorPanelPlacement.LEFT, loaded.getPlacement(EditorSidebarPanel.PROJECT)); assertTrue(loaded.isVisibleInChooser(EditorSidebarPanel.PROJECT)); } diff --git a/modules/editor/src/test/java/com/jvn/editor/ui/WhatsNewCatalogTest.java b/modules/editor/src/test/java/com/jvn/editor/ui/WhatsNewCatalogTest.java new file mode 100644 index 00000000..df730c61 --- /dev/null +++ b/modules/editor/src/test/java/com/jvn/editor/ui/WhatsNewCatalogTest.java @@ -0,0 +1,47 @@ +package com.jvn.editor.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class WhatsNewCatalogTest { + + @Test + void requestsPopupOnlyWhenVersionChanges() { + assertTrue(WhatsNewCatalog.shouldShow("v0.4.2", "")); + assertTrue(WhatsNewCatalog.shouldShow("v0.4.2", "v0.4.1")); + assertTrue(WhatsNewCatalog.shouldShow("v0.4.2", "v0.4.2 Beta")); + assertFalse(WhatsNewCatalog.shouldShow("v0.4.2", " v0.4.2 ")); + assertFalse(WhatsNewCatalog.shouldShow("", "v0.4.1")); + } + + @Test + void currentReleaseContainsCuratedDetailedNotes() { + WhatsNewCatalog.Release release = WhatsNewCatalog.forVersion("v0.4.2"); + + assertTrue(release.curated()); + assertEquals("v0.4.2", release.versionLabel()); + assertEquals(4, release.sections().size()); + assertTrue(release.sections().stream().allMatch(section -> !section.title().isBlank())); + assertTrue(release.sections().stream().allMatch(section -> section.changes().size() >= 2)); + } + + @Test + void maturityBuildUsesNumericReleaseNotesButKeepsFullLabel() { + WhatsNewCatalog.Release release = WhatsNewCatalog.forVersion("v0.4.2 Beta"); + + assertTrue(release.curated()); + assertEquals("v0.4.2 Beta", release.versionLabel()); + } + + @Test + void unknownVersionStillGetsGracefulVersionSpecificScreen() { + WhatsNewCatalog.Release release = WhatsNewCatalog.forVersion("v9.1.0"); + + assertFalse(release.curated()); + assertEquals("v9.1.0", release.versionLabel()); + assertFalse(release.sections().isEmpty()); + } +} diff --git a/modules/editor/src/test/java/com/jvn/editor/ui/actioneditor/PuppeteerLivePreviewReadoutTest.java b/modules/editor/src/test/java/com/jvn/editor/ui/actioneditor/PuppeteerLivePreviewReadoutTest.java new file mode 100644 index 00000000..f9e7ed4f --- /dev/null +++ b/modules/editor/src/test/java/com/jvn/editor/ui/actioneditor/PuppeteerLivePreviewReadoutTest.java @@ -0,0 +1,54 @@ +package com.jvn.editor.ui.actioneditor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class PuppeteerLivePreviewReadoutTest { + + @Test + void describesActiveExpressionAndUpcomingSelectedKeyframe() { + AnimationProject project = new AnimationProject(); + project.setName("preview"); + project.setTotalDurationMs(2_000); + project.setPlayheadMs(500); + + EntityTrack track = new EntityTrack("alice"); + track.addKeyframe(PropertyType.X, new Keyframe(0, 100)); + track.addKeyframe(PropertyType.X, new Keyframe(750, 300)); + project.addTrack(track); + project.addEditorEventCue(new EditorEventCue(250, "expression", Map.of( + "target", "alice", + "value", "smile", + "layers", "base.png|eyes.png|mouth.png" + ))); + + PuppeteerLivePreviewReadout.Snapshot snapshot = PuppeteerLivePreviewReadout.build( + project, "alice", false, false, PropertyType.X, false); + + assertEquals("LIVE PREVIEW", snapshot.mode()); + assertEquals("00:00.500 / 00:02.000", snapshot.clock()); + assertTrue(snapshot.target().contains("alice")); + assertEquals("Expression • smile • 3 layers", snapshot.state()); + assertEquals("Next • Position X in 250 ms", snapshot.upcoming()); + assertEquals(0.25, snapshot.progress(), 0.0001); + } + + @Test + void fallsBackGracefullyForEmptyTimelineAndRuntimeCamera() { + AnimationProject project = new AnimationProject(); + project.setName("empty"); + project.setTotalDurationMs(0); + + PuppeteerLivePreviewReadout.Snapshot snapshot = PuppeteerLivePreviewReadout.build( + project, TimelinePanel.RUNTIME_CAMERA_TARGET, false, true, PropertyType.CAMERA_ZOOM, true); + + assertEquals("RUNTIME PARITY", snapshot.mode()); + assertEquals("Runtime Camera • Camera Zoom", snapshot.target()); + assertEquals("Add a keyframe to begin", snapshot.upcoming()); + assertEquals(0.0, snapshot.progress(), 0.0001); + } +} diff --git a/modules/fx/src/main/java/com/jvn/fx/FxLauncher.java b/modules/fx/src/main/java/com/jvn/fx/FxLauncher.java index 5158f068..ba1fc06e 100644 --- a/modules/fx/src/main/java/com/jvn/fx/FxLauncher.java +++ b/modules/fx/src/main/java/com/jvn/fx/FxLauncher.java @@ -11,6 +11,7 @@ import javax.imageio.ImageIO; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,6 +58,8 @@ import com.sun.management.OperatingSystemMXBean; import javafx.animation.AnimationTimer; +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; import javafx.application.Application; import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; @@ -77,8 +80,10 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.stage.Stage; +import javafx.util.Duration; public class FxLauncher extends Application { private static final Logger log = LoggerFactory.getLogger(FxLauncher.class); @@ -102,7 +107,9 @@ public class FxLauncher extends Application { private ActionMap actionMap; private Cursor configuredCursor = Cursor.DEFAULT; private javafx.scene.Scene fxScene; - private StackPane runtimeLoadingOverlay; + private @Nullable StackPane runtimeLoadingOverlay; + private @Nullable PauseTransition runtimeLoadingRevealDelay; + private @Nullable FadeTransition runtimeLoadingFade; private File runtimeProjectRoot; private ProjectHotReloadTracker hotReloadTracker; private double mouseX = 0; @@ -227,6 +234,18 @@ public void start(Stage primaryStage) { // Input handling scene.setOnKeyPressed(e -> { com.jvn.core.scene.Scene cur = engine != null ? engine.scenes().peek() : null; + if (cur instanceof VnScene vn && vn.getActiveError() != null) { + if (e.getCode() == KeyCode.ENTER || e.getCode() == KeyCode.R || e.getCode() == KeyCode.F5) { + handleRuntimeErrorButton(vn, 1); + } else if (e.getCode() == KeyCode.ESCAPE) { + handleRuntimeErrorButton(vn, 0); + } else if (e.getCode() == KeyCode.C) { + handleRuntimeErrorButton(vn, 2); + } + // Never let an error-screen keystroke advance or mutate the scene below it. + e.consume(); + return; + } if (cur instanceof PhoneScene phone) { if (phoneRenderer != null && phoneRenderer.handleKeyPressed(e.getCode(), e.isShiftDown())) { if (phone.consumeCloseRequested() && engine != null) { @@ -533,18 +552,55 @@ public void handle(long now) { private StackPane createRuntimeLoadingOverlay() { ProgressIndicator indicator = new ProgressIndicator(); indicator.setMouseTransparent(true); - indicator.setMaxSize(42, 42); + indicator.setMaxSize(34, 34); indicator.setStyle( - "-fx-progress-color: #e8d8ad;" - + "-fx-background-color: rgba(10, 10, 10, 0.72);" - + "-fx-background-radius: 8;" - + "-fx-padding: 10;"); + "-fx-progress-color: #82c8f5;" + + "-fx-padding: 2;"); + + String projectTitle = engine != null && engine.getConfig() != null + && engine.getConfig().title() != null && !engine.getConfig().title().isBlank() + ? engine.getConfig().title() + : "JVN"; + Label title = new Label(projectTitle); + title.setStyle( + "-fx-text-fill: #f4f7fa;" + + "-fx-font-size: 18px;" + + "-fx-font-weight: 800;"); + Label status = new Label("Preparing the first frame…"); + status.setStyle( + "-fx-text-fill: #aeb9c5;" + + "-fx-font-size: 12px;"); + + HBox progressRow = new HBox(12, indicator, status); + progressRow.setAlignment(Pos.CENTER_LEFT); + VBox card = new VBox(8, title, progressRow); + card.setAlignment(Pos.CENTER_LEFT); + card.setMaxSize(360, Region.USE_PREF_SIZE); + card.setPadding(new Insets(20, 24, 20, 24)); + card.setStyle( + "-fx-background-color: rgba(17, 21, 27, 0.96);" + + "-fx-background-radius: 12;" + + "-fx-border-color: rgba(145, 170, 194, 0.52);" + + "-fx-border-width: 1;" + + "-fx-border-radius: 12;" + + "-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.58), 28, 0.24, 0, 8);"); - StackPane overlay = new StackPane(indicator); + StackPane overlay = new StackPane(card); overlay.setMouseTransparent(true); overlay.setPickOnBounds(false); overlay.setAlignment(Pos.CENTER); - overlay.setStyle("-fx-background-color: rgba(0, 0, 0, 0.35);"); + overlay.setOpacity(0.0); + overlay.setStyle("-fx-background-color: rgba(3, 5, 8, 0.58);"); + + runtimeLoadingRevealDelay = new PauseTransition(Duration.millis(160)); + runtimeLoadingRevealDelay.setOnFinished(event -> { + if (runtimeLoadingOverlay != overlay || firstFrameRendered) return; + runtimeLoadingFade = new FadeTransition(Duration.millis(150), overlay); + runtimeLoadingFade.setFromValue(overlay.getOpacity()); + runtimeLoadingFade.setToValue(1.0); + runtimeLoadingFade.play(); + }); + runtimeLoadingRevealDelay.play(); return overlay; } @@ -552,11 +608,32 @@ private void hideRuntimeLoadingOverlayAfterFirstFrame() { if (firstFrameRendered) return; firstFrameRendered = true; if (runtimeLoadingOverlay == null) return; - runtimeLoadingOverlay.setVisible(false); - if (runtimeLoadingOverlay.getParent() instanceof StackPane parent) { - parent.getChildren().remove(runtimeLoadingOverlay); + if (runtimeLoadingRevealDelay != null) { + runtimeLoadingRevealDelay.stop(); + runtimeLoadingRevealDelay = null; + } + if (runtimeLoadingFade != null) runtimeLoadingFade.stop(); + + StackPane overlay = runtimeLoadingOverlay; + if (overlay.getOpacity() <= 0.01) { + removeRuntimeLoadingOverlay(overlay); + return; + } + runtimeLoadingFade = new FadeTransition(Duration.millis(130), overlay); + runtimeLoadingFade.setFromValue(overlay.getOpacity()); + runtimeLoadingFade.setToValue(0.0); + runtimeLoadingFade.setOnFinished(event -> removeRuntimeLoadingOverlay(overlay)); + runtimeLoadingFade.play(); + } + + private void removeRuntimeLoadingOverlay(StackPane overlay) { + if (overlay == null) return; + overlay.setVisible(false); + if (overlay.getParent() instanceof StackPane parent) { + parent.getChildren().remove(overlay); } - runtimeLoadingOverlay = null; + if (runtimeLoadingOverlay == overlay) runtimeLoadingOverlay = null; + runtimeLoadingFade = null; } private HBox createPerfHud() { @@ -2004,9 +2081,9 @@ private static double smoothRatio(double current, double target, double alpha) { private void handleRuntimeErrorButton(VnScene vnScene, int buttonIndex) { switch (buttonIndex) { - case 0 -> vnScene.clearActiveError(); // Ignore + case 0 -> vnScene.clearActiveError(); // Continue past the surfaced error. case 1 -> reloadTopVnScene(); // Reload the latest script content from disk. - case 2 -> { // Copy — copy error text to clipboard + case 2 -> { // Copy the complete error details to the clipboard. com.jvn.core.vn.VnErrorOverlay error = vnScene.getActiveError(); if (error != null) { javafx.scene.input.Clipboard clipboard = javafx.scene.input.Clipboard.getSystemClipboard(); diff --git a/modules/fx/src/main/java/com/jvn/fx/vn/VnRenderer.java b/modules/fx/src/main/java/com/jvn/fx/vn/VnRenderer.java index 0881195d..084e817e 100644 --- a/modules/fx/src/main/java/com/jvn/fx/vn/VnRenderer.java +++ b/modules/fx/src/main/java/com/jvn/fx/vn/VnRenderer.java @@ -4032,16 +4032,16 @@ public void dispose() { // ─── Error Overlay ───────────────────────────────────────────────── private static final Color ERROR_BG_COLOR = Color.rgb(28, 30, 34, 0.97); - private static final Color ERROR_HEADER_COLOR = Color.rgb(230, 62, 72); private static final Color ERROR_TEXT_COLOR = Color.web("#F2F2F2"); private static final Color ERROR_DIM_TEXT_COLOR = Color.web("#C8CDD4"); private static final Color ERROR_BOX_COLOR = Color.rgb(18, 20, 24, 0.88); private static final Color ERROR_ACCENT_COLOR = Color.rgb(230, 62, 72); private static final Color ERROR_PANEL_BORDER_COLOR = Color.rgb(86, 92, 102); - private static final Color ERROR_RULE_COLOR = Color.rgb(112, 118, 128); private static final Color ERROR_BUTTON_COLOR = Color.rgb(56, 60, 68); private static final Color ERROR_BUTTON_HOVER_COLOR = Color.rgb(76, 82, 92); private static final Color ERROR_BUTTON_TEXT_COLOR = Color.web("#F0F3F7"); + private static final Color ERROR_PRIMARY_BUTTON_COLOR = Color.web("#236b9a"); + private static final Color ERROR_PRIMARY_BUTTON_HOVER_COLOR = Color.web("#2e84b9"); /** * Renders a full-screen error overlay, similar to Ren'Py's traceback screen. @@ -4052,104 +4052,136 @@ public void dispose() { * @param height canvas height * @param mouseX mouse x for button hover * @param mouseY mouse y for button hover - * @return index of hovered button (0=Ignore, 1=Reload, 2=Copy) or -1 + * @return index of hovered button (0=Continue, 1=Reload, 2=Copy Details) or -1 */ public int renderErrorOverlay(com.jvn.core.vn.VnErrorOverlay error, double width, double height, double mouseX, double mouseY) { if (error == null) return -1; - // Full-screen neutral traceback background. + // Preserve the existing full-screen safety surface, but organize it like a + // recoverable runtime interruption rather than a raw traceback dump. gc.setFill(ERROR_BG_COLOR); gc.fillRect(0, 0, width, height); - - // Subtle gradient stripe at top gc.setFill(new LinearGradient(0, 0, 0, 6, false, CycleMethod.NO_CYCLE, new Stop(0, ERROR_ACCENT_COLOR), new Stop(1, Color.TRANSPARENT))); gc.fillRect(0, 0, width, 6); - double padding = Math.max(28, width * 0.04); - double contentX = padding; - double contentW = width - padding * 2; - double y = padding + 10; + double outerPadding = Math.max(16, Math.min(42, width * 0.045)); + double contentW = Math.min(1080, Math.max(240, width - outerPadding * 2)); + double contentX = (width - contentW) * 0.5; + double y = Math.max(20, Math.min(36, height * 0.045)); - // Title - Font titleFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, Math.min(34, Math.max(26, height * 0.046))); + // Clear error marker and title. + double markerSize = Math.min(38, Math.max(30, height * 0.055)); + gc.setFill(Color.rgb(88, 30, 36, 0.96)); + gc.fillOval(contentX, y, markerSize, markerSize); + gc.setStroke(Color.rgb(244, 100, 108, 0.92)); + gc.setLineWidth(1.5); + gc.strokeOval(contentX + 0.75, y + 0.75, markerSize - 1.5, markerSize - 1.5); + Font markerFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, markerSize * 0.66); + gc.setFont(markerFont); + gc.setFill(Color.web("#FFE4E6")); + gc.fillText("!", contentX + markerSize * 0.40, y + markerSize * 0.73); + + Font titleFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, + Math.min(30, Math.max(21, height * 0.040))); gc.setFont(titleFont); - gc.setFill(ERROR_HEADER_COLOR); - gc.fillText(error.getTitle(), contentX, y + titleFont.getSize()); - y += titleFont.getSize() + 16; - - // Separator line - gc.setStroke(ERROR_RULE_COLOR); - gc.setLineWidth(2); - gc.strokeLine(contentX, y, contentX + contentW, y); - y += 20; - - // Location info - Font infoFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.NORMAL, Math.min(20, Math.max(16, height * 0.025))); + gc.setFill(ERROR_TEXT_COLOR); + String title = error.getTitle() == null || error.getTitle().isBlank() + ? "Runtime Error" + : error.getTitle(); + gc.fillText(title, contentX + markerSize + 14, y + titleFont.getSize()); + + Font subtitleFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.NORMAL, + Math.min(15, Math.max(12, height * 0.020))); + String subtitle = switch (error.getType()) { + case PARSE_ERROR, DSL_PARSE_ERROR, COMPILATION_ERROR -> + "The script could not be loaded. Fix the source, then reload to try again."; + default -> + "Playback paused safely. You can reload, copy the details, or continue past this error."; + }; + gc.setFont(subtitleFont); + gc.setFill(ERROR_DIM_TEXT_COLOR); + gc.fillText(subtitle, contentX + markerSize + 14, + y + titleFont.getSize() + subtitleFont.getSize() + 5); + y += Math.max(markerSize, titleFont.getSize() + subtitleFont.getSize() + 7) + 14; + + // Compact source/type metadata keeps the cause in view on smaller windows. + Font infoFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, + Math.min(14, Math.max(11, height * 0.018))); + String source = error.getSourceName() == null || error.getSourceName().isBlank() + ? "Unknown source" + : error.getSourceName(); + String location = error.getLineNumber() > 0 ? source + ":" + error.getLineNumber() : source; + String type = error.getType().name().replace('_', ' '); + String timeStr = new java.text.SimpleDateFormat("HH:mm:ss").format(new java.util.Date(error.getTimestamp())); + String metadata = location + " • " + type + " • " + timeStr; gc.setFont(infoFont); gc.setFill(ERROR_DIM_TEXT_COLOR); + gc.fillText(metadata, contentX, y + infoFont.getSize()); + y += infoFont.getSize() + 12; - if (error.getSourceName() != null && !error.getSourceName().isEmpty()) { - gc.fillText("File: " + error.getSourceName(), contentX, y + infoFont.getSize()); - y += infoFont.getSize() + 6; - } - if (error.getLineNumber() > 0) { - gc.fillText("Line: " + error.getLineNumber(), contentX, y + infoFont.getSize()); - y += infoFont.getSize() + 6; - } - gc.fillText("Type: " + error.getType().name().replace('_', ' '), contentX, y + infoFont.getSize()); - y += infoFont.getSize() + 16; + double buttonH = Math.min(44, Math.max(38, height * 0.065)); + double buttonY = Math.max(8, height - outerPadding - buttonH); + double bodyBottom = buttonY - 16; + double bodyAvailable = Math.max(72, bodyBottom - y); - if (error.getRawLine() != null && !error.getRawLine().isBlank()) { - gc.setFont(infoFont); - gc.setFill(ERROR_DIM_TEXT_COLOR); - gc.fillText("Script Line:", contentX, y + infoFont.getSize()); - y += infoFont.getSize() + 8; - double lineBoxH = Math.min(height * 0.09, 68); - gc.setFill(ERROR_BOX_COLOR); - gc.fillRoundRect(contentX, y, contentW, lineBoxH, 8, 8); - gc.setStroke(ERROR_PANEL_BORDER_COLOR); - gc.setLineWidth(1); - gc.strokeRoundRect(contentX, y, contentW, lineBoxH, 8, 8); - Font lineFont = Font.font("Monospaced", FontWeight.BOLD, Math.min(18, Math.max(15, height * 0.022))); - gc.setFont(lineFont); - gc.setFill(ERROR_TEXT_COLOR); - drawWrappedText(error.getRawLine(), contentX + 14, y + 22, contentW - 28, lineBoxH - 10, lineFont); - y += lineBoxH + 16; - } - - // Cause message box - Font causeFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, Math.min(22, Math.max(18, height * 0.028))); - gc.setFont(causeFont); - gc.setFill(ERROR_TEXT_COLOR); - gc.fillText("Cause:", contentX, y + causeFont.getSize()); - y += causeFont.getSize() + 8; + Font sectionFont = Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, + Math.min(15, Math.max(12, height * 0.019))); + Font msgFont = Font.font("Monospaced", FontWeight.NORMAL, + Math.min(15, Math.max(12, height * 0.019))); + // The human-readable cause is always first and always visible. String message = error.getMessage() != null ? error.getMessage() : "(unknown error)"; - double msgBoxH = Math.min(height * 0.13, 96); + gc.setFont(sectionFont); + gc.setFill(ERROR_TEXT_COLOR); + gc.fillText("What happened", contentX, y + sectionFont.getSize()); + y += sectionFont.getSize() + 7; + double messageRoom = Math.max(34, bodyBottom - y); + double msgBoxH = Math.min(92, Math.max(54, Math.min(bodyAvailable * 0.25, messageRoom))); gc.setFill(ERROR_BOX_COLOR); gc.fillRoundRect(contentX, y, contentW, msgBoxH, 8, 8); gc.setStroke(ERROR_PANEL_BORDER_COLOR); gc.setLineWidth(1); gc.strokeRoundRect(contentX, y, contentW, msgBoxH, 8, 8); - Font msgFont = Font.font("Monospaced", FontWeight.NORMAL, Math.min(18, Math.max(15, height * 0.022))); gc.setFont(msgFont); gc.setFill(ERROR_TEXT_COLOR); - // Word-wrap the message into the box drawWrappedText(message, contentX + 14, y + 22, contentW - 28, msgBoxH - 10, msgFont); - y += msgBoxH + 20; + y += msgBoxH + 12; + + String rawLine = error.getRawLine(); + boolean showRawLine = rawLine != null && !rawLine.isBlank() && y + 72 <= bodyBottom; + if (showRawLine) { + gc.setFont(sectionFont); + gc.setFill(ERROR_TEXT_COLOR); + gc.fillText("Source line", contentX, y + sectionFont.getSize()); + y += sectionFont.getSize() + 7; + double lineBoxH = Math.min(66, bodyBottom - y); + gc.setFill(ERROR_BOX_COLOR); + gc.fillRoundRect(contentX, y, contentW, lineBoxH, 8, 8); + gc.setStroke(ERROR_PANEL_BORDER_COLOR); + gc.setLineWidth(1); + gc.strokeRoundRect(contentX, y, contentW, lineBoxH, 8, 8); + Font lineFont = Font.font("Monospaced", FontWeight.BOLD, + Math.min(14, Math.max(11, height * 0.018))); + gc.setFont(lineFont); + gc.setFill(ERROR_TEXT_COLOR); + drawWrappedText(rawLine, contentX + 14, y + 20, contentW - 28, lineBoxH - 10, lineFont); + y += lineBoxH + 12; + } String likelyCause = error.getLikelyCause(); - if (likelyCause != null && !likelyCause.isBlank()) { - gc.setFont(causeFont); + boolean showLikelyCause = likelyCause != null && !likelyCause.isBlank() + && y + 90 <= bodyBottom; + if (showLikelyCause) { + gc.setFont(sectionFont); gc.setFill(ERROR_TEXT_COLOR); - gc.fillText("Likely Went Wrong:", contentX, y + causeFont.getSize()); - y += causeFont.getSize() + 8; + gc.fillText("Likely cause", contentX, y + sectionFont.getSize()); + y += sectionFont.getSize() + 7; - double likelyBoxH = Math.min(height * 0.14, 106); + double likelyBoxH = Math.min(88, Math.max(62, (bodyBottom - y) * 0.34)); gc.setFill(ERROR_BOX_COLOR); gc.fillRoundRect(contentX, y, contentW, likelyBoxH, 8, 8); gc.setStroke(ERROR_PANEL_BORDER_COLOR); @@ -4159,65 +4191,67 @@ public int renderErrorOverlay(com.jvn.core.vn.VnErrorOverlay error, gc.setFont(msgFont); gc.setFill(ERROR_TEXT_COLOR); drawWrappedText(likelyCause, contentX + 14, y + 22, contentW - 28, likelyBoxH - 10, msgFont); - y += likelyBoxH + 20; + y += likelyBoxH + 12; } - // Stack trace area (scrollable-looking panel) + // Technical details use only the remaining room; Copy Details always + // includes them even when the viewport is too small to show them. String trace = error.getStackTrace(); - if (trace != null && !trace.isEmpty()) { - gc.setFont(infoFont); + if (trace != null && !trace.isBlank() && y + 72 <= bodyBottom) { + gc.setFont(sectionFont); gc.setFill(ERROR_DIM_TEXT_COLOR); - gc.fillText("Stack Trace:", contentX, y + infoFont.getSize()); - y += infoFont.getSize() + 8; - - double traceBoxH = Math.min(height * 0.32, height - y - 92); - traceBoxH = Math.max(traceBoxH, 80); + gc.fillText("Technical details", contentX, y + sectionFont.getSize()); + y += sectionFont.getSize() + 7; + double traceBoxH = Math.max(54, bodyBottom - y); gc.setFill(ERROR_BOX_COLOR); gc.fillRoundRect(contentX, y, contentW, traceBoxH, 8, 8); gc.setStroke(ERROR_PANEL_BORDER_COLOR); gc.setLineWidth(1); gc.strokeRoundRect(contentX, y, contentW, traceBoxH, 8, 8); - - Font traceFont = Font.font("Monospaced", FontWeight.NORMAL, Math.min(15, Math.max(13, height * 0.018))); + Font traceFont = Font.font("Monospaced", FontWeight.NORMAL, + Math.min(13, Math.max(10, height * 0.016))); gc.setFont(traceFont); gc.setFill(ERROR_DIM_TEXT_COLOR); - drawWrappedText(trace, contentX + 14, y + 20, contentW - 28, traceBoxH - 14, traceFont); - y += traceBoxH + 16; + drawWrappedText(trace, contentX + 14, y + 18, contentW - 28, traceBoxH - 12, traceFont); } - // Action buttons at bottom - double buttonY = Math.max(y + 12, height - 68); - double buttonH = 44; - double buttonW = 156; - double buttonGap = 16; - double buttonsStartX = contentX; + double buttonGap = Math.max(8, Math.min(14, contentW * 0.018)); + double buttonW = Math.min(164, (contentW - buttonGap * 2) / 3.0); + double buttonsWidth = buttonW * 3 + buttonGap * 2; + double buttonsStartX = contentX + Math.max(0, contentW - buttonsWidth); int hoveredButton = -1; - String[] labels = {"Ignore", "Reload", "Copy Traceback"}; + if (contentW - buttonsWidth >= 250) { + gc.setFont(Font.font(DEFAULT_FONT_FAMILY, FontWeight.NORMAL, + Math.min(12, Math.max(10, height * 0.016)))); + gc.setFill(ERROR_DIM_TEXT_COLOR); + gc.fillText("Enter / R Reload Esc Continue C Copy", + contentX, buttonY + buttonH / 2 + 5); + } + + String[] labels = {"Continue", "Reload Script", "Copy Details"}; for (int i = 0; i < labels.length; i++) { double bx = buttonsStartX + i * (buttonW + buttonGap); boolean hovered = mouseX >= bx && mouseX <= bx + buttonW && mouseY >= buttonY && mouseY <= buttonY + buttonH; if (hovered) hoveredButton = i; - gc.setFill(hovered ? ERROR_BUTTON_HOVER_COLOR : ERROR_BUTTON_COLOR); + boolean primary = i == 1; + gc.setFill(primary + ? (hovered ? ERROR_PRIMARY_BUTTON_HOVER_COLOR : ERROR_PRIMARY_BUTTON_COLOR) + : (hovered ? ERROR_BUTTON_HOVER_COLOR : ERROR_BUTTON_COLOR)); gc.fillRoundRect(bx, buttonY, buttonW, buttonH, 6, 6); - gc.setStroke(ERROR_PANEL_BORDER_COLOR); + gc.setStroke(primary ? Color.web("#7cc8f4") : ERROR_PANEL_BORDER_COLOR); gc.setLineWidth(1); gc.strokeRoundRect(bx, buttonY, buttonW, buttonH, 6, 6); - gc.setFont(Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, 15)); + gc.setFont(Font.font(DEFAULT_FONT_FAMILY, FontWeight.BOLD, + Math.min(14, Math.max(11, buttonW / 11.0)))); gc.setFill(ERROR_BUTTON_TEXT_COLOR); double textW = computeTextWidth(labels[i], gc.getFont()); gc.fillText(labels[i], bx + (buttonW - textW) / 2, buttonY + buttonH / 2 + 6); } - // Timestamp in bottom-right corner - gc.setFont(Font.font(DEFAULT_FONT_FAMILY, FontWeight.NORMAL, 12)); - gc.setFill(ERROR_DIM_TEXT_COLOR); - String timeStr = new java.text.SimpleDateFormat("HH:mm:ss").format(new java.util.Date(error.getTimestamp())); - gc.fillText(timeStr, width - padding - 60, height - 12); - return hoveredButton; } @@ -4229,28 +4263,51 @@ private void drawWrappedText(String text, double x, double y, double currentY = y; String[] lines = text.split("\n"); for (String line : lines) { - if (currentY + lineH > y + maxHeight) { - gc.fillText("...", x, currentY); - break; - } - // Simple character-based wrapping if (line.isEmpty()) { currentY += lineH; continue; } - double charW = font.getSize() * 0.62; // Approx monospace char width - int charsPerLine = Math.max(1, (int) (maxWidth / charW)); - int pos = 0; - while (pos < line.length()) { + String remaining = line; + while (!remaining.isEmpty()) { if (currentY + lineH > y + maxHeight) { - gc.fillText("...", x, currentY); + gc.fillText("…", x, Math.min(currentY, y + maxHeight)); return; } - int end = Math.min(pos + charsPerLine, line.length()); - gc.fillText(line.substring(pos, end), x, currentY); + int end = fittingTextEnd(remaining, maxWidth, font); + if (end < remaining.length()) { + int whitespace = lastWhitespaceBefore(remaining, end); + if (whitespace >= Math.max(1, end / 3)) end = whitespace; + } + end = Math.max(1, end); + String visualLine = remaining.substring(0, end).stripTrailing(); + gc.fillText(visualLine, x, currentY); currentY += lineH; - pos = end; + remaining = remaining.substring(end).stripLeading(); + } + } + } + + private int fittingTextEnd(String text, double maxWidth, Font font) { + if (text == null || text.isEmpty()) return 0; + if (computeTextWidth(text, font) <= maxWidth) return text.length(); + int low = 1; + int high = text.length(); + while (low < high) { + int mid = (low + high + 1) >>> 1; + if (computeTextWidth(text.substring(0, mid), font) <= maxWidth) { + low = mid; + } else { + high = mid - 1; } } + return low; + } + + private static int lastWhitespaceBefore(String text, int end) { + int safeEnd = Math.min(text == null ? 0 : text.length(), Math.max(0, end)); + for (int i = safeEnd - 1; i > 0; i--) { + if (Character.isWhitespace(text.charAt(i))) return i; + } + return -1; } } diff --git a/modules/hub/src/main/java/com/jvn/hub/JvnHub.java b/modules/hub/src/main/java/com/jvn/hub/JvnHub.java index 9206961d..d2ca22f2 100644 --- a/modules/hub/src/main/java/com/jvn/hub/JvnHub.java +++ b/modules/hub/src/main/java/com/jvn/hub/JvnHub.java @@ -18,7 +18,6 @@ import java.awt.GridLayout; import java.awt.LayoutManager; import java.awt.LinearGradientPaint; -import java.awt.Point; import java.awt.RadialGradientPaint; import java.awt.Rectangle; import java.awt.RenderingHints; @@ -49,17 +48,11 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.CodeSource; -import java.time.LocalDate; -import java.time.format.DateTimeParseException; import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -207,12 +200,8 @@ private static VectorIcon uiIcon(VectorIcon.Kind kind, int size, Color color) { private int activeStepIndex = -1; private String activeStepLabel = ""; - /** Currently-loaded announcements; refreshed on startup and after Update Engine. */ - private final List announcements = new ArrayList<>(); /** Launcher maintenance state; refreshed on startup and after Update Engine. */ private LauncherMaintenanceState launcherMaintenanceState = LauncherMaintenanceState.available(); - /** IDs (date+title) of announcements the user has already opened in the dialog. */ - private final Set readIds = new HashSet<>(); /** Developer Mode exposes engineering-focused actions and launch flags. */ private boolean developerModeEnabled = false; private DeveloperModeToggleButton developerModeButton; @@ -240,8 +229,6 @@ private static VectorIcon uiIcon(VectorIcon.Kind kind, int size, Color color) { private HeaderIconButton aboutButton; /** Header shortcut that opens the public documentation website. */ private HeaderIconButton documentationButton; - /** Bell button in the header; redrawn so the small badge reflects announcement count. */ - private AnnouncementsButton announcementsButton; /** Update button with a right-aligned incoming-commit badge. */ private UpdateEngineButton updateEngineButton; private HubShellPanel shellPanel; @@ -252,10 +239,6 @@ private static VectorIcon uiIcon(VectorIcon.Kind kind, int size, Color color) { private JvnHub(Path projectRoot) { this.projectRoot = projectRoot; - // Load persisted read-state first so the badge counts only unread entries - // on the very first paint. - readIds.addAll(loadReadIds()); - announcements.addAll(loadAnnouncements()); launcherMaintenanceState = loadLauncherMaintenanceState(); buildUi(); checkIncomingUpdates(true); @@ -648,7 +631,6 @@ protected void paintComponent(Graphics graphics) { JMenu help = hubMenu("Help"); help.add(hubMenuItem("Documentation", this::openDocumentationWebsite)); - help.add(hubMenuItem("Announcements", this::showAnnouncementsDialog)); help.addSeparator(); help.add(hubMenuItem("About Engine Hub", this::showAboutReport)); @@ -824,7 +806,7 @@ private JPanel buildHeader() { left.add(titleBox); header.add(left, BorderLayout.WEST); - // --- Right: documentation + announcements ------------------------------- + // --- Right: mode toggles and utility shortcuts --------------------------- developerModeButton = new DeveloperModeToggleButton(); developerModeButton.addActionListener(e -> setDeveloperModeEnabled(developerModeButton.isSelected())); actionButtons.add(developerModeButton); @@ -851,10 +833,6 @@ private JPanel buildHeader() { documentationButton.addActionListener(e -> openDocumentationWebsite()); actionButtons.add(documentationButton); - announcementsButton = new AnnouncementsButton(); - announcementsButton.refreshBadge(unreadCount()); - announcementsButton.addActionListener(e -> showAnnouncementsDialog()); - JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, ui(6), 0)); right.setOpaque(false); right.add(developerModeButton); @@ -862,272 +840,11 @@ private JPanel buildHeader() { right.add(diagnosticsButton); right.add(aboutButton); right.add(documentationButton); - right.add(announcementsButton); header.add(right, BorderLayout.EAST); return header; } - // --- Announcements --------------------------------------------------------- - - /** - * Reads the announcements file (committed at {@code .jvn/announcements.md} so it - * arrives via {@code git pull --rebase}). The format is markdown-flavoured: - * each entry begins with {@code ## YYYY-MM-DD — Title} followed by a body that - * runs until the next {@code ## } header or end of file. - */ - private List loadAnnouncements() { - Path file = projectRoot.resolve(".jvn/announcements.md"); - if (!Files.isRegularFile(file)) return Collections.emptyList(); - List lines; - try { - lines = Files.readAllLines(file, StandardCharsets.UTF_8); - } catch (IOException e) { - appendLog("[hub] failed to read announcements: " + e.getMessage()); - return Collections.emptyList(); - } - List out = new ArrayList<>(); - String date = null; - String title = null; - StringBuilder body = new StringBuilder(); - for (String raw : lines) { - if (raw.startsWith("## ")) { - if (date != null) { - out.add(new Announcement(date, title, body.toString().strip())); - body.setLength(0); - } - // Header form: "## 2026-04-27 — Title" or "## 2026-04-27 - Title" - String header = raw.substring(3).strip(); - int sep = indexOfFirst(header, " — ", " - ", " – "); - if (sep > 0) { - date = header.substring(0, sep).strip(); - title = header.substring(sep).replaceFirst("^[\\s\u2014\u2013-]+", "").strip(); - } else { - date = header; - title = ""; - } - } else if (date != null) { - body.append(raw).append('\n'); - } - // Lines before the first header are intentionally ignored (file preamble). - } - if (date != null) { - out.add(new Announcement(date, title, body.toString().strip())); - } - sortAnnouncementsNewestFirst(out); - return out; - } - - private static void sortAnnouncementsNewestFirst(List entries) { - if (entries == null || entries.size() < 2) return; - entries.sort(Comparator.comparing(JvnHub::announcementDate).reversed()); - } - - static int compareAnnouncementDatesNewestFirst(String left, String right) { - Announcement leftEntry = new Announcement(left, "", ""); - Announcement rightEntry = new Announcement(right, "", ""); - return Comparator.comparing(JvnHub::announcementDate).reversed().compare(leftEntry, rightEntry); - } - - private static LocalDate announcementDate(Announcement announcement) { - if (announcement == null || announcement.date() == null) return LocalDate.MIN; - try { - return LocalDate.parse(announcement.date().strip()); - } catch (DateTimeParseException ignored) { - return LocalDate.MIN; - } - } - - private static int indexOfFirst(String s, String... needles) { - int best = -1; - for (String n : needles) { - int idx = s.indexOf(n); - if (idx >= 0 && (best < 0 || idx < best)) best = idx; - } - return best; - } - - private void showAnnouncementsDialog() { - sortAnnouncementsNewestFirst(announcements); - JDialog dialog = new JDialog(frame, "Announcements", true); - dialog.setUndecorated(false); - - JPanel root = new JPanel(new BorderLayout(0, ui(12))); - root.setBackground(BG); - root.setBorder(uiPadding(16, 16, 16, 16)); - - JLabel header = new JLabel("Engine Announcements"); - header.setForeground(TEXT_PRIMARY); - header.setFont(header.getFont().deriveFont(Font.BOLD, uiFont(16f))); - - JLabel sub = new JLabel(announcements.isEmpty() - ? "No announcements yet. Click \"Update Engine\" to fetch the latest." - : announcements.size() + " total \u00B7"); - sub.setForeground(TEXT_MUTED); - sub.setFont(sub.getFont().deriveFont(Font.PLAIN, uiFont(11f))); - - JPanel headerBox = new JPanel(); - headerBox.setBackground(BG); - headerBox.setLayout(new BoxLayout(headerBox, BoxLayout.Y_AXIS)); - header.setAlignmentX(Component.LEFT_ALIGNMENT); - sub.setAlignmentX(Component.LEFT_ALIGNMENT); - headerBox.add(header); - headerBox.add(Box.createVerticalStrut(ui(2))); - headerBox.add(sub); - root.add(headerBox, BorderLayout.NORTH); - - // List body. - JPanel list = new JPanel(); - list.setBackground(BG); - list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS)); - list.setBorder(BorderFactory.createEmptyBorder()); - - if (announcements.isEmpty()) { - JLabel empty = new JLabel("—"); - empty.setForeground(TEXT_MUTED); - list.add(empty); - } else { - for (int i = 0; i < announcements.size(); i++) { - list.add(buildAnnouncementCard(announcements.get(i))); - if (i < announcements.size() - 1) list.add(Box.createVerticalStrut(ui(10))); - } - } - - JScrollPane scroll = new JScrollPane(list); - scroll.setBorder(BorderFactory.createLineBorder(BORDER_NEUTRAL)); - scroll.setBackground(BG); - scroll.getViewport().setOpaque(true); - scroll.getViewport().setBackground(BG); - scroll.setPreferredSize(uiDimension(540, 320)); - styleScrollBar(scroll.getVerticalScrollBar()); - styleScrollBar(scroll.getHorizontalScrollBar()); - root.add(scroll, BorderLayout.CENTER); - - FlatButton close = new FlatButton("Close", - uiIcon(VectorIcon.Kind.CLOSE, 14, TEXT_PRIMARY), null); - close.addActionListener(e -> dialog.dispose()); - JPanel footer = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); - footer.setBackground(BG); - footer.add(close); - root.add(footer, BorderLayout.SOUTH); - - dialog.setContentPane(root); - dialog.pack(); - scroll.getViewport().setViewPosition(new Point(0, 0)); - scroll.getVerticalScrollBar().setValue(0); - dialog.setLocationRelativeTo(frame); - // Opening the dialog counts as reading every entry currently visible in it. - // The badge clears before the dialog shows so the user never sees a stale count. - markAllAnnouncementsRead(); - dialog.setVisible(true); - } - - // --- Read-state persistence ------------------------------------------------ - - private static String idOf(Announcement a) { - return a.date + "|" + a.title; - } - - private int unreadCount() { - int n = 0; - for (Announcement a : announcements) { - if (!readIds.contains(idOf(a))) n++; - } - return n; - } - - private void markAllAnnouncementsRead() { - if (announcements.isEmpty()) return; - boolean changed = false; - for (Announcement a : announcements) { - changed |= readIds.add(idOf(a)); - } - if (changed) saveReadIds(); - if (announcementsButton != null) announcementsButton.refreshBadge(0); - } - - /** Per-user, machine-local state file. Lives outside the repo by design. */ - private static Path readStateFile() { - return Paths.get(System.getProperty("user.home", "."), ".jvn", "hub-read.properties"); - } - - private static Set loadReadIds() { - Path file = readStateFile(); - if (!Files.isRegularFile(file)) return new HashSet<>(); - Properties props = new Properties(); - try (InputStream in = Files.newInputStream(file)) { - props.load(in); - } catch (IOException ignored) { - // reason: I/O failure on best-effort save/load; in-memory state remains valid - return new HashSet<>(); - } - Set out = new HashSet<>(); - for (String key : props.stringPropertyNames()) { - if ("true".equalsIgnoreCase(props.getProperty(key))) out.add(key); - } - return out; - } - - private void saveReadIds() { - Path file = readStateFile(); - try { - Files.createDirectories(file.getParent()); - } catch (IOException e) { - appendLog("[hub] could not create state dir: " + e.getMessage()); - return; - } - Properties props = new Properties(); - for (String id : readIds) props.setProperty(id, "true"); - try (var out = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { - props.store(out, "JVN Engine Hub \u2014 announcement read-state. Auto-generated."); - } catch (IOException e) { - appendLog("[hub] could not save read-state: " + e.getMessage()); - } - } - - private JPanel buildAnnouncementCard(Announcement a) { - JPanel card = new JPanel(new BorderLayout(0, ui(4))); - card.setBackground(BG); - card.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(BORDER_NEUTRAL), - uiPadding(10, 12, 10, 12))); - - JLabel dateLbl = new JLabel(a.date); - dateLbl.setForeground(ACCENT_NEUTRAL); - dateLbl.setFont(dateLbl.getFont().deriveFont(Font.BOLD, uiFont(10f))); - - JLabel titleLbl = new JLabel(a.title.isEmpty() ? "Update" : a.title); - titleLbl.setForeground(TEXT_PRIMARY); - titleLbl.setFont(titleLbl.getFont().deriveFont(Font.BOLD, uiFont(13f))); - - JPanel head = new JPanel(); - head.setBackground(BG); - head.setLayout(new BoxLayout(head, BoxLayout.Y_AXIS)); - dateLbl.setAlignmentX(Component.LEFT_ALIGNMENT); - titleLbl.setAlignmentX(Component.LEFT_ALIGNMENT); - head.add(dateLbl); - head.add(Box.createVerticalStrut(ui(1))); - head.add(titleLbl); - card.add(head, BorderLayout.NORTH); - - JTextArea bodyArea = new JTextArea(a.body); - bodyArea.setEditable(false); - bodyArea.setFocusable(false); - bodyArea.setLineWrap(true); - bodyArea.setWrapStyleWord(true); - bodyArea.setOpaque(true); - bodyArea.setBackground(BG); - bodyArea.setForeground(TEXT_SOFT); - bodyArea.setFont(bodyArea.getFont().deriveFont(Font.PLAIN, uiFont(12f))); - bodyArea.setBorder(uiPadding(4, 0, 0, 0)); - card.add(bodyArea, BorderLayout.CENTER); - - return card; - } - - /** Small immutable record describing one announcement entry. */ - private record Announcement(String date, String title, String body) {} - private LauncherMaintenanceState loadLauncherMaintenanceState() { Path file = projectRoot.resolve(".jvn/maintenance.properties"); if (!Files.isRegularFile(file)) return LauncherMaintenanceState.available(); @@ -2779,7 +2496,7 @@ private void startProcess(List command, String label) { } } // Update Engine touched the working tree — re-read on-disk state so - // the version label and announcements list reflect the new HEAD. + // version and maintenance state reflect the new HEAD. if (exit == 0 && "Update Engine".equals(label)) { refreshFromDisk(); checkIncomingUpdates(false); @@ -3267,7 +2984,7 @@ private List stepsForAction(String label) { "Fetch upstream changes", "Rebase local checkout", "Refresh version metadata", - "Reload announcements"); + "Reload engine state"); case "clean before update" -> List.of( "Inspect local files", "Select safe clean command", @@ -4102,30 +3819,21 @@ private static boolean isRunningFromSource() { } /** - * Re-reads the version + announcements file and updates the corresponding - * Swing components. Safe to call from any thread; UI updates are dispatched - * to the EDT. + * Re-reads version and launcher-maintenance state from disk. Safe to call + * from any thread; UI updates are dispatched to the EDT. */ private void refreshFromDisk() { String newVersion = readDiskVersion(); - List fresh = loadAnnouncements(); LauncherMaintenanceState freshLauncherMaintenance = loadLauncherMaintenanceState(); SwingUtilities.invokeLater(() -> { versionLabel.setText(formatVersionLabel(newVersion)); - announcements.clear(); - announcements.addAll(fresh); - sortAnnouncementsNewestFirst(announcements); - int unread = unreadCount(); - if (announcementsButton != null) announcementsButton.refreshBadge(unread); boolean maintenanceChanged = launcherMaintenanceState.underMaintenance() != freshLauncherMaintenance.underMaintenance(); launcherMaintenanceState = freshLauncherMaintenance; if (runLauncherButton instanceof LauncherButton launcherButton) { launcherButton.setMaintenanceState(freshLauncherMaintenance); } - appendLog("[hub] refresh: " + fresh.size() - + " announcement" + (fresh.size() == 1 ? "" : "s") - + " (" + unread + " unread). Version: " + newVersion + appendLog("[hub] refresh: version " + newVersion + ". Launcher maintenance: " + (freshLauncherMaintenance.underMaintenance() ? "on" : "off") + (maintenanceChanged ? " (changed)" : "") + "."); @@ -4964,7 +4672,7 @@ protected void paintComponent(Graphics g) { } } - /** Borderless header icon button with the same hover treatment as the bell. */ + /** Borderless header utility button with the shared Hub hover treatment. */ private static class HeaderIconButton extends JButton { HeaderIconButton(Icon icon, String tooltip) { setIcon(icon); @@ -5013,70 +4721,6 @@ private static void paintHeaderHoverBackdrop(Graphics2D g, JComponent component, Math.round(radius * 2.0f)); } - /** - * Borderless bell button with a small numeric badge in the top-right corner. - * The badge auto-hides when the announcement count is zero. Painted entirely - * by Swing — no rasters required. - */ - private static final class AnnouncementsButton extends JButton { - private int count = 0; - - AnnouncementsButton() { - setIcon(WindowsSevenActionIcon.of(WindowsSevenActionIcon.Kind.ANNOUNCEMENTS, 24)); - setToolTipText("Announcements — small updates pulled from the engine"); - setContentAreaFilled(false); - setBorderPainted(false); - setFocusPainted(false); - setOpaque(false); - setRolloverEnabled(true); - setBorder(uiPadding(8, 8, 8, 8)); - setPreferredSize(uiDimension(40, 40)); - } - - void refreshBadge(int newCount) { - this.count = Math.max(0, newCount); - setToolTipText(count == 0 - ? "No announcements yet" - : count + " announcement" + (count == 1 ? "" : "s") + " — click to read"); - repaint(); - } - - @Override - protected void paintComponent(Graphics g) { - if (getModel().isRollover() || getModel().isPressed()) { - Graphics2D h = (Graphics2D) g.create(); - h.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - paintHeaderHoverBackdrop(h, this, getModel().isPressed()); - h.dispose(); - } - super.paintComponent(g); - - if (count <= 0) return; - - // Badge: small filled circle + numeric label, anchored top-right. - Graphics2D g2 = (Graphics2D) g.create(); - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - - String text = count > 9 ? "9+" : Integer.toString(count); - Font badgeFont = getFont().deriveFont(Font.BOLD, uiFont(9f)); - g2.setFont(badgeFont); - FontMetrics fm = g2.getFontMetrics(); - int textW = fm.stringWidth(text); - int diameter = Math.max(ui(14), textW + ui(8)); - int badgeX = getWidth() - diameter - ui(4); - int badgeY = ui(4); - - g2.setColor(ACCENT_MAINTENANCE); - g2.fillRoundRect(badgeX, badgeY, diameter, ui(14), ui(14), ui(14)); - g2.setColor(Color.WHITE); - int textX = badgeX + (diameter - textW) / 2; - int textY = badgeY + ui(14) - (ui(14) - fm.getAscent() + fm.getDescent()) / 2 - ui(1); - g2.drawString(text, textX, textY); - g2.dispose(); - } - } - /** Temporary terminal-style size readout painted over the Hub during live resize. */ private static final class ResizeOverlay extends JComponent { private final JFrame owner; @@ -5158,7 +4802,7 @@ enum Kind { EDITOR, LAUNCHER, LAUNCHER_MAINTENANCE, BUILD, SHORTCUT, UPDATE, TESTS, OPTIONS, MORE, CANCEL, QUIT, DEVELOPER, DEVELOPER_ACTIVE, SAFE, SAFE_ACTIVE, - DIAGNOSTICS, ABOUT, DOCUMENTATION, ANNOUNCEMENTS + DIAGNOSTICS, ABOUT, DOCUMENTATION } private final Kind kind; @@ -5208,7 +4852,6 @@ public void paintIcon(Component component, Graphics graphics, int x, int y) { case DIAGNOSTICS -> paintDiagnostics(g); case ABOUT -> paintAbout(g); case DOCUMENTATION -> paintDocumentation(g); - case ANNOUNCEMENTS -> paintAnnouncements(g); } } finally { g.dispose(); @@ -5465,25 +5108,6 @@ private static void paintDocumentation(Graphics2D g) { g.drawLine(14, 9, 17, 9); } - private static void paintAnnouncements(Graphics2D g) { - glassOrb(g, Color.decode("#ffe26f"), Color.decode("#d18116")); - g.setPaint(new java.awt.GradientPaint(8, 5, Color.decode("#fff9c9"), 16, 19, Color.decode("#e8a528"))); - Path2D bell = new Path2D.Float(); - bell.moveTo(12, 5); - bell.curveTo(16, 5, 17, 9, 17, 13); - bell.lineTo(19, 17); - bell.lineTo(5, 17); - bell.lineTo(7, 13); - bell.curveTo(7, 9, 8, 5, 12, 5); - bell.closePath(); - g.fill(bell); - g.setColor(Color.decode("#89531a")); - g.draw(bell); - g.fillOval(10, 18, 4, 3); - g.setColor(new Color(255, 255, 255, 130)); - g.fillOval(9, 7, 5, 4); - } - private static void glassTile(Graphics2D g, int x, int y, int width, int height, Color top, Color bottom) { shadow(g, x, y, width, height, 4); @@ -5584,7 +5208,7 @@ private static void gloss(Graphics2D g, int x, int y, int width, int height, int * configurable so the same {@link Kind} can be reused across contexts. */ private static final class VectorIcon implements Icon { - enum Kind { PLAY, EDIT, ROCKET, HAMMER, CHECK, REFRESH, STOP, CLOSE, BELL, SHORTCUT, DOCUMENTATION, HEALTH, INFO, SHIELD, DEVELOPER, SLIDERS } + enum Kind { PLAY, EDIT, ROCKET, HAMMER, CHECK, REFRESH, STOP, CLOSE, SHORTCUT, DOCUMENTATION, HEALTH, INFO, SHIELD, DEVELOPER, SLIDERS } private final Kind kind; private final int size; @@ -5787,26 +5411,6 @@ public void paintIcon(Component c, Graphics g, int x, int y) { head.closePath(); g2.fill(head); } - case BELL -> { - // Top stem (the loop where a real bell would hang). - float stemW = s * 0.12f; - float stemH = s * 0.08f; - g2.fill(new RoundRectangle2D.Float( - (s - stemW) / 2f, s * 0.06f, stemW, stemH, s * 0.04f, s * 0.04f)); - - // Dome that flares into a wide flange — closes flat across the bottom. - Path2D body = new Path2D.Float(); - body.moveTo(s * 0.50f, s * 0.16f); - body.curveTo(s * 0.78f, s * 0.20f, s * 0.78f, s * 0.50f, s * 0.84f, s * 0.70f); - body.lineTo(s * 0.16f, s * 0.70f); - body.curveTo(s * 0.22f, s * 0.50f, s * 0.22f, s * 0.20f, s * 0.50f, s * 0.16f); - body.closePath(); - g2.fill(body); - - // Clapper — small ball just below the flange. - float clapper = s * 0.16f; - g2.fill(new Ellipse2D.Float((s - clapper) / 2f, s * 0.74f, clapper, clapper)); - } case DOCUMENTATION -> { // Document sheet with a folded corner and two guide lines. float pad = s * 0.16f; diff --git a/modules/hub/src/main/java/com/jvn/hub/WindowsSevenHubIconPainter.java b/modules/hub/src/main/java/com/jvn/hub/WindowsSevenHubIconPainter.java index 4041c2f7..49a70db0 100644 --- a/modules/hub/src/main/java/com/jvn/hub/WindowsSevenHubIconPainter.java +++ b/modules/hub/src/main/java/com/jvn/hub/WindowsSevenHubIconPainter.java @@ -58,7 +58,6 @@ static boolean paint(Graphics2D source, String kind) { case "DIAGNOSTICS" -> paintDiagnostics(g); case "ABOUT" -> paintAbout(g); case "DOCUMENTATION" -> paintDocumentation(g); - case "ANNOUNCEMENTS" -> paintAnnouncements(g); default -> { return false; } @@ -370,25 +369,6 @@ private static void paintDocumentation(Graphics2D g) { g.fillPolygon(new int[] {14, 17, 17, 15}, new int[] {14, 14, 21, 19}, 4); } - private static void paintAnnouncements(Graphics2D g) { - chromeOrb(g); - Path2D bell = new Path2D.Float(); - bell.moveTo(12, 5); - bell.curveTo(16, 5, 17, 9, 17, 13); - bell.lineTo(19, 17); bell.lineTo(5, 17); bell.lineTo(7, 13); - bell.curveTo(7, 9, 8, 5, 12, 5); bell.closePath(); - g.setPaint(new LinearGradientPaint(7, 5, 17, 18, - new float[] {0f, 0.48f, 1f}, new Color[] {WHITE, SILVER, GRAPHITE})); - g.fill(bell); - g.setColor(BLACK); - g.draw(bell); - g.setColor(ORANGE); - g.fillOval(10, 18, 4, 3); - g.fill(new Ellipse2D.Float(16.5f, 5.5f, 3.2f, 3.2f)); - g.setColor(WHITE); - g.fill(new Ellipse2D.Float(17.1f, 6f, 0.9f, 0.9f)); - } - private static void chromeTile(Graphics2D g, float x, float y, float width, float height, float arc) { shadow(g, x, y, width, height, arc); g.setPaint(chromeGradient(y, y + height)); diff --git a/modules/hub/src/test/java/com/jvn/hub/JvnHubDisplayScaleTest.java b/modules/hub/src/test/java/com/jvn/hub/JvnHubDisplayScaleTest.java index e19e0ca6..edce5821 100644 --- a/modules/hub/src/test/java/com/jvn/hub/JvnHubDisplayScaleTest.java +++ b/modules/hub/src/test/java/com/jvn/hub/JvnHubDisplayScaleTest.java @@ -35,13 +35,6 @@ void resizeOverlayReportsClampedPixelDimensions() { assertEquals("0 × 480 px", JvnHub.formatWindowPixels(-1, 480)); } - @Test - void announcementDatesAlwaysSortNewestFirst() { - assertTrue(JvnHub.compareAnnouncementDatesNewestFirst("2026-07-18", "2026-06-09") < 0); - assertTrue(JvnHub.compareAnnouncementDatesNewestFirst("not-a-date", "2026-06-09") > 0); - assertEquals(0, JvnHub.compareAnnouncementDatesNewestFirst("2026-07-18", "2026-07-18")); - } - @Test void sourceBuildIndicatorUsesTheHubOrangeAccent() { String label = JvnHub.formatVersionLabel("0.4.2"); diff --git a/modules/hub/src/test/java/com/jvn/hub/WindowsSevenHubIconPainterTest.java b/modules/hub/src/test/java/com/jvn/hub/WindowsSevenHubIconPainterTest.java index da82217a..c7988ce2 100644 --- a/modules/hub/src/test/java/com/jvn/hub/WindowsSevenHubIconPainterTest.java +++ b/modules/hub/src/test/java/com/jvn/hub/WindowsSevenHubIconPainterTest.java @@ -14,7 +14,7 @@ class WindowsSevenHubIconPainterTest { private static final String[] KINDS = { "EDITOR", "LAUNCHER", "LAUNCHER_MAINTENANCE", "BUILD", "SHORTCUT", "UPDATE", "TESTS", "OPTIONS", "MORE", "CANCEL", "QUIT", "DEVELOPER", "DEVELOPER_ACTIVE", - "SAFE", "SAFE_ACTIVE", "DIAGNOSTICS", "ABOUT", "DOCUMENTATION", "ANNOUNCEMENTS" + "SAFE", "SAFE_ACTIVE", "DIAGNOSTICS", "ABOUT", "DOCUMENTATION" }; @Test