From bdde62b2228a77a42e5e0c0ec6e83d05e56f33b5 Mon Sep 17 00:00:00 2001 From: David Given Date: Sat, 24 Oct 2026 22:47:38 +0200 Subject: [PATCH 01/11] Add the skeleton of the flux viewer. --- .../fluxengine/gui/ApplicationFrame.java | 3 +++ .../com/cowlark/fluxengine/gui/FluxPanel.java | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 java/com/cowlark/fluxengine/gui/FluxPanel.java diff --git a/java/com/cowlark/fluxengine/gui/ApplicationFrame.java b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java index 4ee212b0..05f4b280 100644 --- a/java/com/cowlark/fluxengine/gui/ApplicationFrame.java +++ b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java @@ -47,6 +47,7 @@ public class ApplicationFrame extends JFrame private final VisualiserPanel visualiserPanel; private final ImagePanel imagePanel; private final LogPanel logPanel; + private final FluxPanel fluxPanel; private final SummaryPanel summaryPanel; private final FilesystemPanel filesystemPanel; @@ -59,6 +60,7 @@ public class ApplicationFrame extends JFrame currentWorkflow = preferences.makeEnumPreference(WORKFLOW, Workflow.DISK_READING); summaryPanel = new SummaryPanel(model); logPanel = new LogPanel(model); + fluxPanel = new FluxPanel(model); imagePanel = new ImagePanel(model); visualiserPanel = new VisualiserPanel(model); configurationPanel = new ConfigurationPanel(model); @@ -86,6 +88,7 @@ public class ApplicationFrame extends JFrame tabbedPane().withMinSize(100, 0) .add(tab("Image").add(of(imagePanel))) .add(tab("Log").add(of(logPanel))) + .add(tab("Flux").add(of(fluxPanel))) .add(tab("Files").add(of(filesystemPanel)))) .add( BOTTOM, diff --git a/java/com/cowlark/fluxengine/gui/FluxPanel.java b/java/com/cowlark/fluxengine/gui/FluxPanel.java new file mode 100644 index 00000000..09982d69 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/FluxPanel.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UI.of; + +import javax.swing.JPanel; + +public class FluxPanel extends JPanel +{ + private final ImageViewModel model; + + public FluxPanel(ImageViewModel model) + { + /* Retain the shared model for the flux viewer's future controls and display. */ + this.model = model; + + /* Match the layout and padding of the other centre-window panels. */ + of(this).withLayout("fill, insets 5"); + } +} From 1f251f3ac2034368a4a7521d538212668fe2b146 Mon Sep 17 00:00:00 2001 From: David Given Date: Sat, 24 Oct 2026 23:40:01 +0200 Subject: [PATCH 02/11] Add the flux viewer panel. --- .../com/cowlark/fluxengine/gui/FluxPanel.java | 57 ++++++++- .../fluxengine/gui/FluxViewerPanel.java | 110 ++++++++++++++++++ java/com/cowlark/fluxengine/gui/UiUtils.java | 8 +- 3 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 java/com/cowlark/fluxengine/gui/FluxViewerPanel.java diff --git a/java/com/cowlark/fluxengine/gui/FluxPanel.java b/java/com/cowlark/fluxengine/gui/FluxPanel.java index 09982d69..211d7c43 100644 --- a/java/com/cowlark/fluxengine/gui/FluxPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxPanel.java @@ -2,18 +2,69 @@ import static swingtree.UI.of; +import javax.swing.JComponent; +import javax.swing.JInternalFrame; import javax.swing.JPanel; +import javax.swing.event.InternalFrameAdapter; +import javax.swing.event.InternalFrameEvent; +import javax.swing.plaf.basic.BasicInternalFrameUI; +import java.awt.Dimension; +import java.awt.event.MouseMotionListener; public class FluxPanel extends JPanel { private final ImageViewModel model; + private final FluxViewerPanel fluxViewerPanel; public FluxPanel(ImageViewModel model) { - /* Retain the shared model for the flux viewer's future controls and display. */ + /* Create a flux viewer sharing the tab's model. */ this.model = model; + fluxViewerPanel = new FluxViewerPanel(model); - /* Match the layout and padding of the other centre-window panels. */ - of(this).withLayout("fill, insets 5"); + /* Use the look and feel's title bar, but leave positioning to the row layout. */ + JInternalFrame frame = new JInternalFrame("Flux view", false, true, false, false) + { + @Override + public void updateUI() + { + super.updateUI(); + + /* Disable title-bar dragging while preserving its close button and decoration. + * Reapply this whenever the look and feel installs a new title bar. */ + if (getUI() instanceof BasicInternalFrameUI frameUI) + { + JComponent titleBar = frameUI.getNorthPane(); + if (titleBar != null) + { + for (MouseMotionListener listener : titleBar.getMouseMotionListeners()) + titleBar.removeMouseMotionListener(listener); + } + } + } + }; + frame.setFrameIcon(null); + frame.setContentPane(fluxViewerPanel); + frame.setDefaultCloseOperation(JInternalFrame.DISPOSE_ON_CLOSE); + int height = frame.getPreferredSize().height; + frame.setMinimumSize(new Dimension(0, height)); + frame.setPreferredSize(new Dimension(0, height)); + frame.setMaximumSize(new Dimension(Integer.MAX_VALUE, height)); + + /* Remove closed rows so that the remaining viewers move up. */ + frame.addInternalFrameListener(new InternalFrameAdapter() + { + @Override + public void internalFrameClosed(InternalFrameEvent event) + { + remove(frame); + revalidate(); + repaint(); + } + }); + + /* Stack fixed-height viewers in rows, retaining the centre-window panels' padding. */ + of(this).withLayout("fillx, wrap 1, insets 5, aligny top").add("growx, pushx", of(frame)); + frame.setVisible(true); } } diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java new file mode 100644 index 00000000..69aaaf8f --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -0,0 +1,110 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UI.of; + +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JViewport; +import javax.swing.SwingUtilities; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +public class FluxViewerPanel extends JPanel +{ + /* Fixed viewer height in typographic points. */ + private static final int HEIGHT_PTS = 150; + + private final ImageViewModel model; + private final JPanel canvas; + private Point dragPosition; + + public FluxViewerPanel(ImageViewModel model) + { + /* Retain the shared model for the flux viewer's future controls and display. */ + this.model = model; + + /* Draw in content coordinates; the viewport handles translation and clipping. */ + canvas = new JPanel() + { + @Override + protected void paintComponent(Graphics graphics) + { + super.paintComponent(graphics); + Graphics2D contentGraphics = (Graphics2D) graphics.create(); + try + { + redraw(contentGraphics); + } + finally + { + contentGraphics.dispose(); + } + } + }; + + /* Keep the viewer a fixed height and allow scrolling only along the flux timeline. */ + int height = UiUtils.pointsToPixels(HEIGHT_PTS); + setMinimumSize(new Dimension(0, height)); + setPreferredSize(new Dimension(0, height)); + setMaximumSize(new Dimension(Integer.MAX_VALUE, height)); + canvas.setPreferredSize(new Dimension(2000, height)); + JScrollPane scrollPane = new JScrollPane( + canvas, + JScrollPane.VERTICAL_SCROLLBAR_NEVER, + JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); + JViewport viewport = scrollPane.getViewport(); + + /* Drag the content with the primary mouse button, ending the gesture on release. */ + MouseAdapter mouseHandler = new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent event) + { + if (!SwingUtilities.isLeftMouseButton(event)) + return; + dragPosition = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); + canvas.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); + } + + @Override + public void mouseDragged(MouseEvent event) + { + if (dragPosition == null) + return; + /* Measure in viewport coordinates because the canvas moves during the drag. */ + Point position = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); + Point viewPosition = viewport.getViewPosition(); + Dimension extent = viewport.getExtentSize(); + Dimension viewSize = viewport.getViewSize(); + int x = viewPosition.x + dragPosition.x - position.x; + x = Math.max(0, Math.min(x, Math.max(0, viewSize.width - extent.width))); + viewport.setViewPosition(new Point(x, 0)); + dragPosition = position; + } + + @Override + public void mouseReleased(MouseEvent event) + { + if (!SwingUtilities.isLeftMouseButton(event)) + return; + dragPosition = null; + canvas.setCursor(Cursor.getDefaultCursor()); + } + }; + canvas.addMouseListener(mouseHandler); + canvas.addMouseMotionListener(mouseHandler); + + /* Fill this viewer with the scrollable canvas. */ + of(this).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); + } + + /* Draw the flux content in canvas coordinates. */ + protected void redraw(Graphics2D graphics) + { + } +} diff --git a/java/com/cowlark/fluxengine/gui/UiUtils.java b/java/com/cowlark/fluxengine/gui/UiUtils.java index 8521aaf7..1e01927b 100644 --- a/java/com/cowlark/fluxengine/gui/UiUtils.java +++ b/java/com/cowlark/fluxengine/gui/UiUtils.java @@ -91,15 +91,19 @@ public static Graphics2D getGraphics2D(Graphics g) return g2; } + public static int pointsToPixels(float widthPt) + { + return round(widthPt * PIXELS_PER_POINT); + } public static Dimension dimensionPts(float widthPt, float heightPt) { - return new Dimension(round(widthPt * PIXELS_PER_POINT), round(heightPt * PIXELS_PER_POINT)); + return new Dimension(pointsToPixels(widthPt), pointsToPixels(heightPt)); } public static Size sizePts(float widthPt, float heightPt) { - return Size.of(round(widthPt * PIXELS_PER_POINT), round(heightPt * PIXELS_PER_POINT)); + return Size.of(pointsToPixels(widthPt), pointsToPixels(heightPt)); } public static UIForAnySwing createSplitButton( From c7616fc04695e063dc61d250e769ce127224f9ec Mon Sep 17 00:00:00 2001 From: David Given Date: Sat, 24 Oct 2026 23:48:24 +0200 Subject: [PATCH 03/11] Figure out more flux viewer stuff. --- .../com/cowlark/fluxengine/gui/FluxPanel.java | 95 +++++++++++-------- .../fluxengine/gui/FluxViewerPanel.java | 43 ++++++++- 2 files changed, 94 insertions(+), 44 deletions(-) diff --git a/java/com/cowlark/fluxengine/gui/FluxPanel.java b/java/com/cowlark/fluxengine/gui/FluxPanel.java index 211d7c43..aba8d132 100644 --- a/java/com/cowlark/fluxengine/gui/FluxPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxPanel.java @@ -1,70 +1,87 @@ package com.cowlark.fluxengine.gui; import static swingtree.UI.of; +import static swingtree.UIFactoryMethods.label; -import javax.swing.JComponent; -import javax.swing.JInternalFrame; +import swingtree.UI.HorizontalAlignment; import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.Scrollable; import javax.swing.event.InternalFrameAdapter; import javax.swing.event.InternalFrameEvent; -import javax.swing.plaf.basic.BasicInternalFrameUI; import java.awt.Dimension; -import java.awt.event.MouseMotionListener; +import java.awt.Rectangle; public class FluxPanel extends JPanel { private final ImageViewModel model; private final FluxViewerPanel fluxViewerPanel; + private final ViewerRows viewerRows; public FluxPanel(ImageViewModel model) { /* Create a flux viewer sharing the tab's model. */ this.model = model; fluxViewerPanel = new FluxViewerPanel(model); - - /* Use the look and feel's title bar, but leave positioning to the row layout. */ - JInternalFrame frame = new JInternalFrame("Flux view", false, true, false, false) - { - @Override - public void updateUI() - { - super.updateUI(); - - /* Disable title-bar dragging while preserving its close button and decoration. - * Reapply this whenever the look and feel installs a new title bar. */ - if (getUI() instanceof BasicInternalFrameUI frameUI) - { - JComponent titleBar = frameUI.getNorthPane(); - if (titleBar != null) - { - for (MouseMotionListener listener : titleBar.getMouseMotionListeners()) - titleBar.removeMouseMotionListener(listener); - } - } - } - }; - frame.setFrameIcon(null); - frame.setContentPane(fluxViewerPanel); - frame.setDefaultCloseOperation(JInternalFrame.DISPOSE_ON_CLOSE); - int height = frame.getPreferredSize().height; - frame.setMinimumSize(new Dimension(0, height)); - frame.setPreferredSize(new Dimension(0, height)); - frame.setMaximumSize(new Dimension(Integer.MAX_VALUE, height)); + viewerRows = new ViewerRows(); /* Remove closed rows so that the remaining viewers move up. */ - frame.addInternalFrameListener(new InternalFrameAdapter() + fluxViewerPanel.addInternalFrameListener(new InternalFrameAdapter() { @Override public void internalFrameClosed(InternalFrameEvent event) { - remove(frame); - revalidate(); - repaint(); + viewerRows.remove(fluxViewerPanel); + viewerRows.revalidate(); + viewerRows.repaint(); } }); /* Stack fixed-height viewers in rows, retaining the centre-window panels' padding. */ - of(this).withLayout("fillx, wrap 1, insets 5, aligny top").add("growx, pushx", of(frame)); - frame.setVisible(true); + of(viewerRows).withLayout("fillx, wrap 1, insets 5, aligny top") + .add("growx, pushx", of(fluxViewerPanel)) + .add("growx, pushx", label("To add more flux views, select a track below") + .withHorizontalAlignment(HorizontalAlignment.CENTER)); + + /* Scroll overflowing rows vertically while fitting them to the viewport width. */ + JScrollPane scrollPane = new JScrollPane( + viewerRows, + JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + of(this).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); + } + + /* Track the available width while preserving the full combined height of the rows. */ + private static class ViewerRows extends JPanel implements Scrollable + { + @Override + public Dimension getPreferredScrollableViewportSize() + { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) + { + return UiUtils.pointsToPixels(16); + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) + { + return Math.max(1, visibleRect.height - UiUtils.pointsToPixels(16)); + } + + @Override + public boolean getScrollableTracksViewportWidth() + { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() + { + return false; + } } } diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index 69aaaf8f..29b741b6 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -2,10 +2,13 @@ import static swingtree.UI.of; +import javax.swing.JComponent; +import javax.swing.JInternalFrame; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JViewport; import javax.swing.SwingUtilities; +import javax.swing.plaf.basic.BasicInternalFrameUI; import java.awt.Cursor; import java.awt.Dimension; import java.awt.Graphics; @@ -13,8 +16,9 @@ import java.awt.Point; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; -public class FluxViewerPanel extends JPanel +public class FluxViewerPanel extends JInternalFrame { /* Fixed viewer height in typographic points. */ private static final int HEIGHT_PTS = 150; @@ -25,6 +29,8 @@ public class FluxViewerPanel extends JPanel public FluxViewerPanel(ImageViewModel model) { + super("Flux view", false, true, false, false); + /* Retain the shared model for the flux viewer's future controls and display. */ this.model = model; @@ -49,9 +55,6 @@ protected void paintComponent(Graphics graphics) /* Keep the viewer a fixed height and allow scrolling only along the flux timeline. */ int height = UiUtils.pointsToPixels(HEIGHT_PTS); - setMinimumSize(new Dimension(0, height)); - setPreferredSize(new Dimension(0, height)); - setMaximumSize(new Dimension(Integer.MAX_VALUE, height)); canvas.setPreferredSize(new Dimension(2000, height)); JScrollPane scrollPane = new JScrollPane( canvas, @@ -100,7 +103,37 @@ public void mouseReleased(MouseEvent event) canvas.addMouseMotionListener(mouseHandler); /* Fill this viewer with the scrollable canvas. */ - of(this).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); + JPanel content = new JPanel(); + of(content).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); + content.setPreferredSize(new Dimension(0, height)); + setContentPane(content); + + /* Include the title bar in the fixed row height without requesting additional width. */ + setFrameIcon(null); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + int frameHeight = getPreferredSize().height; + setMinimumSize(new Dimension(0, frameHeight)); + setPreferredSize(new Dimension(0, frameHeight)); + setMaximumSize(new Dimension(Integer.MAX_VALUE, frameHeight)); + setVisible(true); + } + + /* Keep the look and feel's decoration while leaving positioning to the row layout. */ + @Override + public void updateUI() + { + super.updateUI(); + + /* Disable title-bar dragging each time the look and feel installs a new title bar. */ + if (getUI() instanceof BasicInternalFrameUI frameUI) + { + JComponent titleBar = frameUI.getNorthPane(); + if (titleBar != null) + { + for (MouseMotionListener listener : titleBar.getMouseMotionListeners()) + titleBar.removeMouseMotionListener(listener); + } + } } /* Draw the flux content in canvas coordinates. */ From 4ef04658199b37a9f4f165566576bdd216d46710 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 00:22:00 +0200 Subject: [PATCH 04/11] Do all the wiring for the flux viewers. --- .../fluxengine/gui/ApplicationFrame.java | 56 +++++++++++---- .../com/cowlark/fluxengine/gui/FluxPanel.java | 69 +++++++++++++++---- .../fluxengine/gui/ImageViewModel.java | 12 ++++ .../cowlark/fluxengine/gui/SummaryPanel.java | 57 ++++++++++++--- .../fluxengine/gui/SummaryPanelTest.java | 16 +++++ 5 files changed, 172 insertions(+), 38 deletions(-) diff --git a/java/com/cowlark/fluxengine/gui/ApplicationFrame.java b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java index 05f4b280..6c9bdc9a 100644 --- a/java/com/cowlark/fluxengine/gui/ApplicationFrame.java +++ b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java @@ -1,7 +1,7 @@ package com.cowlark.fluxengine.gui; -import static com.cowlark.fluxengine.gui.UiUtils.EDT_EXECUTOR; import static com.cowlark.fluxengine.gui.PreferencesReaderWriter.WORKFLOW; +import static com.cowlark.fluxengine.gui.UiUtils.EDT_EXECUTOR; import static com.cowlark.fluxengine.gui.UiUtils.createSplitButton; import static com.cowlark.fluxengine.gui.UiUtils.sizePts; import static swingtree.UI.Axis.HORIZONTAL; @@ -18,6 +18,7 @@ import static swingtree.UILayoutConstants.TOP; import com.cowlark.fluxengine.gui.UiUtils.SplitPanelAction; +import io.reactivex.rxjava3.disposables.Disposable; import org.jspecify.annotations.NonNull; import sprouts.Val; import sprouts.Var; @@ -50,6 +51,8 @@ public class ApplicationFrame extends JFrame private final FluxPanel fluxPanel; private final SummaryPanel summaryPanel; private final FilesystemPanel filesystemPanel; + private final JTabbedPane centreTabs; + private Disposable fluxViewSubscription; private final ImageViewModel model; private final Var currentWorkflow; @@ -80,20 +83,20 @@ public class ApplicationFrame extends JFrame .withVerticalScrollBarPolicy(UI.Active.ALWAYS) .add(of(configurationPanel)))); - UIForSplitPane topPane = splitPane(HORIZONTAL).peek(pane -> { - pane.setResizeWeight(1.0); - }) - .add( - TOP, - tabbedPane().withMinSize(100, 0) - .add(tab("Image").add(of(imagePanel))) - .add(tab("Log").add(of(logPanel))) - .add(tab("Flux").add(of(fluxPanel))) - .add(tab("Files").add(of(filesystemPanel)))) - .add( - BOTTOM, - tabbedPane().withMinSize(100, 0) - .add(tab("Visualiser").add(of(visualiserPanel)))); + /* Retain the centre tabs so flux-view requests can select their destination. */ + centreTabs = tabbedPane().withMinSize(100, 0) + .add(tab("Image").add(of(imagePanel))) + .add(tab("Log").add(of(logPanel))) + .add(tab("Flux").add(of(fluxPanel))) + .add(tab("Files").add(of(filesystemPanel))).get(JTabbedPane.class); + + UIForSplitPane topPane = + splitPane(HORIZONTAL).peek(pane -> pane.setResizeWeight(1.0)) + .add(TOP, of(centreTabs)) + .add( + BOTTOM, + tabbedPane().withMinSize(100, 0) + .add(tab("Visualiser").add(of(visualiserPanel)))); UIForPanel controlPanel = panel("wrap 4, center, nogrid") .add("aligny center", label("I want to")) @@ -151,6 +154,29 @@ public class ApplicationFrame extends JFrame .add(outerSplit); } + /* Activate the flux tab for each request while this frame is attached. */ + @Override + public void addNotify() + { + super.addNotify(); + fluxViewSubscription = model + .getFluxViewRequests() + .observeOn(UiUtils.EDT) + .subscribe(tracks -> centreTabs.setSelectedComponent(fluxPanel)); + } + + /* Stop listening when the window is disposed, allowing it to be collected. */ + @Override + public void removeNotify() + { + if (fluxViewSubscription != null) + { + fluxViewSubscription.dispose(); + fluxViewSubscription = null; + } + super.removeNotify(); + } + private static @NonNull ViewSupplier createControlPanelCard( ImageViewModel model) { diff --git a/java/com/cowlark/fluxengine/gui/FluxPanel.java b/java/com/cowlark/fluxengine/gui/FluxPanel.java index aba8d132..4bdc39de 100644 --- a/java/com/cowlark/fluxengine/gui/FluxPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxPanel.java @@ -3,6 +3,9 @@ import static swingtree.UI.of; import static swingtree.UIFactoryMethods.label; +import com.cowlark.fluxengine.data.Track; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.disposables.Disposable; import swingtree.UI.HorizontalAlignment; import javax.swing.JPanel; import javax.swing.JScrollPane; @@ -17,28 +20,17 @@ public class FluxPanel extends JPanel private final ImageViewModel model; private final FluxViewerPanel fluxViewerPanel; private final ViewerRows viewerRows; + private Disposable fluxViewSubscription; public FluxPanel(ImageViewModel model) { /* Create a flux viewer sharing the tab's model. */ this.model = model; - fluxViewerPanel = new FluxViewerPanel(model); viewerRows = new ViewerRows(); - - /* Remove closed rows so that the remaining viewers move up. */ - fluxViewerPanel.addInternalFrameListener(new InternalFrameAdapter() - { - @Override - public void internalFrameClosed(InternalFrameEvent event) - { - viewerRows.remove(fluxViewerPanel); - viewerRows.revalidate(); - viewerRows.repaint(); - } - }); + fluxViewerPanel = createFluxViewer(); /* Stack fixed-height viewers in rows, retaining the centre-window panels' padding. */ - of(viewerRows).withLayout("fillx, wrap 1, insets 5, aligny top") + of(viewerRows).withLayout("fillx, wrap 1, insets 3, gapy 2pt, aligny top") .add("growx, pushx", of(fluxViewerPanel)) .add("growx, pushx", label("To add more flux views, select a track below") .withHorizontalAlignment(HorizontalAlignment.CENTER)); @@ -51,6 +43,55 @@ public void internalFrameClosed(InternalFrameEvent event) of(this).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); } + /* Listen while attached, delivering viewer requests on the Swing event thread. */ + @Override + public void addNotify() + { + super.addNotify(); + fluxViewSubscription = model.getFluxViewRequests() + .observeOn(UiUtils.EDT) + .subscribe(this::openFluxView); + } + + /* Release the model's reference to this panel when it leaves the component hierarchy. */ + @Override + public void removeNotify() + { + if (fluxViewSubscription != null) + { + fluxViewSubscription.dispose(); + fluxViewSubscription = null; + } + super.removeNotify(); + } + + /* Handle a request to display captured tracks. */ + private void openFluxView(ImmutableList tracks) + { + /* Insert new rows immediately before the instruction label. */ + FluxViewerPanel viewer = createFluxViewer(); + viewerRows.add(viewer, "growx, pushx", viewerRows.getComponentCount() - 1); + viewerRows.revalidate(); + viewerRows.repaint(); + } + + /* Give every viewer the same close behavior, including the initial row. */ + private FluxViewerPanel createFluxViewer() + { + FluxViewerPanel viewer = new FluxViewerPanel(model); + viewer.addInternalFrameListener(new InternalFrameAdapter() + { + @Override + public void internalFrameClosed(InternalFrameEvent event) + { + viewerRows.remove(viewer); + viewerRows.revalidate(); + viewerRows.repaint(); + } + }); + return viewer; + } + /* Track the available width while preserving the full combined height of the rows. */ private static class ViewerRows extends JPanel implements Scrollable { diff --git a/java/com/cowlark/fluxengine/gui/ImageViewModel.java b/java/com/cowlark/fluxengine/gui/ImageViewModel.java index b0f8cba7..a6283d18 100644 --- a/java/com/cowlark/fluxengine/gui/ImageViewModel.java +++ b/java/com/cowlark/fluxengine/gui/ImageViewModel.java @@ -24,6 +24,8 @@ import com.cowlark.fluxengine.core.Logger; import com.cowlark.fluxengine.data.Disk; import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Track; +import com.google.common.collect.ImmutableList; import com.cowlark.fluxengine.fluxsource.FluxSource; import com.cowlark.fluxengine.fluxsource.MemoryFluxSource; import com.cowlark.fluxengine.gui.DriveActivity.ActivityType; @@ -35,6 +37,8 @@ import com.cowlark.fluxengine.vfs.FilesystemOperation; import com.cowlark.fluxengine.vfs.InMemoryBlockDevice; import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.subjects.PublishSubject; +import io.reactivex.rxjava3.subjects.Subject; import lombok.Getter; import org.apache.commons.lang3.function.Consumers; import org.slf4j.LoggerFactory; @@ -73,6 +77,8 @@ public class ImageViewModel @Getter private Var selectedDrive; @Getter private Vars logQueue = Vars.of(LogMessage.class); + @Getter private final Subject> fluxViewRequests = + PublishSubject.>create().toSerialized(); @Getter private Var currentDisposable = Var.ofNull(Disposable.class); @Getter private Var disk = Var.of(new Disk()); @Getter private Var driveActivity = @@ -101,6 +107,12 @@ public class ImageViewModel refreshUsbDevices(); } + /* Entry point for opening the selected captures in a flux view. */ + public void openFluxView(ImmutableList tracks) + { + fluxViewRequests.onNext(tracks); + } + private static void showFatalError(ErrorLogMessage m) { UI.message(String.format("Error: %s", m.message())).showAsError(); diff --git a/java/com/cowlark/fluxengine/gui/SummaryPanel.java b/java/com/cowlark/fluxengine/gui/SummaryPanel.java index cae78012..ec440441 100644 --- a/java/com/cowlark/fluxengine/gui/SummaryPanel.java +++ b/java/com/cowlark/fluxengine/gui/SummaryPanel.java @@ -3,6 +3,8 @@ import static com.cowlark.fluxengine.gui.UiUtils.getGraphics2D; import static swingtree.UI.label; import static swingtree.UI.of; +import static swingtree.UIFactoryMethods.menuItem; +import static swingtree.UIFactoryMethods.popupMenu; import com.cowlark.fluxengine.data.CylinderHead; import com.cowlark.fluxengine.data.Disk; @@ -11,6 +13,8 @@ import com.cowlark.fluxengine.data.PhysicalTrackLayout; import com.cowlark.fluxengine.data.Sector; import com.cowlark.fluxengine.data.Sector.Status; +import com.cowlark.fluxengine.data.Track; +import com.google.common.collect.ImmutableList; import sprouts.From; import sprouts.Viewable; import swingtree.UI.HorizontalAlignment; @@ -19,6 +23,7 @@ import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.UIManager; import java.awt.Color; import java.awt.Dimension; @@ -93,11 +98,17 @@ private void updateUi() SummaryButton button = logicalTrackButtons.get(lch); if (button == null) continue; - button.setTrackAnalysis(analyseTrack( - disk, - driveActivity, - ltl.physicalCylinder, - ltl.physicalHead)); + TrackAnalysis analysis = + analyseTrack(disk, driveActivity, ltl.physicalCylinder, ltl.physicalHead); + + /* Include captures from every physical location belonging to this logical track. */ + ImmutableList.Builder tracks = ImmutableList.builder(); + currentLayout.layoutByPhysicalLocation.forEach((location, layout) -> { + if (layout.logicalTrackLayout.equals(ltl)) + tracks.addAll(disk.tracksByPhysicalLocation.get(location)); + }); + analysis.tracks = tracks.build(); + button.setTrackAnalysis(analysis); } } @@ -230,6 +241,9 @@ private TrackAnalysis analyseTrack( result.tooltip = "No data"; result.label = ""; + /* Snapshot every capture at this physical location, including an empty list if absent. */ + result.tracks = ImmutableList.copyOf(disk.tracksByPhysicalLocation.get(pch)); + if (disk.tracksByPhysicalLocation.containsKey(pch)) { List sectors = disk.sectorsByPhysicalLocation.get(pch); @@ -301,13 +315,14 @@ interface ButtonBuilder private static class TrackAnalysis { + ImmutableList tracks; String tooltip; Color colour; String label; } /* A single cell of the summary grid. */ - private static class SummaryButton extends JButton + private class SummaryButton extends JButton { private TrackAnalysis analysis; @@ -322,6 +337,18 @@ private static class SummaryButton extends JButton setMinimumSize(new Dimension(10, 10)); setPreferredSize(new Dimension(15, 15)); setBackground(StatusColour.NOT_PRESENT.getColour()); + + /* Share the standard context menu between right-click and primary-button actions. */ + JPopupMenu menu = popupMenu() + .add(menuItem("Show in flux viewer").onClick(e -> { + SummaryPanel.this.model.openFluxView(analysis.tracks); + })).get(JPopupMenu.class); + setComponentPopupMenu(menu); + addActionListener(event -> menu.show(this, 0, getHeight())); + + /* Repaint custom cell decorations when the pointer enters or leaves the button. */ + setRolloverEnabled(true); + getModel().addChangeListener(event -> repaint()); } void setTrackAnalysis(TrackAnalysis analysis) @@ -334,14 +361,26 @@ void setTrackAnalysis(TrackAnalysis analysis) @Override protected void paintComponent(Graphics g) { - if (analysis != null) + /* Preserve the status colour while highlighting the cell under the pointer. */ + Color colour = analysis != null ? analysis.colour : getBackground(); + g.setColor(colour); + g.fillRect(0, 0, getWidth(), getHeight()); + if (getModel().isRollover()) { - g.setColor(analysis.colour); + g.setColor(new Color(255, 255, 255, 48)); g.fillRect(0, 0, getWidth(), getHeight()); + } + /* Outline each cell, using the foreground colour to emphasize the hovered cell. */ + g.setColor(getModel().isRollover() ? getForeground() : colour.darker()); + g.drawRect(0, 0, getWidth() - 1, getHeight() - 1); + + if (analysis != null) + { if (!analysis.label.isEmpty()) { - Graphics2D g2 = getGraphics2D(g); + /* Isolate the activity label's XOR drawing from the caller's graphics state. */ + Graphics2D g2 = getGraphics2D(g.create()); try { g2.setXORMode(Color.WHITE); diff --git a/javatests/com/cowlark/fluxengine/gui/SummaryPanelTest.java b/javatests/com/cowlark/fluxengine/gui/SummaryPanelTest.java index a657a949..ce06ed79 100644 --- a/javatests/com/cowlark/fluxengine/gui/SummaryPanelTest.java +++ b/javatests/com/cowlark/fluxengine/gui/SummaryPanelTest.java @@ -10,6 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import sprouts.Var; +import javax.swing.JButton; +import java.awt.Component; @RunWith(MockitoJUnitRunner.class) public class SummaryPanelTest @@ -36,5 +38,19 @@ public void addPhysicalAndLogicalViews() int buttonRow = 82; int expected = 1 + header + 2 * buttonRow + 1 + 2 * buttonRow + header; assertThat(panel.getComponentCount()).isEqualTo(expected); + + /* Both physical and logical cells expose the same hover and context-menu affordances. */ + int buttons = 0; + for (Component component : panel.getComponents()) + { + if (component instanceof JButton button) + { + assertThat(button.isRolloverEnabled()).isTrue(); + assertThat(button.getComponentPopupMenu()).isNotNull(); + assertThat(button.getActionListeners()).isNotEmpty(); + buttons++; + } + } + assertThat(buttons).isEqualTo(320); } } From ed7cf4a19ec0da8c68c3519236703eef259f2f30 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 00:42:48 +0200 Subject: [PATCH 05/11] Machine translation of the old flux viewer code. --- java/com/cowlark/fluxengine/gui/BUILD.bazel | 4 + .../com/cowlark/fluxengine/gui/FluxPanel.java | 22 +- .../fluxengine/gui/FluxViewerPanel.java | 762 ++++++++++++++++-- .../fluxengine/gui/TextViewerDialogue.java | 43 + .../com/cowlark/fluxengine/gui/BUILD.bazel | 14 + .../fluxengine/gui/FluxViewerPanelTest.java | 270 +++++++ 6 files changed, 1029 insertions(+), 86 deletions(-) create mode 100644 java/com/cowlark/fluxengine/gui/TextViewerDialogue.java create mode 100644 javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java diff --git a/java/com/cowlark/fluxengine/gui/BUILD.bazel b/java/com/cowlark/fluxengine/gui/BUILD.bazel index b194ce33..d428f6f1 100644 --- a/java/com/cowlark/fluxengine/gui/BUILD.bazel +++ b/java/com/cowlark/fluxengine/gui/BUILD.bazel @@ -17,10 +17,14 @@ java_library( "//java/com/cowlark/fluxengine/config:config_java_proto", "//java/com/cowlark/fluxengine/core", "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/external", "//java/com/cowlark/fluxengine/fluxsource", "//java/com/cowlark/fluxengine/imagewriter", "//java/com/cowlark/fluxengine/usb", "//java/com/cowlark/fluxengine/vfs", + "//java/com/cowlark/fluxengine/wiring", "@maven//:com_formdev_flatlaf", "@maven//:com_formdev_flatlaf_swingx", "@maven//:com_google_guava_guava", diff --git a/java/com/cowlark/fluxengine/gui/FluxPanel.java b/java/com/cowlark/fluxengine/gui/FluxPanel.java index 4bdc39de..923b2760 100644 --- a/java/com/cowlark/fluxengine/gui/FluxPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxPanel.java @@ -18,7 +18,6 @@ public class FluxPanel extends JPanel { private final ImageViewModel model; - private final FluxViewerPanel fluxViewerPanel; private final ViewerRows viewerRows; private Disposable fluxViewSubscription; @@ -27,12 +26,10 @@ public FluxPanel(ImageViewModel model) /* Create a flux viewer sharing the tab's model. */ this.model = model; viewerRows = new ViewerRows(); - fluxViewerPanel = createFluxViewer(); /* Stack fixed-height viewers in rows, retaining the centre-window panels' padding. */ of(viewerRows).withLayout("fillx, wrap 1, insets 3, gapy 2pt, aligny top") - .add("growx, pushx", of(fluxViewerPanel)) - .add("growx, pushx", label("To add more flux views, select a track below") + .add("growx, pushx", label("To add flux views, select a track below") .withHorizontalAlignment(HorizontalAlignment.CENTER)); /* Scroll overflowing rows vertically while fitting them to the viewport width. */ @@ -69,16 +66,7 @@ public void removeNotify() private void openFluxView(ImmutableList tracks) { /* Insert new rows immediately before the instruction label. */ - FluxViewerPanel viewer = createFluxViewer(); - viewerRows.add(viewer, "growx, pushx", viewerRows.getComponentCount() - 1); - viewerRows.revalidate(); - viewerRows.repaint(); - } - - /* Give every viewer the same close behavior, including the initial row. */ - private FluxViewerPanel createFluxViewer() - { - FluxViewerPanel viewer = new FluxViewerPanel(model); + FluxViewerPanel viewer = new FluxViewerPanel(model, tracks); viewer.addInternalFrameListener(new InternalFrameAdapter() { @Override @@ -89,9 +77,13 @@ public void internalFrameClosed(InternalFrameEvent event) viewerRows.repaint(); } }); - return viewer; + viewerRows.add(viewer, "growx, pushx", viewerRows.getComponentCount() - 1); + viewerRows.revalidate(); + viewerRows.repaint(); } + /* Give every viewer the same close behavior, including the initial row. */ + /* Track the available width while preserving the full combined height of the rows. */ private static class ViewerRows extends JPanel implements Scrollable { diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index 29b741b6..272baabb 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -1,114 +1,205 @@ package com.cowlark.fluxengine.gui; +import static com.cowlark.fluxengine.wiring.FluxEngine.F_BIT_PULSE; import static swingtree.UI.of; +import static swingtree.UIFactoryMethods.menuItem; +import static swingtree.UIFactoryMethods.popupMenu; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.Utils; +import com.cowlark.fluxengine.data.FluxPosition; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.data.Record; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.data.Track; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.decoders.FluxDecoder; +import com.cowlark.fluxengine.external.Crc; +import com.google.common.collect.ImmutableList; import javax.swing.JComponent; import javax.swing.JInternalFrame; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JViewport; +import javax.swing.Scrollable; import javax.swing.SwingUtilities; import javax.swing.plaf.basic.BasicInternalFrameUI; +import java.awt.BasicStroke; +import java.awt.Color; import java.awt.Cursor; import java.awt.Dimension; +import java.awt.Font; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionListener; +import java.awt.event.MouseWheelEvent; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.TreeSet; public class FluxViewerPanel extends JInternalFrame { - /* Fixed viewer height in typographic points. */ + /* Geometry and detail thresholds from the C++ flux viewer. */ private static final int HEIGHT_PTS = 150; + private static final int BORDER = 4; + private static final int MAX_WIDTH = Integer.MAX_VALUE - 1024; + private static final double RENDER_LIMIT = 3000.0; + private static final Color BACKGROUND = new Color(192, 192, 192); + private static final Color FLUX = new Color(64, 64, 255); + private static final Color BAD_SECTOR = new Color(213, 94, 0); + private static final DecoderProto DECODER = DecoderProto.getDefaultInstance(); - private final ImageViewModel model; - private final JPanel canvas; + private final ImmutableList tracks; + private final ImmutableList captures; + private final TreeSet boundaries = new TreeSet<>(); + private final Canvas canvas; + private final JViewport viewport; + private final double totalDurationNs; + private double nanosecondsPerPixel = 30; + private boolean scaleInitialised; private Point dragPosition; + private Point pointer; + private boolean popupShown; + private int previousScrollX; - public FluxViewerPanel(ImageViewModel model) + public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) { super("Flux view", false, true, false, false); + this.tracks = tracks; - /* Retain the shared model for the flux viewer's future controls and display. */ - this.model = model; - - /* Draw in content coordinates; the viewport handles translation and clipping. */ - canvas = new JPanel() + /* Keep captures in input order and accumulate offsets in time, rather than pixels. */ + ImmutableList.Builder captureBuilder = ImmutableList.builder(); + double offset = 0; + for (Track track : tracks) { - @Override - protected void paintComponent(Graphics graphics) + if (track.fluxmap == null) + continue; + double duration = track.fluxmap.durationNs(); + if (!(duration > 0) || !Double.isFinite(duration)) + continue; + captureBuilder.add(new Capture(track, offset, duration)); + for (Record record : track.records) { - super.paintComponent(graphics); - Graphics2D contentGraphics = (Graphics2D) graphics.create(); - try + if (Double.isFinite(record.startTimeNs) && Double.isFinite(record.endTimeNs) && + record.endTimeNs >= record.startTimeNs) { - redraw(contentGraphics); + boundaries.add(offset + record.startTimeNs); + boundaries.add(offset + record.endTimeNs); } - finally + } + offset += duration; + } + captures = captureBuilder.build(); + totalDurationNs = offset; + + /* The viewport supplies horizontal translation and the canvas follows its height. */ + canvas = new Canvas(); + canvas.setBackground(BACKGROUND); + canvas.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, UiUtils.pointsToPixels(6))); + JScrollPane scrollPane = new JScrollPane(canvas, + JScrollPane.VERTICAL_SCROLLBAR_NEVER, + JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); + scrollPane.setWheelScrollingEnabled(false); + viewport = scrollPane.getViewport(); + viewport.addComponentListener(new ComponentAdapter() { + @Override public void componentResized(ComponentEvent event) + { + if (!scaleInitialised && viewport.getWidth() > 0) { - contentGraphics.dispose(); + scaleInitialised = true; + updateScale(fitScale()); } + else + updateScale(nanosecondsPerPixel); } - }; + }); + viewport.addChangeListener(event -> { + int scrollX = viewport.getViewPosition().x; + if (pointer != null) + pointer.translate(scrollX - previousScrollX, 0); + previousScrollX = scrollX; + canvas.repaint(); + }); - /* Keep the viewer a fixed height and allow scrolling only along the flux timeline. */ - int height = UiUtils.pointsToPixels(HEIGHT_PTS); - canvas.setPreferredSize(new Dimension(2000, height)); - JScrollPane scrollPane = new JScrollPane( - canvas, - JScrollPane.VERTICAL_SCROLLBAR_NEVER, - JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); - JViewport viewport = scrollPane.getViewport(); - - /* Drag the content with the primary mouse button, ending the gesture on release. */ - MouseAdapter mouseHandler = new MouseAdapter() - { - @Override - public void mousePressed(MouseEvent event) - { - if (!SwingUtilities.isLeftMouseButton(event)) - return; - dragPosition = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); - canvas.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); - } - - @Override - public void mouseDragged(MouseEvent event) - { - if (dragPosition == null) - return; - /* Measure in viewport coordinates because the canvas moves during the drag. */ - Point position = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); - Point viewPosition = viewport.getViewPosition(); - Dimension extent = viewport.getExtentSize(); - Dimension viewSize = viewport.getViewSize(); - int x = viewPosition.x + dragPosition.x - position.x; - x = Math.max(0, Math.min(x, Math.max(0, viewSize.width - extent.width))); - viewport.setViewPosition(new Point(x, 0)); - dragPosition = position; - } - - @Override - public void mouseReleased(MouseEvent event) - { - if (!SwingUtilities.isLeftMouseButton(event)) - return; + /* Track pointer movement, horizontal dragging, and native popup gestures. */ + MouseAdapter mouse = new MouseAdapter() { + @Override public void mouseMoved(MouseEvent event) + { trackPointer(event); } + + @Override public void mouseExited(MouseEvent event) + { + pointer = null; + canvas.repaint(); + } + + @Override public void mousePressed(MouseEvent event) + { + popupShown = false; + trackPointer(event); + if (event.isPopupTrigger()) + showPopup(event); + else if (SwingUtilities.isLeftMouseButton(event)) + { + dragPosition = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); + canvas.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); + } + } + + @Override public void mouseDragged(MouseEvent event) + { + if (dragPosition != null) + { + Point position = + SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); + setScrollX((long) viewport.getViewPosition().x + dragPosition.x - position.x); + dragPosition = position; + pointer = new Point(viewport.getViewPosition().x + position.x, position.y); + canvas.repaint(); + } + else + trackPointer(event); + } + + @Override public void mouseReleased(MouseEvent event) + { + if (event.isPopupTrigger() && !popupShown) + showPopup(event); dragPosition = null; canvas.setCursor(Cursor.getDefaultCursor()); + trackPointer(event); + } + + @Override public void mouseWheelMoved(MouseWheelEvent event) + { + Point anchor = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); + zoom(event.getPreciseWheelRotation(), anchor.x); + pointer = new Point(viewport.getViewPosition().x + anchor.x, event.getY()); + event.consume(); + canvas.repaint(); } }; - canvas.addMouseListener(mouseHandler); - canvas.addMouseMotionListener(mouseHandler); + canvas.addMouseListener(mouse); + canvas.addMouseMotionListener(mouse); + canvas.addMouseWheelListener(mouse); - /* Fill this viewer with the scrollable canvas. */ + /* Keep each row a fixed height without contributing to the window's preferred width. */ + int height = UiUtils.pointsToPixels(HEIGHT_PTS); JPanel content = new JPanel(); of(content).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); content.setPreferredSize(new Dimension(0, height)); setContentPane(content); - - /* Include the title bar in the fixed row height without requesting additional width. */ setFrameIcon(null); setDefaultCloseOperation(DISPOSE_ON_CLOSE); int frameHeight = getPreferredSize().height; @@ -118,13 +209,10 @@ public void mouseReleased(MouseEvent event) setVisible(true); } - /* Keep the look and feel's decoration while leaving positioning to the row layout. */ - @Override - public void updateUI() + /* Keep the look and feel's title bar, but leave positioning to the row layout. */ + @Override public void updateUI() { super.updateUI(); - - /* Disable title-bar dragging each time the look and feel installs a new title bar. */ if (getUI() instanceof BasicInternalFrameUI frameUI) { JComponent titleBar = frameUI.getNorthPane(); @@ -136,8 +224,540 @@ public void updateUI() } } - /* Draw the flux content in canvas coordinates. */ + /* Scaling and view positioning use a bounded pixel range, with time retained as doubles. */ + private double fitScale() + { return Math.max(minimumScale(), totalDurationNs / Math.max(1, viewport.getWidth())); } + + private double minimumScale() + { return Math.max(30, totalDurationNs / (MAX_WIDTH - 1.0)); } + + private void updateScale(double scale) + { + nanosecondsPerPixel = Math.max(minimumScale(), scale); + for (Capture capture : captures) + capture.updateDensity(); + double maximum = 0; + for (Capture capture : captures) + { + for (double value : capture.density) + maximum = Math.max(maximum, value); + } + if (maximum > 0) + { + for (Capture capture : captures) + { + for (int i = 0; i < capture.density.length; i++) + capture.density[i] /= maximum; + } + } + canvas.revalidate(); + viewport.doLayout(); + setScrollX(viewport.getViewPosition().x); + canvas.repaint(); + } + + /* Keep the time beneath the mouse stationary except where a timeline edge clamps it. */ + void zoom(double rotation, int anchorX) + { + if (totalDurationNs <= 0 || viewport.getWidth() <= 0) + return; + double time = (viewport.getViewPosition().x + (double) anchorX) * nanosecondsPerPixel; + updateScale(Math.min(fitScale(), nanosecondsPerPixel * Math.pow(1.2, rotation))); + setScrollX(Math.round(time / nanosecondsPerPixel - anchorX)); + } + + private void setScrollX(long x) + { + int maximum = Math.max(0, canvas.getWidth() - viewport.getWidth()); + viewport.setViewPosition(new Point((int) Math.max(0, Math.min(x, maximum)), 0)); + } + + private void trackPointer(MouseEvent event) + { + pointer = event.getPoint(); + canvas.repaint(); + } + + /* Coordinates shared by drawing and hit testing prevent drift between blocks and menus. */ + private int pixel(double time) + { return (int) Math.min(MAX_WIDTH, Math.max(0, time / nanosecondsPerPixel)); } + + private int centre(int row) + { + int rowHeight = canvas.getHeight() / 5; + return row * rowHeight + rowHeight / 2; + } + + private int blockHeight() + { return Math.max(1, (canvas.getHeight() / 5) * 2 / 3); } + + private Rectangle block(Capture capture, double start, double end, int row) + { + int left = pixel(capture.offset + start); + int right = pixel(capture.offset + end); + return new Rectangle( + left, centre(row) - blockHeight() / 2, Math.max(0, right - left), blockHeight()); + } + + /* Paint only the visible part of the concatenated timeline, in the original layer order. */ protected void redraw(Graphics2D graphics) { + Rectangle visible = viewport.getViewRect(); + graphics.setColor(BACKGROUND); + graphics.fillRect(visible.x, 0, visible.width, canvas.getHeight()); + graphics.setFont(canvas.getFont()); + if (totalDurationNs <= 0 || visible.width <= 0) + return; + for (Capture capture : captures) + { + int left = pixel(capture.offset); + int right = pixel(capture.offset + capture.duration); + if (right < visible.x || left > visible.x + visible.width) + continue; + if (capture.offset != 0) + { + graphics.setColor(Color.RED); + graphics.drawLine(left, 0, left, canvas.getHeight()); + } + graphics.setColor(Color.BLACK); + for (int row : new int[] {0, 1, 3}) + graphics.drawLine(left, centre(row), right, centre(row)); + graphics.setColor(Color.YELLOW); + for (double index : capture.track.fluxmap.getIndexMarks()) + { + int x = pixel(capture.offset + index); + if (x > visible.x + visible.width) + break; + if (x >= visible.x) + graphics.drawLine(x, 0, x, canvas.getHeight()); + } + drawScale(graphics, capture, visible); + for (Sector sector : capture.track.allSectors) + { + double end = + sector.dataEndTimeNs != 0 ? sector.dataEndTimeNs : sector.headerEndTimeNs; + Rectangle rect = block(capture, sector.headerStartTimeNs, end, 0); + drawBlock(graphics, + rect, + sector.status == Sector.Status.OK ? Color.WHITE : BAD_SECTOR, + sector.logicalLocation + " " + Sector.statusToString(sector.status)); + } + for (Record record : capture.track.records) + { + Rectangle rect = block(capture, record.startTimeNs, record.endTimeNs, 1); + if (!rect.intersects(visible)) + continue; + drawBlock(graphics, + rect, + new Color(200, 200, 200), + nanosecondsPerPixel > RENDER_LIMIT / 4 + ? String.format(Locale.ROOT, "+%.3fms", record.startTimeNs / 1e6) + : ""); + if (nanosecondsPerPixel <= RENDER_LIMIT / 4) + drawDecoded(graphics, capture, record, rect, visible, false); + if (nanosecondsPerPixel < RENDER_LIMIT / 8) + drawDecoded(graphics, capture, record, rect, visible, true); + } + drawFlux(graphics, capture, visible); + } + drawRuler(graphics); + } + + /* Draw tick marks in capture-relative time and labels at the viewport's two edges. */ + private void drawScale(Graphics2D graphics, Capture capture, Rectangle visible) + { + graphics.setColor(Color.BLACK); + double step = 1000; + while (step / nanosecondsPerPixel < 10) + step *= 10; + double start = Math.max(0, visible.x * nanosecondsPerPixel - capture.offset); + long first = (long) Math.floor(start / step); + for (long index = first; index * step < capture.duration; index++) + { + double time = index * step; + int x = pixel(capture.offset + time); + if (x > visible.x + visible.width) + break; + int half = blockHeight() / 2; + int tick = index % 100 == 0 ? half : index % 10 == 0 ? half / 2 : half / 3; + graphics.drawLine(x, centre(3) - tick, x, centre(3) + tick); + if (index % 10 == 0) + text(graphics, milliseconds(time), x, centre(3) - half); + } + int top = centre(2) + blockHeight() / 4; + double leftTime = visible.x * nanosecondsPerPixel - capture.offset; + if (leftTime >= 0 && leftTime < capture.duration) + text(graphics, milliseconds(leftTime), visible.x + BORDER, top); + double rightTime = (visible.x + visible.width) * nanosecondsPerPixel - capture.offset; + if (rightTime >= 0 && rightTime <= capture.duration) + { + String label = milliseconds(rightTime); + text(graphics, + label, + visible.x + visible.width - BORDER - graphics.getFontMetrics().stringWidth(label), + top); + } + } + + /* Clip text to its block while keeping hover strokes and caller graphics state isolated. */ + private void drawBlock(Graphics2D graphics, Rectangle rect, Color colour, String label) + { + if (rect.width <= 0 || !rect.intersects(viewport.getViewRect())) + return; + Graphics2D g = (Graphics2D) graphics.create(); + try + { + g.setColor(colour); + g.fill(rect); + g.setColor(Color.BLACK); + g.setStroke(new BasicStroke(pointer != null && rect.contains(pointer) ? 2 : 1)); + g.draw(rect); + g.clip(rect); + text(g, + label, + rect.x + BORDER, + rect.y + (rect.height - g.getFontMetrics().getHeight()) / 2); + } + finally + { + g.dispose(); + } + } + + /* Decode from a bytecode position with a record-relative clock, as in the C++ viewer. */ + private void drawDecoded(Graphics2D graphics, + Capture capture, + Record record, + Rectangle rect, + Rectangle visible, + boolean rawBits) + { + if (!(record.clockNs > 0) || !Double.isFinite(record.clockNs) || + record.endTimeNs <= record.startTimeNs || record.position < 0 || + record.position >= capture.track.fluxmap.bytes()) + return; + FluxmapReader reader = new FluxmapReader(capture.track.fluxmap, DECODER); + reader.seek(new FluxPosition(record.position, 0, 0)); + FluxDecoder decoder = new FluxDecoder(reader, record.clockNs, DECODER); + Graphics2D g = (Graphics2D) graphics.create(); + try + { + g.setColor(Color.BLACK); + if (!rawBits) + g.clip(rect); + StringBuilder bits = new StringBuilder(); + while (!reader.eof() && + reader.tell().getDurationNs() <= record.endTimeNs - record.startTimeNs) + { + String label; + if (rawBits) + { + boolean bit = decoder.readBit(); + bits.append(bit ? '1' : '0'); + if (!bit) + continue; + label = bits.toString(); + bits.setLength(0); + } + else + { + Bits value = decoder.readBits(8); + if (value.size() < 8) + break; + label = String.format(Locale.ROOT, "%02x", value.toBytes().getByte(0) & 0xff); + } + int x = pixel(capture.offset + record.startTimeNs + reader.tell().getDurationNs()); + if (x > visible.x + visible.width + 50) + break; + if (x < visible.x) + continue; + if (!rawBits) + g.drawLine(x, rect.y, x, rect.y + rect.height); + text(g, + label, + x - g.getFontMetrics().stringWidth(label) - BORDER, + rawBits ? centre(3) : centre(1) - g.getFontMetrics().getHeight() / 2); + } + } + finally + { + g.dispose(); + } + } + + /* Density is normalized across all captures; close views draw individual transitions. */ + private void drawFlux(Graphics2D graphics, Capture capture, Rectangle visible) + { + int half = blockHeight() / 2; + if (nanosecondsPerPixel > RENDER_LIMIT) + { + int first = Math.max(visible.x, pixel(capture.offset)); + int last = + Math.min(visible.x + visible.width, pixel(capture.offset + capture.duration)); + for (int x = first; x < last; x++) + { + int index = x - pixel(capture.offset); + double density = index < capture.density.length ? capture.density[index] : 0; + graphics.setColor(new Color(interpolate(192, 64, density), + interpolate(192, 64, density), + interpolate(192, 255, density))); + graphics.fillRect(x, centre(4) - half, 1, blockHeight()); + } + } + else + { + graphics.setColor(FLUX); + FluxmapReader reader = new FluxmapReader(capture.track.fluxmap, DECODER); + while (reader.findEvent(F_BIT_PULSE).found()) + { + int x = pixel(capture.offset + reader.tell().getDurationNs()); + if (x > visible.x + visible.width) + break; + if (x >= visible.x) + graphics.drawLine(x, centre(4) - half, x, centre(4) + half); + } + } + graphics.setColor(FLUX); + graphics.drawLine( + pixel(capture.offset), centre(4), pixel(capture.offset + capture.duration), centre(4)); + } + + private static int interpolate(int low, int high, double factor) + { return (int) (low + (high - low) * factor); } + + private void drawRuler(Graphics2D graphics) + { + if (pointer == null) + return; + double time = pointer.x * nanosecondsPerPixel; + Double right = boundaries.ceiling(time); + Double left = right == null ? null : boundaries.lower(right); + if (left == null) + return; + int lx = pixel(left); + int rx = pixel(right); + int y = centre(2) - canvas.getHeight() / 10; + graphics.setColor(Color.BLACK); + graphics.drawLine(lx, y, rx, y); + String label = milliseconds(right - left); + text(graphics, + label, + lx + (rx - lx) / 2 - graphics.getFontMetrics().stringWidth(label) / 2, + y); + } + + /* Java draws text at a baseline; wxWidgets' text coordinates refer to the top edge. */ + private static void text(Graphics2D graphics, String text, int x, int top) + { graphics.drawString(text, x, top + graphics.getFontMetrics().getAscent()); } + + private static String milliseconds(double time) + { return String.format(Locale.ROOT, "%.3fms", time / 1e6); } + + /* Popup hit testing runs on the mouse event, never during painting. */ + private void showPopup(MouseEvent event) + { + popupShown = true; + JPopupMenu menu = contextMenuAt(event.getPoint()); + if (menu != null) + menu.show(canvas, event.getX(), event.getY()); + } + + JPopupMenu contextMenuAt(Point position) + { + for (int i = captures.size() - 1; i >= 0; i--) + { + Capture capture = captures.get(i); + List records = capture.track.records; + for (int j = records.size() - 1; j >= 0; j--) + { + Record record = records.get(j); + if (block(capture, record.startTimeNs, record.endTimeNs, 1).contains(position)) + return popupMenu() + .add(menuItem("Show record data") + .onClick(event + -> showText( + "Raw record data " + + (capture.track.ptl == null + ? "" + : "c" + capture.track.ptl.physicalCylinder + + "h" + capture.track.ptl.physicalHead) + + " + " + milliseconds(record.startTimeNs), + recordData(record)))) + .get(JPopupMenu.class); + } + List sectors = capture.track.allSectors; + for (int j = sectors.size() - 1; j >= 0; j--) + { + Sector sector = sectors.get(j); + double end = + sector.dataEndTimeNs != 0 ? sector.dataEndTimeNs : sector.headerEndTimeNs; + if (block(capture, sector.headerStartTimeNs, end, 0).contains(position)) + return popupMenu() + .add(menuItem("Show sector summary") + .onClick(event + -> showText("Sector summary " + sector.logicalLocation, + sectorSummary(sector)))) + .add(menuItem("Show decoded data") + .onClick(event + -> showText("User data " + sector.logicalLocation, + sectorData(sector, false)))) + .add(menuItem("Show raw data") + .onClick(event + -> showText("Raw data " + sector.logicalLocation, + sectorData(sector, true)))) + .get(JPopupMenu.class); + } + } + return null; + } + + private void showText(String title, String text) + { TextViewerDialogue.show(this, title, text); } + + /* Reports retain all occurrences of the same logical sector, including duplicate captures. */ + String sectorSummary(Sector selected) + { + List matches = new ArrayList<>(); + for (Track track : tracks) + { + for (Sector sector : track.allSectors) + { + if (selected.logicalLocation.equals(sector.logicalLocation)) + matches.add(sector); + } + } + StringBuilder result = new StringBuilder("Number of times seen: " + matches.size() + "\n"); + for (int i = 0; i < matches.size(); i++) + result.append("\nInstance ") + .append(i) + .append(":\n\n") + .append(sectorMetadata(matches.get(i))); + return result.toString(); + } + + String sectorData(Sector sector, boolean raw) + { + StringBuilder result = new StringBuilder(sectorMetadata(sector)); + if (!raw) + result.append('\n').append(hexDump(sector.data)); + else + { + result.append("Number of records: ").append(sector.records.size()).append('\n'); + for (int i = 0; i < sector.records.size(); i++) + result.append("\nRecord ") + .append(i) + .append(":\n\n") + .append(recordData(sector.records.get(i))); + } + return result.toString(); + } + + private static String sectorMetadata(Sector sector) + { + return String.format(Locale.ROOT, + "Sector status: %s\nPhysical location: %s\nClock: %.2fus / %s\n" + + "Bytecode position: %d\nData CRC16: %04x\n", + Sector.statusToString(sector.status), + sector.physicalLocation == null ? "unknown" : sector.physicalLocation, + sector.clockNs / 1000, + sector.clockNs > 0 ? String.format(Locale.ROOT, "%.0fkHz", 1e6 / sector.clockNs) + : "unknown", + sector.position, + Crc.crc16(Crc.CCITT_POLY, sector.data)); + } + + static String recordData(Record record) + { + return String.format(Locale.ROOT, + "Bytecode position: %d\nStart: %.2fms\nEnd: %.2fms\n" + + "Data CRC16: %04x\n\n%s", + record.position, + record.startTimeNs / 1e6, + record.endTimeNs / 1e6, + Crc.crc16(Crc.CCITT_POLY, record.rawData), + hexDump(record.rawData)); + } + + private static String hexDump(Bytes bytes) + { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (PrintStream stream = new PrintStream(output, false, StandardCharsets.UTF_8)) + { + Utils.hexdump(stream, bytes); + } + return output.toString(StandardCharsets.UTF_8); + } + + /* Cache coarse-scale pulse counts outside painting, normalized across captures. */ + private final class Capture + { + final Track track; + final double offset; + final double duration; + double densityScale = -1; + double[] density = new double[0]; + + Capture(Track track, double offset, double duration) + { + this.track = track; + this.offset = offset; + this.duration = duration; + } + + void updateDensity() + { + if (nanosecondsPerPixel <= RENDER_LIMIT || densityScale == nanosecondsPerPixel) + return; + densityScale = nanosecondsPerPixel; + density = new double[pixel(offset + duration) - pixel(offset) + 1]; + FluxmapReader reader = new FluxmapReader(track.fluxmap, DECODER); + while (reader.findEvent(F_BIT_PULSE).found()) + { + int index = pixel(offset + reader.tell().getDurationNs()) - pixel(offset); + density[index]++; + } + } + } + + /* A wide timeline whose height always matches the viewport rather than scrolling vertically. */ + private final class Canvas extends JPanel implements Scrollable + { + @Override protected void paintComponent(Graphics graphics) + { + super.paintComponent(graphics); + Graphics2D copy = (Graphics2D) graphics.create(); + try + { + redraw(copy); + } + finally + { + copy.dispose(); + } + } + + @Override public Dimension getPreferredSize() + { + return new Dimension( + Math.max(1, (int) Math.ceil(totalDurationNs / nanosecondsPerPixel)), + UiUtils.pointsToPixels(HEIGHT_PTS)); + } + + @Override public Dimension getPreferredScrollableViewportSize() + { return new Dimension(0, UiUtils.pointsToPixels(HEIGHT_PTS)); } + + @Override public boolean getScrollableTracksViewportHeight() + { return true; } + + @Override public boolean getScrollableTracksViewportWidth() + { return false; } + + @Override + public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) + { return UiUtils.pointsToPixels(16); } + + @Override + public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) + { return Math.max(1, visible.width - UiUtils.pointsToPixels(16)); } } } diff --git a/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java b/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java new file mode 100644 index 00000000..04f3f1c7 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java @@ -0,0 +1,43 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UI.button; +import static swingtree.UI.of; +import static swingtree.UI.scrollPane; + +import javax.swing.JDialog; +import javax.swing.JPanel; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; +import java.awt.Component; +import java.awt.Font; +import java.awt.Window; + +/* A modeless, selectable text report for flux records and sectors. */ +public class TextViewerDialogue extends JDialog +{ + private TextViewerDialogue(Window owner, String title, String text) + { + super(owner, title, ModalityType.MODELESS); + + /* Preserve report alignment and let long metadata and dumps scroll in either direction. */ + JTextArea area = new JTextArea(text); + area.setEditable(false); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, UiUtils.pointsToPixels(10))); + area.setCaretPosition(0); + of((JPanel) getContentPane()) + .withLayout("fill, wrap 1, insets 12, gap 8") + .add("grow, push", scrollPane().withPrefSize(600, 400).add(of(area))) + .add("align right", button("Close").onClick(event -> dispose())); + + /* Keep reports independent of the originating viewer's lifetime. */ + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + pack(); + setLocationRelativeTo(owner); + } + + public static void show(Component parent, String title, String text) + { + Window owner = SwingUtilities.getWindowAncestor(parent); + new TextViewerDialogue(owner, title, text).setVisible(true); + } +} diff --git a/javatests/com/cowlark/fluxengine/gui/BUILD.bazel b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel index ffa3e883..e8713dbe 100644 --- a/javatests/com/cowlark/fluxengine/gui/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel @@ -111,3 +111,17 @@ java_test( "@maven//:junit_junit", ], ) + +java_test( + name = "FluxViewerPanelTest", + srcs = ["FluxViewerPanelTest.java"], + jvm_flags = ["-Djava.awt.headless=true"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/gui", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java new file mode 100644 index 00000000..a14cbfdb --- /dev/null +++ b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java @@ -0,0 +1,270 @@ +package com.cowlark.fluxengine.gui; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.CylinderHeadSector; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Record; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.data.Track; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import javax.swing.JComponent; +import javax.imageio.ImageIO; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JViewport; +import javax.swing.SwingUtilities; +import java.awt.Component; +import java.awt.Container; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.event.ComponentEvent; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; + +@RunWith(JUnit4.class) public class FluxViewerPanelTest +{ + /* Use a simple timed pulse stream with one index and a sector/record over its middle. */ + private static Track track(boolean pulses) + { + Track track = new Track(); + track.fluxmap = new Fluxmap(); + for (int i = 0; i < 200; i++) + { + track.fluxmap.appendInterval(2400); + if (pulses) + track.fluxmap.appendPulse(); + if (i == 100) + track.fluxmap.appendIndex(); + } + Record record = new Record(); + record.startTimeNs + = track.fluxmap.durationNs() / 4; + record.endTimeNs + = track.fluxmap.durationNs() * 3 / 4; + record.clockNs + = 2000; + record.rawData + = Bytes.of(0x12, 0x34); + track.records.add(record); + Sector sector = new Sector(new CylinderHeadSector(1, 0, 3)); + sector.status = Sector.Status.OK; + sector.headerStartTimeNs = record.startTimeNs; + sector.headerEndTimeNs = record.endTimeNs; + sector.records.add(record); + sector.data = Bytes.of(0xab, 0xcd); + track.allSectors.add(sector); + return track; + } + + private static JViewport viewport(FluxViewerPanel panel) + { + JViewport viewport = find(panel, JViewport.class); + viewport.setSize(400, 200); + viewport.dispatchEvent(new ComponentEvent(viewport, ComponentEvent.COMPONENT_RESIZED)); + viewport.doLayout(); + return viewport; + } + + private static T find(Container root, Class type) + { + for (Component child : root.getComponents()) + { + if (type.isInstance(child)) + return type.cast(child); + if (child instanceof Container container) + { + T result = find(container, type); + if (result != null) + return result; + } + } + return null; + } + + private static BufferedImage paint(JViewport viewport) + { + BufferedImage image = new BufferedImage(400, 200, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try + { + viewport.paint(graphics); + } + finally + { + graphics.dispose(); + } + return image; + } + + /* Retain a representative rendering as a Bazel test artifact for visual inspection. */ + private static void snapshot(String name, BufferedImage image) + { + String output = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR"); + if (output == null) + return; + try + { + ImageIO.write(image, "png", new File(output, name + ".png")); + } + catch (IOException exception) + { + throw new AssertionError(exception); + } + } + + @Test public void fitZoomAnchorsAndClamps() throws Exception + { + SwingUtilities.invokeAndWait(() -> { + FluxViewerPanel panel = new FluxViewerPanel(null, ImmutableList.of(track(true))); + JViewport viewport = viewport(panel); + assertThat(viewport.getView().getWidth()).isEqualTo(400); + assertThat(viewport.getView().getHeight()).isEqualTo(200); + assertThat(panel.getPreferredSize().width).isEqualTo(0); + + /* At the centre, one inward notch increases both content width and view offset. */ + panel.zoom(-1, 200); + assertThat(viewport.getView().getWidth()).isEqualTo(480); + assertThat(viewport.getViewPosition().x).isEqualTo(40); + panel.zoom(1, 200); + assertThat(viewport.getViewPosition().x).isEqualTo(0); + assertThat(viewport.getView().getWidth()).isEqualTo(400); + panel.zoom(100, 200); + assertThat(viewport.getView().getWidth()).isEqualTo(400); + + /* Zoom to the minimum scale, then resize without resetting that scale. */ + panel.zoom(-100, 200); + int width = viewport.getView().getWidth(); + panel.zoom(-100, 200); + assertThat(viewport.getView().getWidth()).isEqualTo(width); + viewport.setSize(300, 160); + viewport.dispatchEvent(new ComponentEvent(viewport, ComponentEvent.COMPONENT_RESIZED)); + assertThat(viewport.getView().getWidth()).isEqualTo(width); + assertThat(viewport.getViewPosition().y).isEqualTo(0); + panel.dispose(); + }); + } + + @Test public void drawsLayersAndTargetsMenusInContentCoordinates() throws Exception + { + SwingUtilities.invokeAndWait(() -> { + Track first = track(true); + Track second = track(true); + second.allSectors.getFirst().status = Sector.Status.BAD_CHECKSUM; + FluxViewerPanel panel = new FluxViewerPanel(null, ImmutableList.of(first, second)); + JViewport viewport = viewport(panel); + BufferedImage image = paint(viewport); + snapshot("captures", image); + + /* Sample block interiors away from text, outlines, and the index marker. */ + assertThat(image.getRGB(60, 10) & 0xffffff).isEqualTo(0xffffff); + assertThat(image.getRGB(260, 10) & 0xffffff).isEqualTo(0xd55e00); + assertThat(image.getRGB(200, 5) & 0xffffff).isEqualTo(0xff0000); + assertThat(image.getRGB(60, 50) & 0xffffff).isEqualTo(0xc8c8c8); + JPopupMenu menu = panel.contextMenuAt(new Point(60, 10)); + assertThat(menu.getComponentCount()).isEqualTo(3); + assertThat(((JMenuItem) menu.getComponent(0)).getText()) + .isEqualTo("Show sector summary"); + assertThat(panel.contextMenuAt(new Point(60, 50)).getComponentCount()).isEqualTo(1); + assertThat(panel.contextMenuAt(new Point(60, 110))).isNull(); + + /* Reports include both captures even when their statuses differ. */ + assertThat(panel.sectorSummary(first.allSectors.getFirst())) + .contains("Number of times seen: 2"); + assertThat(panel.sectorSummary(first.allSectors.getFirst())).contains("bad checksum"); + assertThat(panel.sectorData(first.allSectors.getFirst(), false)).contains("ab cd"); + assertThat(panel.sectorData(first.allSectors.getFirst(), true)).contains("12 34"); + assertThat(FluxViewerPanel.recordData(first.records.getFirst())) + .contains("Data CRC16:"); + panel.dispose(); + }); + } + + @Test public void wheelHoverDragAndRenderingAtEveryDetailLevel() throws Exception + { + SwingUtilities.invokeAndWait(() -> { + FluxViewerPanel panel = new FluxViewerPanel(null, ImmutableList.of(track(true))); + JViewport viewport = viewport(panel); + JComponent canvas = (JComponent) viewport.getView(); + BufferedImage normal = paint(viewport); + assertThat(normal.getRGB(120, 170) & 0xffffff).isEqualTo(0x4040ff); + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, 0, 0, 120, 10, 0, false)); + BufferedImage hovered = paint(viewport); + assertThat(hovered.getRGB(99, 10)).isNotEqualTo(normal.getRGB(99, 10)); + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_EXITED, 0, 0, 120, 10, 0, false)); + assertThat(paint(viewport).getRGB(99, 10)).isEqualTo(normal.getRGB(99, 10)); + + /* A wheel event must zoom and be consumed instead of scrolling the outer panel. */ + MouseWheelEvent wheel = new MouseWheelEvent(canvas, + MouseEvent.MOUSE_WHEEL, + 0, + 0, + 200, + 100, + 0, + false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, + 3, + -1); + canvas.dispatchEvent(wheel); + assertThat(wheel.isConsumed()).isTrue(); + assertThat(canvas.getWidth()).isEqualTo(480); + canvas.dispatchEvent(new MouseEvent( + canvas, MouseEvent.MOUSE_PRESSED, 0, 0, 240, 100, 1, false, MouseEvent.BUTTON1)); + canvas.dispatchEvent(new MouseEvent(canvas, + MouseEvent.MOUSE_DRAGGED, + 0, + MouseEvent.BUTTON1_DOWN_MASK, + 220, + 130, + 0, + false, + MouseEvent.NOBUTTON)); + assertThat(viewport.getViewPosition().x).isEqualTo(60); + assertThat(viewport.getViewPosition().y).isEqualTo(0); + canvas.dispatchEvent(new MouseEvent( + canvas, MouseEvent.MOUSE_RELEASED, 0, 0, 240, 100, 1, false, MouseEvent.BUTTON1)); + + /* Exercise density, individual pulses, bytes, and raw bits, including record EOF. */ + for (int i = 0; i < 42; i++) + { + panel.zoom(-1, 200); + paint(viewport); + if (i == 32) + snapshot("raw-bits", paint(viewport)); + } + panel.dispose(); + }); + } + + @Test public void absentZeroDurationAndPulseFreeInputsAreSafe() throws Exception + { + SwingUtilities.invokeAndWait(() -> { + Track absent = new Track(); + Track empty = new Track(); + empty.fluxmap = new Fluxmap(); + for (ImmutableList tracks : ImmutableList.of(ImmutableList.of(), + ImmutableList.of(absent, empty), + ImmutableList.of(track(false)))) + { + FluxViewerPanel panel = new FluxViewerPanel(null, tracks); + JViewport viewport = viewport(panel); + paint(viewport); + panel.zoom(-100, 200); + paint(viewport); + panel.zoom(100, 200); + paint(viewport); + panel.dispose(); + } + }); + } +} From 33f682d6ba6872fa662f57552765ff656b1aeda2 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 01:43:24 +0200 Subject: [PATCH 06/11] Clean up and restyle the flux viewer. --- .../fluxengine/gui/FluxViewerPanel.java | 632 ++++++++++++------ .../com/cowlark/fluxengine/gui/BUILD.bazel | 1 + .../fluxengine/gui/FluxViewerPanelTest.java | 87 ++- 3 files changed, 505 insertions(+), 215 deletions(-) diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index 272baabb..cc99556c 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -26,7 +26,6 @@ import javax.swing.Scrollable; import javax.swing.SwingUtilities; import javax.swing.plaf.basic.BasicInternalFrameUI; -import java.awt.BasicStroke; import java.awt.Color; import java.awt.Cursor; import java.awt.Dimension; @@ -51,15 +50,13 @@ public class FluxViewerPanel extends JInternalFrame { - /* Geometry and detail thresholds from the C++ flux viewer. */ - private static final int HEIGHT_PTS = 150; + private static final int HEIGHT_PTS = 200; private static final int BORDER = 4; + private static final int CONTENT_PADDING = 5 * BORDER; private static final int MAX_WIDTH = Integer.MAX_VALUE - 1024; private static final double RENDER_LIMIT = 3000.0; - private static final Color BACKGROUND = new Color(192, 192, 192); - private static final Color FLUX = new Color(64, 64, 255); - private static final Color BAD_SECTOR = new Color(213, 94, 0); private static final DecoderProto DECODER = DecoderProto.getDefaultInstance(); + public static final int FONT_SIZE_PTS = 12; private final ImmutableList tracks; private final ImmutableList captures; @@ -77,6 +74,8 @@ public class FluxViewerPanel extends JInternalFrame public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) { super("Flux view", false, true, false, false); + + /* Retain the captures supplied by the flux-view request. */ this.tracks = tracks; /* Keep captures in input order and accumulate offsets in time, rather than pixels. */ @@ -86,14 +85,18 @@ public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) { if (track.fluxmap == null) continue; + + /* Ignore captures without a usable duration. */ double duration = track.fluxmap.durationNs(); if (!(duration > 0) || !Double.isFinite(duration)) continue; + + /* Index record boundaries for the ruler before advancing to the next capture. */ captureBuilder.add(new Capture(track, offset, duration)); for (Record record : track.records) { if (Double.isFinite(record.startTimeNs) && Double.isFinite(record.endTimeNs) && - record.endTimeNs >= record.startTimeNs) + record.endTimeNs >= record.startTimeNs) { boundaries.add(offset + record.startTimeNs); boundaries.add(offset + record.endTimeNs); @@ -106,25 +109,33 @@ public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) /* The viewport supplies horizontal translation and the canvas follows its height. */ canvas = new Canvas(); - canvas.setBackground(BACKGROUND); - canvas.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, UiUtils.pointsToPixels(6))); - JScrollPane scrollPane = new JScrollPane(canvas, - JScrollPane.VERTICAL_SCROLLBAR_NEVER, - JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); + canvas.setFont(new Font( + Font.SANS_SERIF, + Font.PLAIN, + UiUtils.pointsToPixels(FONT_SIZE_PTS))); + JScrollPane scrollPane = new JScrollPane( + canvas, + JScrollPane.VERTICAL_SCROLLBAR_NEVER, + JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS); scrollPane.setWheelScrollingEnabled(false); viewport = scrollPane.getViewport(); - viewport.addComponentListener(new ComponentAdapter() { - @Override public void componentResized(ComponentEvent event) + + /* Fit the timeline on first layout and preserve its scale on subsequent resizes. */ + viewport.addComponentListener(new ComponentAdapter() + { + @Override + public void componentResized(ComponentEvent event) { if (!scaleInitialised && viewport.getWidth() > 0) { scaleInitialised = true; updateScale(fitScale()); - } - else + } else updateScale(nanosecondsPerPixel); } }); + + /* Keep the pointer at the same screen position when the viewport scrolls. */ viewport.addChangeListener(event -> { int scrollX = viewport.getViewPosition().x; if (pointer != null) @@ -134,20 +145,28 @@ public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) }); /* Track pointer movement, horizontal dragging, and native popup gestures. */ - MouseAdapter mouse = new MouseAdapter() { - @Override public void mouseMoved(MouseEvent event) - { trackPointer(event); } + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent event) + { + trackPointer(event); + } - @Override public void mouseExited(MouseEvent event) + @Override + public void mouseExited(MouseEvent event) { pointer = null; canvas.repaint(); } - @Override public void mousePressed(MouseEvent event) + @Override + public void mousePressed(MouseEvent event) { popupShown = false; trackPointer(event); + + /* Distinguish native popup gestures from the start of a primary-button drag. */ if (event.isPopupTrigger()) showPopup(event); else if (SwingUtilities.isLeftMouseButton(event)) @@ -157,35 +176,43 @@ else if (SwingUtilities.isLeftMouseButton(event)) } } - @Override public void mouseDragged(MouseEvent event) + @Override + public void mouseDragged(MouseEvent event) { if (dragPosition != null) { Point position = - SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); + SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); setScrollX((long) viewport.getViewPosition().x + dragPosition.x - position.x); dragPosition = position; + + /* Restore pointer coordinates after moving the canvas beneath the mouse. */ pointer = new Point(viewport.getViewPosition().x + position.x, position.y); canvas.repaint(); - } - else + } else trackPointer(event); } - @Override public void mouseReleased(MouseEvent event) + @Override + public void mouseReleased(MouseEvent event) { if (event.isPopupTrigger() && !popupShown) showPopup(event); + + /* Finish any active drag and restore the normal cursor. */ dragPosition = null; canvas.setCursor(Cursor.getDefaultCursor()); trackPointer(event); } - @Override public void mouseWheelMoved(MouseWheelEvent event) + @Override + public void mouseWheelMoved(MouseWheelEvent event) { Point anchor = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); zoom(event.getPreciseWheelRotation(), anchor.x); pointer = new Point(viewport.getViewPosition().x + anchor.x, event.getY()); + + /* Reserve the wheel for zooming rather than scrolling the surrounding panel. */ event.consume(); canvas.repaint(); } @@ -197,9 +224,12 @@ else if (SwingUtilities.isLeftMouseButton(event)) /* Keep each row a fixed height without contributing to the window's preferred width. */ int height = UiUtils.pointsToPixels(HEIGHT_PTS); JPanel content = new JPanel(); - of(content).withLayout("fill, insets 0").add("grow, push", of(scrollPane)); + of(content).withLayout("fill, insets 0") + .add("grow, push", of(scrollPane)); content.setPreferredSize(new Dimension(0, height)); setContentPane(content); + + /* Include the title bar in the fixed row height and keep only the close control. */ setFrameIcon(null); setDefaultCloseOperation(DISPOSE_ON_CLOSE); int frameHeight = getPreferredSize().height; @@ -210,7 +240,8 @@ else if (SwingUtilities.isLeftMouseButton(event)) } /* Keep the look and feel's title bar, but leave positioning to the row layout. */ - @Override public void updateUI() + @Override + public void updateUI() { super.updateUI(); if (getUI() instanceof BasicInternalFrameUI frameUI) @@ -226,22 +257,33 @@ else if (SwingUtilities.isLeftMouseButton(event)) /* Scaling and view positioning use a bounded pixel range, with time retained as doubles. */ private double fitScale() - { return Math.max(minimumScale(), totalDurationNs / Math.max(1, viewport.getWidth())); } + { + return Math.max( + minimumScale(), + totalDurationNs / Math.max(1, viewport.getWidth() - 2 * CONTENT_PADDING)); + } private double minimumScale() - { return Math.max(30, totalDurationNs / (MAX_WIDTH - 1.0)); } + { + return Math.max(30, totalDurationNs / (MAX_WIDTH - 2 * CONTENT_PADDING - 1.0)); + } private void updateScale(double scale) { + /* Apply the bounded scale and refresh any density caches that depend on it. */ nanosecondsPerPixel = Math.max(minimumScale(), scale); for (Capture capture : captures) capture.updateDensity(); + + /* Find a shared density maximum so captures use a consistent colour scale. */ double maximum = 0; for (Capture capture : captures) { for (double value : capture.density) maximum = Math.max(maximum, value); } + + /* Normalize pulse counts while leaving pulse-free captures at zero. */ if (maximum > 0) { for (Capture capture : captures) @@ -250,6 +292,8 @@ private void updateScale(double scale) capture.density[i] /= maximum; } } + + /* Lay out the resized canvas and clamp the viewport to its new extent. */ canvas.revalidate(); viewport.doLayout(); setScrollX(viewport.getViewPosition().x); @@ -261,9 +305,14 @@ void zoom(double rotation, int anchorX) { if (totalDurationNs <= 0 || viewport.getWidth() <= 0) return; - double time = (viewport.getViewPosition().x + (double) anchorX) * nanosecondsPerPixel; + + /* Record the time under the pointer before changing scale. */ + double time = (viewport.getViewPosition().x + (double) anchorX - CONTENT_PADDING) * + nanosecondsPerPixel; + + /* Apply wheel zoom and restore the anchor within the available scroll range. */ updateScale(Math.min(fitScale(), nanosecondsPerPixel * Math.pow(1.2, rotation))); - setScrollX(Math.round(time / nanosecondsPerPixel - anchorX)); + setScrollX(Math.round(CONTENT_PADDING + time / nanosecondsPerPixel - anchorX)); } private void setScrollX(long x) @@ -280,7 +329,11 @@ private void trackPointer(MouseEvent event) /* Coordinates shared by drawing and hit testing prevent drift between blocks and menus. */ private int pixel(double time) - { return (int) Math.min(MAX_WIDTH, Math.max(0, time / nanosecondsPerPixel)); } + { + return CONTENT_PADDING + (int) Math.min( + MAX_WIDTH - 2 * CONTENT_PADDING, + Math.max(0, time / nanosecondsPerPixel)); + } private int centre(int row) { @@ -289,40 +342,62 @@ private int centre(int row) } private int blockHeight() - { return Math.max(1, (canvas.getHeight() / 5) * 2 / 3); } + { + return Math.max(1, (canvas.getHeight() / 5) * 2 / 3); + } private Rectangle block(Capture capture, double start, double end, int row) { int left = pixel(capture.offset + start); int right = pixel(capture.offset + end); return new Rectangle( - left, centre(row) - blockHeight() / 2, Math.max(0, right - left), blockHeight()); + left, + centre(row) - blockHeight() / 2, + Math.max(0, right - left), + blockHeight()); } /* Paint only the visible part of the concatenated timeline, in the original layer order. */ protected void redraw(Graphics2D graphics) { + /* Clear the visible canvas and leave empty timelines with just their background. */ Rectangle visible = viewport.getViewRect(); - graphics.setColor(BACKGROUND); + graphics.setColor(canvas.getBackground()); graphics.fillRect(visible.x, 0, visible.width, canvas.getHeight()); graphics.setFont(canvas.getFont()); if (totalDurationNs <= 0 || visible.width <= 0) return; + + /* Keep all drawing, including labels, inside the padded timeline extent. */ + graphics.clipRect( + CONTENT_PADDING, + 0, + Math.max(0, canvas.getWidth() - 2 * CONTENT_PADDING), + canvas.getHeight()); + + /* Render each capture in timeline order, skipping captures outside the viewport. */ for (Capture capture : captures) { int left = pixel(capture.offset); int right = pixel(capture.offset + capture.duration); if (right < visible.x || left > visible.x + visible.width) continue; + + /* Separate consecutive captures and match connecting lines to their box fills. */ if (capture.offset != 0) { - graphics.setColor(Color.RED); + graphics.setColor(UiUtils.DATA_BAD_COLOUR); graphics.drawLine(left, 0, left, canvas.getHeight()); } - graphics.setColor(Color.BLACK); - for (int row : new int[] {0, 1, 3}) - graphics.drawLine(left, centre(row), right, centre(row)); - graphics.setColor(Color.YELLOW); + graphics.setColor(UiUtils.DATA_OK_COLOUR.darker()); + graphics.drawLine(left, centre(0), right, centre(0)); + graphics.setColor(UiUtils.HEADER_COLOUR.darker()); + graphics.drawLine(left, centre(1), right, centre(1)); + graphics.setColor(canvas.getForeground()); + graphics.drawLine(left, centre(3), right, centre(3)); + + /* Mark index events across all five bands. */ + graphics.setColor(UiUtils.INDEX_LINE_COLOUR); for (double index : capture.track.fluxmap.getIndexMarks()) { int x = pixel(capture.offset + index); @@ -331,47 +406,74 @@ protected void redraw(Graphics2D graphics) if (x >= visible.x) graphics.drawLine(x, 0, x, canvas.getHeight()); } + + /* Draw the capture-relative time scale before overlaying data blocks. */ drawScale(graphics, capture, visible); + + /* Show sector extents, including headers whose data was not recovered. */ for (Sector sector : capture.track.allSectors) { double end = - sector.dataEndTimeNs != 0 ? sector.dataEndTimeNs : sector.headerEndTimeNs; + sector.dataEndTimeNs != 0 ? sector.dataEndTimeNs : sector.headerEndTimeNs; Rectangle rect = block(capture, sector.headerStartTimeNs, end, 0); - drawBlock(graphics, - rect, - sector.status == Sector.Status.OK ? Color.WHITE : BAD_SECTOR, - sector.logicalLocation + " " + Sector.statusToString(sector.status)); + drawBlock( + graphics, + rect, + (sector.status == Sector.Status.OK ? + UiUtils.DATA_OK_COLOUR : + UiUtils.DATA_BAD_COLOUR).darker(), + sector.logicalLocation + " " + Sector.statusToString(sector.status)); } + + /* Show record extents, decoded bytes, and raw bits at their respective detail levels. */ for (Record record : capture.track.records) { Rectangle rect = block(capture, record.startTimeNs, record.endTimeNs, 1); if (!rect.intersects(visible)) continue; - drawBlock(graphics, - rect, - new Color(200, 200, 200), - nanosecondsPerPixel > RENDER_LIMIT / 4 - ? String.format(Locale.ROOT, "+%.3fms", record.startTimeNs / 1e6) - : ""); + drawBlock( + graphics, + rect, + recordColour(capture.track, record), + nanosecondsPerPixel > RENDER_LIMIT / 4 ? + String.format(Locale.ROOT, "+%.3fms", record.startTimeNs / 1e6) : + ""); if (nanosecondsPerPixel <= RENDER_LIMIT / 4) - drawDecoded(graphics, capture, record, rect, visible, false); + drawDecodedBytes(graphics, capture, record, rect, visible); if (nanosecondsPerPixel < RENDER_LIMIT / 8) - drawDecoded(graphics, capture, record, rect, visible, true); + drawRawBits(graphics, capture, record, visible); } + + /* Finish the capture with its pulse-density or discrete-flux chart. */ drawFlux(graphics, capture, visible); } + + /* Keep the timeline names at the visible left edge when the canvas is scrolled. */ + int labelX = Math.max(CONTENT_PADDING, visible.x) + BORDER; + int labelHeight = graphics.getFontMetrics().getHeight(); + graphics.setColor(UiUtils.DATA_OK_COLOUR.darker()); + text(graphics, "Sectors", labelX, centre(0) - labelHeight); + graphics.setColor(UiUtils.HEADER_COLOUR.darker()); + text(graphics, "Raw records", labelX, centre(1) - labelHeight); + + /* Overlay the interval ruler for the current pointer position. */ drawRuler(graphics); } /* Draw tick marks in capture-relative time and labels at the viewport's two edges. */ private void drawScale(Graphics2D graphics, Capture capture, Rectangle visible) { - graphics.setColor(Color.BLACK); + /* Choose a power-of-ten tick interval with enough room between marks. */ + graphics.setColor(canvas.getForeground()); double step = 1000; while (step / nanosecondsPerPixel < 10) step *= 10; - double start = Math.max(0, visible.x * nanosecondsPerPixel - capture.offset); + + /* Start at the first potentially visible tick and draw progressively larger marks. */ + double start = + Math.max(0, (visible.x - CONTENT_PADDING) * nanosecondsPerPixel - capture.offset); long first = (long) Math.floor(start / step); + int top = centre(2) + blockHeight() / 4; for (long index = first; index * step < capture.duration; index++) { double time = index * step; @@ -382,104 +484,180 @@ private void drawScale(Graphics2D graphics, Capture capture, Rectangle visible) int tick = index % 100 == 0 ? half : index % 10 == 0 ? half / 2 : half / 3; graphics.drawLine(x, centre(3) - tick, x, centre(3) + tick); if (index % 10 == 0) - text(graphics, milliseconds(time), x, centre(3) - half); + { + /* Centre each label above its tick, leaving room for the full text height. */ + String label = milliseconds(time); + text(graphics, label, x - graphics.getFontMetrics().stringWidth(label) / 2, top); + } } - int top = centre(2) + blockHeight() / 4; - double leftTime = visible.x * nanosecondsPerPixel - capture.offset; + + /* Label the times at the two viewport edges relative to this capture. */ + int leftEdge = Math.max(CONTENT_PADDING, visible.x); + int rightEdge = Math.min(canvas.getWidth() - CONTENT_PADDING, visible.x + visible.width); + double leftTime = (leftEdge - CONTENT_PADDING) * nanosecondsPerPixel - capture.offset; if (leftTime >= 0 && leftTime < capture.duration) - text(graphics, milliseconds(leftTime), visible.x + BORDER, top); - double rightTime = (visible.x + visible.width) * nanosecondsPerPixel - capture.offset; + text(graphics, milliseconds(leftTime), leftEdge + BORDER, top); + double rightTime = (rightEdge - CONTENT_PADDING) * nanosecondsPerPixel - capture.offset; if (rightTime >= 0 && rightTime <= capture.duration) { String label = milliseconds(rightTime); - text(graphics, - label, - visible.x + visible.width - BORDER - graphics.getFontMetrics().stringWidth(label), - top); + text( + graphics, + label, + rightEdge - BORDER - graphics.getFontMetrics().stringWidth(label), + top); + } + } + + /* Records inherit an error colour when they belong to a sector with a bad status. */ + private static Color recordColour(Track track, Record record) + { + for (Sector sector : track.allSectors) + { + if (sector.status != Sector.Status.OK && sector.records.contains(record)) + return UiUtils.DATA_BAD_COLOUR.darker(); } + return UiUtils.HEADER_COLOUR.darker(); } - /* Clip text to its block while keeping hover strokes and caller graphics state isolated. */ + /* Clip text to its block while keeping hover shading and caller graphics state isolated. */ private void drawBlock(Graphics2D graphics, Rectangle rect, Color colour, String label) { if (rect.width <= 0 || !rect.intersects(viewport.getViewRect())) return; - Graphics2D g = (Graphics2D) graphics.create(); + + /* Highlight hovered blocks through their fill rather than an outline. */ + Graphics2D g = UiUtils.getGraphics2D(graphics.create()); try { - g.setColor(colour); + g.setColor(pointer != null && rect.contains(pointer) ? + blend(colour, canvas.getForeground(), 0.1) : + colour); g.fill(rect); - g.setColor(Color.BLACK); - g.setStroke(new BasicStroke(pointer != null && rect.contains(pointer) ? 2 : 1)); - g.draw(rect); + g.setColor(canvas.getForeground()); + + /* Keep the label inside the block and centre it vertically. */ g.clip(rect); - text(g, - label, - rect.x + BORDER, - rect.y + (rect.height - g.getFontMetrics().getHeight()) / 2); + text( + g, + label, + rect.x + BORDER, + rect.y + (rect.height - g.getFontMetrics().getHeight()) / 2); + } finally + { + g.dispose(); } - finally + } + + /* Draw decoded byte values within their record boxes. */ + private void drawDecodedBytes( + Graphics2D graphics, + Capture capture, + Record record, + Rectangle rect, + Rectangle visible) + { + /* Reject records that cannot be decoded safely. */ + if (!(record.clockNs > 0) || !Double.isFinite(record.clockNs) || + record.endTimeNs <= record.startTimeNs || record.position < 0 || + record.position >= capture.track.fluxmap.bytes()) + return; + + /* Seek directly to the record bytecode with a record-relative time origin. */ + FluxmapReader reader = new FluxmapReader(capture.track.fluxmap, DECODER); + reader.seek(new FluxPosition(record.position, 0, 0)); + FluxDecoder decoder = new FluxDecoder(reader, record.clockNs, DECODER); + + /* Keep byte labels and endpoint marks within the record box. */ + Graphics2D g = UiUtils.getGraphics2D(graphics.create()); + try + { + g.setColor(canvas.getForeground()); + g.clip(rect); + + /* Read labels until the record ends or the reader reaches EOF. */ + while (!reader.eof() && + reader.tell().getDurationNs() <= record.endTimeNs - record.startTimeNs) + { + /* Decode the next complete byte and format it as two hexadecimal digits. */ + Bits value = decoder.readBits(8); + if (value.size() < 8) + break; + String label = String.format(Locale.ROOT, "%02x", value.toBytes().getByte(0) & 0xff); + + /* Stop beyond the visible region and skip labels that are still to its left. */ + int x = pixel(capture.offset + record.startTimeNs + reader.tell().getDurationNs()); + if (x > visible.x + visible.width + 50) + break; + if (x < visible.x) + continue; + + /* Place each label immediately before its decoded endpoint. */ + g.drawLine(x, rect.y, x, rect.y + rect.height); + text( + g, + label, + x - g.getFontMetrics().stringWidth(label) - BORDER, + centre(1) - g.getFontMetrics().getHeight() / 2); + } + } finally { g.dispose(); } } - /* Decode from a bytecode position with a record-relative clock, as in the C++ viewer. */ - private void drawDecoded(Graphics2D graphics, - Capture capture, - Record record, - Rectangle rect, - Rectangle visible, - boolean rawBits) + /* Draw raw bit values above the flux timeline. */ + private void drawRawBits( + Graphics2D graphics, + Capture capture, + Record record, + Rectangle visible) { + /* Reject records that cannot be decoded safely. */ if (!(record.clockNs > 0) || !Double.isFinite(record.clockNs) || - record.endTimeNs <= record.startTimeNs || record.position < 0 || - record.position >= capture.track.fluxmap.bytes()) + record.endTimeNs <= record.startTimeNs || record.position < 0 || + record.position >= capture.track.fluxmap.bytes()) return; + + /* Seek directly to the record bytecode with a record-relative time origin. */ FluxmapReader reader = new FluxmapReader(capture.track.fluxmap, DECODER); reader.seek(new FluxPosition(record.position, 0, 0)); FluxDecoder decoder = new FluxDecoder(reader, record.clockNs, DECODER); - Graphics2D g = (Graphics2D) graphics.create(); + + /* Isolate raw-bit labels from the record boxes and the caller's graphics state. */ + Graphics2D g = UiUtils.getGraphics2D(graphics.create()); try { - g.setColor(Color.BLACK); - if (!rawBits) - g.clip(rect); + g.setColor(UiUtils.HEADER_COLOUR); + + /* Read labels until the record ends or the reader reaches EOF. */ StringBuilder bits = new StringBuilder(); while (!reader.eof() && - reader.tell().getDurationNs() <= record.endTimeNs - record.startTimeNs) + reader.tell().getDurationNs() <= record.endTimeNs - record.startTimeNs) { - String label; - if (rawBits) - { - boolean bit = decoder.readBit(); - bits.append(bit ? '1' : '0'); - if (!bit) - continue; - label = bits.toString(); - bits.setLength(0); - } - else - { - Bits value = decoder.readBits(8); - if (value.size() < 8) - break; - label = String.format(Locale.ROOT, "%02x", value.toBytes().getByte(0) & 0xff); - } + /* Accumulate raw bits through the next pulse before placing their label. */ + boolean bit = decoder.readBit(); + bits.append(bit ? '1' : '0'); + if (!bit) + continue; + String label = bits.toString(); + bits.setLength(0); + + /* Stop beyond the visible region and skip labels that are still to its left. */ int x = pixel(capture.offset + record.startTimeNs + reader.tell().getDurationNs()); if (x > visible.x + visible.width + 50) break; if (x < visible.x) continue; - if (!rawBits) - g.drawLine(x, rect.y, x, rect.y + rect.height); - text(g, - label, - x - g.getFontMetrics().stringWidth(label) - BORDER, - rawBits ? centre(3) : centre(1) - g.getFontMetrics().getHeight() / 2); + + /* Place each label immediately before its decoded endpoint. */ + text( + g, + label, + x - g.getFontMetrics().stringWidth(label) - BORDER, + centre(4) - g.getFontMetrics().getHeight()); } - } - finally + } finally { g.dispose(); } @@ -491,22 +669,21 @@ private void drawFlux(Graphics2D graphics, Capture capture, Rectangle visible) int half = blockHeight() / 2; if (nanosecondsPerPixel > RENDER_LIMIT) { + /* Shade visible columns using the cached, normalized pulse density. */ int first = Math.max(visible.x, pixel(capture.offset)); int last = - Math.min(visible.x + visible.width, pixel(capture.offset + capture.duration)); + Math.min(visible.x + visible.width, pixel(capture.offset + capture.duration)); for (int x = first; x < last; x++) { int index = x - pixel(capture.offset); double density = index < capture.density.length ? capture.density[index] : 0; - graphics.setColor(new Color(interpolate(192, 64, density), - interpolate(192, 64, density), - interpolate(192, 255, density))); + graphics.setColor(blend(canvas.getBackground(), UiUtils.HEADER_COLOUR, density)); graphics.fillRect(x, centre(4) - half, 1, blockHeight()); } - } - else + } else { - graphics.setColor(FLUX); + /* At close scales, draw each visible transition as an individual pulse. */ + graphics.setColor(UiUtils.HEADER_COLOUR); FluxmapReader reader = new FluxmapReader(capture.track.fluxmap, DECODER); while (reader.findEvent(F_BIT_PULSE).found()) { @@ -517,41 +694,66 @@ private void drawFlux(Graphics2D graphics, Capture capture, Rectangle visible) graphics.drawLine(x, centre(4) - half, x, centre(4) + half); } } - graphics.setColor(FLUX); + + /* Draw the flux baseline across the full capture extent. */ + graphics.setColor(UiUtils.HEADER_COLOUR); graphics.drawLine( - pixel(capture.offset), centre(4), pixel(capture.offset + capture.duration), centre(4)); + pixel(capture.offset), + centre(4), + pixel(capture.offset + capture.duration), + centre(4)); + } + + /* Blend semantic highlights into the current theme's background rather than a fixed grey. */ + private static Color blend(Color background, Color highlight, double factor) + { + return new Color( + interpolate(background.getRed(), highlight.getRed(), factor), + interpolate(background.getGreen(), highlight.getGreen(), factor), + interpolate(background.getBlue(), highlight.getBlue(), factor)); } private static int interpolate(int low, int high, double factor) - { return (int) (low + (high - low) * factor); } + { + return (int) (low + (high - low) * factor); + } private void drawRuler(Graphics2D graphics) { if (pointer == null) return; - double time = pointer.x * nanosecondsPerPixel; + + /* Find the record boundaries immediately surrounding the pointer time. */ + double time = (pointer.x - CONTENT_PADDING) * nanosecondsPerPixel; Double right = boundaries.ceiling(time); Double left = right == null ? null : boundaries.lower(right); if (left == null) return; + + /* Draw the interval and centre its duration label between the two boundaries. */ int lx = pixel(left); int rx = pixel(right); int y = centre(2) - canvas.getHeight() / 10; - graphics.setColor(Color.BLACK); + graphics.setColor(canvas.getForeground()); graphics.drawLine(lx, y, rx, y); String label = milliseconds(right - left); - text(graphics, - label, - lx + (rx - lx) / 2 - graphics.getFontMetrics().stringWidth(label) / 2, - y); + text( + graphics, + label, + lx + (rx - lx) / 2 - graphics.getFontMetrics().stringWidth(label) / 2, + y); } /* Java draws text at a baseline; wxWidgets' text coordinates refer to the top edge. */ private static void text(Graphics2D graphics, String text, int x, int top) - { graphics.drawString(text, x, top + graphics.getFontMetrics().getAscent()); } + { + graphics.drawString(text, x, top + graphics.getFontMetrics().getAscent()); + } private static String milliseconds(double time) - { return String.format(Locale.ROOT, "%.3fms", time / 1e6); } + { + return String.format(Locale.ROOT, "%.3fms", time / 1e6); + } /* Popup hit testing runs on the mouse event, never during painting. */ private void showPopup(MouseEvent event) @@ -564,59 +766,59 @@ private void showPopup(MouseEvent event) JPopupMenu contextMenuAt(Point position) { + /* Search in reverse painting order so the uppermost block receives the menu. */ for (int i = captures.size() - 1; i >= 0; i--) { Capture capture = captures.get(i); + + /* Offer raw-record data when the pointer is over a record block. */ List records = capture.track.records; for (int j = records.size() - 1; j >= 0; j--) { Record record = records.get(j); if (block(capture, record.startTimeNs, record.endTimeNs, 1).contains(position)) return popupMenu() - .add(menuItem("Show record data") - .onClick(event - -> showText( - "Raw record data " + - (capture.track.ptl == null - ? "" - : "c" + capture.track.ptl.physicalCylinder + - "h" + capture.track.ptl.physicalHead) + - " + " + milliseconds(record.startTimeNs), - recordData(record)))) - .get(JPopupMenu.class); + .add(menuItem("Show record data").onClick(event -> showText( + "Raw record data " + (capture.track.ptl == null ? + "" : + "c" + capture.track.ptl.physicalCylinder + "h" + + capture.track.ptl.physicalHead) + " + " + + milliseconds(record.startTimeNs), recordData(record)))) + .get(JPopupMenu.class); } + + /* Offer sector summaries and decoded or raw data for a sector block. */ List sectors = capture.track.allSectors; for (int j = sectors.size() - 1; j >= 0; j--) { Sector sector = sectors.get(j); double end = - sector.dataEndTimeNs != 0 ? sector.dataEndTimeNs : sector.headerEndTimeNs; + sector.dataEndTimeNs != 0 ? sector.dataEndTimeNs : sector.headerEndTimeNs; if (block(capture, sector.headerStartTimeNs, end, 0).contains(position)) return popupMenu() - .add(menuItem("Show sector summary") - .onClick(event - -> showText("Sector summary " + sector.logicalLocation, - sectorSummary(sector)))) - .add(menuItem("Show decoded data") - .onClick(event - -> showText("User data " + sector.logicalLocation, - sectorData(sector, false)))) - .add(menuItem("Show raw data") - .onClick(event - -> showText("Raw data " + sector.logicalLocation, - sectorData(sector, true)))) - .get(JPopupMenu.class); + .add(menuItem("Show sector summary").onClick(event -> showText( + "Sector summary " + sector.logicalLocation, + sectorSummary(sector)))) + .add(menuItem("Show decoded data").onClick(event -> showText( + "User data " + sector.logicalLocation, + sectorData(sector, false)))) + .add(menuItem("Show raw data").onClick(event -> showText( + "Raw data " + sector.logicalLocation, + sectorData(sector, true)))).get(JPopupMenu.class); } } return null; } private void showText(String title, String text) - { TextViewerDialogue.show(this, title, text); } + { + TextViewerDialogue.show(this, title, text); + } /* Reports retain all occurrences of the same logical sector, including duplicate captures. */ String sectorSummary(Sector selected) { + /* Collect every occurrence of the selected logical sector across the supplied captures. */ List matches = new ArrayList<>(); for (Track track : tracks) { @@ -626,56 +828,66 @@ String sectorSummary(Sector selected) matches.add(sector); } } + + /* Report the occurrence count followed by metadata for each matching sector. */ StringBuilder result = new StringBuilder("Number of times seen: " + matches.size() + "\n"); for (int i = 0; i < matches.size(); i++) - result.append("\nInstance ") - .append(i) - .append(":\n\n") - .append(sectorMetadata(matches.get(i))); + result + .append("\nInstance ") + .append(i) + .append(":\n\n") + .append(sectorMetadata(matches.get(i))); return result.toString(); } String sectorData(Sector sector, boolean raw) { + /* Start with sector metadata, then append decoded bytes or the underlying records. */ StringBuilder result = new StringBuilder(sectorMetadata(sector)); if (!raw) result.append('\n').append(hexDump(sector.data)); else { result.append("Number of records: ").append(sector.records.size()).append('\n'); + + /* Keep each raw record separate so its timing and bytecode position remain visible. */ for (int i = 0; i < sector.records.size(); i++) - result.append("\nRecord ") - .append(i) - .append(":\n\n") - .append(recordData(sector.records.get(i))); + result + .append("\nRecord ") + .append(i) + .append(":\n\n") + .append(recordData(sector.records.get(i))); } return result.toString(); } private static String sectorMetadata(Sector sector) { - return String.format(Locale.ROOT, - "Sector status: %s\nPhysical location: %s\nClock: %.2fus / %s\n" - + "Bytecode position: %d\nData CRC16: %04x\n", - Sector.statusToString(sector.status), - sector.physicalLocation == null ? "unknown" : sector.physicalLocation, - sector.clockNs / 1000, - sector.clockNs > 0 ? String.format(Locale.ROOT, "%.0fkHz", 1e6 / sector.clockNs) - : "unknown", - sector.position, - Crc.crc16(Crc.CCITT_POLY, sector.data)); + return String.format( + Locale.ROOT, + "Sector status: %s\nPhysical location: %s\nClock: %.2fus / %s\n" + + "Bytecode position: %d\nData CRC16: %04x\n", + Sector.statusToString(sector.status), + sector.physicalLocation == null ? "unknown" : sector.physicalLocation, + sector.clockNs / 1000, + sector.clockNs > 0 ? + String.format(Locale.ROOT, "%.0fkHz", 1e6 / sector.clockNs) : + "unknown", + sector.position, + Crc.crc16(Crc.CCITT_POLY, sector.data)); } static String recordData(Record record) { - return String.format(Locale.ROOT, - "Bytecode position: %d\nStart: %.2fms\nEnd: %.2fms\n" - + "Data CRC16: %04x\n\n%s", - record.position, - record.startTimeNs / 1e6, - record.endTimeNs / 1e6, - Crc.crc16(Crc.CCITT_POLY, record.rawData), - hexDump(record.rawData)); + return String.format( + Locale.ROOT, + "Bytecode position: %d\nStart: %.2fms\nEnd: %.2fms\n" + + "Data CRC16: %04x\n\n%s", + record.position, + record.startTimeNs / 1e6, + record.endTimeNs / 1e6, + Crc.crc16(Crc.CCITT_POLY, record.rawData), + hexDump(record.rawData)); } private static String hexDump(Bytes bytes) @@ -708,8 +920,12 @@ void updateDensity() { if (nanosecondsPerPixel <= RENDER_LIMIT || densityScale == nanosecondsPerPixel) return; + + /* Allocate fresh bins only when a coarse-scale density cache needs rebuilding. */ densityScale = nanosecondsPerPixel; density = new double[pixel(offset + duration) - pixel(offset) + 1]; + + /* Count pulse endpoints in timeline-aligned pixel bins for this capture. */ FluxmapReader reader = new FluxmapReader(track.fluxmap, DECODER); while (reader.findEvent(F_BIT_PULSE).found()) { @@ -722,42 +938,56 @@ void updateDensity() /* A wide timeline whose height always matches the viewport rather than scrolling vertically. */ private final class Canvas extends JPanel implements Scrollable { - @Override protected void paintComponent(Graphics graphics) + @Override + protected void paintComponent(Graphics graphics) { super.paintComponent(graphics); - Graphics2D copy = (Graphics2D) graphics.create(); + Graphics2D copy = UiUtils.getGraphics2D(graphics.create()); try { redraw(copy); - } - finally + } finally { copy.dispose(); } } - @Override public Dimension getPreferredSize() + @Override + public Dimension getPreferredSize() { return new Dimension( - Math.max(1, (int) Math.ceil(totalDurationNs / nanosecondsPerPixel)), - UiUtils.pointsToPixels(HEIGHT_PTS)); + 2 * CONTENT_PADDING + (int) Math.ceil(totalDurationNs / nanosecondsPerPixel), + UiUtils.pointsToPixels(HEIGHT_PTS)); } - @Override public Dimension getPreferredScrollableViewportSize() - { return new Dimension(0, UiUtils.pointsToPixels(HEIGHT_PTS)); } + @Override + public Dimension getPreferredScrollableViewportSize() + { + return new Dimension(0, UiUtils.pointsToPixels(HEIGHT_PTS)); + } - @Override public boolean getScrollableTracksViewportHeight() - { return true; } + @Override + public boolean getScrollableTracksViewportHeight() + { + return true; + } - @Override public boolean getScrollableTracksViewportWidth() - { return false; } + @Override + public boolean getScrollableTracksViewportWidth() + { + return false; + } @Override public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) - { return UiUtils.pointsToPixels(16); } + { + return UiUtils.pointsToPixels(16); + } @Override public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) - { return Math.max(1, visible.width - UiUtils.pointsToPixels(16)); } + { + return Math.max(1, visible.width - UiUtils.pointsToPixels(16)); + } } } diff --git a/javatests/com/cowlark/fluxengine/gui/BUILD.bazel b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel index e8713dbe..a2242d47 100644 --- a/javatests/com/cowlark/fluxengine/gui/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel @@ -120,6 +120,7 @@ java_test( "//java/com/cowlark/fluxengine/core", "//java/com/cowlark/fluxengine/data", "//java/com/cowlark/fluxengine/gui", + "@maven//:com_formdev_flatlaf", "@maven//:com_google_guava_guava", "@maven//:com_google_truth_truth", "@maven//:junit_junit", diff --git a/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java index a14cbfdb..4c9ad014 100644 --- a/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java +++ b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java @@ -18,6 +18,10 @@ import javax.swing.JPopupMenu; import javax.swing.JViewport; import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.LookAndFeel; +import com.formdev.flatlaf.FlatDarkLaf; +import com.formdev.flatlaf.FlatLightLaf; import java.awt.Component; import java.awt.Container; import java.awt.Graphics2D; @@ -131,8 +135,8 @@ private static void snapshot(String name, BufferedImage image) /* At the centre, one inward notch increases both content width and view offset. */ panel.zoom(-1, 200); - assertThat(viewport.getView().getWidth()).isEqualTo(480); - assertThat(viewport.getViewPosition().x).isEqualTo(40); + assertThat(viewport.getView().getWidth()).isEqualTo(472); + assertThat(viewport.getViewPosition().x).isEqualTo(36); panel.zoom(1, 200); assertThat(viewport.getViewPosition().x).isEqualTo(0); assertThat(viewport.getView().getWidth()).isEqualTo(400); @@ -163,17 +167,26 @@ private static void snapshot(String name, BufferedImage image) BufferedImage image = paint(viewport); snapshot("captures", image); + /* Both timeline ends reserve blank space even where tick labels are clipped. */ + int paper = viewport.getView().getBackground().getRGB(); + for (int x : new int[]{0, 1, 18, 19, 380, 381, 398, 399}) + { + for (int y = 0; y < image.getHeight(); y++) + assertThat(image.getRGB(x, y)).isEqualTo(paper); + } + /* Sample block interiors away from text, outlines, and the index marker. */ - assertThat(image.getRGB(60, 10) & 0xffffff).isEqualTo(0xffffff); - assertThat(image.getRGB(260, 10) & 0xffffff).isEqualTo(0xd55e00); - assertThat(image.getRGB(200, 5) & 0xffffff).isEqualTo(0xff0000); - assertThat(image.getRGB(60, 50) & 0xffffff).isEqualTo(0xc8c8c8); - JPopupMenu menu = panel.contextMenuAt(new Point(60, 10)); + assertThat(image.getRGB(80, 10) & 0xffffff).isEqualTo(UiUtils.DATA_OK_COLOUR.darker().getRGB() & 0xffffff); + assertThat(image.getRGB(260, 10)).isEqualTo(UiUtils.DATA_BAD_COLOUR.darker().getRGB()); + assertThat(image.getRGB(200, 5) & 0xffffff).isEqualTo(UiUtils.DATA_BAD_COLOUR.getRGB() & 0xffffff); + assertThat(image.getRGB(80, 50)).isEqualTo(UiUtils.HEADER_COLOUR.darker().getRGB()); + assertThat(image.getRGB(260, 50)).isEqualTo(UiUtils.DATA_BAD_COLOUR.darker().getRGB()); + JPopupMenu menu = panel.contextMenuAt(new Point(80, 10)); assertThat(menu.getComponentCount()).isEqualTo(3); assertThat(((JMenuItem) menu.getComponent(0)).getText()) .isEqualTo("Show sector summary"); - assertThat(panel.contextMenuAt(new Point(60, 50)).getComponentCount()).isEqualTo(1); - assertThat(panel.contextMenuAt(new Point(60, 110))).isNull(); + assertThat(panel.contextMenuAt(new Point(80, 50)).getComponentCount()).isEqualTo(1); + assertThat(panel.contextMenuAt(new Point(80, 110))).isNull(); /* Reports include both captures even when their statuses differ. */ assertThat(panel.sectorSummary(first.allSectors.getFirst())) @@ -194,14 +207,14 @@ private static void snapshot(String name, BufferedImage image) JViewport viewport = viewport(panel); JComponent canvas = (JComponent) viewport.getView(); BufferedImage normal = paint(viewport); - assertThat(normal.getRGB(120, 170) & 0xffffff).isEqualTo(0x4040ff); + assertThat(normal.getRGB(120, 170) & 0xffffff).isEqualTo(UiUtils.HEADER_COLOUR.getRGB() & 0xffffff); canvas.dispatchEvent( new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, 0, 0, 120, 10, 0, false)); BufferedImage hovered = paint(viewport); - assertThat(hovered.getRGB(99, 10)).isNotEqualTo(normal.getRGB(99, 10)); + assertThat(hovered.getRGB(120, 10)).isNotEqualTo(normal.getRGB(120, 10)); canvas.dispatchEvent( new MouseEvent(canvas, MouseEvent.MOUSE_EXITED, 0, 0, 120, 10, 0, false)); - assertThat(paint(viewport).getRGB(99, 10)).isEqualTo(normal.getRGB(99, 10)); + assertThat(paint(viewport).getRGB(120, 10)).isEqualTo(normal.getRGB(120, 10)); /* A wheel event must zoom and be consumed instead of scrolling the outer panel. */ MouseWheelEvent wheel = new MouseWheelEvent(canvas, @@ -217,7 +230,7 @@ private static void snapshot(String name, BufferedImage image) -1); canvas.dispatchEvent(wheel); assertThat(wheel.isConsumed()).isTrue(); - assertThat(canvas.getWidth()).isEqualTo(480); + assertThat(canvas.getWidth()).isEqualTo(472); canvas.dispatchEvent(new MouseEvent( canvas, MouseEvent.MOUSE_PRESSED, 0, 0, 240, 100, 1, false, MouseEvent.BUTTON1)); canvas.dispatchEvent(new MouseEvent(canvas, @@ -229,7 +242,7 @@ private static void snapshot(String name, BufferedImage image) 0, false, MouseEvent.NOBUTTON)); - assertThat(viewport.getViewPosition().x).isEqualTo(60); + assertThat(viewport.getViewPosition().x).isEqualTo(56); assertThat(viewport.getViewPosition().y).isEqualTo(0); canvas.dispatchEvent(new MouseEvent( canvas, MouseEvent.MOUSE_RELEASED, 0, 0, 240, 100, 1, false, MouseEvent.BUTTON1)); @@ -240,12 +253,58 @@ private static void snapshot(String name, BufferedImage image) panel.zoom(-1, 200); paint(viewport); if (i == 32) + { + viewport.setViewPosition(new Point((int) (canvas.getWidth() * 0.539), 0)); snapshot("raw-bits", paint(viewport)); + } } panel.dispose(); }); } + @Test + public void paletteFollowsLightAndDarkThemes() throws Exception + { + SwingUtilities.invokeAndWait(() -> { + LookAndFeel original = UIManager.getLookAndFeel(); + try + { + FluxViewerPanel panel = new FluxViewerPanel(null, ImmutableList.of(track(true))); + for (LookAndFeel theme : ImmutableList.of(new FlatLightLaf(), new FlatDarkLaf())) + { + UIManager.setLookAndFeel(theme); + SwingUtilities.updateComponentTreeUI(panel); + JViewport viewport = viewport(panel); + BufferedImage image = paint(viewport); + assertThat(image.getRGB(10, 100)) + .isEqualTo(UIManager.getColor("Panel.background").getRGB()); + assertThat(image.getRGB(120, 10)) + .isEqualTo(UiUtils.DATA_OK_COLOUR.darker().getRGB()); + /* Semantic record fills remain consistent under both themes. */ + assertThat(image.getRGB(120, 50)) + .isEqualTo(UiUtils.HEADER_COLOUR.darker().getRGB()); + snapshot(theme instanceof FlatDarkLaf ? "dark-theme" : "light-theme", image); + } + panel.dispose(); + } + catch (Exception exception) + { + throw new AssertionError(exception); + } + finally + { + try + { + UIManager.setLookAndFeel(original); + } + catch (Exception exception) + { + throw new AssertionError(exception); + } + } + }); + } + @Test public void absentZeroDurationAndPulseFreeInputsAreSafe() throws Exception { SwingUtilities.invokeAndWait(() -> { From 212a9348373818b399901086985f7b8950c95cc8 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 01:56:20 +0200 Subject: [PATCH 07/11] Do proper hidpi scaling in the flux viewer. --- java/com/cowlark/fluxengine/gui/FluxViewerPanel.java | 9 +++++---- java/com/cowlark/fluxengine/gui/TextViewerDialogue.java | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index cc99556c..22162bec 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -17,6 +17,7 @@ import com.cowlark.fluxengine.decoders.FluxDecoder; import com.cowlark.fluxengine.external.Crc; import com.google.common.collect.ImmutableList; +import com.formdev.flatlaf.util.UIScale; import javax.swing.JComponent; import javax.swing.JInternalFrame; import javax.swing.JPanel; @@ -50,13 +51,13 @@ public class FluxViewerPanel extends JInternalFrame { - private static final int HEIGHT_PTS = 200; + private static final int HEIGHT_PTS = 100; private static final int BORDER = 4; private static final int CONTENT_PADDING = 5 * BORDER; private static final int MAX_WIDTH = Integer.MAX_VALUE - 1024; private static final double RENDER_LIMIT = 3000.0; private static final DecoderProto DECODER = DecoderProto.getDefaultInstance(); - public static final int FONT_SIZE_PTS = 12; + public static final int FONT_SIZE_PTS = 7; private final ImmutableList tracks; private final ImmutableList captures; @@ -112,7 +113,7 @@ public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) canvas.setFont(new Font( Font.SANS_SERIF, Font.PLAIN, - UiUtils.pointsToPixels(FONT_SIZE_PTS))); + UIScale.scale(UiUtils.pointsToPixels(FONT_SIZE_PTS)))); JScrollPane scrollPane = new JScrollPane( canvas, JScrollPane.VERTICAL_SCROLLBAR_NEVER, @@ -222,7 +223,7 @@ public void mouseWheelMoved(MouseWheelEvent event) canvas.addMouseWheelListener(mouse); /* Keep each row a fixed height without contributing to the window's preferred width. */ - int height = UiUtils.pointsToPixels(HEIGHT_PTS); + int height = UiUtils.pointsToPixels(UIScale.scale(HEIGHT_PTS)); JPanel content = new JPanel(); of(content).withLayout("fill, insets 0") .add("grow, push", of(scrollPane)); diff --git a/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java b/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java index 04f3f1c7..84828e3f 100644 --- a/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java +++ b/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java @@ -4,6 +4,7 @@ import static swingtree.UI.of; import static swingtree.UI.scrollPane; +import com.formdev.flatlaf.util.UIScale; import javax.swing.JDialog; import javax.swing.JPanel; import javax.swing.JTextArea; @@ -22,7 +23,7 @@ private TextViewerDialogue(Window owner, String title, String text) /* Preserve report alignment and let long metadata and dumps scroll in either direction. */ JTextArea area = new JTextArea(text); area.setEditable(false); - area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, UiUtils.pointsToPixels(10))); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, UIScale.scale(UiUtils.pointsToPixels(10)))); area.setCaretPosition(0); of((JPanel) getContentPane()) .withLayout("fill, wrap 1, insets 12, gap 8") From 0678894f70fccacd7f2dc5cbc7b4cf4a377e9139 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 02:05:10 +0200 Subject: [PATCH 08/11] Set the flux viewer title correctly. --- java/com/cowlark/fluxengine/gui/FluxViewerPanel.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index 22162bec..af57bfdf 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -18,6 +18,7 @@ import com.cowlark.fluxengine.external.Crc; import com.google.common.collect.ImmutableList; import com.formdev.flatlaf.util.UIScale; +import com.google.common.collect.Iterables; import javax.swing.JComponent; import javax.swing.JInternalFrame; import javax.swing.JPanel; @@ -74,7 +75,7 @@ public class FluxViewerPanel extends JInternalFrame public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) { - super("Flux view", false, true, false, false); + super(getTitle(tracks), false, true, false, false); /* Retain the captures supplied by the flux-view request. */ this.tracks = tracks; @@ -991,4 +992,9 @@ public int getScrollableBlockIncrement(Rectangle visible, int orientation, int d return Math.max(1, visible.width - UiUtils.pointsToPixels(16)); } } + + private static String getTitle(ImmutableList tracks){ + var ptl = Iterables.getFirst(tracks,null).ptl; + return String.format("Physical track c%dh%d", ptl.physicalCylinder, ptl.physicalHead); + } } From 1c5209dec6171085db6bc9a64ec6cf19a60689a4 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 13:34:59 +0100 Subject: [PATCH 09/11] Draw the timeline legends a bit smaller. Make the tests pass again. --- .../fluxengine/gui/FluxViewerPanel.java | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index af57bfdf..a008421a 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -16,8 +16,8 @@ import com.cowlark.fluxengine.decoders.DecoderProto; import com.cowlark.fluxengine.decoders.FluxDecoder; import com.cowlark.fluxengine.external.Crc; -import com.google.common.collect.ImmutableList; import com.formdev.flatlaf.util.UIScale; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import javax.swing.JComponent; import javax.swing.JInternalFrame; @@ -427,7 +427,8 @@ protected void redraw(Graphics2D graphics) sector.logicalLocation + " " + Sector.statusToString(sector.status)); } - /* Show record extents, decoded bytes, and raw bits at their respective detail levels. */ + /* Show record extents, decoded bytes, and raw bits at their respective detail levels + . */ for (Record record : capture.track.records) { Rectangle rect = block(capture, record.startTimeNs, record.endTimeNs, 1); @@ -451,12 +452,15 @@ protected void redraw(Graphics2D graphics) } /* Keep the timeline names at the visible left edge when the canvas is scrolled. */ + Font normalFont = graphics.getFont(); + graphics.setFont(normalFont.deriveFont(normalFont.getSize2D() * 0.75f)); int labelX = Math.max(CONTENT_PADDING, visible.x) + BORDER; int labelHeight = graphics.getFontMetrics().getHeight(); graphics.setColor(UiUtils.DATA_OK_COLOUR.darker()); text(graphics, "Sectors", labelX, centre(0) - labelHeight); graphics.setColor(UiUtils.HEADER_COLOUR.darker()); text(graphics, "Raw records", labelX, centre(1) - labelHeight); + graphics.setFont(normalFont); /* Overlay the interval ruler for the current pointer position. */ drawRuler(graphics); @@ -585,7 +589,8 @@ private void drawDecodedBytes( Bits value = decoder.readBits(8); if (value.size() < 8) break; - String label = String.format(Locale.ROOT, "%02x", value.toBytes().getByte(0) & 0xff); + String label = + String.format(Locale.ROOT, "%02x", value.toBytes().getByte(0) & 0xff); /* Stop beyond the visible region and skip labels that are still to its left. */ int x = pixel(capture.offset + record.startTimeNs + reader.tell().getDurationNs()); @@ -609,11 +614,7 @@ private void drawDecodedBytes( } /* Draw raw bit values above the flux timeline. */ - private void drawRawBits( - Graphics2D graphics, - Capture capture, - Record record, - Rectangle visible) + private void drawRawBits(Graphics2D graphics, Capture capture, Record record, Rectangle visible) { /* Reject records that cannot be decoded safely. */ if (!(record.clockNs > 0) || !Double.isFinite(record.clockNs) || @@ -993,8 +994,22 @@ public int getScrollableBlockIncrement(Rectangle visible, int orientation, int d } } - private static String getTitle(ImmutableList tracks){ - var ptl = Iterables.getFirst(tracks,null).ptl; - return String.format("Physical track c%dh%d", ptl.physicalCylinder, ptl.physicalHead); + private static String getTitle(ImmutableList tracks) + { + Track track = Iterables.getFirst(tracks, null); + if (track != null) + { + if (track.ptl != null) + return String.format( + "Physical track c%dh%d", + track.ptl.physicalCylinder, + track.ptl.physicalHead); + else if (track.ltl != null) + return String.format( + "Logical track c%dh%d", + track.ltl.logicalCylinder, + track.ltl.logicalHead); + } + return "Test track"; } } From 557d8a2eb62a2f8f289472257b237fde2117e477 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 13:41:37 +0100 Subject: [PATCH 10/11] Cleanup. --- .../fluxengine/gui/FluxViewerPanel.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index a008421a..38702fdc 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -868,8 +868,13 @@ private static String sectorMetadata(Sector sector) { return String.format( Locale.ROOT, - "Sector status: %s\nPhysical location: %s\nClock: %.2fus / %s\n" + - "Bytecode position: %d\nData CRC16: %04x\n", + """ + Sector status: %s + Physical location: %s + Clock: %.2fus / %s + Bytecode position: %d + Data CRC16: %04x + """, Sector.statusToString(sector.status), sector.physicalLocation == null ? "unknown" : sector.physicalLocation, sector.clockNs / 1000, @@ -884,8 +889,13 @@ static String recordData(Record record) { return String.format( Locale.ROOT, - "Bytecode position: %d\nStart: %.2fms\nEnd: %.2fms\n" + - "Data CRC16: %04x\n\n%s", + """ + Bytecode position: %d + Start: %.2fms + End: %.2fms + Data CRC16: %04x + + %s""", record.position, record.startTimeNs / 1e6, record.endTimeNs / 1e6, From ee533941cc81396e9b01a373b60829cce222293c Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 25 Oct 2026 14:37:24 +0100 Subject: [PATCH 11/11] Finally sort out the flux view redraw lag. --- Makefile | 5 +- .../fluxengine/gui/FluxViewerPanel.java | 169 +++++++++++++----- java/com/cowlark/fluxengine/gui/UiUtils.java | 14 ++ .../fluxengine/gui/FluxViewerPanelTest.java | 62 +++++++ 4 files changed, 202 insertions(+), 48 deletions(-) diff --git a/Makefile b/Makefile index 86748f61..cfa52c6b 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: all corpus +.PHONY: all run all: bazel test //... //:fluxengine bazel run //java/com/cowlark/fluxengine/buildtools:mkdocindex -- $(PWD)/README.md rm $(PWD)/doc/disk-*.md bazel run //java/com/cowlark/fluxengine/buildtools:mkdoc -- $(PWD)/doc - +run: + bazel run //:fluxengine -- gui --jvm_flag=-Dorg.slf4j.simpleLogger.defaultLogLevel=info diff --git a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java index 38702fdc..8be62f21 100644 --- a/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -27,6 +27,7 @@ import javax.swing.JViewport; import javax.swing.Scrollable; import javax.swing.SwingUtilities; +import javax.swing.Timer; import javax.swing.plaf.basic.BasicInternalFrameUI; import java.awt.Color; import java.awt.Cursor; @@ -64,12 +65,14 @@ public class FluxViewerPanel extends JInternalFrame private final ImmutableList captures; private final TreeSet boundaries = new TreeSet<>(); private final Canvas canvas; - private final JViewport viewport; + private final TimelineViewport viewport; + private final Timer interactionTimer; private final double totalDurationNs; private double nanosecondsPerPixel = 30; private boolean scaleInitialised; private Point dragPosition; private Point pointer; + private boolean interactionDirty; private boolean popupShown; private int previousScrollX; @@ -118,9 +121,20 @@ public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) JScrollPane scrollPane = new JScrollPane( canvas, JScrollPane.VERTICAL_SCROLLBAR_NEVER, - JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS); + JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS) + { + @Override + protected JViewport createViewport() + { + return new TimelineViewport(); + } + }; scrollPane.setWheelScrollingEnabled(false); - viewport = scrollPane.getViewport(); + viewport = (TimelineViewport) scrollPane.getViewport(); + + /* Apply the latest pointer and scroll state together at about 30 Hz. */ + interactionTimer = new Timer(33, event -> processInteractionFrame()); + interactionTimer.setCoalesce(true); /* Fit the timeline on first layout and preserve its scale on subsequent resizes. */ viewport.addComponentListener(new ComponentAdapter() @@ -143,7 +157,7 @@ public void componentResized(ComponentEvent event) if (pointer != null) pointer.translate(scrollX - previousScrollX, 0); previousScrollX = scrollX; - canvas.repaint(); + interactionDirty = true; }); /* Track pointer movement, horizontal dragging, and native popup gestures. */ @@ -159,7 +173,7 @@ public void mouseMoved(MouseEvent event) public void mouseExited(MouseEvent event) { pointer = null; - canvas.repaint(); + interactionDirty = true; } @Override @@ -185,12 +199,12 @@ public void mouseDragged(MouseEvent event) { Point position = SwingUtilities.convertPoint(canvas, event.getPoint(), viewport); - setScrollX((long) viewport.getViewPosition().x + dragPosition.x - position.x); + setScrollX((long) viewport.requestedPosition().x + dragPosition.x - position.x); dragPosition = position; /* Restore pointer coordinates after moving the canvas beneath the mouse. */ pointer = new Point(viewport.getViewPosition().x + position.x, position.y); - canvas.repaint(); + interactionDirty = true; } else trackPointer(event); } @@ -308,25 +322,63 @@ void zoom(double rotation, int anchorX) if (totalDurationNs <= 0 || viewport.getWidth() <= 0) return; - /* Record the time under the pointer before changing scale. */ - double time = (viewport.getViewPosition().x + (double) anchorX - CONTENT_PADDING) * - nanosecondsPerPixel; + /* Wheel zoom remains immediate, including any scroll position already awaiting a frame. */ + viewport.immediate = true; + try + { + viewport.applyPendingPosition(); + + /* Record the time under the pointer before changing scale. */ + double time = (viewport.getViewPosition().x + (double) anchorX - CONTENT_PADDING) * + nanosecondsPerPixel; - /* Apply wheel zoom and restore the anchor within the available scroll range. */ - updateScale(Math.min(fitScale(), nanosecondsPerPixel * Math.pow(1.2, rotation))); - setScrollX(Math.round(CONTENT_PADDING + time / nanosecondsPerPixel - anchorX)); + /* Apply wheel zoom and restore the anchor within the available scroll range. */ + updateScale(Math.min(fitScale(), nanosecondsPerPixel * Math.pow(1.2, rotation))); + setScrollX(Math.round(CONTENT_PADDING + time / nanosecondsPerPixel - anchorX)); + } + finally + { + viewport.immediate = false; + } } private void setScrollX(long x) { int maximum = Math.max(0, canvas.getWidth() - viewport.getWidth()); - viewport.setViewPosition(new Point((int) Math.max(0, Math.min(x, maximum)), 0)); + viewport.setViewPosition(new Point((int) Math.clamp(x, 0, maximum), 0)); + } + + /* Commit only the latest interaction state, avoiding native scroll paints between ticks. */ + void processInteractionFrame() + { + viewport.applyPendingPosition(); + if (interactionDirty) + { + interactionDirty = false; + canvas.repaint(); + } + } + + /* Run the interaction timer only while the viewer belongs to a displayable hierarchy. */ + @Override + public void addNotify() + { + super.addNotify(); + interactionTimer.start(); + } + + @Override + public void removeNotify() + { + interactionTimer.stop(); + viewport.applyPendingPosition(); + super.removeNotify(); } private void trackPointer(MouseEvent event) { pointer = event.getPoint(); - canvas.repaint(); + interactionDirty = true; } /* Coordinates shared by drawing and hit testing prevent drift between blocks and menus. */ @@ -427,8 +479,7 @@ protected void redraw(Graphics2D graphics) sector.logicalLocation + " " + Sector.statusToString(sector.status)); } - /* Show record extents, decoded bytes, and raw bits at their respective detail levels - . */ + /* Show record extents, decoded bytes, and raw bits at their respective detail levels. */ for (Record record : capture.track.records) { Rectangle rect = block(capture, record.startTimeNs, record.endTimeNs, 1); @@ -537,7 +588,7 @@ private void drawBlock(Graphics2D graphics, Rectangle rect, Color colour, String try { g.setColor(pointer != null && rect.contains(pointer) ? - blend(colour, canvas.getForeground(), 0.1) : + UiUtils.blend(colour, canvas.getForeground(), 0.1) : colour); g.fill(rect); g.setColor(canvas.getForeground()); @@ -666,7 +717,7 @@ private void drawRawBits(Graphics2D graphics, Capture capture, Record record, Re } } - /* Density is normalized across all captures; close views draw individual transitions. */ + /* Density is normalised across all captures; close views draw individual transitions. */ private void drawFlux(Graphics2D graphics, Capture capture, Rectangle visible) { int half = blockHeight() / 2; @@ -680,7 +731,7 @@ private void drawFlux(Graphics2D graphics, Capture capture, Rectangle visible) { int index = x - pixel(capture.offset); double density = index < capture.density.length ? capture.density[index] : 0; - graphics.setColor(blend(canvas.getBackground(), UiUtils.HEADER_COLOUR, density)); + graphics.setColor(UiUtils.blend(canvas.getBackground(), UiUtils.HEADER_COLOUR, density)); graphics.fillRect(x, centre(4) - half, 1, blockHeight()); } } else @@ -707,20 +758,6 @@ private void drawFlux(Graphics2D graphics, Capture capture, Rectangle visible) centre(4)); } - /* Blend semantic highlights into the current theme's background rather than a fixed grey. */ - private static Color blend(Color background, Color highlight, double factor) - { - return new Color( - interpolate(background.getRed(), highlight.getRed(), factor), - interpolate(background.getGreen(), highlight.getGreen(), factor), - interpolate(background.getBlue(), highlight.getBlue(), factor)); - } - - private static int interpolate(int low, int high, double factor) - { - return (int) (low + (high - low) * factor); - } - private void drawRuler(Graphics2D graphics) { if (pointer == null) @@ -869,12 +906,12 @@ private static String sectorMetadata(Sector sector) return String.format( Locale.ROOT, """ - Sector status: %s - Physical location: %s - Clock: %.2fus / %s - Bytecode position: %d - Data CRC16: %04x - """, + Sector status: %s + Physical location: %s + Clock: %.2fus / %s + Bytecode position: %d + Data CRC16: %04x + """, Sector.statusToString(sector.status), sector.physicalLocation == null ? "unknown" : sector.physicalLocation, sector.clockNs / 1000, @@ -890,12 +927,12 @@ static String recordData(Record record) return String.format( Locale.ROOT, """ - Bytecode position: %d - Start: %.2fms - End: %.2fms - Data CRC16: %04x - - %s""", + Bytecode position: %d + Start: %.2fms + End: %.2fms + Data CRC16: %04x + + %s""", record.position, record.startTimeNs / 1e6, record.endTimeNs / 1e6, @@ -948,12 +985,52 @@ void updateDensity() } } + /* Delay native viewport scrolling as well as explicit repaint requests during interaction. */ + private final class TimelineViewport extends JViewport + { + private Point pendingPosition; + private boolean immediate; + + @Override + public void setViewPosition(Point position) + { + if (immediate || interactionTimer == null || !interactionTimer.isRunning()) + { + pendingPosition = null; + super.setViewPosition(position); + } + else + { + pendingPosition = new Point(position); + interactionDirty = true; + } + } + + /* Drag deltas accumulate against the latest requested position, even before it is drawn. */ + Point requestedPosition() + { + return pendingPosition == null ? getViewPosition() : new Point(pendingPosition); + } + + void applyPendingPosition() + { + if (pendingPosition == null) + return; + Point position = pendingPosition; + pendingPosition = null; + int maximum = Math.max(0, getViewSize().width - getExtentSize().width); + super.setViewPosition(new Point(Math.clamp(position.x, 0, maximum), 0)); + } + } + /* A wide timeline whose height always matches the viewport rather than scrolling vertically. */ private final class Canvas extends JPanel implements Scrollable { @Override protected void paintComponent(Graphics graphics) { + /* Any paint consumes the latest interaction state, including native Swing paints. */ + interactionDirty = false; super.paintComponent(graphics); Graphics2D copy = UiUtils.getGraphics2D(graphics.create()); try diff --git a/java/com/cowlark/fluxengine/gui/UiUtils.java b/java/com/cowlark/fluxengine/gui/UiUtils.java index 1e01927b..e0a07ec2 100644 --- a/java/com/cowlark/fluxengine/gui/UiUtils.java +++ b/java/com/cowlark/fluxengine/gui/UiUtils.java @@ -304,6 +304,20 @@ else if (c == '\r') return sb.toString(); } + /* Blend semantic highlights into the current theme's background rather than a fixed grey. */ + static Color blend(Color background, Color highlight, double factor) + { + return new Color( + interpolate(background.getRed(), highlight.getRed(), factor), + interpolate(background.getGreen(), highlight.getGreen(), factor), + interpolate(background.getBlue(), highlight.getBlue(), factor)); + } + + private static int interpolate(int low, int high, double factor) + { + return (int) (low + (high - low) * factor); + } + @Builder(setterPrefix = "set") record SplitPanelAction(String label, Val isEnabled, Consumer> onClick) diff --git a/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java index 4c9ad014..2572a719 100644 --- a/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java +++ b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java @@ -17,6 +17,8 @@ import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.JViewport; +import javax.swing.JScrollPane; +import javax.swing.JScrollBar; import javax.swing.SwingUtilities; import javax.swing.UIManager; import javax.swing.LookAndFeel; @@ -28,6 +30,8 @@ import java.awt.Point; import java.awt.event.ComponentEvent; import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.awt.event.MouseMotionListener; import java.awt.event.MouseWheelEvent; import java.awt.image.BufferedImage; import java.io.File; @@ -305,6 +309,64 @@ public void paletteFollowsLightAndDarkThemes() throws Exception }); } + @Test + public void scrollingAndDraggingApplyOnlyTheLatestPositionPerFrame() throws Exception + { + SwingUtilities.invokeAndWait(() -> { + FluxViewerPanel panel = new FluxViewerPanel(null, ImmutableList.of(track(true))); + JViewport viewport = viewport(panel); + panel.zoom(-5, 0); + panel.addNotify(); + try + { + /* Scrollbar motion updates its model immediately but defers the canvas movement. */ + JScrollBar bar = find(panel, JScrollPane.class).getHorizontalScrollBar(); + bar.setValueIsAdjusting(true); + bar.setValue(20); + bar.setValue(40); + bar.setValue(60); + assertThat(viewport.getViewPosition().x).isEqualTo(0); + panel.processInteractionFrame(); + assertThat(viewport.getViewPosition().x).isEqualTo(60); + bar.setValueIsAdjusting(false); + + /* A release before the tick still preserves the final requested position. */ + bar.setValue(0); + panel.processInteractionFrame(); + JComponent canvas = (JComponent) viewport.getView(); + MouseEvent press = new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, 0, 0, + 200, 100, 0, 0, 1, false, MouseEvent.BUTTON1); + for (MouseListener listener : canvas.getMouseListeners()) + listener.mousePressed(press); + for (int x : new int[]{180, 160}) + { + MouseEvent drag = new MouseEvent(canvas, MouseEvent.MOUSE_DRAGGED, 0, + MouseEvent.BUTTON1_DOWN_MASK, x, 100, 0, 0, 0, false, MouseEvent.NOBUTTON); + for (MouseMotionListener listener : canvas.getMouseMotionListeners()) + listener.mouseDragged(drag); + } + MouseEvent release = new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, 0, 0, + 160, 100, 0, 0, 1, false, MouseEvent.BUTTON1); + for (MouseListener listener : canvas.getMouseListeners()) + listener.mouseReleased(release); + assertThat(viewport.getViewPosition().x).isEqualTo(0); + panel.processInteractionFrame(); + assertThat(viewport.getViewPosition()).isEqualTo(new Point(40, 0)); + + /* Wheel zoom commits its anchored position without waiting for another tick. */ + int width = canvas.getWidth(); + panel.zoom(-1, 200); + assertThat(canvas.getWidth()).isGreaterThan(width); + assertThat(viewport.getViewPosition().x).isGreaterThan(40); + } + finally + { + panel.removeNotify(); + panel.dispose(); + } + }); + } + @Test public void absentZeroDurationAndPulseFreeInputsAreSafe() throws Exception { SwingUtilities.invokeAndWait(() -> {