EditorPanel.java
package org.egothor.methodatlas.gui.panel;
import org.egothor.methodatlas.gui.model.AnalysisModel;
import org.egothor.methodatlas.gui.model.MethodEntry;
import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea;
import org.fife.ui.rsyntaxtextarea.SyntaxConstants;
import org.fife.ui.rtextarea.RTextScrollPane;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.beans.PropertyChangeEvent;
import java.awt.geom.Rectangle2D;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Source-code editor panel with syntax highlighting and line numbers.
*
* <p>Uses RSyntaxTextArea (BSD 3-Clause) displayed inside an
* {@code RTextScrollPane} which adds the line-number gutter. The editor
* is read-only; patching is performed by the {@link TagEditorPanel}.</p>
*
* <p>When the user selects a method in the results tree the panel loads the
* corresponding source file and centers the viewport on the method's first
* line so that context lines above and below remain visible.</p>
*/
public final class EditorPanel extends JPanel {
@java.io.Serial
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(EditorPanel.class.getName());
private final RSyntaxTextArea textArea;
private final JLabel fileLabel = new JLabel(" ");
private Path currentFile;
/** @param model model to observe for selection changes */
public EditorPanel(AnalysisModel model) {
super(new BorderLayout());
textArea = buildTextArea();
RTextScrollPane scrollPane = new RTextScrollPane(textArea);
scrollPane.setLineNumbersEnabled(true);
scrollPane.setFoldIndicatorEnabled(true);
scrollPane.setBorder(BorderFactory.createEmptyBorder());
// Header bar showing the current file path
fileLabel.setFont(fileLabel.getFont().deriveFont(Font.PLAIN, 11f));
fileLabel.setForeground(UIManager.getColor("Label.disabledForeground"));
fileLabel.setBorder(new EmptyBorder(3, 8, 3, 8));
JPanel header = new JPanel(new BorderLayout());
header.add(fileLabel, BorderLayout.WEST);
header.setBorder(BorderFactory.createMatteBorder(
0, 0, 1, 0, UIManager.getColor("Separator.foreground")));
add(header, BorderLayout.NORTH);
add(scrollPane, BorderLayout.CENTER);
model.addPropertyChangeListener("selectedEntry", this::onSelectionChanged);
model.addPropertyChangeListener("cleared", e -> clearEditor());
}
// ── Event handlers ────────────────────────────────────────────────────
private void onSelectionChanged(PropertyChangeEvent evt) {
MethodEntry entry = (MethodEntry) evt.getNewValue();
if (entry == null) { return; }
Path filePath = entry.discovered().filePath();
if (filePath == null) {
showInlineSource(entry);
return;
}
loadFile(filePath, entry.discovered().beginLine());
}
private void showInlineSource(MethodEntry entry) {
String src = entry.discovered().sourceContent().get().orElse(null);
if (src == null) {
clearEditor();
return;
}
setSource(src, inferSyntaxStyle(null), null, entry.discovered().beginLine());
}
private void loadFile(Path file, int targetLine) {
if (file.equals(currentFile)) {
scrollToLine(targetLine);
return;
}
try {
String content = Files.readString(file);
setSource(content, inferSyntaxStyle(file), file, targetLine);
} catch (IOException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Cannot read source file: " + file, e);
}
fileLabel.setText("Cannot read: " + file.getFileName());
}
}
private void setSource(String content, String syntaxStyle, Path file, int targetLine) {
currentFile = file;
textArea.setSyntaxEditingStyle(syntaxStyle);
textArea.setText(content);
textArea.setCaretPosition(0);
String labelText = file != null ? file.toAbsolutePath().toString() : "(inline source)";
fileLabel.setText(labelText);
scrollToLine(targetLine);
}
private void scrollToLine(int line) {
if (line <= 0) { return; }
try {
int offset = textArea.getLineStartOffset(line - 1);
textArea.setCaretPosition(offset);
// Defer centering until after the viewport has settled its layout
SwingUtilities.invokeLater(() -> centerViewOnOffset(offset));
} catch (Exception e) {
// best-effort scroll; out-of-range lines silently ignored — nothing to do
}
}
private void centerViewOnOffset(int offset) {
try {
Rectangle2D r = textArea.modelToView2D(offset);
if (r == null) { return; }
Container parent = textArea.getParent();
if (!(parent instanceof JViewport vp)) { return; }
Dimension extent = vp.getExtentSize();
int newY = Math.max(0, (int) r.getY() - extent.height / 2 + (int) r.getHeight() / 2);
vp.setViewPosition(new Point(vp.getViewPosition().x, newY));
} catch (Exception ex) {
// best-effort centering; viewport not yet realized or offset out of range — nothing to do
}
}
private void clearEditor() {
currentFile = null;
textArea.setText("");
fileLabel.setText(" ");
}
// ── Helpers ───────────────────────────────────────────────────────────
private static RSyntaxTextArea buildTextArea() {
RSyntaxTextArea area = new RSyntaxTextArea();
area.setEditable(false);
area.setCodeFoldingEnabled(true);
area.setAntiAliasingEnabled(true);
area.setHighlightCurrentLine(true);
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13));
area.setTabSize(4);
area.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVA);
return area;
}
private static String inferSyntaxStyle(Path file) {
if (file == null) { return SyntaxConstants.SYNTAX_STYLE_JAVA; }
String name = file.getFileName().toString().toLowerCase(Locale.ROOT);
if (name.endsWith(".cs")) { return SyntaxConstants.SYNTAX_STYLE_CSHARP; }
if (name.endsWith(".ts") || name.endsWith(".tsx")) { return SyntaxConstants.SYNTAX_STYLE_TYPESCRIPT; }
if (name.endsWith(".js") || name.endsWith(".jsx")) { return SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT; }
return SyntaxConstants.SYNTAX_STYLE_JAVA;
}
/**
* Reloads the currently displayed file from disk, preserving the current
* viewport scroll position.
*
* <p>Called after "Save All Changes" so the user sees the freshly written
* annotations without losing their place in the file.</p>
*/
public void reloadCurrentFilePreservingScroll() {
if (currentFile == null) { return; }
Container parent = textArea.getParent();
Point savedPos = parent instanceof JViewport vp ? vp.getViewPosition() : new Point(0, 0);
try {
String content = Files.readString(currentFile);
textArea.setSyntaxEditingStyle(inferSyntaxStyle(currentFile));
textArea.setText(content);
SwingUtilities.invokeLater(() -> {
Container p = textArea.getParent();
if (p instanceof JViewport v) {
v.setViewPosition(savedPos);
}
});
} catch (IOException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Cannot reload source file: " + currentFile, e);
}
}
}
/**
* Reloads the currently displayed file if it is one of the given paths.
*
* @param savedPaths set of file paths that were just written to disk
*/
public void reloadIfAmong(java.util.Collection<Path> savedPaths) {
if (currentFile != null && savedPaths.contains(currentFile)) {
reloadCurrentFilePreservingScroll();
}
}
}