DiagnosisPanel.java

package org.hammer;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.util.List;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.SwingConstants;
import javax.swing.border.EmptyBorder;
import org.hammer.audio.diagnosis.DiagnosisFinding;
import org.hammer.audio.diagnosis.DiagnosisSeverity;
import org.hammer.audio.diagnosis.DiagnosisSnapshot;

/**
 * Compact dashboard panel that renders the current top {@link DiagnosisFinding}s with severity
 * styling.
 *
 * <p>Caps the number of displayed findings to keep the UI compact; remaining findings can be
 * inspected in the exported evidence bundle.
 */
public final class DiagnosisPanel extends JPanel {

  private static final long serialVersionUID = 1L;
  private static final int MAX_DISPLAYED_FINDINGS = 4;

  private static final Color INFO_FOREGROUND = new Color(180, 210, 255);
  private static final Color WARNING_FOREGROUND = new Color(255, 198, 102);
  private static final Color CRITICAL_FOREGROUND = new Color(255, 120, 120);
  private static final Color FROZEN_BADGE = new Color(170, 140, 60);

  private final JPanel findingsList;
  private final JLabel statusLabel;
  private boolean frozen;
  private transient DiagnosisSnapshot currentSnapshot = DiagnosisSnapshot.empty();

  /** Create an empty diagnosis panel. */
  public DiagnosisPanel() {
    super(new BorderLayout(4, 4));
    setBorder(new EmptyBorder(4, 4, 4, 4));
    setPreferredSize(new Dimension(380, 160));
    setMinimumSize(new Dimension(280, 120));

    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
    JLabel title = new JLabel("Diagnosis");
    title.setFont(title.getFont().deriveFont(Font.BOLD));
    header.add(title);
    statusLabel = new JLabel("");
    statusLabel.setForeground(FROZEN_BADGE);
    header.add(statusLabel);
    add(header, BorderLayout.NORTH);

    findingsList = new JPanel();
    findingsList.setLayout(new BoxLayout(findingsList, BoxLayout.Y_AXIS));
    add(findingsList, BorderLayout.CENTER);
    setFindings(DiagnosisSnapshot.empty());
  }

  /**
   * Update the displayed findings. Must be called on the EDT.
   *
   * @param snapshot latest diagnosis snapshot, never {@code null}
   */
  public void setFindings(DiagnosisSnapshot snapshot) {
    DiagnosisSnapshot effective = snapshot == null ? DiagnosisSnapshot.empty() : snapshot;
    this.currentSnapshot = effective;
    rebuild();
  }

  /**
   * @return latest diagnosis snapshot displayed
   */
  public DiagnosisSnapshot currentSnapshot() {
    return currentSnapshot;
  }

  /**
   * Update the frozen state shown in the header.
   *
   * @param frozen true if the diagnosis is frozen alongside the rest of the dashboard
   */
  public void setFrozen(boolean frozen) {
    this.frozen = frozen;
    rebuild();
  }

  private void rebuild() {
    findingsList.removeAll();
    statusLabel.setText(frozen ? "(frozen)" : "");

    List<DiagnosisFinding> findings = currentSnapshot.findings();
    if (findings.isEmpty()) {
      JLabel empty = new JLabel("No findings.");
      empty.setForeground(INFO_FOREGROUND);
      findingsList.add(empty);
    } else {
      int shown = 0;
      for (DiagnosisFinding finding : findings) {
        if (shown >= MAX_DISPLAYED_FINDINGS) {
          break;
        }
        findingsList.add(createFindingRow(finding));
        shown++;
      }
      if (findings.size() > MAX_DISPLAYED_FINDINGS) {
        JLabel more = new JLabel("+ " + (findings.size() - MAX_DISPLAYED_FINDINGS) + " more");
        more.setForeground(INFO_FOREGROUND);
        findingsList.add(more);
      }
    }
    findingsList.add(Box.createVerticalGlue());
    findingsList.revalidate();
    findingsList.repaint();
  }

  private static Component createFindingRow(DiagnosisFinding finding) {
    JPanel row = new JPanel(new BorderLayout(8, 0));
    JLabel severityLabel = new JLabel(badgeText(finding.severity()), SwingConstants.LEFT);
    severityLabel.setForeground(severityForeground(finding.severity()));
    severityLabel.setFont(severityLabel.getFont().deriveFont(Font.BOLD));
    severityLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 6));
    row.add(severityLabel, BorderLayout.WEST);

    JLabel message =
        new JLabel(String.format("%s (conf %.2f)", finding.message(), finding.confidence()));
    message.setForeground(severityForeground(finding.severity()));
    row.add(message, BorderLayout.CENTER);
    return row;
  }

  private static String badgeText(DiagnosisSeverity severity) {
    return switch (severity) {
      case CRITICAL -> "CRITICAL";
      case WARNING -> "WARN";
      case INFO -> "INFO";
    };
  }

  private static Color severityForeground(DiagnosisSeverity severity) {
    return switch (severity) {
      case CRITICAL -> CRITICAL_FOREGROUND;
      case WARNING -> WARNING_FOREGROUND;
      case INFO -> INFO_FOREGROUND;
    };
  }
}