// Near Infinity - An Infinity Engine Browser and Editor // Copyright (C) 2001 - 2005 Jon Olav Hauglid // See LICENSE.txt for license information package org.infinity.gui.hexview; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.Font; import java.awt.FontMetrics; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.awt.Window; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.geom.Rectangle2D; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import javax.swing.BorderFactory; import javax.swing.BoxLayout; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.JTabbedPane; import javax.swing.JTable; import javax.swing.ListSelectionModel; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.table.AbstractTableModel; import javax.swing.table.TableModel; import org.infinity.NearInfinity; import org.infinity.datatype.Bitmap; import org.infinity.datatype.ColorPicker; import org.infinity.datatype.ColorValue; import org.infinity.datatype.DecNumber; import org.infinity.datatype.Flag; import org.infinity.datatype.FloatNumber; import org.infinity.datatype.HashBitmap; import org.infinity.datatype.MultiNumber; import org.infinity.datatype.ProRef; import org.infinity.datatype.ResourceBitmap; import org.infinity.datatype.ResourceRef; import org.infinity.datatype.SectionCount; import org.infinity.datatype.SectionOffset; import org.infinity.datatype.StringRef; import org.infinity.datatype.TextBitmap; import org.infinity.datatype.TextEdit; import org.infinity.datatype.TextString; import org.infinity.datatype.Unknown; import org.infinity.gui.BrowserMenuBar; import org.infinity.gui.ButtonPanel; import org.infinity.gui.StatusBar; import org.infinity.gui.ViewerUtil; import org.infinity.gui.WindowBlocker; import org.infinity.icon.Icons; import org.infinity.resource.AbstractStruct; import org.infinity.resource.Closeable; import org.infinity.resource.Profile; import org.infinity.resource.ResourceFactory; import org.infinity.resource.StructEntry; import org.infinity.resource.dlg.AbstractCode; import org.infinity.resource.key.BIFFResourceEntry; import org.infinity.resource.key.ResourceEntry; import org.infinity.util.io.FileManager; import org.infinity.util.io.StreamUtils; import tv.porst.jhexview.DataChangedEvent; import tv.porst.jhexview.HexViewEvent; import tv.porst.jhexview.IColormap; import tv.porst.jhexview.IDataChangedListener; import tv.porst.jhexview.IDataProvider; import tv.porst.jhexview.IHexViewListener; import tv.porst.jhexview.IMenuCreator; import tv.porst.jhexview.JHexView; /** * A hex viewer and editor component designed to be used as separate tab in resource viewers. * * Not implemented: proper support for AbstractAction (changes in referenced text blocks are ignored) */ public class StructHexViewer extends JPanel implements IHexViewListener, IDataChangedListener, ActionListener, ChangeListener, Closeable { private static final ButtonPanel.Control BUTTON_FIND = ButtonPanel.Control.FIND_BUTTON; private static final ButtonPanel.Control BUTTON_FINDNEXT = ButtonPanel.Control.CUSTOM_1; private static final ButtonPanel.Control BUTTON_EXPORT = ButtonPanel.Control.EXPORT_BUTTON; private static final ButtonPanel.Control BUTTON_SAVE = ButtonPanel.Control.SAVE; private static final ButtonPanel.Control BUTTON_REFRESH = ButtonPanel.Control.CUSTOM_2; private static final String FMT_OFFSET = "%1$Xh (%1$d)"; private final AbstractStruct struct; private final JHexView hexView; private final IMenuCreator menuCreator; private final IDataProvider dataProvider; private final IColormap colorMap; private final InfoPanel pInfo; private final ButtonPanel buttonPanel; private FindDataDialog findData; private JScrollPane spInfo; private boolean tabSelected; private int cachedSize; // Returns a short description of the specified structure type public static String getTypeDesc(StructEntry type) { if (type instanceof AbstractStruct) { return "Nested structure"; } else if (type instanceof AbstractCode) { return "Script code"; } else if (type instanceof ColorPicker) { return "RGB Color"; } else if (type instanceof ColorValue) { return "Palette index"; } else if (type instanceof SectionCount) { return "Count of a structure type"; } else if (type instanceof SectionOffset) { return "Start offset of a structure type"; } else if (type instanceof Flag) { return "Flags/Bitfield"; } else if (type instanceof ProRef) { return "Projectile"; } else if (type instanceof ResourceRef) { return "Resource reference"; } else if (type instanceof StringRef) { return "String reference"; } else if (type instanceof DecNumber || type instanceof MultiNumber || type instanceof FloatNumber) { return "Number"; } else if (type instanceof Bitmap || type instanceof HashBitmap || type instanceof ResourceBitmap) { return "Numeric type or identifier"; } else if (type instanceof TextBitmap || type instanceof TextEdit || type instanceof TextString) { return "Text field"; } else if (type instanceof Unknown) { return "Unknown or unused data"; } else { return "n/a"; } } public StructHexViewer(AbstractStruct struct) { this(struct, null, null); } public StructHexViewer(AbstractStruct struct, IColormap colorMap) { this(struct, colorMap, null); } public StructHexViewer(AbstractStruct struct, IColormap colorMap, IDataProvider dataProvider) { super(); if (struct == null) { throw new NullPointerException("struct is null"); } this.struct = struct; this.hexView = new JHexView(); this.dataProvider = (dataProvider == null) ? new StructuredDataProvider(this.struct) : dataProvider; this.dataProvider.addListener(this); this.colorMap = colorMap; this.menuCreator = new ResourceMenuCreator(hexView, this.struct); this.buttonPanel = new ButtonPanel(); if (this.dataProvider instanceof StructuredDataProvider) { this.pInfo = new InfoPanel(); } else { this.pInfo = null; } initGui(); } //--------------------- Begin Interface IHexViewListener --------------------- @Override public void stateChanged(HexViewEvent event) { if (event.getSource() instanceof JHexView && event.getCause() == HexViewEvent.Cause.SelectionChanged && getStruct().isRawTabSelected()) { JHexView hv = (JHexView)event.getSource(); int offset = (int)hv.getCurrentOffset(); // updating info panel updateInfoPanel(offset); // updating statusbar updateStatusBar(offset); } } //--------------------- End Interface IHexViewListener --------------------- //--------------------- Begin Interface IDataChangedListener --------------------- @Override public void dataChanged(DataChangedEvent event) { getStruct().setStructChanged(true); } //--------------------- End Interface IDataChangedListener --------------------- //--------------------- Begin Interface ActionListener --------------------- @Override public void actionPerformed(ActionEvent event) { if (event.getSource() == buttonPanel.getControlByType(BUTTON_FIND)) { if (getFindData().find()) { boolean b; String s = null; if (getFindData().getDataType() == FindDataDialog.Type.TEXT) { b = !getFindData().getText().isEmpty(); s = getFindData().getText(); } else { b = (getFindData().getBytes().length > 0); if (getFindData().getBytes().length > 0) { s = byteArrayToString(getFindData().getBytes()); } } findPattern((int)getHexView().getCurrentOffset()); // Setting up "Find next" button JComponent c = buttonPanel.getControlByType(BUTTON_FINDNEXT); c.setEnabled(b); if (s == null) { c.setToolTipText(null); } else if (s.length() <= 30) { c.setToolTipText(String.format("Find \"%1$s\"", s)); } else { c.setToolTipText(String.format("Find \"%1$s...\"", s.substring(0, 30))); } } getHexView().requestFocusInWindow(); } else if (event.getSource() == buttonPanel.getControlByType(BUTTON_FINDNEXT)) { findPattern((int)getHexView().getCurrentOffset()); getHexView().requestFocusInWindow(); } else if (event.getSource() == buttonPanel.getControlByType(BUTTON_EXPORT)) { ResourceFactory.exportResource(getStruct().getResourceEntry(), getTopLevelAncestor()); getHexView().requestFocusInWindow(); } else if (event.getSource() == buttonPanel.getControlByType(BUTTON_SAVE)) { // XXX: Ugly hack: mimicking ResourceFactory.saveResource() IDataProvider dataProvider = getHexView().getData(); ResourceEntry entry = getStruct().getResourceEntry(); Path outPath; if (entry instanceof BIFFResourceEntry) { Path overridePath = FileManager.query(Profile.getGameRoot(), Profile.getOverrideFolderName()); if (!Files.isDirectory(overridePath)) { try { Files.createDirectory(overridePath); } catch (IOException e) { JOptionPane.showMessageDialog(this, "Unable to create override folder.", "Error", JOptionPane.ERROR_MESSAGE); e.printStackTrace(); return; } } outPath = FileManager.query(overridePath, entry.toString()); ((BIFFResourceEntry)entry).setOverride(true); } else { outPath = entry.getActualPath(); } if (Files.exists(outPath)) { outPath = outPath.toAbsolutePath(); String options[] = {"Overwrite", "Cancel"}; if (JOptionPane.showOptionDialog(this, outPath + " exists. Overwrite?", "Save resource", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]) == 0) { if (BrowserMenuBar.getInstance().backupOnSave()) { try { Path bakPath = outPath.getParent().resolve(outPath.getFileName() + ".bak"); if (Files.isRegularFile(bakPath)) { Files.delete(bakPath); } if (!Files.exists(bakPath)) { Files.move(outPath, bakPath); } } catch (IOException e) { e.printStackTrace(); } } } else { return; } } try { byte[] buffer = dataProvider.getData(0, dataProvider.getDataLength()); try (OutputStream os = StreamUtils.getOutputStream(outPath, true)) { // make sure that nothing interferes with the writing process getHexView().setEnabled(false); StreamUtils.writeBytes(os, buffer); } finally { getHexView().setEnabled(true); } buffer = null; getStruct().setStructChanged(false); getHexView().clearModified(); JOptionPane.showMessageDialog(this, "File saved to \"" + outPath.toAbsolutePath() + '\"', "Save complete", JOptionPane.INFORMATION_MESSAGE); } catch (IOException e) { JOptionPane.showMessageDialog(this, "Error while saving " + getStruct().getResourceEntry().toString(), "Error", JOptionPane.ERROR_MESSAGE); e.printStackTrace(); return; } getHexView().requestFocusInWindow(); } else if (event.getSource() == buttonPanel.getControlByType(BUTTON_REFRESH)) { dataModified(true); getHexView().requestFocusInWindow(); } } //--------------------- End Interface ActionListener --------------------- //--------------------- Begin Interface ChangeListener --------------------- @Override public void stateChanged(ChangeEvent e) { if (e.getSource() instanceof JTabbedPane) { if (!tabSelected && getStruct().isRawTabSelected()) { // actions when entering Raw tab tabSelected = true; getHexView().requestFocusInWindow(); updateStatusBar((int)getHexView().getCurrentOffset()); } else if (tabSelected) { // actions when leaving Raw tab tabSelected = false; getHexView().clearModified(); getHexView().resetUndo(); updateStatusBar(-1); } } } //--------------------- End Interface ChangeListener --------------------- //--------------------- Begin Interface Closeable --------------------- @Override public void close() throws Exception { hexView.setVisible(false); hexView.dispose(); if (dataProvider instanceof StructuredDataProvider) { ((StructuredDataProvider)dataProvider).close(); } else if (dataProvider instanceof ResourceDataProvider) { ((ResourceDataProvider)dataProvider).clear(); } if (colorMap instanceof BasicColorMap) { ((BasicColorMap)colorMap).close(); } if (findData != null) { findData.dispose(); findData = null; } } //--------------------- End Interface Closeable --------------------- /** Returns the associated resource structure. */ public AbstractStruct getStruct() { return struct; } /** Returns the HexView component. */ public JHexView getHexView() { return hexView; } /** Notify HexViewer that data has been changed. Executes a forced reset. */ public void dataModified() { dataModified(true); } /** Notify HexViewer that data has been changed. Specify whether to force a reset. */ public void dataModified(boolean force) { if (force || cachedSize != getDataProvider().getDataLength()) { WindowBlocker.blockWindow(true); try { if (getDataProvider() instanceof StructuredDataProvider) { // notifying data provider that data has changed ((StructuredDataProvider)getDataProvider()).reset(); } if (hexView.isColorMapEnabled()) { // notifying color map that data has changed if (getColorMap() instanceof BasicColorMap) { ((BasicColorMap)getColorMap()).reset(); } } cachedSize = getDataProvider().getDataLength(); } finally { WindowBlocker.blockWindow(false); } } } // initialize controls private void initGui() { setLayout(new BorderLayout()); // configuring hexview Color textColor = dataProvider.isEditable() ? Color.BLACK: Color.GRAY; hexView.setEnabled(false); hexView.setDefinitionStatus(JHexView.DefinitionStatus.UNDEFINED); hexView.setAddressMode(JHexView.AddressMode.BIT32); hexView.setSeparatorsVisible(false); hexView.setBytesPerColumn(1); hexView.setBytesPerRow(16); hexView.setColumnSpacing(8); hexView.setMouseOverHighlighted(false); hexView.setShowModified(true); hexView.setCaretColor(Color.BLACK); hexView.setFontSize(13); hexView.setHeaderFontStyle(Font.BOLD); hexView.setFontColorHeader(new Color(0x0000c0)); hexView.setBackgroundColorOffsetView(hexView.getBackground()); hexView.setFontColorOffsetView(new Color(0x0000c0)); hexView.setBackgroundColorHexView(hexView.getBackground()); hexView.setFontColorHexView1(textColor); hexView.setFontColorHexView2(textColor); hexView.setBackgroundColorAsciiView(hexView.getBackground()); hexView.setFontColorAsciiView(textColor); hexView.setFontColorModified(Color.RED); hexView.setSelectionColor(new Color(0xc0c0c0)); hexView.setColormap(colorMap); hexView.setColorMapEnabled(BrowserMenuBar.getInstance().getHexColorMapEnabled()); hexView.setMenuCreator(menuCreator); hexView.setEnabled(true); hexView.addHexListener(this); hexView.setData(dataProvider); hexView.setDefinitionStatus(hexView.getData().getDataLength() > 0 ? JHexView.DefinitionStatus.DEFINED : JHexView.DefinitionStatus.UNDEFINED); cachedSize = getDataProvider().getDataLength(); // Info panel only available for structured data if (pInfo != null) { spInfo = new JScrollPane(pInfo); spInfo.getVerticalScrollBar().setUnitIncrement(16); JSplitPane splitv = new JSplitPane(JSplitPane.VERTICAL_SPLIT, hexView, spInfo); splitv.setDividerLocation(2 * NearInfinity.getInstance().getContentPane().getHeight() / 3); add(splitv, BorderLayout.CENTER); pInfo.setOffset(0); } else { add(hexView, BorderLayout.CENTER); } // setting up button panel JButton b = (JButton)buttonPanel.addControl(BUTTON_FIND); b.addActionListener(this); b = (JButton)buttonPanel.addControl(new JButton("Find next"), BUTTON_FINDNEXT); b.setEnabled(false); b.addActionListener(this); b = (JButton)buttonPanel.addControl(BUTTON_EXPORT); b.addActionListener(this); b = (JButton)buttonPanel.addControl(BUTTON_SAVE); b.addActionListener(this); b = (JButton)buttonPanel.addControl(new JButton("Refresh"), BUTTON_REFRESH); b.setIcon(Icons.getIcon(Icons.ICON_REFRESH_16)); b.setToolTipText("Force a refresh of the displayed data"); b.addActionListener(this); add(buttonPanel, BorderLayout.SOUTH); } private FindDataDialog getFindData() { if (findData == null) { Window w = null; if (getStruct().getViewer() != null) { w = SwingUtilities.getWindowAncestor(getStruct().getViewer()); } if (w == null) { w = NearInfinity.getInstance(); } findData = new FindDataDialog(w); } return findData; } // Attempts to find the next match of the search string as defined in the FindData instance, starting at offset. private void findPattern(int offset) { if (getFindData().getDataType() == FindDataDialog.Type.TEXT) { offset = getHexView().findAscii(offset, getFindData().getText(), getFindData().isCaseSensitive()); if (offset >= 0) { getHexView().setCurrentOffset(offset); getHexView().setSelectionLength(getFindData().getText().length()*2); } else { JOptionPane.showMessageDialog(this, "No match found.", "Find", JOptionPane.INFORMATION_MESSAGE); } } else { if (getFindData().getBytes().length > 0) { offset = getHexView().findHex(offset, getFindData().getBytes()); if (offset >= 0) { getHexView().setCurrentOffset(offset); getHexView().setSelectionLength(getFindData().getBytes().length*2); } else { JOptionPane.showMessageDialog(this, "No match found.", "Find", JOptionPane.INFORMATION_MESSAGE); } } else { JOptionPane.showMessageDialog(this, "Search string does not contain valid byte values", "Find", JOptionPane.ERROR_MESSAGE); } } } private String byteArrayToString(byte[] buffer) { StringBuilder sb = new StringBuilder(); sb.append('['); if (buffer != null) { for (int i = 0; i < buffer.length; i++) { sb.append(String.format("%1$02X", buffer[i] & 0xff)); if (i+1 < buffer.length) { sb.append(", "); } } } sb.append(']'); return sb.toString(); } // Update statusbar private void updateStatusBar(int offset) { StatusBar sb = NearInfinity.getInstance().getStatusBar(); if (offset >= 0) { sb.setCursorText(String.format(FMT_OFFSET, offset)); } else { sb.setCursorText(""); } } // Update info panel private void updateInfoPanel(int offset) { if (pInfo != null) { pInfo.setOffset(offset); } } private IDataProvider getDataProvider() { return dataProvider; } private IColormap getColorMap() { return colorMap; } //-------------------------- INNER CLASSES -------------------------- // Panel component showing information about the currently selected data. private final class InfoPanel extends JPanel { private final List<StructEntryTableModel> listModels = new ArrayList<StructEntryTableModel>(); private final List<Component> listComponents = new ArrayList<Component>(); private JPanel mainPanel; private int offset; public InfoPanel() { super(); if (struct == null) { throw new NullPointerException("struct is null"); } init(); } // /** Returns current offset. */ // public int getOffset() // { // return offset; // } /** Sets new offset and updates info panel. */ public void setOffset(int offset) { if (offset != this.offset) { this.offset = offset; updatePanel(this.offset); } } // Initialize controls private void init() { setLayout(new GridBagLayout()); setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); offset = -1; mainPanel = new JPanel(); mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS)); GridBagConstraints gbc = new GridBagConstraints(); gbc = ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.FIRST_LINE_START, GridBagConstraints.HORIZONTAL, new Insets(0, 0, 0, 0), 0, 0); add(mainPanel, gbc); gbc = ViewerUtil.setGBC(gbc, 0, 1, 1, 1, 1.0, 1.0, GridBagConstraints.FIRST_LINE_START, GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0); add(new JPanel(), gbc); } // Updates tables and table models based on the data at the specified offset private void updatePanel(int offset) { StructuredDataProvider data = (getDataProvider() instanceof StructuredDataProvider) ? (StructuredDataProvider)getDataProvider() : null; if (data != null) { // creating list of nested StructEntry objects StructEntry newEntry = data.getFieldAt(offset); final List<StructEntry> list; if (newEntry != null) { list = newEntry.getStructChain(); if (!list.isEmpty() && list.get(0) == getStruct()) { list.remove(0); } } else { list = new ArrayList<StructEntry>(); } // removing invalid models and controls int lastIdx = listModels.size() - 1; for ( ; lastIdx >= 0; lastIdx--) { StructEntry curEntry = listModels.get(lastIdx).getStruct(); if (!list.contains(curEntry)) { listModels.remove(lastIdx); Component c = listComponents.remove(lastIdx); removeComponentFromPanel(c); } else { break; } } // lastIdx contains the highest index of remaining structures // adding updated models and tables to the lists for (int i = lastIdx + 1; i < list.size(); i++) { StructEntryTableModel model = new StructEntryTableModel(list.get(i)); listModels.add(model); Component c = createInfoTable(model, listComponents.size() + 1); listComponents.add(c); addComponentToPanel(c); } } else { for (int i = listModels.size() - 1; i >= 0; i--) { listModels.remove(i); Component c = listComponents.remove(i); removeComponentFromPanel(c); } } // notifying panel of the changed layout revalidate(); repaint(); } // Constructs and initializes a new table panel based on the specified model and level information private Component createInfoTable(TableModel model, int level) { final String[] suffix = {"th", "st", "nd", "rd", "th"}; JPanel retVal = new JPanel(new GridBagLayout()); // creating title JLabel l = new JLabel(String.format("%1$d%2$s level structure:", level, suffix[Math.max(0, Math.min(level, 4))])); l.setFont(l.getFont().deriveFont(Font.BOLD)); GridBagConstraints gbc = new GridBagConstraints(); gbc = ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.FIRST_LINE_START, GridBagConstraints.HORIZONTAL, new Insets(0, 0, 0, 0), 0, 0); retVal.add(l, gbc); // creating table JTable table = new JTable(model); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setFont(BrowserMenuBar.getInstance().getScriptFont()); table.setBorder(BorderFactory.createLineBorder(Color.GRAY)); table.getTableHeader().setBorder(BorderFactory.createLineBorder(Color.GRAY)); table.getTableHeader().setReorderingAllowed(false); table.getTableHeader().setResizingAllowed(true); table.setFocusable(false); table.setEnabled(false); final String maxString = String.format("%1$080d", 0); Font f = BrowserMenuBar.getInstance().getScriptFont(); FontMetrics fm = table.getFontMetrics(f); Rectangle2D rect = f.getStringBounds(maxString, fm.getFontRenderContext()); Dimension d = table.getPreferredSize(); d.width = (int)rect.getWidth(); table.setPreferredSize(d); gbc = ViewerUtil.setGBC(gbc, 0, 1, 1, 1, 0.0, 0.0, GridBagConstraints.FIRST_LINE_START, GridBagConstraints.HORIZONTAL, new Insets(2, 0, 16, 0), 0, 0); retVal.add(table, gbc); return retVal; } // Removes the specified component from the info panel private void removeComponentFromPanel(Component c) { if (c != null) { mainPanel.remove(c); } } // Adds the specified component to the info panel private void addComponentToPanel(Component c) { if (c != null) { if (!isAncestorOf(c)) { mainPanel.add(c); } } } } // Manages the representation of a single StructEntry instance private class StructEntryTableModel extends AbstractTableModel { private final String[] names = {"Name", "Start offset", "Length", "Structure type", "Value"}; private final StructEntry entry; public StructEntryTableModel(StructEntry entry) { super(); this.entry = entry; } //--------------------- Begin Class AbstractTableModel --------------------- @Override public Class<?> getColumnClass(int columnIndex) { return String.class; } @Override public int getRowCount() { return names.length; } @Override public int getColumnCount() { return 2; } @Override public Object getValueAt(int rowIndex, int columnIndex) { if (columnIndex == 0) { if (rowIndex >= 0 && rowIndex < getRowCount()) { return names[rowIndex]; } } else if (columnIndex == 1) { if (getStruct() != null) { switch (rowIndex) { case 0: // Name return getStruct().getName(); case 1: // Start offset return String.format("%1$Xh (%1$d)", getStruct().getOffset()); case 2: // Length return String.format("%1$d byte%2$s", getStruct().getSize(), (getStruct().getSize() != 1) ? "s" : ""); case 3: // Structure type return getTypeDesc(getStruct()); case 4: // Field value { String s = getStruct().toString(); return (s.length() > 30) ? s.substring(0, 30) + "..." : s; } } } } return ""; } //--------------------- End Class AbstractTableModel --------------------- /** Returns the associated StructEntry instance. */ public StructEntry getStruct() { return entry; } } }