ActivityPanel.java
package org.egothor.methodatlas.gui.panel;
import org.egothor.methodatlas.gui.model.AnalysisModel;
import javax.swing.*;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.border.MatteBorder;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.beans.PropertyChangeEvent;
import java.time.Instant;
/**
* Collapsible panel that provides real-time visibility into the analysis
* progress, placed above the {@link StatusBar} in the main window.
*
* <h2>Visible regions</h2>
* <pre>
* ┌──────────────────────────────────────────────────────────────┐
* │ ▶ Activity │ → FooTest │ 3 / 42 │ 00:01:23 │
* ├──────────────────────────────────────────────────────────────┤
* │ ✓ BarTest 0.8 s, 3 method(s) │
* │ ✓ BazTest 1.2 s, 5 method(s) │
* │ ✗ QuxTest — AI error 3.0 s, 2 method(s) │
* └──────────────────────────────────────────────────────────────┘
* </pre>
* <p>The header row is always visible when analysis is running and shows:
* <ul>
* <li>a toggle button ({@code ▶} / {@code ▼}) that expands or collapses
* the log area below it</li>
* <li>the simple class name currently being processed by the AI engine,
* prefixed with {@code →}</li>
* <li>a progress counter ({@code current / total}) during the AI phase</li>
* <li>elapsed wall-clock time since the analysis started, updated every
* second by a {@link Timer}</li>
* </ul>
* <p>The log area (collapsed by default) appends one line per completed class,
* showing a success ({@code ✓}) or error ({@code ✗}) indicator, the class
* name, the AI call duration, and the method count. The area auto-scrolls to
* keep the most recent entry visible.</p>
*
* <h2>Visibility lifecycle</h2>
* <p>The panel hides itself when the model is {@link AnalysisModel.Status#IDLE},
* and shows itself as soon as a
* {@link AnalysisModel.Status#SCANNING} or
* {@link AnalysisModel.Status#AI_RUNNING} status is received. It remains
* visible after the analysis reaches {@link AnalysisModel.Status#DONE} or
* {@link AnalysisModel.Status#ERROR} so the user can review the log, and
* hides again only when a new run calls {@link AnalysisModel#clear()}.</p>
*
* <h2>Thread safety</h2>
* <p>This component registers itself as a
* {@link java.beans.PropertyChangeListener} on the supplied
* {@link AnalysisModel} and must therefore be created and used exclusively
* on the Swing Event Dispatch Thread.</p>
*
* @see StatusBar
* @see AnalysisModel
*/
public final class ActivityPanel extends JPanel {
@java.io.Serial
private static final long serialVersionUID = 1L;
/** Number of visible text rows in the collapsed log area. */
private static final int LOG_ROWS = 4;
/** Monospaced font used in the log area for column-aligned output. */
private static final Font MONO = new Font(Font.MONOSPACED, Font.PLAIN, 11);
// ── Header controls ───────────────────────────────────────────────────
private final JButton toggleButton = new JButton("▶ Activity");
private final JLabel currentLabel = new JLabel();
private final JLabel progressLabel = new JLabel();
private final JLabel elapsedLabel = new JLabel();
// ── Log area ──────────────────────────────────────────────────────────
private final JTextArea logArea = new JTextArea(LOG_ROWS, 0);
private final JScrollPane logScroll;
// ── State ─────────────────────────────────────────────────────────────
/** {@code true} when the log area scroll pane is visible. */
private boolean logExpanded;
/** Wall-clock instant at which the most recent analysis run started. */
private Instant analysisStart;
/** Fires every second to refresh the elapsed-time label. */
private final Timer elapsedTimer;
/**
* Constructs the activity panel and registers it as a property-change
* listener on {@code model}.
*
* <p>The panel is initially invisible; it becomes visible automatically
* when the model transitions to {@link AnalysisModel.Status#SCANNING}
* or {@link AnalysisModel.Status#AI_RUNNING}. Must be called on the
* Swing Event Dispatch Thread.</p>
*
* @param model model whose status, progress, current-class, and
* class-completion events drive this panel; must not be
* {@code null}
*/
public ActivityPanel(AnalysisModel model) {
super();
setLayout(new BorderLayout(0, 0));
setBorder(new CompoundBorder(
new MatteBorder(1, 0, 0, 0, UIManager.getColor("Separator.foreground")),
new EmptyBorder(2, 4, 2, 8)));
logArea.setEditable(false);
logArea.setFont(MONO);
logArea.setBackground(UIManager.getColor("TextArea.background"));
logScroll = new JScrollPane(logArea,
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
logScroll.setBorder(new MatteBorder(1, 0, 0, 0, UIManager.getColor("Separator.foreground")));
logScroll.setVisible(false);
toggleButton.setFocusable(false);
toggleButton.putClientProperty("JButton.buttonType", "borderless");
toggleButton.setFont(toggleButton.getFont().deriveFont(Font.BOLD, 11f));
toggleButton.addActionListener(this::onToggle);
currentLabel.setFont(currentLabel.getFont().deriveFont(Font.PLAIN, 11f));
progressLabel.setFont(progressLabel.getFont().deriveFont(Font.PLAIN, 11f));
progressLabel.setForeground(UIManager.getColor("Label.disabledForeground"));
elapsedLabel.setFont(elapsedLabel.getFont().deriveFont(Font.PLAIN, 11f));
elapsedLabel.setForeground(UIManager.getColor("Label.disabledForeground"));
JPanel headerRight = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
headerRight.setOpaque(false);
headerRight.add(progressLabel);
headerRight.add(elapsedLabel);
JPanel header = new JPanel(new BorderLayout(6, 0));
header.setOpaque(false);
header.add(toggleButton, BorderLayout.WEST);
header.add(currentLabel, BorderLayout.CENTER);
header.add(headerRight, BorderLayout.EAST);
add(header, BorderLayout.NORTH);
add(logScroll, BorderLayout.CENTER);
elapsedTimer = new Timer(1000, e -> updateElapsed());
elapsedTimer.setRepeats(true);
model.addPropertyChangeListener(this::onModelChange);
setVisible(false);
}
// ── Event handlers ────────────────────────────────────────────────────
private void onToggle(ActionEvent ignored) {
logExpanded = !logExpanded;
toggleButton.setText(logExpanded ? "▼ Activity" : "▶ Activity");
logScroll.setVisible(logExpanded);
revalidate();
repaint();
}
private void onModelChange(PropertyChangeEvent evt) {
switch (evt.getPropertyName()) {
case "status" -> {
AnalysisModel.Status s = (AnalysisModel.Status) evt.getNewValue();
boolean busy = s == AnalysisModel.Status.SCANNING
|| s == AnalysisModel.Status.AI_RUNNING;
if (busy && !isVisible()) {
analysisStart = Instant.now();
logArea.setText("");
elapsedTimer.start();
setVisible(true);
} else if (!busy) {
elapsedTimer.stop();
if (s == AnalysisModel.Status.DONE || s == AnalysisModel.Status.ERROR) {
updateElapsed();
}
}
if (s == AnalysisModel.Status.IDLE) {
setVisible(false);
}
}
case "cleared" -> {
logArea.setText("");
currentLabel.setText("");
progressLabel.setText("");
elapsedLabel.setText("");
setVisible(false);
}
case "currentAiClass" -> {
String fqcn = (String) evt.getNewValue();
String simple = simpleName(fqcn);
currentLabel.setText(simple.isEmpty() ? "" : "→ " + simple);
}
case "progress" -> {
AnalysisModel src = (AnalysisModel) evt.getSource();
int cur = src.getProgressCurrent();
int tot = src.getProgressTotal();
progressLabel.setText(tot > 0 ? cur + " / " + tot : "");
}
case "aiClassDone" -> {
AnalysisModel.AiClassResult r = (AnalysisModel.AiClassResult) evt.getNewValue();
appendLog(r);
}
default -> { /* ignore */ }
}
}
// ── Helpers ───────────────────────────────────────────────────────────
/** Refreshes the elapsed-time label; called by the one-second timer. */
private void updateElapsed() {
if (analysisStart == null) { return; }
long secs = java.time.Duration.between(analysisStart, Instant.now()).getSeconds();
long h = secs / 3600;
long m = secs % 3600 / 60;
long s = secs % 60;
elapsedLabel.setText(String.format("%02d:%02d:%02d", h, m, s));
}
/**
* Appends a single line for {@code result} to the log area and
* auto-scrolls to the bottom.
*/
private void appendLog(AnalysisModel.AiClassResult r) {
String icon = r.hadError() ? "✗" : "✓";
String duration = String.format("%.1f s", r.durationMs() / 1000.0);
String line = String.format(" %s %-40s %s, %d method(s)%n",
icon, simpleName(r.fqcn()), duration, r.methodCount());
logArea.append(line);
logArea.setCaretPosition(logArea.getDocument().getLength());
}
/** Returns the simple class name from a fully-qualified name. */
private static String simpleName(String fqcn) {
if (fqcn == null || fqcn.isBlank()) { return ""; }
int dot = fqcn.lastIndexOf('.');
return dot >= 0 ? fqcn.substring(dot + 1) : fqcn;
}
}