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/ApplicationFrame.java b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java index 4ee212b0..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; @@ -47,8 +48,11 @@ 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; + private final JTabbedPane centreTabs; + private Disposable fluxViewSubscription; private final ImageViewModel model; private final Var currentWorkflow; @@ -59,6 +63,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); @@ -78,19 +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("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")) @@ -148,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/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 new file mode 100644 index 00000000..923b2760 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/FluxPanel.java @@ -0,0 +1,120 @@ +package com.cowlark.fluxengine.gui; + +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; +import javax.swing.Scrollable; +import javax.swing.event.InternalFrameAdapter; +import javax.swing.event.InternalFrameEvent; +import java.awt.Dimension; +import java.awt.Rectangle; + +public class FluxPanel extends JPanel +{ + private final ImageViewModel model; + private final ViewerRows viewerRows; + private Disposable fluxViewSubscription; + + public FluxPanel(ImageViewModel model) + { + /* Create a flux viewer sharing the tab's model. */ + this.model = model; + viewerRows = new ViewerRows(); + + /* 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", label("To add 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)); + } + + /* 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 = new FluxViewerPanel(model, tracks); + viewer.addInternalFrameListener(new InternalFrameAdapter() + { + @Override + public void internalFrameClosed(InternalFrameEvent event) + { + viewerRows.remove(viewer); + viewerRows.revalidate(); + viewerRows.repaint(); + } + }); + 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 + { + @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 new file mode 100644 index 00000000..8be62f21 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/FluxViewerPanel.java @@ -0,0 +1,1102 @@ +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.formdev.flatlaf.util.UIScale; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +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.Timer; +import javax.swing.plaf.basic.BasicInternalFrameUI; +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 +{ + 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 = 7; + + private final ImmutableList tracks; + private final ImmutableList captures; + private final TreeSet boundaries = new TreeSet<>(); + private final Canvas canvas; + 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; + + public FluxViewerPanel(ImageViewModel model, ImmutableList tracks) + { + super(getTitle(tracks), 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. */ + ImmutableList.Builder captureBuilder = ImmutableList.builder(); + double offset = 0; + for (Track track : 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) + { + boundaries.add(offset + record.startTimeNs); + boundaries.add(offset + record.endTimeNs); + } + } + offset += duration; + } + captures = captureBuilder.build(); + totalDurationNs = offset; + + /* The viewport supplies horizontal translation and the canvas follows its height. */ + canvas = new Canvas(); + canvas.setFont(new Font( + Font.SANS_SERIF, + Font.PLAIN, + UIScale.scale(UiUtils.pointsToPixels(FONT_SIZE_PTS)))); + JScrollPane scrollPane = new JScrollPane( + canvas, + JScrollPane.VERTICAL_SCROLLBAR_NEVER, + JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS) + { + @Override + protected JViewport createViewport() + { + return new TimelineViewport(); + } + }; + scrollPane.setWheelScrollingEnabled(false); + 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() + { + @Override + public void componentResized(ComponentEvent event) + { + if (!scaleInitialised && viewport.getWidth() > 0) + { + scaleInitialised = true; + updateScale(fitScale()); + } 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) + pointer.translate(scrollX - previousScrollX, 0); + previousScrollX = scrollX; + interactionDirty = true; + }); + + /* 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; + interactionDirty = true; + } + + @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)) + { + 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.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); + interactionDirty = true; + } else + trackPointer(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) + { + 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(); + } + }; + canvas.addMouseListener(mouse); + canvas.addMouseMotionListener(mouse); + canvas.addMouseWheelListener(mouse); + + /* Keep each row a fixed height without contributing to the window's preferred width. */ + int height = UiUtils.pointsToPixels(UIScale.scale(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 and keep only the close control. */ + 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 title bar, but leave positioning to the row layout. */ + @Override + public void updateUI() + { + super.updateUI(); + if (getUI() instanceof BasicInternalFrameUI frameUI) + { + JComponent titleBar = frameUI.getNorthPane(); + if (titleBar != null) + { + for (MouseMotionListener listener : titleBar.getMouseMotionListeners()) + titleBar.removeMouseMotionListener(listener); + } + } + } + + /* 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() - 2 * CONTENT_PADDING)); + } + + private double minimumScale() + { + 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) + { + for (int i = 0; i < capture.density.length; i++) + 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); + 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; + + /* 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)); + } + finally + { + viewport.immediate = false; + } + } + + private void setScrollX(long x) + { + int maximum = Math.max(0, canvas.getWidth() - viewport.getWidth()); + 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(); + interactionDirty = true; + } + + /* Coordinates shared by drawing and hit testing prevent drift between blocks and menus. */ + private int pixel(double time) + { + return CONTENT_PADDING + (int) Math.min( + MAX_WIDTH - 2 * CONTENT_PADDING, + 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) + { + /* Clear the visible canvas and leave empty timelines with just their background. */ + Rectangle visible = viewport.getViewRect(); + 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(UiUtils.DATA_BAD_COLOUR); + graphics.drawLine(left, 0, left, canvas.getHeight()); + } + 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); + if (x > visible.x + visible.width) + break; + 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; + Rectangle rect = block(capture, sector.headerStartTimeNs, end, 0); + 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, + recordColour(capture.track, record), + nanosecondsPerPixel > RENDER_LIMIT / 4 ? + String.format(Locale.ROOT, "+%.3fms", record.startTimeNs / 1e6) : + ""); + if (nanosecondsPerPixel <= RENDER_LIMIT / 4) + drawDecodedBytes(graphics, capture, record, rect, visible); + if (nanosecondsPerPixel < RENDER_LIMIT / 8) + 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. */ + 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); + } + + /* Draw tick marks in capture-relative time and labels at the viewport's two edges. */ + private void drawScale(Graphics2D graphics, Capture capture, Rectangle visible) + { + /* 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; + + /* 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; + 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) + { + /* 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); + } + } + + /* 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), leftEdge + BORDER, top); + double rightTime = (rightEdge - CONTENT_PADDING) * nanosecondsPerPixel - capture.offset; + if (rightTime >= 0 && rightTime <= capture.duration) + { + String label = milliseconds(rightTime); + 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 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; + + /* Highlight hovered blocks through their fill rather than an outline. */ + Graphics2D g = UiUtils.getGraphics2D(graphics.create()); + try + { + g.setColor(pointer != null && rect.contains(pointer) ? + UiUtils.blend(colour, canvas.getForeground(), 0.1) : + colour); + g.fill(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); + } finally + { + g.dispose(); + } + } + + /* 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(); + } + } + + /* 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()) + 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); + + /* Isolate raw-bit labels from the record boxes and the caller's graphics state. */ + Graphics2D g = UiUtils.getGraphics2D(graphics.create()); + try + { + 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) + { + /* 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; + + /* Place each label immediately before its decoded endpoint. */ + text( + g, + label, + x - g.getFontMetrics().stringWidth(label) - BORDER, + centre(4) - g.getFontMetrics().getHeight()); + } + } finally + { + g.dispose(); + } + } + + /* Density is normalised 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) + { + /* 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)); + 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(UiUtils.blend(canvas.getBackground(), UiUtils.HEADER_COLOUR, density)); + graphics.fillRect(x, centre(4) - half, 1, blockHeight()); + } + } else + { + /* 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()) + { + 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); + } + } + + /* 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)); + } + + private void drawRuler(Graphics2D graphics) + { + if (pointer == null) + return; + + /* 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(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); + } + + /* 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) + { + /* 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); + } + + /* 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; + 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) + { + /* Collect every occurrence of the selected logical sector across the supplied captures. */ + List matches = new ArrayList<>(); + for (Track track : tracks) + { + for (Sector sector : track.allSectors) + { + if (selected.logicalLocation.equals(sector.logicalLocation)) + 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))); + 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))); + } + return result.toString(); + } + + 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.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 + Start: %.2fms + End: %.2fms + Data CRC16: %04x + + %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; + + /* 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()) + { + int index = pixel(offset + reader.tell().getDurationNs()) - pixel(offset); + density[index]++; + } + } + } + + /* 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 + { + redraw(copy); + } finally + { + copy.dispose(); + } + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension( + 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 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)); + } + } + + 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"; + } +} 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/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java b/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java new file mode 100644 index 00000000..84828e3f --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/TextViewerDialogue.java @@ -0,0 +1,44 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UI.button; +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; +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, UIScale.scale(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/java/com/cowlark/fluxengine/gui/UiUtils.java b/java/com/cowlark/fluxengine/gui/UiUtils.java index 8521aaf7..e0a07ec2 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( @@ -300,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/BUILD.bazel b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel index ffa3e883..a2242d47 100644 --- a/javatests/com/cowlark/fluxengine/gui/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel @@ -111,3 +111,18 @@ 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_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 new file mode 100644 index 00000000..2572a719 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/gui/FluxViewerPanelTest.java @@ -0,0 +1,391 @@ +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.JScrollPane; +import javax.swing.JScrollBar; +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; +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; +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(472); + assertThat(viewport.getViewPosition().x).isEqualTo(36); + 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); + + /* 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(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(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())) + .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(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(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(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, + 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(472); + 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(56); + 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) + { + 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 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(() -> { + 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(); + } + }); + } +} 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); } }