diff --git a/src/main/java/io/github/dsheirer/channel/details/ChannelDetailPanel.java b/src/main/java/io/github/dsheirer/channel/details/ChannelDetailPanel.java
deleted file mode 100644
index ddb88d4d..00000000
--- a/src/main/java/io/github/dsheirer/channel/details/ChannelDetailPanel.java
+++ /dev/null
@@ -1,137 +0,0 @@
-/*******************************************************************************
- * sdrtrunk
- * Copyright (C) 2014-2017 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- *
- ******************************************************************************/
-package io.github.dsheirer.channel.details;
-
-import io.github.dsheirer.channel.state.DecoderState;
-import io.github.dsheirer.controller.channel.Channel;
-import io.github.dsheirer.controller.channel.ChannelProcessingManager;
-import io.github.dsheirer.module.ProcessingChain;
-import io.github.dsheirer.sample.Listener;
-import net.miginfocom.swing.MigLayout;
-
-import javax.swing.JButton;
-import javax.swing.JLabel;
-import javax.swing.JPanel;
-import javax.swing.JScrollPane;
-import javax.swing.JTextArea;
-import javax.swing.text.DefaultCaret;
-import java.awt.EventQueue;
-import java.awt.event.ActionEvent;
-import java.awt.event.ActionListener;
-
-public class ChannelDetailPanel extends JPanel implements Listener
-{
- private static final String EMPTY_DETAILS = "Please select a channel to view details";
-
- private JLabel mSystemLabel;
- private JLabel mSiteLabel;
- private JLabel mNameLabel;
- private JTextArea mDetailTextPane;
-
- private ChannelProcessingManager mChannelProcessingManager;
- private ProcessingChain mProcessingChain;
-
- public ChannelDetailPanel(ChannelProcessingManager channelProcessingManager)
- {
- mChannelProcessingManager = channelProcessingManager;
-
- init();
- }
-
- private void init()
- {
- setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[]0[grow,fill]"));
-
- JPanel buttonPanel = new JPanel();
- buttonPanel.setLayout(new MigLayout("insets 1 1 1 1", "[][grow,fill][][grow,fill][][grow,fill][]", ""));
-
- buttonPanel.add(new JLabel("System:"));
- mSystemLabel = new JLabel(" ");
- buttonPanel.add(mSystemLabel);
-
- buttonPanel.add(new JLabel("Site:"));
- mSiteLabel = new JLabel(" ");
- buttonPanel.add(mSiteLabel);
-
- buttonPanel.add(new JLabel("Channel Name:"));
- mNameLabel = new JLabel(" ");
- buttonPanel.add(mNameLabel);
-
- JButton refreshButton = new JButton("Refresh");
- refreshButton.addActionListener(new ActionListener()
- {
- @Override
- public void actionPerformed(ActionEvent e)
- {
- receive(mProcessingChain);
- }
- });
- buttonPanel.add(refreshButton);
-
- add(buttonPanel, "wrap");
-
- mDetailTextPane = new JTextArea(EMPTY_DETAILS);
- DefaultCaret caret = (DefaultCaret)mDetailTextPane.getCaret();
- caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
- add(new JScrollPane(mDetailTextPane));
- }
-
- @Override
- public void receive(ProcessingChain processingChain)
- {
- mProcessingChain = processingChain;
-
- Channel channel = mChannelProcessingManager.getChannel(processingChain);
-
- final String system = channel != null ? channel.getSystem() : null;
- final String site = channel != null ? channel.getSite() : null;
- final String name = channel != null ?
- (channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null;
-
- final String details;
-
- if(processingChain != null)
- {
- StringBuilder sb = new StringBuilder();
-
- for(DecoderState decoderState : processingChain.getDecoderStates())
- {
- sb.append(decoderState.getActivitySummary());
- }
-
- details = sb.toString();
- }
- else
- {
- details = EMPTY_DETAILS;
- }
-
- EventQueue.invokeLater(new Runnable()
- {
- @Override
- public void run()
- {
- mSystemLabel.setText(system);
- mSiteLabel.setText(site);
- mNameLabel.setText(name);
- mDetailTextPane.setText(details);
- }
- });
- }
-}
diff --git a/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataModel.java b/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataModel.java
index e1a09a0a..58837654 100644
--- a/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataModel.java
+++ b/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataModel.java
@@ -1,6 +1,6 @@
/*
* *****************************************************************************
- * Copyright (C) 2014-2024 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -18,67 +18,73 @@
*/
package io.github.dsheirer.channel.metadata;
-import com.google.common.eventbus.Subscribe;
-import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.controller.channel.Channel;
-import io.github.dsheirer.eventbus.MyEventBus;
-import io.github.dsheirer.identifier.Identifier;
-import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
-import io.github.dsheirer.preference.PreferenceType;
-import io.github.dsheirer.sample.Listener;
-import java.awt.EventQueue;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import java.util.concurrent.CopyOnWriteArrayList;
-import javax.swing.table.AbstractTableModel;
-
-public class ChannelMetadataModel extends AbstractTableModel implements IChannelMetadataUpdateListener
+/**
+ * Model of the channel metadata for the currently processing channels, with a map from each channel metadata to the
+ * channel that it belongs to. The model is thread-safe and isn't tied to a user interface toolkit: listeners are
+ * notified on the thread that changes the model and pass the changes to their user interface thread.
+ */
+public class ChannelMetadataModel implements IChannelMetadataUpdateListener
{
- private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataModel.class);
+ private final List mChannelMetadata = new ArrayList<>();
+ private final Map mMetadataChannelMap = new HashMap<>();
+ private final List mListeners = new CopyOnWriteArrayList<>();
- public static final int COLUMN_DECODER_STATE = 0;
- public static final int COLUMN_DECODER_TYPE = 1;
- public static final int COLUMN_USER_FROM = 2;
- public static final int COLUMN_USER_FROM_ALIAS = 3;
- public static final int COLUMN_USER_TO = 4;
- public static final int COLUMN_USER_TO_ALIAS = 5;
- public static final int COLUMN_DECODER_LOGICAL_CHANNEL_NAME = 6;
- public static final int COLUMN_CONFIGURATION_FREQUENCY = 7;
- public static final int COLUMN_CONFIGURATION_CHANNEL = 8;
-
- private static final String[] COLUMNS = {"Status", "Decoder", "From", "Alias", "To", "Alias", "Channel", "Frequency", "Channel Name"};
-
- private List mChannelMetadata = new ArrayList();
- private Map mMetadataChannelMap = new HashMap();
- private Listener mChannelAddListener;
-
- public ChannelMetadataModel()
+ /**
+ * Listener for changes to the channel metadata model. Notifications arrive on the thread that changed the
+ * model, sometimes while the model is locked, so implementations must not block and must not call back into
+ * the model from the notification thread.
+ */
+ public interface Listener
{
- MyEventBus.getGlobalEventBus().register(this);
+ /**
+ * Channel metadata(s) for a channel were added to the model.
+ */
+ void added(ChannelAndMetadata channelAndMetadata);
+
+ /**
+ * Channel metadata was removed from the model.
+ */
+ void removed(ChannelMetadata channelMetadata);
+
+ /**
+ * A field of a channel metadata in the model was updated.
+ */
+ void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField);
}
/**
- * Receives preference update notifications via the event bus
- * @param preferenceType that was updated
+ * Constructs an instance
*/
- @Subscribe
- public void preferenceUpdated(PreferenceType preferenceType)
+ public ChannelMetadataModel()
{
- if(preferenceType == PreferenceType.TALKGROUP_FORMAT)
- {
- EventQueue.invokeLater(() -> {
- for(int row = 0; row < mChannelMetadata.size(); row++)
- {
- fireTableCellUpdated(row, COLUMN_USER_FROM);
- fireTableCellUpdated(row, COLUMN_USER_TO);
- }
- });
- }
+ }
+
+ /**
+ * Registers the listener to receive model change notifications.
+ * @param listener to register
+ * @return the channel metadata in the model when the listener is registered, so that the listener starts from
+ * the current state and receives the changes that follow.
+ */
+ public synchronized List addListener(Listener listener)
+ {
+ mListeners.add(listener);
+ return new ArrayList<>(mChannelMetadata);
+ }
+
+ /**
+ * Removes the listener from receiving model change notifications.
+ */
+ public void removeListener(Listener listener)
+ {
+ mListeners.remove(listener);
}
/**
@@ -87,10 +93,16 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
*/
public String getDiagnosticInformation()
{
- StringBuilder sb = new StringBuilder();
- Map mapCopy = new HashMap<>(mMetadataChannelMap);
- List metadataCopy = new ArrayList<>(mChannelMetadata);
+ Map mapCopy;
+ List metadataCopy;
+ synchronized(this)
+ {
+ mapCopy = new HashMap<>(mMetadataChannelMap);
+ metadataCopy = new ArrayList<>(mChannelMetadata);
+ }
+
+ StringBuilder sb = new StringBuilder();
sb.append("Channel Metadata Model Diagnostics\n");
for(ChannelMetadata channelMetadata: metadataCopy)
@@ -122,51 +134,34 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
return sb.toString();
}
+ /**
+ * Prepares the model for disposal.
+ */
public void dispose()
{
- MyEventBus.getGlobalEventBus().unregister(this);
+ mListeners.clear();
}
/**
- * Lookup model row for the specified channel metadata
+ * Adds the channel metadata(s) for a channel to the model.
*/
- public int getRow(ChannelMetadata channelMetadata)
+ public synchronized void add(ChannelAndMetadata channelAndMetadata)
{
- if(mChannelMetadata.contains(channelMetadata))
+ for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
{
- return mChannelMetadata.indexOf(channelMetadata);
- }
-
- return -1;
- }
-
- /**
- * Registers the listener to be notified when channel metadata(s) are added to the model.
- * @param listener to receive channel associated with the channel metadata(s) that were added
- */
- public void setChannelAddListener(Listener listener)
- {
- mChannelAddListener = listener;
- }
-
- public void add(ChannelAndMetadata channelAndMetadata)
- {
- //Execute on the swing thread to avoid threading issues
- EventQueue.invokeLater(() -> {
- for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
+ if(!mChannelMetadata.contains(channelMetadata))
{
mChannelMetadata.add(channelMetadata);
- mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
- int index = mChannelMetadata.indexOf(channelMetadata);
- fireTableRowsInserted(index, index);
- channelMetadata.setUpdateEventListener(ChannelMetadataModel.this);
}
- if(mChannelAddListener != null)
- {
- mChannelAddListener.receive(channelAndMetadata);
- }
- });
+ mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
+ channelMetadata.setUpdateEventListener(this);
+ }
+
+ for(Listener listener: mListeners)
+ {
+ listener.added(channelAndMetadata);
+ }
}
/**
@@ -174,7 +169,7 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
* @param channelMetadatas
* @param channel
*/
- public void updateChannelMetadataToChannelMap(Collection channelMetadatas, Channel channel)
+ public synchronized void updateChannelMetadataToChannelMap(Collection channelMetadatas, Channel channel)
{
for(ChannelMetadata channelMetadata: channelMetadatas)
{
@@ -182,178 +177,46 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
}
}
- public void remove(ChannelMetadata channelMetadata)
+ /**
+ * Removes the channel metadata from the model.
+ */
+ public synchronized void remove(ChannelMetadata channelMetadata)
{
- //Execute on the swing thread to avoid threading issues
- EventQueue.invokeLater(() -> {
- channelMetadata.removeUpdateEventListener();
- int index = mChannelMetadata.indexOf(channelMetadata);
- mChannelMetadata.remove(channelMetadata);
- mMetadataChannelMap.remove(channelMetadata);
+ channelMetadata.removeUpdateEventListener();
+ mMetadataChannelMap.remove(channelMetadata);
- if(index >= 0)
+ if(mChannelMetadata.remove(channelMetadata))
+ {
+ for(Listener listener: mListeners)
{
- fireTableRowsDeleted(index, index);
+ listener.removed(channelMetadata);
}
- });
+ }
}
/**
- * Get the channel metadata at the specified model row index
+ * Current channel metadata in the model.
+ * @return copy of the channel metadata list.
*/
- public ChannelMetadata getChannelMetadata(int row)
+ public synchronized List getChannelMetadata()
{
- if(row < mChannelMetadata.size())
- {
- return mChannelMetadata.get(row);
- }
-
- return null;
+ return new ArrayList<>(mChannelMetadata);
}
/**
* Returns the channel that matches the metadata or null
*/
- public Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
+ public synchronized Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
{
return mMetadataChannelMap.get(channelMetadata);
}
- @Override
- public int getRowCount()
- {
- return mChannelMetadata.size();
- }
-
- @Override
- public int getColumnCount()
- {
- return COLUMNS.length;
- }
-
- @Override
- public String getColumnName(int column)
- {
- return COLUMNS[column];
- }
-
- @Override
- public Class> getColumnClass(int columnIndex)
- {
- switch(columnIndex)
- {
- case COLUMN_USER_FROM:
- case COLUMN_USER_TO:
- return ChannelMetadata.class;
- case COLUMN_USER_FROM_ALIAS:
- case COLUMN_USER_TO_ALIAS:
- return Alias.class;
- case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
- return String.class;
- default:
- return Identifier.class;
- }
- }
-
- @Override
- public Object getValueAt(int rowIndex, int columnIndex)
- {
- if(rowIndex <= mChannelMetadata.size())
- {
- ChannelMetadata channelMetadata = mChannelMetadata.get(rowIndex);
-
- switch(columnIndex)
- {
- case COLUMN_DECODER_STATE:
- return channelMetadata.getChannelStateIdentifier();
- case COLUMN_DECODER_TYPE:
- return channelMetadata.getDecoderTypeConfigurationIdentifier();
- case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
- DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
-
- if(channelMetadata.hasTimeslot())
- {
- if(id == null)
- {
- return "TS:" + channelMetadata.getTimeslot();
- }
- else
- {
- String value = id.getValue().replace(" TS1", "")
- .replace(" TS2", "");
- return value + " TS:" + channelMetadata.getTimeslot();
- }
- }
- else
- {
- if(id == null)
- {
- return null;
- }
- else
- {
- return id.getValue();
- }
- }
- case COLUMN_CONFIGURATION_FREQUENCY:
- return channelMetadata.getFrequencyConfigurationIdentifier();
- case COLUMN_CONFIGURATION_CHANNEL:
- return channelMetadata.getChannelNameConfigurationIdentifier();
- case COLUMN_USER_TO:
- return channelMetadata;
- case COLUMN_USER_FROM:
- return channelMetadata;
- case COLUMN_USER_FROM_ALIAS:
- return channelMetadata.getFromIdentifierAliases();
- case COLUMN_USER_TO_ALIAS:
- return channelMetadata.getToIdentifierAliases();
- }
- }
-
- return null;
- }
-
@Override
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
{
- final int rowIndex = mChannelMetadata.indexOf(channelMetadata);
-
- if(rowIndex >= 0)
+ for(Listener listener: mListeners)
{
- //Execute on the swing thread to avoid threading issues
- EventQueue.invokeLater(new Runnable()
- {
- @Override
- public void run()
- {
- switch(channelMetadataField)
- {
- case CONFIGURATION_CHANNEL:
- fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_CHANNEL);
- break;
- case CONFIGURATION_FREQUENCY:
- fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_FREQUENCY);
- break;
- case DECODER_CHANNEL_NAME:
- fireTableCellUpdated(rowIndex, COLUMN_DECODER_LOGICAL_CHANNEL_NAME);
- break;
- case DECODER_TYPE:
- fireTableCellUpdated(rowIndex, COLUMN_DECODER_TYPE);
- break;
- case DECODER_STATE:
- fireTableCellUpdated(rowIndex, COLUMN_DECODER_STATE);
- break;
- case USER_FROM:
- fireTableCellUpdated(rowIndex, COLUMN_USER_FROM);
- fireTableCellUpdated(rowIndex, COLUMN_USER_FROM_ALIAS);
- break;
- case USER_TO:
- fireTableCellUpdated(rowIndex, COLUMN_USER_TO);
- fireTableCellUpdated(rowIndex, COLUMN_USER_TO_ALIAS);
- break;
- }
- }
- });
+ listener.updated(channelMetadata, channelMetadataField);
}
}
}
diff --git a/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java b/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java
deleted file mode 100644
index 15343ffd..00000000
--- a/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java
+++ /dev/null
@@ -1,494 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2024 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-
-package io.github.dsheirer.channel.metadata;
-
-import com.google.common.base.Joiner;
-import io.github.dsheirer.alias.Alias;
-import io.github.dsheirer.channel.state.State;
-import io.github.dsheirer.controller.channel.Channel;
-import io.github.dsheirer.controller.channel.ChannelModel;
-import io.github.dsheirer.controller.channel.ChannelProcessingManager;
-import io.github.dsheirer.eventbus.MyEventBus;
-import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
-import io.github.dsheirer.gui.theme.ThemeManager;
-import io.github.dsheirer.icon.IconModel;
-import io.github.dsheirer.identifier.Identifier;
-import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier;
-import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
-import io.github.dsheirer.module.ProcessingChain;
-import io.github.dsheirer.playlist.PlaylistManager;
-import io.github.dsheirer.preference.UserPreferences;
-import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
-import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
-import io.github.dsheirer.sample.Broadcaster;
-import io.github.dsheirer.sample.Listener;
-import java.awt.Color;
-import java.awt.Component;
-import java.awt.event.MouseAdapter;
-import java.awt.event.MouseEvent;
-import java.text.DecimalFormat;
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
-import net.miginfocom.swing.MigLayout;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.swing.JLabel;
-import javax.swing.JMenuItem;
-import javax.swing.JPanel;
-import javax.swing.JPopupMenu;
-import javax.swing.JScrollPane;
-import javax.swing.JTable;
-import javax.swing.SwingConstants;
-import javax.swing.event.ListSelectionEvent;
-import javax.swing.event.ListSelectionListener;
-import javax.swing.table.DefaultTableCellRenderer;
-
-public class ChannelMetadataPanel extends JPanel implements ListSelectionListener
-{
- private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataPanel.class);
-
- private static final String TABLE_PREFERENCE_KEY = "channel.metadata.panel";
- private ChannelModel mChannelModel;
- private ChannelProcessingManager mChannelProcessingManager;
- private IconModel mIconModel;
- private UserPreferences mUserPreferences;
- private JTable mTable;
- private Broadcaster mSelectedProcessingChainBroadcaster = new Broadcaster<>();
- private Map mBackgroundColors = new EnumMap<>(State.class);
- private Map mForegroundColors = new EnumMap<>(State.class);
- private JTableColumnWidthMonitor mTableColumnMonitor;
- private Channel mUserSelectedChannel;
-
- /**
- * Table view for currently decoding channel metadata
- */
- public ChannelMetadataPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences)
- {
- mChannelModel = playlistManager.getChannelModel();
- mChannelProcessingManager = playlistManager.getChannelProcessingManager();
- mIconModel = iconModel;
- mUserPreferences = userPreferences;
- init();
- }
-
- /**
- * Initializes the panel
- */
- private void init()
- {
- setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
-
- mTable = new JTable(mChannelProcessingManager.getChannelMetadataModel());
- mChannelProcessingManager.getChannelMetadataModel().setChannelAddListener(new ChannelAddListener());
-
- DefaultTableCellRenderer renderer = (DefaultTableCellRenderer)mTable.getDefaultRenderer(String.class);
- renderer.setHorizontalAlignment(SwingConstants.CENTER);
-
- mTable.getSelectionModel().addListSelectionListener(this);
- mTable.addMouseListener(new MouseSupport());
-
- mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_DECODER_STATE)
- .setCellRenderer(new ColoredStateCellRenderer());
- mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM)
- .setCellRenderer(new FromCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
- mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO)
- .setCellRenderer(new ToCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
- mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM_ALIAS)
- .setCellRenderer(new AliasCellRenderer());
- mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO_ALIAS)
- .setCellRenderer(new AliasCellRenderer());
- mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_CONFIGURATION_FREQUENCY)
- .setCellRenderer(new FrequencyCellRenderer());
-
- //Add a table column width monitor to store/restore column widths
- mTableColumnMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
-
- JScrollPane scrollPane = new JScrollPane(mTable, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
- JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
-
- add(scrollPane);
-
- setColors();
- }
-
- /**
- * Setup the background and foreground color palette for the various channel states.
- */
- private void setColors()
- {
- mBackgroundColors.put(State.ACTIVE, Color.CYAN);
- mForegroundColors.put(State.ACTIVE, Color.BLUE);
- mBackgroundColors.put(State.CALL, Color.BLUE);
- mForegroundColors.put(State.CALL, Color.YELLOW);
- mBackgroundColors.put(State.CONTROL, Color.ORANGE);
- mForegroundColors.put(State.CONTROL, Color.BLUE);
- mBackgroundColors.put(State.DATA, Color.GREEN);
- mForegroundColors.put(State.DATA, Color.BLUE);
- mBackgroundColors.put(State.ENCRYPTED, Color.MAGENTA);
- mForegroundColors.put(State.ENCRYPTED, Color.WHITE);
- mBackgroundColors.put(State.FADE, Color.LIGHT_GRAY);
- mForegroundColors.put(State.FADE, Color.DARK_GRAY);
- mBackgroundColors.put(State.IDLE, Color.WHITE);
- mForegroundColors.put(State.IDLE, Color.DARK_GRAY);
- mBackgroundColors.put(State.RESET, Color.PINK);
- mForegroundColors.put(State.RESET, Color.YELLOW);
- mBackgroundColors.put(State.TEARDOWN, Color.DARK_GRAY);
- mForegroundColors.put(State.TEARDOWN, Color.WHITE);
- }
-
- @Override
- public void valueChanged(ListSelectionEvent e)
- {
- if(!mTable.getSelectionModel().getValueIsAdjusting())
- {
- ProcessingChain processingChain = null;
-
- int selectedViewRow = mTable.getSelectedRow();
-
- if(selectedViewRow >= 0)
- {
- int selectedModelRow = mTable.convertRowIndexToModel(selectedViewRow);
-
- ChannelMetadata selectedMetadata = mChannelProcessingManager.getChannelMetadataModel()
- .getChannelMetadata(selectedModelRow);
-
- if(selectedMetadata != null)
- {
- mUserSelectedChannel = mChannelProcessingManager.getChannelMetadataModel()
- .getChannelFromMetadata(selectedMetadata);
-
- processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
- }
- }
-
- mSelectedProcessingChainBroadcaster.broadcast(processingChain);
- }
- }
-
- /**
- * Adds the listener to receive the processing chain associated with the metadata selected in the
- * metadata table.
- */
- public void addProcessingChainSelectionListener(Listener listener)
- {
- mSelectedProcessingChainBroadcaster.addListener(listener);
- }
-
- /**
- * Cell renderer for frequency values
- */
- public class FrequencyCellRenderer extends DefaultTableCellRenderer
- {
- private final DecimalFormat FREQUENCY_FORMATTER = new DecimalFormat( "#.00000" );
-
- public FrequencyCellRenderer()
- {
- setHorizontalAlignment(JLabel.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- if(value instanceof FrequencyConfigurationIdentifier)
- {
- long frequency = ((FrequencyConfigurationIdentifier)value).getValue();
- label.setText(FREQUENCY_FORMATTER.format(frequency / 1e6d));
- }
- else
- {
- label.setText(null);
- }
-
- return label;
- }
- }
-
- /**
- * Alias cell renderer
- */
- public class AliasCellRenderer extends DefaultTableCellRenderer
- {
- public AliasCellRenderer()
- {
- setHorizontalAlignment(JLabel.LEFT);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- if(value instanceof List>)
- {
- List aliases = (List)value;
-
- if(!aliases.isEmpty())
- {
- label.setText(Joiner.on(", ").skipNulls().join(aliases));
- label.setIcon(mIconModel.getIcon(aliases.get(0).getIconName(), IconModel.DEFAULT_ICON_SIZE));
- label.setForeground(ThemeManager.getInstance().getReadableColor(aliases.get(0).getDisplayColor(),
- label.getBackground()));
- }
- else
- {
- label.setText(null);
- label.setIcon(null);
- label.setForeground(table.getForeground());
- }
- }
- else
- {
- label.setText(null);
- label.setIcon(null);
- label.setForeground(table.getForeground());
- }
-
- return label;
- }
- }
-
- /**
- * Abstract cell renderer for identifiers
- */
- public abstract class IdentifierCellRenderer extends DefaultTableCellRenderer
- {
- private final static String EMPTY_VALUE = "-----";
- private TalkgroupFormatPreference mTalkgroupFormatPreference;
-
- public IdentifierCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
- {
- mTalkgroupFormatPreference = talkgroupFormatPreference;
- setHorizontalAlignment(JLabel.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- if(value instanceof ChannelMetadata)
- {
- ChannelMetadata channelMetadata = (ChannelMetadata)value;
- Identifier identifier = getIdentifier(channelMetadata);
- String text = mTalkgroupFormatPreference.format(identifier);
- if(text == null || text.isEmpty())
- {
- text = EMPTY_VALUE;
- }
- else if(hasAdditionalIdentifier(channelMetadata))
- {
- text = text + " " + getAdditionalIdentifier(channelMetadata);
- }
-
- label.setText(text);
- }
- else
- {
- label.setText(EMPTY_VALUE);
- }
-
- return label;
- }
-
- public abstract Identifier getIdentifier(ChannelMetadata channelMetadata);
- public abstract boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata);
- public abstract Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata);
- }
-
- /**
- * Cell renderer for the FROM identifier
- */
- public class FromCellRenderer extends IdentifierCellRenderer
- {
- public FromCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
- {
- super(talkgroupFormatPreference);
- }
-
- @Override
- public Identifier getIdentifier(ChannelMetadata channelMetadata)
- {
- return channelMetadata.getFromIdentifier();
- }
-
- @Override
- public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata)
- {
- return channelMetadata.getTalkerAliasIdentifier();
- }
-
- @Override
- public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata)
- {
- return channelMetadata.hasTalkerAliasIdentifier();
- }
- }
-
- /**
- * Cell renderer for the TO identifier
- */
- public class ToCellRenderer extends IdentifierCellRenderer
- {
- public ToCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
- {
- super(talkgroupFormatPreference);
- }
-
- @Override
- public Identifier getIdentifier(ChannelMetadata channelMetadata)
- {
- return channelMetadata.getToIdentifier();
- }
-
- @Override
- public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata) {return null;}
- @Override
- public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata) {return false;}
- }
-
- public class ColoredStateCellRenderer extends DefaultTableCellRenderer
- {
- public ColoredStateCellRenderer()
- {
- setHorizontalAlignment(SwingConstants.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus,
- int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- Color background = table.getBackground();
- Color foreground = table.getForeground();
-
- if(value instanceof ChannelStateIdentifier)
- {
- State state = ((ChannelStateIdentifier)value).getValue();
- label.setText(state.getDisplayValue());
-
- if(mBackgroundColors.containsKey(state))
- {
- background = mBackgroundColors.get(state);
- }
-
- if(mForegroundColors.containsKey(state))
- {
- foreground = mForegroundColors.get(state);
- }
- }
- else
- {
- setText("----");
- }
-
- setBackground(background);
- setForeground(foreground);
-
- return label;
- }
- }
-
- public class MouseSupport extends MouseAdapter
- {
- @Override
- public void mouseClicked(MouseEvent e)
- {
- if(e.getButton() == MouseEvent.BUTTON3) //Right click for context
- {
- JPopupMenu popupMenu = new JPopupMenu();
-
- boolean populated = false;
-
- int viewRowIndex = mTable.rowAtPoint(e.getPoint());
-
- if(viewRowIndex >= 0)
- {
- int modelRowIndex = mTable.convertRowIndexToModel(viewRowIndex);
-
- if(modelRowIndex >= 0)
- {
- ChannelMetadata metadata = mChannelProcessingManager.getChannelMetadataModel().getChannelMetadata(modelRowIndex);
-
- if(metadata != null)
- {
- Channel channel = mChannelProcessingManager.getChannelMetadataModel()
- .getChannelFromMetadata(metadata);
-
- if(channel != null)
- {
- JMenuItem viewChannel = new JMenuItem("View/Edit: " + channel.getShortTitle());
- viewChannel.addActionListener(e2 -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
- popupMenu.add(viewChannel);
- populated = true;
- }
- }
- }
- }
-
- if(!populated)
- {
- popupMenu.add(new JMenuItem("No Actions Available"));
- }
-
- popupMenu.show(mTable, e.getX(), e.getY());
- }
- }
- }
-
- /**
- * Listener to be notified when a channel and associated channel metadata(s) are added to the underlying
- * channel metadata model.
- *
- * When a channel is added, it is compared the the last user selected channel and if they are the same, it
- * invokes a selection event on the channel metadata row so that the channel metadata is re-selected. This is
- * primarily a hack to counter-act the DMR Capacity+ REST channel rotation where a channel is converted to a
- * traffic channel and the previous channel is restarted. The UI effect is that the user selected channel row
- * in the Now Playing window continually loses selection over the channel and causes the user to perpetually
- * chase the channel row.
- */
- public class ChannelAddListener implements Listener
- {
- @Override
- public void receive(ChannelAndMetadata channelAndMetadata)
- {
- if(mUserSelectedChannel != null &&
- mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
- {
- List metadata = channelAndMetadata.getChannelMetadata();
-
- if(metadata.size() > 0)
- {
- int modelRow = mChannelProcessingManager.getChannelMetadataModel().getRow(metadata.get(0));
-
- if(modelRow >= 0)
- {
- int tableRow = mTable.convertRowIndexToView(modelRow);
- mTable.getSelectionModel().setSelectionInterval(tableRow, tableRow);
- }
- }
- }
- }
- }
-}
diff --git a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java
deleted file mode 100644
index ce1eb97b..00000000
--- a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java
+++ /dev/null
@@ -1,138 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2025 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-package io.github.dsheirer.channel.metadata;
-
-import com.jidesoft.swing.JideSplitPane;
-import com.jidesoft.swing.JideTabbedPane;
-import io.github.dsheirer.channel.details.ChannelDetailPanel;
-import io.github.dsheirer.gui.channel.ChannelSpectrumPanel;
-import io.github.dsheirer.icon.IconModel;
-import io.github.dsheirer.module.decode.event.DecodeEventPanel;
-import io.github.dsheirer.module.decode.event.MessageActivityPanel;
-import io.github.dsheirer.playlist.PlaylistManager;
-import io.github.dsheirer.preference.UserPreferences;
-import io.github.dsheirer.settings.SettingsManager;
-import net.miginfocom.swing.MigLayout;
-
-import javax.swing.JPanel;
-
-/**
- * Swing panel for Now Playing channels table and channel details tab set.
- */
-public class NowPlayingPanel extends JPanel
-{
- private final ChannelMetadataPanel mChannelMetadataPanel;
- private final ChannelDetailPanel mChannelDetailPanel;
- private final DecodeEventPanel mDecodeEventPanel;
- private final MessageActivityPanel mMessageActivityPanel;
- private final ChannelSpectrumPanel mChannelSpectrumSquelchPanel;
- private JideTabbedPane mTabbedPane;
- private JideSplitPane mSplitPane;
- private boolean mDetailTabsVisible;
-
- /**
- * GUI panel that combines the currently decoding channels metadata table and viewers for channel details,
- * messages, events, and spectral view.
- */
- public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
- SettingsManager settingsManager, boolean detailTabsVisible)
- {
- mChannelDetailPanel = new ChannelDetailPanel(playlistManager.getChannelProcessingManager());
- mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
- mMessageActivityPanel = new MessageActivityPanel(userPreferences);
- mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
- mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager);
- mDetailTabsVisible = detailTabsVisible;
-
- init();
- }
-
- /**
- * Change the visibility of the channel details tabs panel.
- * @param visible true to show or false to hide.
- */
- public void setDetailTabsVisible(boolean visible)
- {
- //Only adjust if there is a change in state
- if(visible ^ mDetailTabsVisible)
- {
- mDetailTabsVisible = visible;
-
- if(mDetailTabsVisible)
- {
- getSplitPane().add(getTabbedPane());
- }
- else
- {
- getSplitPane().remove(getTabbedPane());
- }
-
- revalidate();
- }
- }
-
- private JideTabbedPane getTabbedPane()
- {
- if(mTabbedPane == null)
- {
- mTabbedPane = new JideTabbedPane();
- mTabbedPane.addTab("Details", mChannelDetailPanel);
- mTabbedPane.addTab("Events", mDecodeEventPanel);
- mTabbedPane.addTab("Messages", mMessageActivityPanel);
- mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel);
- mTabbedPane.setFont(this.getFont());
- //Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing
- mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane()
- .indexOfComponent(mChannelSpectrumSquelchPanel)));
- }
-
- return mTabbedPane;
- }
-
- /**
- * Split pane for channels table and channel details tabs.
- */
- private JideSplitPane getSplitPane()
- {
- if(mSplitPane == null)
- {
- mSplitPane = new JideSplitPane(JideSplitPane.VERTICAL_SPLIT);
- mSplitPane.setShowGripper(true);
- }
-
- return mSplitPane;
- }
-
- private void init()
- {
- setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
- getSplitPane().add(mChannelMetadataPanel);
-
- if(mDetailTabsVisible)
- {
- getSplitPane().add(getTabbedPane());
- }
-
- add(getSplitPane());
- mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelDetailPanel);
- mChannelMetadataPanel.addProcessingChainSelectionListener(mDecodeEventPanel);
- mChannelMetadataPanel.addProcessingChainSelectionListener(mMessageActivityPanel);
- mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelSpectrumSquelchPanel);
- }
-}
diff --git a/src/main/java/io/github/dsheirer/controller/ControllerPanel.java b/src/main/java/io/github/dsheirer/controller/ControllerPanel.java
index 374a775a..55fb25b7 100644
--- a/src/main/java/io/github/dsheirer/controller/ControllerPanel.java
+++ b/src/main/java/io/github/dsheirer/controller/ControllerPanel.java
@@ -21,8 +21,8 @@ package io.github.dsheirer.controller;
import com.jidesoft.swing.JideTabbedPane;
import io.github.dsheirer.audio.playback.AudioPanel;
import io.github.dsheirer.audio.playback.AudioPlaybackManager;
-import io.github.dsheirer.channel.metadata.NowPlayingPanel;
import io.github.dsheirer.eventbus.MyEventBus;
+import io.github.dsheirer.gui.nowplaying.NowPlayingPanel;
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
@@ -63,7 +63,7 @@ public class ControllerPanel extends JPanel
{
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
playlistManager.getAliasModel());
- mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
+ mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, detailTabsVisible);
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
new RadioReferencePlaylistActions(playlistManager));
diff --git a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java
deleted file mode 100644
index e2e1a6dc..00000000
--- a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java
+++ /dev/null
@@ -1,483 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2026 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-
-package io.github.dsheirer.gui.channel;
-
-import io.github.dsheirer.controller.channel.Channel;
-import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
-import io.github.dsheirer.gui.power.SignalPowerView;
-import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
-import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
-import io.github.dsheirer.gui.squelch.NoiseSquelchView;
-import io.github.dsheirer.gui.symbol.SymbolView;
-import io.github.dsheirer.gui.theme.ThemeManager;
-import io.github.dsheirer.module.ProcessingChain;
-import io.github.dsheirer.module.decode.FeedbackDecoder;
-import io.github.dsheirer.module.decode.PrimaryDecoder;
-import io.github.dsheirer.module.decode.am.AMDecoder;
-import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
-import io.github.dsheirer.playlist.PlaylistManager;
-import io.github.dsheirer.sample.Listener;
-import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
-import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
-import io.github.dsheirer.source.Source;
-import io.github.dsheirer.source.SourceEvent;
-import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
-import io.github.dsheirer.source.tuner.channel.TunerChannel;
-import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
-import java.awt.Component;
-import java.awt.EventQueue;
-import java.net.URL;
-import java.text.DecimalFormat;
-import java.util.List;
-import javafx.application.Platform;
-import javafx.embed.swing.JFXPanel;
-import javafx.scene.Scene;
-import net.miginfocom.swing.MigLayout;
-import org.apache.commons.lang3.StringUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.swing.JButton;
-import javax.swing.JLabel;
-import javax.swing.JPanel;
-import javax.swing.JSplitPane;
-
-/**
- * Display for channel FFT and squelch details
- */
-public class ChannelSpectrumPanel extends JPanel implements Listener
-{
- private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumPanel.class);
- private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
- private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
- private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
- private static final String LABEL_PREFIX_TUNER = "Tuner:";
- private static final String LABEL_PREFIX_CHANNEL = "Channel:";
- private static final String LABEL_PREFIX_DECODER = "Decoder:";
- private final PlaylistManager mPlaylistManager;
- private ProcessingChain mProcessingChain;
- private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
- private final JFXPanel mSpectralPanel = new JFXPanel();
- //Created on the JavaFX thread - guarded by this. Channel state is kept here until the display exists.
- private ChannelSpectralDisplay mChannelSpectralDisplay;
- private long mChannelFrequency;
- private int mChannelSampleRate;
- private int mChannelBandwidth;
- private Long mCarrierOffset;
- private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
- private final JLabel mTunerPPMLabel;
- private final JLabel mTunerCorrectionLabel;
- private final JLabel mChannelCorrectionLabel;
- private final JLabel mDecoderCorrectionLabel;
- private final JLabel mDummyLabel = new JLabel(" ");
- private boolean mPanelVisible = false;
- private final NoiseSquelchView mNoiseSquelchView;
- private final SignalPowerView mSignalPowerView;
- private final SymbolView mSymbolView = new SymbolView();
- private final JFXPanel mNoiseSquelchPanel;
- private final JFXPanel mSymbolPanel;
- private JSplitPane mSplitPane;
- private FeedbackDecoder mFeedbackDecoder;
-
- /**
- * Constructs an instance.
- */
- public ChannelSpectrumPanel(PlaylistManager playlistManager)
- {
- mPlaylistManager = playlistManager;
- mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
- mSignalPowerView = new SignalPowerView(mPlaylistManager);
-
- //Register the three swappable right-pane components with the theme manager. Only one is
- //attached to the split pane at a time, so the LAF-toggle walk of Window.getWindows() can
- //miss the detached ones - they get refreshed explicitly via registerSwing.
- ThemeManager.getInstance().registerSwing(mSignalPowerView);
- setLayout(new MigLayout("insets 0", "[grow,fill]", "[grow,fill]"));
-
- JPanel fftPanel = new JPanel();
- fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
-
- JPanel labelPanel = new JPanel();
- labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", ""));
-
- mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
- mTunerPPMLabel.setEnabled(false);
- labelPanel.add(mTunerPPMLabel);
-
- labelPanel.add(new JLabel("Corrections (Hz) "));
-
- mTunerCorrectionLabel = new JLabel(LABEL_PREFIX_TUNER);
- mTunerCorrectionLabel.setEnabled(false);
- labelPanel.add(mTunerCorrectionLabel);
-
- mChannelCorrectionLabel = new JLabel(LABEL_PREFIX_CHANNEL);
- mChannelCorrectionLabel.setEnabled(false);
- labelPanel.add(mChannelCorrectionLabel);
-
- mDecoderCorrectionLabel = new JLabel(LABEL_PREFIX_DECODER);
- mDecoderCorrectionLabel.setEnabled(false);
- labelPanel.add(mDecoderCorrectionLabel);
-
- labelPanel.add(mDummyLabel);
-
- JButton logIndexesButton = new JButton("Log Settings");
- logIndexesButton.addActionListener(e -> {
- if(mProcessingChain != null)
- {
- Source source = mProcessingChain.getSource();
-
- if(source instanceof PolyphaseChannelSource pcs)
- {
- List indexes = pcs.getOutputProcessorIndexes();
- double sampleRate = pcs.getSampleRate();
- long indexCenterFrequency = pcs.getIndexCenterFrequency();
- long appliedFrequencyOffset = pcs.getFrequencyOffset();
- long requestedCenterFrequency = pcs.getFrequency();
-
- StringBuilder sb = new StringBuilder();
- sb.append("Polyphase Channel - BW: ").append(FREQUENCY_FORMAT.format(sampleRate / 1E6d));
- sb.append(" Center/Requested/Mixer: ").append(FREQUENCY_FORMAT.format(indexCenterFrequency / 1E6d));
- sb.append("/").append(FREQUENCY_FORMAT.format(requestedCenterFrequency / 1E6d));
- sb.append("/").append(FREQUENCY_FORMAT.format(appliedFrequencyOffset / 1E6d));
- sb.append(" Polyphase Indexes: ").append(indexes);
- sb.append(" Tuner SR:").append(FREQUENCY_FORMAT.format(pcs.getTunerSampleRate() / 1E6d));
- sb.append(" CF:").append(FREQUENCY_FORMAT.format(pcs.getTunerCenterFrequency() / 1E6d));
- LOGGER.info(sb.toString());
- LOGGER.info("Output Processor: " + pcs.getStateDescription());
- }
- else if(source instanceof HalfBandTunerChannelSource> hbtcs)
- {
- StringBuilder sb = new StringBuilder();
- sb.append("Heterodyne Channel - CF:").append(FREQUENCY_FORMAT.format(hbtcs.getFrequency() / 1E6d));
- sb.append(" SR:").append(FREQUENCY_FORMAT.format(hbtcs.getSampleRate() / 1E6d));
- sb.append(" Mixer:").append(FREQUENCY_FORMAT.format(hbtcs.getMixerFrequency() / 1E6d));
- LOGGER.info(sb.toString());
- }
- else
- {
- LOGGER.info("Unsupported channel type: " + (source != null ? source.getClass() : " null"));
- }
- }
- });
- //This is a debug button to log the current settings to the app log.
-// labelPanel.add(mLogIndexesButton);
-
- fftPanel.add(labelPanel, "wrap");
-
- fftPanel.add(mSpectralPanel);
- ThemeManager.getInstance().registerSwing(mSpectralPanel);
-
- Platform.runLater(() -> {
- ChannelSpectralDisplay display = new ChannelSpectralDisplay();
- Scene scene = new Scene(display);
- ThemeManager.getInstance().register(scene);
- mSpectralPanel.setScene(scene);
- displayCreated(display);
- });
-
- mNoiseSquelchPanel = new JFXPanel();
- mSymbolPanel = new JFXPanel();
- ThemeManager.getInstance().registerSwing(mNoiseSquelchPanel);
- ThemeManager.getInstance().registerSwing(mSymbolPanel);
-
- //Spin noise squelch panel construction off onto the JavafX UI thread.
- Platform.runLater(() -> {
- Scene scene = new Scene(mNoiseSquelchView);
- ThemeManager.getInstance().register(scene);
- mNoiseSquelchPanel.setScene(scene);
- Scene scene2 = new Scene(mSymbolView);
- URL resource = getClass().getResource("/sdrtrunk_style.css");
-
- if(resource != null)
- {
- scene2.getStylesheets().add(resource.toExternalForm());
- }
- else
- {
- LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
- }
-
- ThemeManager.getInstance().register(scene2);
- //Use the same color theme as the channel spectral display
- mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
- mSymbolPanel.setScene(scene2);
- });
-
- mSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
- mSplitPane.add(fftPanel, JSplitPane.LEFT);
- mSplitPane.add(mNoiseSquelchPanel, JSplitPane.RIGHT);
- add(mSplitPane);
-// mSplitPane.setDividerLocation(0.5);
-
- }
-
- /**
- * Stores the spectral display once it's created on the JavaFX thread and applies the current channel state.
- */
- private synchronized void displayCreated(ChannelSpectralDisplay display)
- {
- mChannelSpectralDisplay = display;
-
- if(mChannelSampleRate > 0)
- {
- display.setChannel(mChannelFrequency, mChannelSampleRate, mChannelBandwidth);
- }
-
- if(mCarrierOffset != null)
- {
- display.setCarrierOffset(mCarrierOffset);
- }
-
- updateFFTProcessing();
- }
-
- private synchronized void setChannel(long frequency, int sampleRate, int bandwidth)
- {
- mChannelFrequency = frequency;
- mChannelSampleRate = sampleRate;
- mChannelBandwidth = bandwidth;
- mCarrierOffset = null;
-
- if(mChannelSpectralDisplay != null)
- {
- mChannelSpectralDisplay.setChannel(frequency, sampleRate, bandwidth);
- }
- }
-
- private synchronized void setCarrierOffset(long offset)
- {
- mCarrierOffset = offset;
-
- if(mChannelSpectralDisplay != null)
- {
- mChannelSpectralDisplay.setCarrierOffset(offset);
- }
- }
-
- /**
- * Pads the value to a fixed width and returns the previx and padded value.
- * @param prefix of the label
- * @param value to pad with spaces
- * @return padded, prefixed label
- */
- private String getPaddedLabel(String prefix, Number value)
- {
- return prefix + StringUtils.leftPad(String.valueOf(value), 7);
- }
-
- /**
- * Signals this panel to indicate if this panel is visible to turn on the FFT processor when the panel is visible
- * and turn off the FFT processor when it's not.
- *
- * Note: this method is intended to be called by the Swing event thread to ensure that only a single thread is
- * invoking either this method, or the receive() method, since there is no thread synchronization between these
- * two methods and they each depend on stable access to the mPanelVisible variable.
- *
- * @param visible true to indicate that this panel is showing/visible.
- */
- public void setPanelVisible(boolean visible)
- {
- mPanelVisible = visible;
- updateFFTProcessing();
- mNoiseSquelchView.setShowing(visible);
- mSymbolView.setShowing(visible);
- }
-
- /**
- * Updates processing state for the spectral display. Turns on DFT processing when we have a processing chain and
- * when the user has this tab selected and visible. Otherwise, turns off DFT processing.
- */
- private synchronized void updateFFTProcessing()
- {
- if(mChannelSpectralDisplay == null)
- {
- return;
- }
-
- boolean active = mPanelVisible && mProcessingChain != null;
-
- if(active)
- {
- mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
- }
- else
- {
- mSampleStreamTapModule.removeListener();
- }
-
- mChannelSpectralDisplay.setActive(active);
- }
-
- private void broadcast(SourceEvent sourceEvent)
- {
- if(mProcessingChain != null)
- {
- mProcessingChain.broadcast(sourceEvent);
- }
- }
-
- /**
- * Resets controls when changing processing chain source. Note: this must be called on the Swing
- * dispatch thread because it directly invokes swing components.
- */
- private void reset()
- {
- mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
- mTunerPPMLabel.setEnabled(false);
- mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
- mTunerCorrectionLabel.setEnabled(false);
- mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
- mChannelCorrectionLabel.setEnabled(false);
- mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
- mDecoderCorrectionLabel.setEnabled(false);
- setChannel(0, 0, 0);
- }
-
- /**
- * Receive notifications of request to provide display of processing chain details.
- */
- @Override
- public void receive(ProcessingChain processingChain)
- {
- //Disconnect the previous processing chain.
- if(mProcessingChain != null)
- {
- mNoiseSquelchView.setController(null);
- mSignalPowerView.setProcessingChain(null);
- mSymbolView.removeSymbolProvider();
- mSymbolView.setProtocol("");
- mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
- mProcessingChain.removeModule(mSampleStreamTapModule);
- mFeedbackDecoder = null;
- }
-
- //Invoking reset - we're on the Swing dispatch thread here
- reset();
-
- mProcessingChain = processingChain;
-
- if(mProcessingChain != null)
- {
- mProcessingChain.addSourceEventListener(mSourceEventProcessor);
-
- PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
- if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
- {
- setRightComponent(mNoiseSquelchPanel);
- mNoiseSquelchView.setController(nbfmDecoder);
- }
- else if(primaryDecoder instanceof AMDecoder)
- {
- setRightComponent(mSignalPowerView);
- mSignalPowerView.setProcessingChain(mProcessingChain);
- }
- else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
- {
- setRightComponent(mSymbolPanel);
- mSymbolView.setSymbolProvider(feedbackDecoder);
- mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
- mFeedbackDecoder = feedbackDecoder;
- }
-
- mProcessingChain.addModule(mSampleStreamTapModule);
- Source source = mProcessingChain.getSource();
-
- int bandwidth = 0;
- Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
-
- if(channel != null)
- {
- List tunerChannels = channel.getTunerChannels();
-
- if(!tunerChannels.isEmpty())
- {
- bandwidth = tunerChannels.getFirst().getBandwidth();
- }
- }
-
- if(source instanceof TunerChannelSource tcs)
- {
- setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
- }
- }
-
- updateFFTProcessing();
- }
-
- /**
- * Shows the component on the right side of the split pane.
- * @param component to show.
- */
- private void setRightComponent(Component component)
- {
- Component rightComponent = mSplitPane.getRightComponent();
-
- if(rightComponent != component)
- {
- mSplitPane.remove(rightComponent);
- mSplitPane.setRightComponent(component);
- mSplitPane.setDividerLocation(0.4);
- }
- }
-
- /**
- * Processor for source event stream to capture power level and squelch related source events.
- */
- private class SourceEventProcessor implements Listener
- {
- @Override
- public void receive(SourceEvent sourceEvent)
- {
- if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS)
- {
- if(sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
- {
- //Note: we flip the sign on the error measurement because the value represents the amount of offset the PLL
- //has to apply to move the signal to center/baseband
- EventQueue.invokeLater(() -> {
- mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
- mTunerPPMLabel.setEnabled(true);
-
- if(status.isAutoPPM())
- {
- mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
- }
- else
- {
- mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
- }
- mTunerCorrectionLabel.setEnabled(true);
- mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
- mChannelCorrectionLabel.setEnabled(true);
- mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
- mDecoderCorrectionLabel.setEnabled(true);
-
- //We se the offset cursor line to negative so that it aligns with where the signal is located
- //instead of the correction that's needed to bring the signal to zero.
- setCarrierOffset(-status.getDecoderCorrection());
- });
- }
- }
-
-//TODO: is this still needed?
- mSignalPowerView.receive(sourceEvent);
- }
- }
-}
diff --git a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumView.java b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumView.java
new file mode 100644
index 00000000..3a80dbff
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumView.java
@@ -0,0 +1,309 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.channel;
+
+import io.github.dsheirer.controller.channel.Channel;
+import io.github.dsheirer.gui.power.SignalPowerView;
+import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
+import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
+import io.github.dsheirer.gui.squelch.NoiseSquelchView;
+import io.github.dsheirer.gui.symbol.SymbolView;
+import io.github.dsheirer.module.ProcessingChain;
+import io.github.dsheirer.module.decode.FeedbackDecoder;
+import io.github.dsheirer.module.decode.PrimaryDecoder;
+import io.github.dsheirer.module.decode.am.AMDecoder;
+import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
+import io.github.dsheirer.playlist.PlaylistManager;
+import io.github.dsheirer.sample.Listener;
+import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
+import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
+import io.github.dsheirer.source.Source;
+import io.github.dsheirer.source.SourceEvent;
+import io.github.dsheirer.source.tuner.channel.TunerChannel;
+import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
+import java.net.URL;
+import java.text.DecimalFormat;
+import java.util.List;
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.control.Label;
+import javafx.scene.control.SplitPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Display for channel spectrum, frequency corrections and the decoder squelch, power or symbol view.
+ *
+ * Use this class on the JavaFX application thread.
+ */
+public class ChannelSpectrumView extends SplitPane implements Listener
+{
+ private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumView.class);
+ private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
+ private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
+ private static final String LABEL_PREFIX_TUNER = "Tuner:";
+ private static final String LABEL_PREFIX_CHANNEL = "Channel:";
+ private static final String LABEL_PREFIX_DECODER = "Decoder:";
+ private static final double DIVIDER_POSITION = 0.4;
+ private final PlaylistManager mPlaylistManager;
+ private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
+ private final ChannelSpectralDisplay mChannelSpectralDisplay = new ChannelSpectralDisplay();
+ private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
+ private final Label mTunerPPMLabel = new Label(LABEL_PREFIX_PPM);
+ private final Label mTunerCorrectionLabel = new Label(LABEL_PREFIX_TUNER);
+ private final Label mChannelCorrectionLabel = new Label(LABEL_PREFIX_CHANNEL);
+ private final Label mDecoderCorrectionLabel = new Label(LABEL_PREFIX_DECODER);
+ private final NoiseSquelchView mNoiseSquelchView;
+ private final SignalPowerView mSignalPowerView;
+ private final SymbolView mSymbolView = new SymbolView();
+ private ProcessingChain mProcessingChain;
+ private boolean mPanelVisible = false;
+
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param playlistManager for the channel configurations
+ */
+ public ChannelSpectrumView(PlaylistManager playlistManager)
+ {
+ mPlaylistManager = playlistManager;
+ mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
+ mSignalPowerView = new SignalPowerView(mPlaylistManager);
+
+ URL resource = getClass().getResource("/sdrtrunk_style.css");
+
+ if(resource != null)
+ {
+ mSymbolView.getStylesheets().add(resource.toExternalForm());
+ }
+ else
+ {
+ LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
+ }
+
+ //Use the same color theme as the channel spectral display
+ mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
+
+ Region leftSpacer = new Region();
+ Region rightSpacer = new Region();
+ HBox.setHgrow(leftSpacer, Priority.ALWAYS);
+ HBox.setHgrow(rightSpacer, Priority.ALWAYS);
+ HBox labels = new HBox(10, leftSpacer, mTunerPPMLabel, new Label("Corrections (Hz) "), mTunerCorrectionLabel,
+ mChannelCorrectionLabel, mDecoderCorrectionLabel, rightSpacer);
+ labels.setAlignment(Pos.CENTER);
+ labels.setPadding(new Insets(2));
+
+ VBox.setVgrow(mChannelSpectralDisplay, Priority.ALWAYS);
+ VBox spectrumBox = new VBox(labels, mChannelSpectralDisplay);
+ spectrumBox.setMinWidth(0);
+
+ getItems().addAll(spectrumBox, mNoiseSquelchView);
+ reset();
+ }
+
+ /**
+ * Pads the value to a fixed width and returns the prefix and padded value.
+ */
+ private static String getPaddedLabel(String prefix, Number value)
+ {
+ return prefix + StringUtils.leftPad(String.valueOf(value), 7);
+ }
+
+ /**
+ * Signals this view to indicate if it is visible, to turn on the DFT processor when the view is visible and turn
+ * it off when it's not.
+ * @param visible true to indicate that this view is showing/visible.
+ */
+ public void setPanelVisible(boolean visible)
+ {
+ mPanelVisible = visible;
+ updateFFTProcessing();
+ mNoiseSquelchView.setShowing(visible);
+ mSymbolView.setShowing(visible);
+ }
+
+ /**
+ * Turns on DFT processing when there is a processing chain and the user has this view selected and visible.
+ * Otherwise, turns off DFT processing.
+ */
+ private void updateFFTProcessing()
+ {
+ boolean active = mPanelVisible && mProcessingChain != null;
+
+ if(active)
+ {
+ mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
+ }
+ else
+ {
+ mSampleStreamTapModule.removeListener();
+ }
+
+ mChannelSpectralDisplay.setActive(active);
+ }
+
+ /**
+ * Resets controls when changing processing chain source.
+ */
+ private void reset()
+ {
+ mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
+ mTunerPPMLabel.setDisable(true);
+ mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
+ mTunerCorrectionLabel.setDisable(true);
+ mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
+ mChannelCorrectionLabel.setDisable(true);
+ mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
+ mDecoderCorrectionLabel.setDisable(true);
+ mChannelSpectralDisplay.setChannel(0, 0, 0);
+ }
+
+ /**
+ * Shows the processing chain details. Call on the JavaFX application thread.
+ * @param processingChain to show, or null to clear the view
+ */
+ @Override
+ public void receive(ProcessingChain processingChain)
+ {
+ //Disconnect the previous processing chain.
+ if(mProcessingChain != null)
+ {
+ mNoiseSquelchView.setController(null);
+ mSignalPowerView.setProcessingChain(null);
+ mSymbolView.removeSymbolProvider();
+ mSymbolView.setProtocol("");
+ mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
+ mProcessingChain.removeModule(mSampleStreamTapModule);
+ }
+
+ reset();
+
+ mProcessingChain = processingChain;
+
+ if(mProcessingChain != null)
+ {
+ mProcessingChain.addSourceEventListener(mSourceEventProcessor);
+
+ PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
+
+ if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
+ {
+ setRightComponent(mNoiseSquelchView);
+ mNoiseSquelchView.setController(nbfmDecoder);
+ }
+ else if(primaryDecoder instanceof AMDecoder)
+ {
+ setRightComponent(mSignalPowerView);
+ mSignalPowerView.setProcessingChain(mProcessingChain);
+ }
+ else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
+ {
+ setRightComponent(mSymbolView);
+ mSymbolView.setSymbolProvider(feedbackDecoder);
+ mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
+ }
+
+ mProcessingChain.addModule(mSampleStreamTapModule);
+ Source source = mProcessingChain.getSource();
+
+ int bandwidth = 0;
+ Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
+
+ if(channel != null)
+ {
+ List tunerChannels = channel.getTunerChannels();
+
+ if(!tunerChannels.isEmpty())
+ {
+ bandwidth = tunerChannels.getFirst().getBandwidth();
+ }
+ }
+
+ if(source instanceof TunerChannelSource tcs)
+ {
+ mChannelSpectralDisplay.setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
+ }
+ }
+
+ updateFFTProcessing();
+ }
+
+ /**
+ * Shows the node on the right side of the split pane.
+ */
+ private void setRightComponent(Node node)
+ {
+ if(getItems().size() < 2 || getItems().get(1) != node)
+ {
+ if(getItems().size() > 1)
+ {
+ getItems().remove(1);
+ }
+
+ getItems().add(node);
+ setDividerPositions(DIVIDER_POSITION);
+ }
+ }
+
+ /**
+ * Processor for source event stream to capture power level and squelch related source events.
+ */
+ private class SourceEventProcessor implements Listener
+ {
+ @Override
+ public void receive(SourceEvent sourceEvent)
+ {
+ if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS &&
+ sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
+ {
+ Platform.runLater(() -> {
+ mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
+ mTunerPPMLabel.setDisable(false);
+
+ if(status.isAutoPPM())
+ {
+ mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
+ }
+ else
+ {
+ mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
+ }
+
+ mTunerCorrectionLabel.setDisable(false);
+ mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
+ mChannelCorrectionLabel.setDisable(false);
+ mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
+ mDecoderCorrectionLabel.setDisable(false);
+ });
+
+ //The offset cursor line is negative so that it aligns with where the signal is located instead of the
+ //correction that's needed to bring the signal to zero.
+ mChannelSpectralDisplay.setCarrierOffset(-status.getDecoderCorrection());
+ }
+
+ mSignalPowerView.receive(sourceEvent);
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/control/DbPowerMeter.java b/src/main/java/io/github/dsheirer/gui/control/DbPowerMeter.java
deleted file mode 100644
index 71bc98e7..00000000
--- a/src/main/java/io/github/dsheirer/gui/control/DbPowerMeter.java
+++ /dev/null
@@ -1,349 +0,0 @@
-package io.github.dsheirer.gui.control;
-
-import javax.swing.BorderFactory;
-import javax.swing.JComponent;
-import java.awt.Color;
-import java.awt.Dimension;
-import java.awt.Graphics;
-import java.text.DecimalFormat;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Power meter for displaying signal power levels in dB scale with optional peak value and squelch threshold lines.
- */
-public class DbPowerMeter extends JComponent
-{
- public static final double DEFAULT_MINIMUM_POWER = -110.0d;
- public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
- private static final int BAR_WIDTH = 30;
- private static final int PADDING = 3;
- private static final int DOUBLE_PADDING = PADDING * 2;
- private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
- private static final Color COLOR_BAR = Color.LIGHT_GRAY;
- private static final Color COLOR_THRESHOLD = Color.BLUE;
- private static final Color COLOR_PEAK = Color.PINK;
-
- private double mMinimumValue = DEFAULT_MINIMUM_POWER;
- private double mMaximumValue = DEFAULT_MAXIMUM_POWER;
- private double mExtent = mMaximumValue - mMinimumValue;
- private double mPeak = mMinimumValue;
- private double mPower = mMinimumValue;
- private double mSquelchThreshold = mMinimumValue;
-
- private boolean mPeakVisible = false;
- private boolean mSquelchThresholdVisible = false;
-
- /**
- * Constructs an instance.
- */
- public DbPowerMeter()
- {
- setPreferredSize(new Dimension(90, 100));
- setBorder(BorderFactory.createTitledBorder("Power (dB)"));
- }
-
- /**
- * Resets peak, power and squelch to minimums
- */
- public void reset()
- {
- mPeak = mMinimumValue;
- mPower = mMinimumValue;
- mSquelchThreshold = mMinimumValue;
- repaint();
- }
-
- /**
- * Primary method for rendering the control
- */
- @Override
- protected void paintComponent(Graphics g)
- {
- super.paintComponent(g);
-
- //Draw meter border
- g.setColor(getForeground());
- g.drawRect(getInsets().left, getInsets().top, BAR_WIDTH, getHeight() - getInsets().top - getInsets().bottom);
-
- //Draw filled bar
- g.setColor(COLOR_BAR);
- int fillHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
- int height = (int) (fillHeight * getPowerPercent() - PADDING);
- int top = (fillHeight - height) + getInsets().top + PADDING;
- g.fillRect(getInsets().left + PADDING, top, BAR_WIDTH - DOUBLE_PADDING, height);
-
- //Draw threshold
- if(isSquelchThresholdVisible())
- {
- g.setColor(COLOR_THRESHOLD);
- drawPercentLine(g, getSquelchThresholdPercent());
- }
-
- //Draw peak line
- if(isPeakVisible())
- {
- g.setColor(COLOR_PEAK);
- drawPercentLine(g, getPeakPercent());
- }
-
- //Draw legend/scale
- g.setColor(getForeground());
- for(double legendValue: getScaleValues())
- {
- drawScaleText(g, legendValue);
- }
- }
-
- /**
- * Draws a line as a percentage of the value range (extent) using the current graphics color.
- */
- private void drawPercentLine(Graphics g, double percentValue)
- {
- int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
- int height = (int) (totalHeight * percentValue - PADDING);
- int top = (totalHeight - height) + getInsets().top + PADDING;
- int left = getInsets().left;
- g.drawLine(left, top, left + BAR_WIDTH, top);
- }
-
- /**
- * Draws a tick and text for the value
- * @param g graphics object
- * @param value to draw
- */
- private void drawScaleText(Graphics g, double value)
- {
- double percent = getPercent(value);
- int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
- int height = (int) (totalHeight * percent - PADDING);
- int top = (totalHeight - height) + getInsets().top + PADDING;
- int left = getInsets().left + BAR_WIDTH - PADDING;
- int textOffset = getFontMetrics(getFont()).getMaxAscent() / 2 - 2;
- String label = DECIMAL_FORMAT.format(value);
- g.drawString(label, left + DOUBLE_PADDING, top + textOffset);
- }
-
- /**
- * Scale values to display, dynamically adapted to the height of the control
- */
- private List getScaleValues()
- {
- double ascent = getFontMetrics(getFont()).getMaxAscent() * 2;
- double labelCount = getHeight() / ascent;
- double interval = getExtent() / labelCount;
- List values = new ArrayList<>();
- for(double x = 0; x > getMinimumValue(); x -= interval)
- {
- values.add(x);
- }
-
- return values;
- }
-
- /**
- * Current power level
- * @return power level (dB)
- */
- public double getPower()
- {
- return mPower;
- }
-
- /**
- * Sets the current power level constrained to the minimum and maximum values for this control.
- * @param power (dB)
- */
- public void setPower(double power)
- {
- if(power > getMaximumValue())
- {
- mPower = getMaximumValue();
- }
- else if(power < getMinimumValue())
- {
- mPower = getMinimumValue();
- }
- else
- {
- mPower = power;
- }
-
- repaint();
- }
-
- /**
- * Squelch threshold value.
- * @return threshold (dB)
- */
- public double getSquelchThreshold()
- {
- return mSquelchThreshold;
- }
-
- /**
- * Sets the squelch thresold value
- * @param squelchThreshold (dB)
- */
- public void setSquelchThreshold(double squelchThreshold)
- {
- mSquelchThreshold = squelchThreshold;
- repaint();
- }
-
- /**
- * Minimum displayable power level
- * @return minimum (dB) - defaults to -110 dB
- */
- public double getMinimumValue()
- {
- return mMinimumValue;
- }
-
- /**
- * Sets the minimum displayable power level
- * @param minimumValue (dB)
- */
- public void setMinimumValue(double minimumValue)
- {
- mMinimumValue = minimumValue;
- updateExtent();
- repaint();
- }
-
- /**
- * Maximum displayable power level
- * @return maximum (dB) - defaults to 0 dB
- */
- public double getMaximumValue()
- {
- return mMaximumValue;
- }
-
- /**
- * Sets the maximum displayable power level
- * @param maximumValue (dB)
- */
- public void setMaximumValue(double maximumValue)
- {
- mMaximumValue = maximumValue;
- updateExtent();
- repaint();
- }
-
- /**
- * Updates the min/max extent
- */
- private void updateExtent()
- {
- mExtent = getMaximumValue() - getMinimumValue();
- }
-
- /**
- * Extent or range of displayable values.
- * @return extent (dB)
- */
- private double getExtent()
- {
- return mExtent;
- }
-
- /**
- * Peak value line
- * @return peak (dB)
- */
- public double getPeak()
- {
- return mPeak;
- }
-
- /**
- * Sets the peak value line
- * @param peak (db)
- */
- public void setPeak(double peak)
- {
- mPeak = peak;
- repaint();
- }
-
- /**
- * Indicates if the peak line is set to visible and the current peak value is within the min/max range.
- */
- public boolean isPeakVisible()
- {
- return mPeakVisible & isValidValue(getPeak());
- }
-
- /**
- * Sets the peak line visibility
- * @param peakVisible value
- */
- public void setPeakVisible(boolean peakVisible)
- {
- mPeakVisible = peakVisible;
- repaint();
- }
-
- /**
- * Indicates if the squelch threshold line is set to visible and the current squelch threshold value is
- * within the min/max displayable value range.
- * @return true if visible.
- */
- public boolean isSquelchThresholdVisible()
- {
- return mSquelchThresholdVisible & isValidValue(getSquelchThreshold());
- }
-
- /**
- * Sets the visibility of the squelch threshold line
- * @param squelchThresholdVisible visibility
- */
- public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
- {
- mSquelchThresholdVisible = squelchThresholdVisible;
- repaint();
- }
-
- /**
- * Calculates the percentage of the min/max extent for the value
- * @param value to calculate
- * @return percentage of displayable value range
- */
- private double getPercent(double value)
- {
- return (value - getMinimumValue()) / getExtent();
- }
-
- /**
- * Power as percentage of displayable range
- */
- private double getPowerPercent()
- {
- return getPercent(mPower);
- }
-
- /**
- * Squelch threshold as percentage of displayable range
- */
- private double getSquelchThresholdPercent()
- {
- return getPercent(mSquelchThreshold);
- }
-
- /**
- * Peak as percentage of displayable range
- */
- private double getPeakPercent()
- {
- return getPercent(mPeak);
- }
-
- /**
- * Indicates if the value is valid, meaning it falls within the min/max range of displayable values.
- */
- private boolean isValidValue(double value)
- {
- return getMinimumValue() <= value && value <= getMaximumValue();
- }
-}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/AliasDisplay.java b/src/main/java/io/github/dsheirer/gui/nowplaying/AliasDisplay.java
new file mode 100644
index 00000000..a6fe9dc4
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/AliasDisplay.java
@@ -0,0 +1,64 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import com.google.common.base.Joiner;
+import io.github.dsheirer.alias.Alias;
+import java.util.List;
+
+/**
+ * Alias table cell value: the alias names to show, with the color and icon of the alias that styles the cell.
+ * @param text of the alias names
+ * @param color of the styling alias as a 32-bit RGB integer
+ * @param iconName of the styling alias, or null
+ */
+public record AliasDisplay(String text, int color, String iconName) implements Comparable
+{
+ /**
+ * Creates the display for the aliases, styled by the first alias.
+ * @param aliases to display
+ * @return display or null when there are no aliases.
+ */
+ public static AliasDisplay of(List aliases)
+ {
+ if(aliases == null || aliases.isEmpty())
+ {
+ return null;
+ }
+
+ return of(Joiner.on(", ").skipNulls().join(aliases), aliases.getFirst());
+ }
+
+ /**
+ * Creates the display for the text, styled by the alias
+ * @param text to display
+ * @param alias to style the display
+ * @return display
+ */
+ public static AliasDisplay of(String text, Alias alias)
+ {
+ return new AliasDisplay(text, alias.getColor(), alias.getIconName());
+ }
+
+ @Override
+ public int compareTo(AliasDisplay other)
+ {
+ return String.CASE_INSENSITIVE_ORDER.compare(text, other.text);
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java
new file mode 100644
index 00000000..00251fd2
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java
@@ -0,0 +1,112 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.channel.state.DecoderState;
+import io.github.dsheirer.controller.channel.Channel;
+import io.github.dsheirer.controller.channel.ChannelProcessingManager;
+import io.github.dsheirer.module.ProcessingChain;
+import io.github.dsheirer.sample.Listener;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextArea;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+
+/**
+ * Activity summary details for the selected channel.
+ */
+public class ChannelDetailView extends VBox implements Listener
+{
+ private static final String EMPTY_DETAILS = "Please select a channel to view details";
+ private final ChannelProcessingManager mChannelProcessingManager;
+ private final Label mSystemLabel = new Label();
+ private final Label mSiteLabel = new Label();
+ private final Label mNameLabel = new Label();
+ private final TextArea mDetailTextArea = new TextArea(EMPTY_DETAILS);
+ private ProcessingChain mProcessingChain;
+
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param channelProcessingManager for the channel of the processing chain
+ */
+ public ChannelDetailView(ChannelProcessingManager channelProcessingManager)
+ {
+ mChannelProcessingManager = channelProcessingManager;
+
+ Button refreshButton = new Button("Refresh");
+ refreshButton.setTooltip(new Tooltip("Update the channel details"));
+ refreshButton.setOnAction(event -> receive(mProcessingChain));
+
+ HBox labels = new HBox(5, new Label("System:"), grow(mSystemLabel), new Label("Site:"), grow(mSiteLabel),
+ new Label("Channel Name:"), grow(mNameLabel), refreshButton);
+ labels.setAlignment(Pos.CENTER_LEFT);
+ labels.setPadding(new Insets(3, 2, 3, 2));
+
+ mDetailTextArea.setEditable(false);
+ VBox.setVgrow(mDetailTextArea, Priority.ALWAYS);
+ getChildren().addAll(labels, mDetailTextArea);
+ }
+
+ private static Label grow(Label label)
+ {
+ label.setMaxWidth(Double.MAX_VALUE);
+ HBox.setHgrow(label, Priority.ALWAYS);
+ return label;
+ }
+
+ /**
+ * Shows the details for the processing chain. Call on the JavaFX application thread.
+ * @param processingChain to show, or null to clear the view
+ */
+ @Override
+ public void receive(ProcessingChain processingChain)
+ {
+ mProcessingChain = processingChain;
+
+ Channel channel = mChannelProcessingManager.getChannel(processingChain);
+ mSystemLabel.setText(channel != null ? channel.getSystem() : null);
+ mSiteLabel.setText(channel != null ? channel.getSite() : null);
+ mNameLabel.setText(channel != null ?
+ (channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null);
+
+ if(processingChain != null)
+ {
+ StringBuilder sb = new StringBuilder();
+
+ for(DecoderState decoderState: processingChain.getDecoderStates())
+ {
+ sb.append(decoderState.getActivitySummary());
+ }
+
+ //Keep the scroll position, so that a refresh doesn't move the user back to the top
+ double scrollTop = mDetailTextArea.getScrollTop();
+ mDetailTextArea.setText(sb.toString());
+ mDetailTextArea.setScrollTop(scrollTop);
+ }
+ else
+ {
+ mDetailTextArea.setText(EMPTY_DETAILS);
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java
new file mode 100644
index 00000000..781dceab
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java
@@ -0,0 +1,452 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import com.google.common.eventbus.Subscribe;
+import io.github.dsheirer.channel.metadata.ChannelAndMetadata;
+import io.github.dsheirer.channel.metadata.ChannelMetadata;
+import io.github.dsheirer.channel.metadata.ChannelMetadataField;
+import io.github.dsheirer.channel.metadata.ChannelMetadataModel;
+import io.github.dsheirer.channel.state.State;
+import io.github.dsheirer.controller.channel.Channel;
+import io.github.dsheirer.controller.channel.ChannelProcessingManager;
+import io.github.dsheirer.eventbus.MyEventBus;
+import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
+import io.github.dsheirer.gui.table.ColumnConfigurationButton;
+import io.github.dsheirer.gui.table.TableColumnMonitor;
+import io.github.dsheirer.icon.IconModel;
+import io.github.dsheirer.identifier.Identifier;
+import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
+import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
+import io.github.dsheirer.module.ProcessingChain;
+import io.github.dsheirer.preference.PreferenceType;
+import io.github.dsheirer.preference.UserPreferences;
+import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
+import io.github.dsheirer.sample.Broadcaster;
+import io.github.dsheirer.sample.Listener;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javafx.application.Platform;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableRow;
+import javafx.scene.control.TableView;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+
+/**
+ * Table of the channel metadata for the currently processing channels. Selecting a row broadcasts the processing
+ * chain for the channel to the registered listeners.
+ */
+public class ChannelMetadataView extends VBox implements ChannelMetadataModel.Listener
+{
+ private static final String TABLE_KEY = "now.playing.channels";
+ private static final String SWING_TABLE_KEY = "channel.metadata.panel";
+ private static final String EMPTY_IDENTIFIER = "-----";
+ private static final String EMPTY_STATE = "----";
+ private static final Map STATE_STYLES = new EnumMap<>(State.class);
+
+ static
+ {
+ //Colors match the Swing version of the table (java.awt.Color constants)
+ STATE_STYLES.put(State.ACTIVE, style("#00FFFF", "#0000FF"));
+ STATE_STYLES.put(State.CALL, style("#0000FF", "#FFFF00"));
+ STATE_STYLES.put(State.CONTROL, style("#FFC800", "#0000FF"));
+ STATE_STYLES.put(State.DATA, style("#00FF00", "#0000FF"));
+ STATE_STYLES.put(State.ENCRYPTED, style("#FF00FF", "#FFFFFF"));
+ STATE_STYLES.put(State.FADE, style("#C0C0C0", "#404040"));
+ STATE_STYLES.put(State.IDLE, style("#FFFFFF", "#404040"));
+ STATE_STYLES.put(State.RESET, style("#FFAFAF", "#FFFF00"));
+ STATE_STYLES.put(State.TEARDOWN, style("#404040", "#FFFFFF"));
+ }
+
+ private final ChannelProcessingManager mChannelProcessingManager;
+ private final ChannelMetadataModel mChannelMetadataModel;
+ private final IconModel mIconModel;
+ private final TalkgroupFormatPreference mTalkgroupFormatPreference;
+ private final TableView mTableView = new TableView<>();
+ private final ObservableList mRows = FXCollections.observableArrayList();
+ //Changed on the JavaFX thread and read on the decoder threads that send channel metadata updates
+ private final Map mRowMap = new ConcurrentHashMap<>();
+ private final Broadcaster mSelectedProcessingChainBroadcaster = new Broadcaster<>();
+ private final TableColumnMonitor mTableColumnMonitor;
+ private Channel mUserSelectedChannel;
+
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param channelProcessingManager for the channel metadata and processing chains
+ * @param iconModel for alias icons
+ * @param userPreferences for talkgroup formatting and column settings
+ */
+ public ChannelMetadataView(ChannelProcessingManager channelProcessingManager, IconModel iconModel,
+ UserPreferences userPreferences)
+ {
+ mChannelProcessingManager = channelProcessingManager;
+ mChannelMetadataModel = mChannelProcessingManager.getChannelMetadataModel();
+ mIconModel = iconModel;
+ mTalkgroupFormatPreference = userPreferences.getTalkgroupFormatPreference();
+
+ mTableView.getColumns().addAll(createColumns());
+ mTableView.setItems(mRows);
+ mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
+ mTableView.setPlaceholder(new javafx.scene.control.Label("No channels are processing"));
+ mTableView.setRowFactory(tableView -> createRow());
+ mTableView.getSelectionModel().selectedItemProperty().addListener((observable, oldRow, newRow) -> selected(newRow));
+ mTableColumnMonitor = new TableColumnMonitor<>(mTableView, TABLE_KEY, userPreferences.getSwingPreference(),
+ SWING_TABLE_KEY);
+
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+ HBox toolbar = new HBox(5, spacer, new ColumnConfigurationButton(mTableColumnMonitor, "Now Playing Columns"));
+ toolbar.setAlignment(Pos.CENTER_RIGHT);
+ toolbar.setPadding(new Insets(2, 2, 2, 2));
+
+ VBox.setVgrow(mTableView, Priority.ALWAYS);
+ getChildren().addAll(toolbar, mTableView);
+
+ for(ChannelMetadata channelMetadata: mChannelMetadataModel.addListener(this))
+ {
+ addRow(channelMetadata);
+ }
+
+ MyEventBus.getGlobalEventBus().register(this);
+ }
+
+ /**
+ * Adds the listener to receive the processing chain for the channel that the user selects in the table, or null
+ * when the selection is cleared. Notifications arrive on the JavaFX application thread.
+ */
+ public void addProcessingChainSelectionListener(Listener listener)
+ {
+ mSelectedProcessingChainBroadcaster.addListener(listener);
+ }
+
+ /**
+ * Prepares this view for disposal.
+ */
+ public void dispose()
+ {
+ mChannelMetadataModel.removeListener(this);
+ MyEventBus.getGlobalEventBus().unregister(this);
+ }
+
+ /**
+ * Receives preference update notifications via the event bus
+ */
+ @Subscribe
+ public void preferenceUpdated(PreferenceType preferenceType)
+ {
+ //Talkgroup format changes the identifiers and the application preference can change the alias colors
+ if(preferenceType == PreferenceType.TALKGROUP_FORMAT || preferenceType == PreferenceType.APPLICATION)
+ {
+ Platform.runLater(() -> mRows.forEach(Row::refresh));
+ }
+ }
+
+ @Override
+ public void added(ChannelAndMetadata channelAndMetadata)
+ {
+ List channelMetadata = new ArrayList<>(channelAndMetadata.getChannelMetadata());
+
+ Platform.runLater(() -> {
+ for(ChannelMetadata metadata: channelMetadata)
+ {
+ addRow(metadata);
+ }
+
+ //The DMR Capacity+ rest channel rotation converts the selected channel to a traffic channel and restarts
+ //the previous channel. Re-select the row of the restarted channel so that the user isn't chasing it.
+ if(mUserSelectedChannel != null && !channelMetadata.isEmpty() &&
+ mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
+ {
+ Row row = mRowMap.get(channelMetadata.getFirst());
+
+ if(row != null)
+ {
+ mTableView.getSelectionModel().select(row);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void removed(ChannelMetadata channelMetadata)
+ {
+ Platform.runLater(() -> {
+ Row row = mRowMap.remove(channelMetadata);
+
+ if(row != null)
+ {
+ mRows.remove(row);
+ }
+ });
+ }
+
+ @Override
+ public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
+ {
+ Row row = mRowMap.get(channelMetadata);
+
+ if(row != null)
+ {
+ row.scheduleRefresh();
+ }
+ }
+
+ /**
+ * Adds a row for the channel metadata. Call on the JavaFX application thread.
+ */
+ private void addRow(ChannelMetadata channelMetadata)
+ {
+ if(!mRowMap.containsKey(channelMetadata))
+ {
+ Row row = new Row(channelMetadata);
+ mRowMap.put(channelMetadata, row);
+ mRows.add(row);
+ }
+ }
+
+ private void selected(Row row)
+ {
+ ProcessingChain processingChain = null;
+
+ if(row != null)
+ {
+ mUserSelectedChannel = mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata());
+ processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
+ }
+
+ mSelectedProcessingChainBroadcaster.broadcast(processingChain);
+ }
+
+ private TableRow createRow()
+ {
+ TableRow tableRow = new TableRow<>();
+ ContextMenu contextMenu = new ContextMenu();
+ contextMenu.setOnShowing(event -> {
+ contextMenu.getItems().clear();
+ Row row = tableRow.getItem();
+ Channel channel = row != null ? mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata()) : null;
+
+ if(channel != null)
+ {
+ MenuItem viewChannel = new MenuItem("View/Edit: " + channel.getShortTitle());
+ viewChannel.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
+ contextMenu.getItems().add(viewChannel);
+ }
+ else
+ {
+ MenuItem none = new MenuItem("No Actions Available");
+ none.setDisable(true);
+ contextMenu.getItems().add(none);
+ }
+ });
+ tableRow.setContextMenu(contextMenu);
+ return tableRow;
+ }
+
+ private List> createColumns()
+ {
+ List> columns = new ArrayList<>();
+
+ TableColumn status = NowPlayingCells.column("status", "Status", null, Pos.CENTER,
+ row -> {
+ ChannelStateIdentifier identifier = row.getChannelMetadata().getChannelStateIdentifier();
+ return identifier != null ? identifier.getValue() : null;
+ }, Row::revisionProperty);
+ status.setCellFactory(column -> new NowPlayingCells.AlignedCell<>(column)
+ {
+ @Override
+ protected void updateItem(State state, boolean empty)
+ {
+ super.updateItem(state, empty);
+
+ if(empty)
+ {
+ setText(null);
+ setStyle(null);
+ }
+ else if(state == null)
+ {
+ setText(EMPTY_STATE);
+ setStyle(null);
+ }
+ else
+ {
+ setText(state.getDisplayValue());
+ setStyle(STATE_STYLES.get(state));
+ }
+ }
+ });
+ columns.add(status);
+
+ columns.add(NowPlayingCells.column("decoder", "Decoder", null, Pos.CENTER,
+ row -> toText(row.getChannelMetadata().getDecoderTypeConfigurationIdentifier()), Row::revisionProperty));
+
+ columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
+ row -> {
+ ChannelMetadata metadata = row.getChannelMetadata();
+ String text = mTalkgroupFormatPreference.format(metadata.getFromIdentifier());
+
+ if(text == null || text.isEmpty())
+ {
+ return EMPTY_IDENTIFIER;
+ }
+ else if(metadata.hasTalkerAliasIdentifier())
+ {
+ return text + " " + metadata.getTalkerAliasIdentifier();
+ }
+
+ return text;
+ }, Row::revisionProperty));
+
+ TableColumn fromAlias = NowPlayingCells.column("from.alias", "Alias", "From Alias",
+ Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getFromIdentifierAliases()),
+ Row::revisionProperty);
+ fromAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
+ columns.add(fromAlias);
+
+ columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
+ row -> {
+ String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getToIdentifier());
+ return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
+ }, Row::revisionProperty));
+
+ TableColumn toAlias = NowPlayingCells.column("to.alias", "Alias", "To Alias",
+ Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getToIdentifierAliases()),
+ Row::revisionProperty);
+ toAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
+ columns.add(toAlias);
+
+ columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
+ row -> getLogicalChannelName(row.getChannelMetadata()), Row::revisionProperty));
+
+ TableColumn frequency = NowPlayingCells.column("frequency", "Frequency", null, Pos.CENTER,
+ row -> row.getChannelMetadata().getFrequencyConfigurationIdentifier() != null ?
+ row.getChannelMetadata().getFrequencyConfigurationIdentifier().getValue() : null,
+ Row::revisionProperty);
+ DecimalFormat frequencyFormat = new DecimalFormat("#.00000");
+ frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
+ columns.add(frequency);
+
+ columns.add(NowPlayingCells.column("channel.name", "Channel Name", null, Pos.CENTER,
+ row -> toText(row.getChannelMetadata().getChannelNameConfigurationIdentifier()), Row::revisionProperty));
+
+ //The channel rows are in channel start order, the same as the Swing version of the table
+ for(TableColumn column: columns)
+ {
+ column.setSortable(false);
+ }
+
+ return columns;
+ }
+
+ /**
+ * Decoder logical channel name with the timeslot, if any
+ */
+ private static String getLogicalChannelName(ChannelMetadata channelMetadata)
+ {
+ DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
+
+ if(channelMetadata.hasTimeslot())
+ {
+ if(id == null)
+ {
+ return "TS:" + channelMetadata.getTimeslot();
+ }
+
+ return id.getValue().replace(" TS1", "").replace(" TS2", "") + " TS:" + channelMetadata.getTimeslot();
+ }
+
+ return id != null ? id.getValue() : null;
+ }
+
+ private static String toText(Identifier identifier)
+ {
+ return identifier != null ? identifier.toString() : null;
+ }
+
+ private static String style(String background, String foreground)
+ {
+ return "-fx-background-color: " + background + "; -fx-text-fill: " + foreground + ";";
+ }
+
+ /**
+ * Table row item for a channel metadata. The revision increments each time the channel metadata changes so that
+ * the row cells update.
+ */
+ public static class Row
+ {
+ private final ChannelMetadata mChannelMetadata;
+ private final IntegerProperty mRevision = new SimpleIntegerProperty();
+ private final AtomicBoolean mRefreshPending = new AtomicBoolean();
+
+ Row(ChannelMetadata channelMetadata)
+ {
+ mChannelMetadata = channelMetadata;
+ }
+
+ public ChannelMetadata getChannelMetadata()
+ {
+ return mChannelMetadata;
+ }
+
+ public IntegerProperty revisionProperty()
+ {
+ return mRevision;
+ }
+
+ /**
+ * Updates the row cells. Call on the JavaFX application thread.
+ */
+ void refresh()
+ {
+ mRevision.set(mRevision.get() + 1);
+ }
+
+ /**
+ * Schedules a row cell update on the JavaFX application thread. Can be called from any thread. Updates
+ * that arrive while one is pending are combined.
+ */
+ void scheduleRefresh()
+ {
+ if(mRefreshPending.compareAndSet(false, true))
+ {
+ Platform.runLater(() -> {
+ mRefreshPending.set(false);
+ refresh();
+ });
+ }
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java
new file mode 100644
index 00000000..3c07187f
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java
@@ -0,0 +1,357 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import com.google.common.base.Joiner;
+import com.google.common.eventbus.Subscribe;
+import io.github.dsheirer.alias.Alias;
+import io.github.dsheirer.alias.AliasList;
+import io.github.dsheirer.alias.AliasModel;
+import io.github.dsheirer.channel.IChannelDescriptor;
+import io.github.dsheirer.eventbus.MyEventBus;
+import io.github.dsheirer.filter.FilterSet;
+import io.github.dsheirer.icon.IconModel;
+import io.github.dsheirer.identifier.Form;
+import io.github.dsheirer.identifier.Identifier;
+import io.github.dsheirer.identifier.IdentifierCollection;
+import io.github.dsheirer.identifier.Role;
+import io.github.dsheirer.module.ProcessingChain;
+import io.github.dsheirer.module.decode.event.DecodeEventHistory;
+import io.github.dsheirer.module.decode.event.IDecodeEvent;
+import io.github.dsheirer.module.decode.event.filter.DecodeEventFilterSet;
+import io.github.dsheirer.preference.PreferenceType;
+import io.github.dsheirer.preference.UserPreferences;
+import io.github.dsheirer.sample.Listener;
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import javafx.application.Platform;
+import javafx.geometry.Pos;
+import javafx.scene.control.TableColumn;
+
+/**
+ * Table of the decode events for the selected channel.
+ */
+public class DecodeEventView extends HistoryTableView implements Listener
+{
+ private static final String TABLE_KEY = "now.playing.events";
+ private static final String SWING_TABLE_KEY = "decode.event.panel";
+ private final TimestampFormatter mTimestampFormatter;
+ private final FilterSet mFilterSet = new DecodeEventFilterSet();
+ private DecodeEventHistory mCurrentEventHistory;
+ private Listener mCurrentEventListener;
+
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param iconModel for alias icons
+ * @param userPreferences for display formats and column settings
+ * @param aliasModel for aliasing identifiers
+ */
+ public DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel)
+ {
+ this(iconModel, userPreferences, aliasModel, new TimestampFormatter(userPreferences));
+ }
+
+ private DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel,
+ TimestampFormatter timestampFormatter)
+ {
+ super(createColumns(iconModel, userPreferences, aliasModel, timestampFormatter), TABLE_KEY,
+ userPreferences.getSwingPreference(), SWING_TABLE_KEY, "Event Filter Editor", "Event Columns");
+ mTimestampFormatter = timestampFormatter;
+ getHistoryControls().setFilterSet(mFilterSet);
+ //Filter changes come from the Swing filter editor
+ mFilterSet.register(() -> Platform.runLater(this::refilter));
+ MyEventBus.getGlobalEventBus().register(this);
+ }
+
+ /**
+ * Prepares this view for disposal
+ */
+ public void dispose()
+ {
+ MyEventBus.getGlobalEventBus().unregister(this);
+ receive(null);
+ }
+
+ /**
+ * Receives preference update notifications via the event bus
+ */
+ @Subscribe
+ public void preferenceUpdated(PreferenceType preferenceType)
+ {
+ if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT ||
+ preferenceType == PreferenceType.APPLICATION)
+ {
+ Platform.runLater(this::refreshAll);
+ }
+ }
+
+ @Override
+ protected boolean include(IDecodeEvent event)
+ {
+ return mFilterSet.canProcess(event) && mFilterSet.passes(event);
+ }
+
+ /**
+ * Shows the decode events for the processing chain. Call on the JavaFX application thread.
+ * @param processingChain to show, or null to clear the view
+ */
+ @Override
+ public void receive(ProcessingChain processingChain)
+ {
+ if(mCurrentEventHistory != null)
+ {
+ mCurrentEventHistory.removeListener(mCurrentEventListener);
+ }
+
+ mCurrentEventHistory = null;
+ mCurrentEventListener = null;
+
+ if(processingChain != null)
+ {
+ DecodeEventHistory history = processingChain.getDecodeEventHistory();
+ //A listener for each history so that events from the previous channel are ignored after a change
+ Listener listener = new Listener<>()
+ {
+ @Override
+ public void receive(IDecodeEvent event)
+ {
+ Platform.runLater(() -> {
+ if(mCurrentEventListener == this)
+ {
+ add(event);
+ }
+ });
+ }
+ };
+ mCurrentEventHistory = history;
+ mCurrentEventListener = listener;
+ setItems(history.getItems());
+ history.addListener(listener);
+ getHistoryControls().setControlsEnabled(true);
+ }
+ else
+ {
+ clear();
+ getHistoryControls().setControlsEnabled(false);
+ }
+ }
+
+ @Override
+ protected void refreshAll()
+ {
+ mTimestampFormatter.update();
+ super.refreshAll();
+ }
+
+ private static List,?>> createColumns(IconModel iconModel,
+ UserPreferences userPreferences,
+ AliasModel aliasModel,
+ TimestampFormatter timestampFormatter)
+ {
+ List,?>> columns = new ArrayList<>();
+
+ TableColumn,Long> time = NowPlayingCells.column("time", "Time", null, Pos.CENTER,
+ row -> row.getItem().getTimeStart(), Row::revisionProperty);
+ time.setCellFactory(NowPlayingCells.formatted(timestampFormatter::format));
+ columns.add(time);
+
+ TableColumn,Long> duration = NowPlayingCells.column("duration", "Duration", null,
+ Pos.CENTER, row -> row.getItem().getDuration(), Row::revisionProperty);
+ DecimalFormat durationFormat = new DecimalFormat("0.0");
+ duration.setCellFactory(NowPlayingCells.formatted(value -> value > 0 ? durationFormat.format(value / 1e3d) : null));
+ columns.add(duration);
+
+ columns.add(NowPlayingCells.column("event", "Event", null, Pos.CENTER_LEFT,
+ row -> row.getItem().getEventType() != null ? row.getItem().getEventType().getLabel() : null,
+ Row::revisionProperty));
+
+ columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
+ row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.FROM, userPreferences),
+ Row::revisionProperty));
+
+ TableColumn,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias",
+ "From Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.FROM, aliasModel),
+ Row::revisionProperty);
+ fromAlias.setCellFactory(NowPlayingCells.alias(iconModel));
+ columns.add(fromAlias);
+
+ columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
+ row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.TO, userPreferences),
+ Row::revisionProperty));
+
+ TableColumn,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias",
+ "To Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.TO, aliasModel),
+ Row::revisionProperty);
+ toAlias.setCellFactory(NowPlayingCells.alias(iconModel));
+ columns.add(toAlias);
+
+ columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
+ row -> getChannel(row.getItem()), Row::revisionProperty));
+
+ TableColumn,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null,
+ Pos.CENTER, row -> {
+ IChannelDescriptor descriptor = row.getItem().getChannelDescriptor();
+ return descriptor != null && descriptor.getDownlinkFrequency() > 0 ?
+ descriptor.getDownlinkFrequency() : null;
+ }, Row::revisionProperty);
+ DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
+ frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
+ columns.add(frequency);
+
+ columns.add(NowPlayingCells.column("details", "Details", null, Pos.CENTER_LEFT,
+ row -> row.getItem().getDetails(), Row::revisionProperty));
+
+ return columns;
+ }
+
+ /**
+ * Event timestamp formatter that uses the timestamp format preference.
+ */
+ private static class TimestampFormatter
+ {
+ private final UserPreferences mUserPreferences;
+ private SimpleDateFormat mFormat;
+
+ TimestampFormatter(UserPreferences userPreferences)
+ {
+ mUserPreferences = userPreferences;
+ update();
+ }
+
+ /**
+ * Updates the format from the preference
+ */
+ void update()
+ {
+ mFormat = mUserPreferences.getDecodeEventPreference().getTimestampFormat().getFormatter();
+ }
+
+ String format(long timestamp)
+ {
+ return mFormat.format(new Date(timestamp));
+ }
+ }
+
+ /**
+ * Channel descriptor and timeslot for the event
+ */
+ private static String getChannel(IDecodeEvent event)
+ {
+ IChannelDescriptor channelDescriptor = event.getChannelDescriptor();
+ boolean timeslot = event.hasTimeslot() && !event.toString().contains("TS");
+
+ if(channelDescriptor != null)
+ {
+ return timeslot ? channelDescriptor + " TS" + event.getTimeslot() : channelDescriptor.toString();
+ }
+
+ return timeslot ? "TS" + event.getTimeslot() : null;
+ }
+
+ /**
+ * Formats the identifiers for the role as a comma separated list
+ */
+ private static String formatIdentifiers(IdentifierCollection identifierCollection, Role role,
+ UserPreferences userPreferences)
+ {
+ if(identifierCollection == null)
+ {
+ return null;
+ }
+
+ List identifiers = identifierCollection.getIdentifiers(role);
+
+ if(identifiers == null || identifiers.isEmpty())
+ {
+ return null;
+ }
+
+ StringBuilder sb = new StringBuilder();
+
+ for(Identifier identifier: identifiers)
+ {
+ if(!sb.isEmpty())
+ {
+ sb.append(",");
+ }
+
+ if(identifier.getForm() == Form.TALKGROUP || identifier.getForm() == Form.RADIO ||
+ identifier.getForm() == Form.PATCH_GROUP)
+ {
+ sb.append(userPreferences.getTalkgroupFormatPreference().format(identifier));
+ }
+ else
+ {
+ sb.append(identifier);
+ }
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Aliases for the identifiers with the role. The display is styled by the first alias of the last aliased
+ * identifier, the same as the Swing version of the table.
+ */
+ private static AliasDisplay getAliases(IdentifierCollection identifierCollection, Role role, AliasModel aliasModel)
+ {
+ if(identifierCollection == null)
+ {
+ return null;
+ }
+
+ List identifiers = identifierCollection.getIdentifiers(role);
+
+ if(identifiers == null || identifiers.isEmpty())
+ {
+ return null;
+ }
+
+ AliasList aliasList = aliasModel.getAliasList(identifierCollection);
+
+ if(aliasList == null)
+ {
+ return null;
+ }
+
+ StringBuilder sb = new StringBuilder();
+ Alias styleAlias = null;
+
+ for(Identifier identifier: identifiers)
+ {
+ List aliases = aliasList.getAliases(identifier);
+
+ if(!aliases.isEmpty())
+ {
+ if(!sb.isEmpty())
+ {
+ sb.append(",");
+ }
+
+ sb.append(Joiner.on(", ").skipNulls().join(aliases));
+ styleAlias = aliases.getFirst();
+ }
+ }
+
+ return styleAlias != null ? AliasDisplay.of(sb.toString(), styleAlias) : null;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/HistoryControls.java b/src/main/java/io/github/dsheirer/gui/nowplaying/HistoryControls.java
new file mode 100644
index 00000000..d0b30d1a
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/HistoryControls.java
@@ -0,0 +1,153 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.filter.FilterEditor;
+import io.github.dsheirer.filter.FilterSet;
+import io.github.dsheirer.gui.table.ColumnConfigurationButton;
+import io.github.dsheirer.gui.table.TableColumnMonitor;
+import java.awt.EventQueue;
+import java.util.function.IntConsumer;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.Slider;
+import javafx.scene.control.Tooltip;
+import javafx.scene.input.MouseButton;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+
+/**
+ * Toolbar for a history table: filter editor, clear, history size and column configuration controls.
+ */
+public class HistoryControls extends HBox
+{
+ /**
+ * Default number of items in the history
+ */
+ public static final int DEFAULT_HISTORY_SIZE = 200;
+ private static final int MAXIMUM_HISTORY_SIZE = 2000;
+
+ private final String mFilterEditorTitle;
+ private final Button mFilterButton = new Button("Filters");
+ private final Button mClearButton = new Button("Clear");
+ private final Label mHistoryLabel = new Label("History:");
+ private final Slider mHistorySlider = new Slider(0, MAXIMUM_HISTORY_SIZE, DEFAULT_HISTORY_SIZE);
+ private final Label mHistoryValueLabel = new Label(String.valueOf(DEFAULT_HISTORY_SIZE));
+ //Swing filter editor, accessed on the Swing event thread
+ private FilterEditor mFilterEditor;
+ private FilterSet mFilterSet;
+
+ /**
+ * Constructs an instance
+ * @param filterEditorTitle for the filter editor window
+ * @param clear action for the clear button
+ * @param historySizeListener to receive history size changes
+ * @param tableColumnMonitor for the column configuration button
+ * @param columnsTitle for the column configuration editor
+ */
+ public HistoryControls(String filterEditorTitle, Runnable clear, IntConsumer historySizeListener,
+ TableColumnMonitor> tableColumnMonitor, String columnsTitle)
+ {
+ super(5);
+ mFilterEditorTitle = filterEditorTitle;
+ setAlignment(Pos.CENTER_LEFT);
+ setPadding(new Insets(3, 2, 3, 2));
+
+ mFilterButton.setTooltip(new Tooltip("Edit filters"));
+ mFilterButton.setOnAction(event -> showFilterEditor());
+
+ mClearButton.setTooltip(new Tooltip("Clears the history"));
+ mClearButton.setOnAction(event -> clear.run());
+
+ mHistorySlider.setTooltip(new Tooltip("Adjust history size. Double-click to reset to default " +
+ DEFAULT_HISTORY_SIZE));
+ mHistorySlider.setBlockIncrement(25);
+ mHistorySlider.setPrefWidth(200);
+ mHistorySlider.valueProperty().addListener((observable, oldValue, newValue) -> {
+ int size = newValue.intValue();
+ mHistoryValueLabel.setText(String.valueOf(size));
+ historySizeListener.accept(size);
+ });
+ mHistorySlider.setOnMouseClicked(event -> {
+ if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2)
+ {
+ mHistorySlider.setValue(DEFAULT_HISTORY_SIZE);
+ }
+ });
+ mHistoryValueLabel.setMinWidth(40);
+
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+ getChildren().addAll(mFilterButton, mClearButton, mHistoryLabel, mHistorySlider, mHistoryValueLabel, spacer,
+ new ColumnConfigurationButton(tableColumnMonitor, columnsTitle));
+ setControlsEnabled(false);
+ }
+
+ /**
+ * Sets the enabled state of the history controls. The column configuration button is always enabled.
+ */
+ public void setControlsEnabled(boolean enabled)
+ {
+ mFilterButton.setDisable(!enabled);
+ mClearButton.setDisable(!enabled);
+ mHistoryLabel.setDisable(!enabled);
+ mHistorySlider.setDisable(!enabled);
+ mHistoryValueLabel.setDisable(!enabled);
+ }
+
+ /**
+ * Updates the filter set for the filter editor.
+ * @param filterSet to edit, or null
+ */
+ public void setFilterSet(FilterSet filterSet)
+ {
+ mFilterSet = filterSet;
+
+ EventQueue.invokeLater(() -> {
+ if(mFilterEditor != null && filterSet != null)
+ {
+ mFilterEditor.updateFilterSet(filterSet);
+ }
+ });
+ }
+
+ /**
+ * Shows the filter editor. The filter editor is a Swing window.
+ */
+ private void showFilterEditor()
+ {
+ FilterSet filterSet = mFilterSet;
+
+ if(filterSet != null)
+ {
+ EventQueue.invokeLater(() -> {
+ if(mFilterEditor == null)
+ {
+ mFilterEditor = new FilterEditor<>(mFilterEditorTitle, null, filterSet);
+ }
+
+ mFilterEditor.setVisible(true);
+ mFilterEditor.toFront();
+ });
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/HistoryTableView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/HistoryTableView.java
new file mode 100644
index 00000000..153ebd7d
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/HistoryTableView.java
@@ -0,0 +1,206 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.gui.table.TableColumnMonitor;
+import io.github.dsheirer.preference.swing.SwingPreference;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.collections.transformation.FilteredList;
+import javafx.collections.transformation.SortedList;
+import javafx.scene.control.Label;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+
+/**
+ * Table of a history of items, newest first, with filter, clear, history size and column configuration controls.
+ *
+ * Use this class on the JavaFX application thread.
+ *
+ * @param type of the history items
+ * @param type of the filtered objects
+ */
+public abstract class HistoryTableView extends VBox
+{
+ private final ObservableList> mRows = FXCollections.observableArrayList();
+ private final FilteredList> mFilteredRows = new FilteredList<>(mRows);
+ private final Map> mRowMap = new HashMap<>();
+ private final TableView> mTableView = new TableView<>();
+ private final TableColumnMonitor> mTableColumnMonitor;
+ private final HistoryControls mHistoryControls;
+ private int mHistorySize = HistoryControls.DEFAULT_HISTORY_SIZE;
+
+ /**
+ * Constructs an instance
+ * @param columns for the table
+ * @param tableKey for the stored column settings
+ * @param swingPreference with the legacy Swing column widths
+ * @param swingTableKey of the legacy Swing table
+ * @param filterEditorTitle for the filter editor window
+ * @param columnsTitle for the column configuration editor window
+ */
+ protected HistoryTableView(List,?>> columns, String tableKey, SwingPreference swingPreference,
+ String swingTableKey, String filterEditorTitle, String columnsTitle)
+ {
+ mTableView.getColumns().addAll(columns);
+ SortedList> sortedRows = new SortedList<>(mFilteredRows);
+ sortedRows.comparatorProperty().bind(mTableView.comparatorProperty());
+ mTableView.setItems(sortedRows);
+ mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
+ mTableView.setPlaceholder(new Label(""));
+ mTableColumnMonitor = new TableColumnMonitor<>(mTableView, tableKey, swingPreference, swingTableKey);
+ mHistoryControls = new HistoryControls<>(filterEditorTitle, this::clear, this::setHistorySize,
+ mTableColumnMonitor, columnsTitle);
+
+ VBox.setVgrow(mTableView, Priority.ALWAYS);
+ getChildren().addAll(mHistoryControls, mTableView);
+ refilter();
+ }
+
+ /**
+ * Indicates if the item passes the current filters
+ */
+ protected abstract boolean include(T item);
+
+ /**
+ * History controls toolbar
+ */
+ protected HistoryControls getHistoryControls()
+ {
+ return mHistoryControls;
+ }
+
+ /**
+ * Re-applies the filters to the items, after a filter change.
+ */
+ protected void refilter()
+ {
+ mFilteredRows.setPredicate(row -> include(row.getItem()));
+ }
+
+ /**
+ * Adds the item to the top of the history, or updates the row of the item when it is already in the history.
+ * Removes the oldest items when the history is larger than the history size.
+ */
+ protected void add(T item)
+ {
+ Row row = mRowMap.get(item);
+
+ if(row != null)
+ {
+ //Replace the row with itself so that the filter re-checks the updated item
+ int index = mRows.indexOf(row);
+
+ if(index >= 0)
+ {
+ mRows.set(index, row);
+ }
+
+ row.refresh();
+ }
+ else
+ {
+ row = new Row<>(item);
+ mRowMap.put(item, row);
+ mRows.addFirst(row);
+ trim();
+ }
+ }
+
+ /**
+ * Replaces the history with the items, oldest first.
+ */
+ protected void setItems(List items)
+ {
+ clear();
+
+ for(T item: items)
+ {
+ add(item);
+ }
+ }
+
+ /**
+ * Removes all items from the history
+ */
+ protected void clear()
+ {
+ mRows.clear();
+ mRowMap.clear();
+ }
+
+ /**
+ * Updates the cells of all rows, after a change to the display formats.
+ */
+ protected void refreshAll()
+ {
+ mRows.forEach(Row::refresh);
+ }
+
+ private void setHistorySize(int historySize)
+ {
+ mHistorySize = historySize;
+ trim();
+ }
+
+ private void trim()
+ {
+ while(mRows.size() > mHistorySize)
+ {
+ mRowMap.remove(mRows.removeLast().getItem());
+ }
+ }
+
+ /**
+ * Table row item for a history item. The revision increments each time the item changes so that the row cells
+ * update.
+ */
+ public static class Row
+ {
+ private final T mItem;
+ private final IntegerProperty mRevision = new SimpleIntegerProperty();
+
+ Row(T item)
+ {
+ mItem = item;
+ }
+
+ public T getItem()
+ {
+ return mItem;
+ }
+
+ public IntegerProperty revisionProperty()
+ {
+ return mRevision;
+ }
+
+ void refresh()
+ {
+ mRevision.set(mRevision.get() + 1);
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/MessageActivityView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/MessageActivityView.java
new file mode 100644
index 00000000..6402946f
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/MessageActivityView.java
@@ -0,0 +1,164 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.filter.FilterSet;
+import io.github.dsheirer.message.IMessage;
+import io.github.dsheirer.message.MessageHistory;
+import io.github.dsheirer.message.StuffBitsMessage;
+import io.github.dsheirer.module.ProcessingChain;
+import io.github.dsheirer.module.decode.DecoderFactory;
+import io.github.dsheirer.module.decode.event.MessageItem;
+import io.github.dsheirer.preference.UserPreferences;
+import io.github.dsheirer.sample.Listener;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.List;
+import javafx.application.Platform;
+import javafx.geometry.Pos;
+import javafx.scene.control.TableColumn;
+
+/**
+ * Table of the decoded messages for the selected channel.
+ */
+public class MessageActivityView extends HistoryTableView implements Listener
+{
+ private static final String TABLE_KEY = "now.playing.messages";
+ private static final String SWING_TABLE_KEY = "message.activity.panel";
+ private MessageHistory mCurrentMessageHistory;
+ private Listener mCurrentMessageListener;
+ private FilterSet mMessageFilterSet;
+
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param userPreferences for the column settings
+ */
+ public MessageActivityView(UserPreferences userPreferences)
+ {
+ super(createColumns(), TABLE_KEY, userPreferences.getSwingPreference(), SWING_TABLE_KEY,
+ "Message Filter Editor", "Message Columns");
+ }
+
+ @Override
+ protected boolean include(MessageItem item)
+ {
+ FilterSet filterSet = mMessageFilterSet;
+
+ if(filterSet != null && item.getMessage() != null)
+ {
+ return filterSet.canProcess(item.getMessage()) && filterSet.passes(item.getMessage());
+ }
+
+ return false;
+ }
+
+ /**
+ * Shows the messages for the processing chain. Call on the JavaFX application thread.
+ * @param processingChain to show, or null to clear the view
+ */
+ @Override
+ public void receive(ProcessingChain processingChain)
+ {
+ if(mCurrentMessageHistory != null)
+ {
+ mCurrentMessageHistory.removeListener(mCurrentMessageListener);
+ }
+
+ //Unregister from changes made to the filter set
+ if(mMessageFilterSet != null)
+ {
+ mMessageFilterSet.register(null);
+ }
+
+ mCurrentMessageHistory = null;
+ mCurrentMessageListener = null;
+
+ if(processingChain != null)
+ {
+ MessageHistory history = processingChain.getMessageHistory();
+ FilterSet filterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
+ mMessageFilterSet = filterSet;
+ //Filter changes come from the Swing filter editor
+ filterSet.register(() -> Platform.runLater(() -> {
+ if(mMessageFilterSet == filterSet)
+ {
+ refilter();
+ }
+ }));
+ getHistoryControls().setFilterSet(filterSet);
+
+ //A listener for each history so that messages from the previous channel are ignored after a change
+ Listener listener = new Listener<>()
+ {
+ @Override
+ public void receive(IMessage message)
+ {
+ //Don't process tail bits or stuff bits message fragments
+ if(!(message instanceof StuffBitsMessage))
+ {
+ MessageItem item = new MessageItem(message);
+
+ Platform.runLater(() -> {
+ if(mCurrentMessageListener == this)
+ {
+ add(item);
+ }
+ });
+ }
+ }
+ };
+
+ mCurrentMessageHistory = history;
+ mCurrentMessageListener = listener;
+
+ List items = new ArrayList<>();
+
+ for(IMessage message: history.getItems())
+ {
+ items.add(new MessageItem(message));
+ }
+
+ setItems(items);
+ refilter();
+ history.addListener(listener);
+ getHistoryControls().setControlsEnabled(true);
+ }
+ else
+ {
+ mMessageFilterSet = null;
+ clear();
+ getHistoryControls().setControlsEnabled(false);
+ }
+ }
+
+ private static List,?>> createColumns()
+ {
+ SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
+ List,?>> columns = new ArrayList<>();
+ columns.add(NowPlayingCells.column("time", "Time", null, Pos.CENTER_LEFT,
+ row -> row.getItem().getTimestamp(timeFormat), null));
+ columns.add(NowPlayingCells.column("protocol", "Protocol", null, Pos.CENTER_LEFT,
+ row -> row.getItem().getProtocol(), null));
+ columns.add(NowPlayingCells.column("timeslot", "Timeslot", null, Pos.CENTER_LEFT,
+ row -> row.getItem().getTimeslot(), null));
+ columns.add(NowPlayingCells.column("message", "Message", null, Pos.CENTER_LEFT,
+ row -> row.getItem().getText(), null));
+ return columns;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingCells.java b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingCells.java
new file mode 100644
index 00000000..4529e5ce
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingCells.java
@@ -0,0 +1,166 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.gui.table.TableColumnMonitor;
+import io.github.dsheirer.gui.theme.ThemeManager;
+import io.github.dsheirer.icon.Icon;
+import io.github.dsheirer.icon.IconModel;
+import java.util.function.Function;
+import javafx.beans.binding.Bindings;
+import javafx.beans.value.ObservableValue;
+import javafx.geometry.Pos;
+import javafx.scene.control.TableCell;
+import javafx.scene.control.TableColumn;
+import javafx.scene.image.ImageView;
+import javafx.util.Callback;
+
+import javax.swing.UIManager;
+
+/**
+ * Table column and cell helpers for the Now Playing tables.
+ */
+public final class NowPlayingCells
+{
+ private static final String ALIGNMENT = "now.playing.cell.alignment";
+
+ private NowPlayingCells()
+ {
+ }
+
+ /**
+ * Creates a table column.
+ * @param id that identifies the column for storing the column settings. Don't change it, or users lose the
+ * stored order, visibility and width of the column.
+ * @param text for the column header
+ * @param configurationName for the column in the column configuration editor, or null to use the header text
+ * @param alignment of the cell contents
+ * @param value of the column for the row item. The value is recalculated each time the row revision changes.
+ * @param revision of the row item, or null when the row value doesn't change
+ */
+ public static TableColumn column(String id, String text, String configurationName, Pos alignment,
+ Function value, Function> revision)
+ {
+ TableColumn column = new TableColumn<>(text);
+ column.setId(id);
+
+ if(configurationName != null)
+ {
+ TableColumnMonitor.setName(column, configurationName);
+ }
+
+ column.getProperties().put(ALIGNMENT, alignment);
+ column.setCellFactory(formatted(Object::toString));
+
+ if(revision == null)
+ {
+ column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue())));
+ }
+ else
+ {
+ column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue()),
+ revision.apply(param.getValue())));
+ }
+
+ return column;
+ }
+
+ /**
+ * Cell factory that shows the cell value formatted as text.
+ * @param formatter for non-null values. Can return null for an empty cell.
+ */
+ public static Callback,TableCell> formatted(Function formatter)
+ {
+ return column -> new AlignedCell<>(column)
+ {
+ @Override
+ protected void updateItem(T item, boolean empty)
+ {
+ super.updateItem(item, empty);
+ setText(empty || item == null ? null : formatter.apply(item));
+ }
+ };
+ }
+
+ /**
+ * Cell factory for alias values that shows the alias names in the alias color with the alias icon.
+ * @param iconModel for the alias icons
+ */
+ public static Callback,TableCell> alias(IconModel iconModel)
+ {
+ return column -> new AlignedCell<>(column)
+ {
+ @Override
+ protected void updateItem(AliasDisplay item, boolean empty)
+ {
+ super.updateItem(item, empty);
+
+ if(empty || item == null)
+ {
+ setText(null);
+ setGraphic(null);
+ setStyle(null);
+ }
+ else
+ {
+ setText(item.text());
+
+ Icon icon = iconModel.getIcon(item.iconName());
+ setGraphic(icon != null && icon.getFxImage() != null ? new ImageView(icon.getFxImage()) : null);
+
+ java.awt.Color color = new java.awt.Color(item.color());
+ java.awt.Color background = UIManager.getColor("Table.background");
+ java.awt.Color readable = background != null ?
+ ThemeManager.getInstance().getReadableColor(color, background) : color;
+ String fill = toWebColor(readable.getRGB());
+ //An inline style, since the themed table row style otherwise replaces the text fill
+ setStyle("-fx-text-fill: " + fill + ";");
+ }
+ }
+ };
+ }
+
+ /**
+ * Formats the RGB value as a web color.
+ * @param rgb color value
+ * @return web color, e.g. #FF0000
+ */
+ public static String toWebColor(int rgb)
+ {
+ return String.format("#%06X", rgb & 0xFFFFFF);
+ }
+
+ /**
+ * Table cell that aligns its contents with the alignment of its column.
+ */
+ public static class AlignedCell extends TableCell
+ {
+ /**
+ * Constructs an instance
+ * @param column of the cell
+ */
+ public AlignedCell(TableColumn column)
+ {
+ if(column.getProperties().get(ALIGNMENT) instanceof Pos alignment)
+ {
+ setAlignment(alignment);
+ }
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingPanel.java b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingPanel.java
new file mode 100644
index 00000000..03e2c939
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingPanel.java
@@ -0,0 +1,81 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.gui.theme.ThemeManager;
+import io.github.dsheirer.icon.IconModel;
+import io.github.dsheirer.playlist.PlaylistManager;
+import io.github.dsheirer.preference.UserPreferences;
+import javafx.application.Platform;
+import javafx.embed.swing.JFXPanel;
+import javafx.scene.Scene;
+
+/**
+ * Swing panel that holds the JavaFX Now Playing view for the main application window. The JavaFX view is created
+ * on the JavaFX application thread after construction.
+ */
+public class NowPlayingPanel extends JFXPanel
+{
+ private NowPlayingView mNowPlayingView;
+ private boolean mDetailTabsVisible;
+
+ /**
+ * Constructs an instance
+ * @param playlistManager for the channels
+ * @param iconModel for alias icons
+ * @param userPreferences for display formats and column settings
+ * @param detailTabsVisible to show the channel detail tabs
+ */
+ public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
+ boolean detailTabsVisible)
+ {
+ mDetailTabsVisible = detailTabsVisible;
+ Platform.setImplicitExit(false);
+ ThemeManager.getInstance().registerSwing(this);
+
+ Platform.runLater(() -> {
+ NowPlayingView view;
+
+ synchronized(NowPlayingPanel.this)
+ {
+ view = new NowPlayingView(playlistManager, iconModel, userPreferences, mDetailTabsVisible);
+ mNowPlayingView = view;
+ }
+
+ Scene scene = new Scene(view);
+ ThemeManager.getInstance().register(scene);
+ setScene(scene);
+ });
+ }
+
+ /**
+ * Changes the visibility of the channel details tabs. Can be called from any thread.
+ * @param visible true to show or false to hide.
+ */
+ public synchronized void setDetailTabsVisible(boolean visible)
+ {
+ mDetailTabsVisible = visible;
+
+ if(mNowPlayingView != null)
+ {
+ NowPlayingView view = mNowPlayingView;
+ Platform.runLater(() -> view.setDetailTabsVisible(visible));
+ }
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java
new file mode 100644
index 00000000..2ccac4f0
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java
@@ -0,0 +1,113 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.nowplaying;
+
+import io.github.dsheirer.gui.channel.ChannelSpectrumView;
+import io.github.dsheirer.icon.IconModel;
+import io.github.dsheirer.playlist.PlaylistManager;
+import io.github.dsheirer.preference.UserPreferences;
+import javafx.geometry.Orientation;
+import javafx.scene.control.SplitPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+
+/**
+ * Now Playing view: the table of currently processing channels above the details, events, messages and channel tabs
+ * for the selected channel.
+ *
+ * Use this class on the JavaFX application thread.
+ */
+public class NowPlayingView extends SplitPane
+{
+ private static final double DIVIDER_POSITION = 0.5;
+ private final ChannelMetadataView mChannelMetadataView;
+ private final ChannelDetailView mChannelDetailView;
+ private final DecodeEventView mDecodeEventView;
+ private final MessageActivityView mMessageActivityView;
+ private final ChannelSpectrumView mChannelSpectrumView;
+ private final TabPane mTabPane = new TabPane();
+ private boolean mDetailTabsVisible;
+
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param playlistManager for the channels
+ * @param iconModel for alias icons
+ * @param userPreferences for display formats and column settings
+ * @param detailTabsVisible to show the detail tabs
+ */
+ public NowPlayingView(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
+ boolean detailTabsVisible)
+ {
+ setOrientation(Orientation.VERTICAL);
+
+ mChannelMetadataView = new ChannelMetadataView(playlistManager.getChannelProcessingManager(), iconModel, userPreferences);
+ mChannelDetailView = new ChannelDetailView(playlistManager.getChannelProcessingManager());
+ mDecodeEventView = new DecodeEventView(iconModel, userPreferences, playlistManager.getAliasModel());
+ mMessageActivityView = new MessageActivityView(userPreferences);
+ mChannelSpectrumView = new ChannelSpectrumView(playlistManager);
+
+ Tab channelTab = new Tab("Channel", mChannelSpectrumView);
+ mTabPane.getTabs().addAll(new Tab("Details", mChannelDetailView), new Tab("Events", mDecodeEventView),
+ new Tab("Messages", mMessageActivityView), channelTab);
+ mTabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
+
+ //Turn the channel spectrum processing on only while the channel tab is selected
+ mTabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) ->
+ mChannelSpectrumView.setPanelVisible(newTab == channelTab));
+
+ mChannelMetadataView.addProcessingChainSelectionListener(mChannelDetailView);
+ mChannelMetadataView.addProcessingChainSelectionListener(mDecodeEventView);
+ mChannelMetadataView.addProcessingChainSelectionListener(mMessageActivityView);
+ mChannelMetadataView.addProcessingChainSelectionListener(mChannelSpectrumView);
+
+ getItems().add(mChannelMetadataView);
+ setDetailTabsVisible(detailTabsVisible);
+ }
+
+ /**
+ * Changes the visibility of the channel details tabs.
+ * @param visible true to show or false to hide.
+ */
+ public void setDetailTabsVisible(boolean visible)
+ {
+ if(visible && !getItems().contains(mTabPane))
+ {
+ getItems().add(mTabPane);
+ setDividerPositions(DIVIDER_POSITION);
+ }
+ else if(!visible)
+ {
+ getItems().remove(mTabPane);
+ }
+
+ mDetailTabsVisible = visible;
+ //The channel tab is hidden with the detail tabs, so stop its spectrum processing
+ mChannelSpectrumView.setPanelVisible(mDetailTabsVisible &&
+ mTabPane.getSelectionModel().getSelectedIndex() == mTabPane.getTabs().size() - 1);
+ }
+
+ /**
+ * Prepares this view for disposal.
+ */
+ public void dispose()
+ {
+ mChannelMetadataView.dispose();
+ mDecodeEventView.dispose();
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java
index a707142c..07e7aae9 100644
--- a/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java
+++ b/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java
@@ -21,7 +21,7 @@ package io.github.dsheirer.gui.playlist.channel;
import io.github.dsheirer.gui.control.AccessibilityUtil;
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
-import io.github.dsheirer.gui.control.DbPowerMeter;
+import io.github.dsheirer.gui.power.PowerMeter;
import io.github.dsheirer.gui.control.HexFormatter;
import io.github.dsheirer.gui.control.IntegerFormatter;
import io.github.dsheirer.gui.playlist.eventlog.EventLogConfigurationEditor;
@@ -80,8 +80,8 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
private TextField mSquelchThresholdField;
private ToggleSwitch mSquelchAutoTrackSwitch;
private TextFormatter mTalkgroupTextFormatter;
- private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)DbPowerMeter.DEFAULT_MINIMUM_POWER,
- (int)DbPowerMeter.DEFAULT_MAXIMUM_POWER);
+ private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)PowerMeter.DEFAULT_MINIMUM_POWER,
+ (int)PowerMeter.DEFAULT_MAXIMUM_POWER);
private EventLogConfigurationEditor mEventLogConfigurationEditor;
private TalkgroupValueChangeListener mTalkgroupValueChangeListener = new TalkgroupValueChangeListener();
private IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);
diff --git a/src/main/java/io/github/dsheirer/gui/power/PowerMeter.java b/src/main/java/io/github/dsheirer/gui/power/PowerMeter.java
new file mode 100644
index 00000000..c77a1cac
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/power/PowerMeter.java
@@ -0,0 +1,235 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.power;
+
+import java.text.DecimalFormat;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.geometry.VPos;
+import javafx.scene.canvas.Canvas;
+import javafx.scene.canvas.GraphicsContext;
+import javafx.scene.layout.Region;
+import javafx.scene.paint.Color;
+import javafx.scene.paint.Paint;
+import javafx.scene.text.Font;
+import javafx.scene.text.TextAlignment;
+
+/**
+ * Vertical bar meter for a decibel power level, with optional peak and squelch threshold lines and a value scale.
+ *
+ * Use this class on the JavaFX application thread.
+ */
+public class PowerMeter extends Region
+{
+ public static final double DEFAULT_MINIMUM_POWER = -110.0d;
+ public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
+ private static final double BAR_WIDTH = 30;
+ private static final double PADDING = 3;
+ private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
+ private static final Color COLOR_BAR = Color.LIGHTGRAY;
+ private static final Color COLOR_THRESHOLD = Color.BLUE;
+ private static final Color COLOR_PEAK = Color.PINK;
+
+ private final Canvas mCanvas = new Canvas();
+ private final ObjectProperty mForeground = new SimpleObjectProperty<>(Color.BLACK);
+ private final ObjectProperty mFont = new SimpleObjectProperty<>(Font.getDefault());
+ private final double mMinimumValue = DEFAULT_MINIMUM_POWER;
+ private final double mMaximumValue = DEFAULT_MAXIMUM_POWER;
+ private double mPeak = mMinimumValue;
+ private double mPower = mMinimumValue;
+ private double mSquelchThreshold = mMinimumValue;
+ private boolean mPeakVisible = false;
+ private boolean mSquelchThresholdVisible = false;
+
+ /**
+ * Constructs an instance.
+ */
+ public PowerMeter()
+ {
+ getChildren().add(mCanvas);
+ setPrefSize(90, 100);
+ setMinSize(70, 60);
+ mForeground.addListener((observable, oldValue, newValue) -> draw());
+ mFont.addListener((observable, oldValue, newValue) -> draw());
+ }
+
+ /**
+ * Color for the meter outline and scale text. Bind it to a themed control's text color.
+ */
+ public ObjectProperty foregroundProperty()
+ {
+ return mForeground;
+ }
+
+ /**
+ * Font for the scale text.
+ */
+ public ObjectProperty fontProperty()
+ {
+ return mFont;
+ }
+
+ @Override
+ protected void layoutChildren()
+ {
+ double width = snapSizeX(getWidth());
+ double height = snapSizeY(getHeight());
+
+ if(mCanvas.getWidth() != width || mCanvas.getHeight() != height)
+ {
+ mCanvas.setWidth(width);
+ mCanvas.setHeight(height);
+ draw();
+ }
+ }
+
+ /**
+ * Resets peak, power and squelch to minimums
+ */
+ public void reset()
+ {
+ mPeak = mMinimumValue;
+ mPower = mMinimumValue;
+ mSquelchThreshold = mMinimumValue;
+ draw();
+ }
+
+ /**
+ * Sets the current power level constrained to the minimum and maximum values for this control.
+ * @param power (dB)
+ */
+ public void setPower(double power)
+ {
+ mPower = Math.clamp(power, mMinimumValue, mMaximumValue);
+ draw();
+ }
+
+ /**
+ * Sets the peak value line
+ * @param peak (db)
+ */
+ public void setPeak(double peak)
+ {
+ mPeak = peak;
+ draw();
+ }
+
+ /**
+ * Sets the squelch threshold value
+ * @param squelchThreshold (dB)
+ */
+ public void setSquelchThreshold(double squelchThreshold)
+ {
+ mSquelchThreshold = squelchThreshold;
+ draw();
+ }
+
+ /**
+ * Sets the peak line visibility
+ */
+ public void setPeakVisible(boolean peakVisible)
+ {
+ mPeakVisible = peakVisible;
+ draw();
+ }
+
+ /**
+ * Sets the visibility of the squelch threshold line
+ */
+ public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
+ {
+ mSquelchThresholdVisible = squelchThresholdVisible;
+ draw();
+ }
+
+ private void draw()
+ {
+ double width = mCanvas.getWidth();
+ double height = mCanvas.getHeight();
+ GraphicsContext gc = mCanvas.getGraphicsContext2D();
+ gc.clearRect(0, 0, width, height);
+
+ if(width <= 0 || height <= 0)
+ {
+ return;
+ }
+
+ double meterHeight = height - 1;
+
+ //Meter outline
+ gc.setStroke(mForeground.get());
+ gc.setLineWidth(1);
+ gc.strokeRect(0.5, 0.5, BAR_WIDTH, meterHeight - 0.5);
+
+ //Filled bar
+ gc.setFill(COLOR_BAR);
+ double top = getY(getPercent(mPower), meterHeight);
+ gc.fillRect(PADDING, top, BAR_WIDTH - 2 * PADDING + 1, meterHeight - PADDING - top);
+
+ if(mSquelchThresholdVisible && isValidValue(mSquelchThreshold))
+ {
+ gc.setStroke(COLOR_THRESHOLD);
+ drawLine(gc, getY(getPercent(mSquelchThreshold), meterHeight));
+ }
+
+ if(mPeakVisible && isValidValue(mPeak))
+ {
+ gc.setStroke(COLOR_PEAK);
+ drawLine(gc, getY(getPercent(mPeak), meterHeight));
+ }
+
+ //Scale, with the number of labels adapted to the height of the control
+ Font font = mFont.get() != null ? mFont.get() : Font.getDefault();
+ gc.setFont(font);
+ gc.setFill(mForeground.get());
+ gc.setTextAlign(TextAlignment.LEFT);
+ gc.setTextBaseline(VPos.CENTER);
+ double extent = mMaximumValue - mMinimumValue;
+ double labelCount = Math.max(1, height / (font.getSize() * 2.5));
+ double interval = extent / labelCount;
+
+ for(double value = mMaximumValue; value > mMinimumValue; value -= interval)
+ {
+ double y = Math.clamp(getY(getPercent(value), meterHeight), font.getSize() / 2, height - font.getSize() / 2);
+ gc.fillText(DECIMAL_FORMAT.format(value), BAR_WIDTH + PADDING * 2, y);
+ }
+ }
+
+ private void drawLine(GraphicsContext gc, double y)
+ {
+ double snapped = Math.floor(y) + 0.5;
+ gc.strokeLine(0.5, snapped, BAR_WIDTH + 0.5, snapped);
+ }
+
+ private double getY(double percent, double meterHeight)
+ {
+ double fillHeight = meterHeight - 2 * PADDING;
+ return PADDING + fillHeight * (1.0 - percent);
+ }
+
+ private double getPercent(double value)
+ {
+ return (value - mMinimumValue) / (mMaximumValue - mMinimumValue);
+ }
+
+ private boolean isValidValue(double value)
+ {
+ return mMinimumValue <= value && value <= mMaximumValue;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java b/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java
index 96ba82f5..2093e3d3 100644
--- a/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java
+++ b/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java
@@ -1,6 +1,6 @@
/*
* *****************************************************************************
- * Copyright (C) 2014-2025 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -21,99 +21,109 @@ package io.github.dsheirer.gui.power;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.dsp.squelch.ISquelchConfiguration;
-import io.github.dsheirer.gui.control.DbPowerMeter;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.source.SourceEvent;
-import java.awt.EventQueue;
import java.text.DecimalFormat;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
import jiconfont.icons.font_awesome.FontAwesome;
-import jiconfont.swing.IconFontSwing;
-import net.miginfocom.swing.MigLayout;
-
-import javax.swing.Icon;
-import javax.swing.JButton;
-import javax.swing.JCheckBox;
-import javax.swing.JLabel;
-import javax.swing.JPanel;
+import jiconfont.javafx.IconNode;
/**
- * Swing view for displaying signal power measurements with integrated squelch control.
+ * Signal power meter with squelch threshold controls for the AM decoder.
*/
-public class SignalPowerView extends JPanel
+public class SignalPowerView extends HBox
{
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final String NOT_AVAILABLE = "Not Available";
- private final DbPowerMeter mPowerMeter = new DbPowerMeter();
- private final PeakMonitor mPeakMonitor = new PeakMonitor(DbPowerMeter.DEFAULT_MINIMUM_POWER);
- private final JLabel mPowerLabel;
- private final JLabel mPeakLabel;
- private final JLabel mSquelchLabel;
- private final JLabel mSquelchValueLabel;
- private final JButton mSquelchUpButton;
- private final JButton mSquelchDownButton;
- private final JCheckBox mSquelchAutoTrackCheckBox;
- private double mSquelchThreshold;
+ private final PowerMeter mPowerMeter = new PowerMeter();
+ private final PeakMonitor mPeakMonitor = new PeakMonitor(PowerMeter.DEFAULT_MINIMUM_POWER);
+ private final Label mPowerLabel = new Label("0");
+ private final Label mPeakLabel = new Label("0");
+ private final Label mSquelchLabel = new Label("Squelch:");
+ private final Label mSquelchValueLabel = new Label(NOT_AVAILABLE);
+ private final Button mSquelchUpButton = new Button();
+ private final Button mSquelchDownButton = new Button();
+ private final CheckBox mSquelchAutoTrackCheckBox = new CheckBox("Auto Track");
+ private final AtomicBoolean mPowerUpdatePending = new AtomicBoolean();
+ private volatile double mPower;
+ private volatile double mPeak;
+ private volatile double mSquelchThreshold;
private final PlaylistManager mPlaylistManager;
- private ProcessingChain mProcessingChain;
+ private volatile ProcessingChain mProcessingChain;
+ /**
+ * Constructs an instance. Construct on the JavaFX application thread.
+ * @param playlistManager for saving squelch changes to the channel configuration
+ */
public SignalPowerView(PlaylistManager playlistManager)
{
+ super(10);
mPlaylistManager = playlistManager;
+ setPadding(new Insets(5));
- setLayout(new MigLayout("", "[][][][grow,fill]", "[grow,fill]"));
+ Label meterTitle = new Label("Power (dB)");
mPowerMeter.setPeakVisible(true);
mPowerMeter.setSquelchThresholdVisible(true);
- add(mPowerMeter);
+ //Follow the themed label text color and font so that the meter is readable in light and dark themes
+ mPowerMeter.foregroundProperty().bind(meterTitle.textFillProperty());
+ mPowerMeter.fontProperty().bind(meterTitle.fontProperty());
+ VBox.setVgrow(mPowerMeter, Priority.ALWAYS);
+ VBox meterBox = new VBox(3, meterTitle, mPowerMeter);
+ meterBox.setMinWidth(90);
- JPanel valuePanel = new JPanel();
- valuePanel.setLayout(new MigLayout("", "[right][left][][]", ""));
+ mPeakLabel.setTooltip(new Tooltip("Current peak power level in decibels."));
+ mPowerLabel.setTooltip(new Tooltip("Current Power level in decibels"));
+ mSquelchValueLabel.setTooltip(new Tooltip("Squelch threshold value in decibels"));
- mPeakLabel = new JLabel("0");
- mPeakLabel.setToolTipText("Current peak power level in decibels.");
- valuePanel.add(new JLabel("Peak:"));
- valuePanel.add(mPeakLabel, "wrap");
+ mSquelchUpButton.setGraphic(icon(FontAwesome.ANGLE_UP));
+ mSquelchUpButton.setTooltip(new Tooltip("Increases the squelch threshold value"));
+ mSquelchUpButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
- mPowerLabel = new JLabel("0");
- mPowerLabel.setToolTipText("Current Power level in decibels");
- valuePanel.add(new JLabel("Power:"));
- valuePanel.add(mPowerLabel, "wrap");
+ mSquelchDownButton.setGraphic(icon(FontAwesome.ANGLE_DOWN));
+ mSquelchDownButton.setTooltip(new Tooltip("Decreases the squelch threshold value."));
+ mSquelchDownButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
- mSquelchLabel = new JLabel("Squelch:");
- mSquelchLabel.setEnabled(false);
- valuePanel.add(mSquelchLabel);
- mSquelchValueLabel = new JLabel(NOT_AVAILABLE);
- mSquelchValueLabel.setToolTipText("Squelch threshold value in decibels");
- mSquelchValueLabel.setEnabled(false);
- valuePanel.add(mSquelchValueLabel, "wrap");
-
- IconFontSwing.register(FontAwesome.getIconFont());
- Icon iconUp = IconFontSwing.buildIcon(FontAwesome.ANGLE_UP, 12);
- mSquelchUpButton = new JButton(iconUp);
- mSquelchUpButton.setToolTipText("Increases the squelch threshold value");
- mSquelchUpButton.setEnabled(false);
- mSquelchUpButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
- valuePanel.add(mSquelchUpButton);
-
- Icon iconDown = IconFontSwing.buildIcon(FontAwesome.ANGLE_DOWN, 12);
- mSquelchDownButton = new JButton(iconDown);
- mSquelchDownButton.setToolTipText("Decreases the squelch threshold value.");
- mSquelchDownButton.setEnabled(false);
- mSquelchDownButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
- valuePanel.add(mSquelchDownButton, "wrap");
-
- mSquelchAutoTrackCheckBox = new JCheckBox("Auto Track");
- mSquelchAutoTrackCheckBox.setToolTipText("Enable or disable monitoring of the noise floor to auto-adjust the " +
- "squelch threshold value maintaining a consistent level/buffer above the noise floor");
- mSquelchAutoTrackCheckBox.setEnabled(false);
- mSquelchAutoTrackCheckBox.addActionListener(e ->
- {
+ mSquelchAutoTrackCheckBox.setTooltip(new Tooltip("Enable or disable monitoring of the noise floor to " +
+ "auto-adjust the squelch threshold value maintaining a consistent level/buffer above the noise floor"));
+ mSquelchAutoTrackCheckBox.setOnAction(e -> {
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
setConfigSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected());
});
- valuePanel.add(mSquelchAutoTrackCheckBox, "span,left");
- add(valuePanel);
+ GridPane values = new GridPane();
+ values.setHgap(5);
+ values.setVgap(5);
+ values.add(new Label("Peak:"), 0, 0);
+ values.add(mPeakLabel, 1, 0);
+ values.add(new Label("Power:"), 0, 1);
+ values.add(mPowerLabel, 1, 1);
+ values.add(mSquelchLabel, 0, 2);
+ values.add(mSquelchValueLabel, 1, 2);
+ values.add(new HBox(3, mSquelchUpButton, mSquelchDownButton), 0, 3, 2, 1);
+ values.add(mSquelchAutoTrackCheckBox, 0, 4, 2, 1);
+ values.setAlignment(Pos.TOP_LEFT);
+
+ getChildren().addAll(meterBox, values);
+ reset();
+ }
+
+ private IconNode icon(FontAwesome fontAwesome)
+ {
+ IconNode iconNode = new IconNode(fontAwesome);
+ iconNode.fillProperty().bind(mSquelchLabel.textFillProperty());
+ return iconNode;
}
/**
@@ -125,9 +135,11 @@ public class SignalPowerView extends JPanel
*/
private void setConfigSquelchThreshold(int threshold)
{
- if(mProcessingChain != null)
+ ProcessingChain processingChain = mProcessingChain;
+
+ if(processingChain != null)
{
- Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
+ Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(processingChain);
if(channel != null && channel.getDecodeConfiguration() instanceof ISquelchConfiguration configuration)
{
@@ -154,15 +166,16 @@ public class SignalPowerView extends JPanel
private void broadcast(SourceEvent sourceEvent)
{
- if(mProcessingChain != null)
+ ProcessingChain processingChain = mProcessingChain;
+
+ if(processingChain != null)
{
- mProcessingChain.broadcast(sourceEvent);
+ processingChain.broadcast(sourceEvent);
}
}
/**
- * Resets controls when changing processing chain source. Note: this must be called on the Swing
- * dispatch thread because it directly invokes swing components.
+ * Resets controls when changing processing chain source. Call on the JavaFX application thread.
*/
private void reset()
{
@@ -170,44 +183,53 @@ public class SignalPowerView extends JPanel
mPowerMeter.reset();
mPeakLabel.setText("0");
mPowerLabel.setText("0");
- mSquelchLabel.setEnabled(false);
- mSquelchValueLabel.setText("Not Available");
- mSquelchValueLabel.setEnabled(false);
- mSquelchUpButton.setEnabled(false);
- mSquelchDownButton.setEnabled(false);
- mSquelchAutoTrackCheckBox.setEnabled(false);
+ mSquelchLabel.setDisable(true);
+ mSquelchValueLabel.setText(NOT_AVAILABLE);
+ mSquelchValueLabel.setDisable(true);
+ mSquelchUpButton.setDisable(true);
+ mSquelchDownButton.setDisable(true);
+ mSquelchAutoTrackCheckBox.setDisable(true);
mSquelchAutoTrackCheckBox.setSelected(false);
}
+ /**
+ * Processes power and squelch source events from the processing chain. Can be called from any thread.
+ */
public void receive(SourceEvent sourceEvent)
{
switch(sourceEvent.getEvent())
{
case NOTIFICATION_CHANNEL_POWER ->
{
- final double power = sourceEvent.getValue().doubleValue();
- final double peak = mPeakMonitor.process(power);
+ mPower = sourceEvent.getValue().doubleValue();
+ mPeak = mPeakMonitor.process(mPower);
- EventQueue.invokeLater(() -> {
- mPowerMeter.setPower(power);
- mPowerLabel.setText(DECIMAL_FORMAT.format(power));
-
- mPowerMeter.setPeak(peak);
- mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
- });
+ //Power notifications are frequent, so combine the updates that arrive while one is pending
+ if(mPowerUpdatePending.compareAndSet(false, true))
+ {
+ Platform.runLater(() -> {
+ mPowerUpdatePending.set(false);
+ double power = mPower;
+ double peak = mPeak;
+ mPowerMeter.setPower(power);
+ mPowerLabel.setText(DECIMAL_FORMAT.format(power));
+ mPowerMeter.setPeak(peak);
+ mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
+ });
+ }
}
case NOTIFICATION_SQUELCH_THRESHOLD ->
{
final double threshold = sourceEvent.getValue().doubleValue();
mSquelchThreshold = threshold;
- EventQueue.invokeLater(() -> {
+ Platform.runLater(() -> {
mPowerMeter.setSquelchThreshold(threshold);
- mSquelchLabel.setEnabled(true);
- mSquelchValueLabel.setEnabled(true);
+ mSquelchLabel.setDisable(false);
+ mSquelchValueLabel.setDisable(false);
mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold));
- mSquelchDownButton.setEnabled(true);
- mSquelchUpButton.setEnabled(true);
+ mSquelchDownButton.setDisable(false);
+ mSquelchUpButton.setDisable(false);
});
}
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
@@ -215,16 +237,16 @@ public class SignalPowerView extends JPanel
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
{
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
- EventQueue.invokeLater(() -> {
+ Platform.runLater(() -> {
mSquelchAutoTrackCheckBox.setSelected(autoTrack);
- mSquelchAutoTrackCheckBox.setEnabled(true);
+ mSquelchAutoTrackCheckBox.setDisable(false);
});
}
}
}
/**
- * Sets the processing chain for this view
+ * Sets the processing chain for this view. Call on the JavaFX application thread.
*/
public void setProcessingChain(ProcessingChain processingChain)
{
diff --git a/src/main/java/io/github/dsheirer/gui/table/ColumnConfigurationButton.java b/src/main/java/io/github/dsheirer/gui/table/ColumnConfigurationButton.java
new file mode 100644
index 00000000..64752e09
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/table/ColumnConfigurationButton.java
@@ -0,0 +1,57 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.table;
+
+import javafx.scene.control.Button;
+import javafx.scene.control.Tooltip;
+import jiconfont.icons.font_awesome.FontAwesome;
+import jiconfont.javafx.IconNode;
+
+/**
+ * Button that opens the column configuration editor for a table.
+ */
+public class ColumnConfigurationButton extends Button
+{
+ private final TableColumnMonitor> mTableColumnMonitor;
+ private ColumnConfigurationEditor mEditor;
+
+ /**
+ * Constructs an instance
+ * @param tableColumnMonitor for the table to configure
+ * @param title for the editor window
+ */
+ public ColumnConfigurationButton(TableColumnMonitor> tableColumnMonitor, String title)
+ {
+ super("Columns");
+ mTableColumnMonitor = tableColumnMonitor;
+ IconNode iconNode = new IconNode(FontAwesome.COLUMNS);
+ //Follow the themed text color so that the icon is readable in light and dark themes
+ iconNode.fillProperty().bind(textFillProperty());
+ setGraphic(iconNode);
+ setTooltip(new Tooltip("Choose the columns to show and the column order"));
+ setOnAction(event -> {
+ if(mEditor == null)
+ {
+ mEditor = new ColumnConfigurationEditor(mTableColumnMonitor, title);
+ }
+
+ mEditor.show(this);
+ });
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/table/ColumnConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/table/ColumnConfigurationEditor.java
new file mode 100644
index 00000000..b0b1d836
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/table/ColumnConfigurationEditor.java
@@ -0,0 +1,199 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.table;
+
+import io.github.dsheirer.gui.theme.ThemeManager;
+import javafx.collections.ListChangeListener;
+import javafx.geometry.Bounds;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.Tooltip;
+import javafx.scene.control.cell.CheckBoxListCell;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+import javafx.stage.Stage;
+import javafx.util.StringConverter;
+
+/**
+ * Editor window for choosing the columns to show in a table and the order of the columns. Changes apply to the
+ * table immediately and are stored by the table column monitor.
+ */
+public class ColumnConfigurationEditor
+{
+ private final TableColumnMonitor> mTableColumnMonitor;
+ private final String mTitle;
+ private Stage mStage;
+ private ListView> mListView;
+ private Button mMoveUpButton;
+ private Button mMoveDownButton;
+ private Button mResetButton;
+
+ /**
+ * Constructs an instance
+ * @param tableColumnMonitor for the table
+ * @param title for the window
+ */
+ public ColumnConfigurationEditor(TableColumnMonitor> tableColumnMonitor, String title)
+ {
+ mTableColumnMonitor = tableColumnMonitor;
+ mTitle = title;
+ }
+
+ /**
+ * Shows the editor next to the node, or brings it to the front when it is already showing.
+ * @param anchor node for positioning the editor window
+ */
+ public void show(Node anchor)
+ {
+ if(mStage == null)
+ {
+ mStage = createStage();
+ }
+
+ if(!mStage.isShowing())
+ {
+ mListView.getSelectionModel().clearSelection();
+
+ if(anchor != null && anchor.getScene() != null)
+ {
+ Bounds bounds = anchor.localToScreen(anchor.getBoundsInLocal());
+
+ if(bounds != null)
+ {
+ mStage.setX(bounds.getMinX());
+ mStage.setY(bounds.getMaxY());
+ }
+ }
+
+ mStage.show();
+ }
+
+ mStage.toFront();
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private Stage createStage()
+ {
+ Stage stage = new Stage();
+ stage.setTitle(mTitle);
+
+ mListView = new ListView<>();
+ //The list shares the table's column list, so that column header drags in the table show here too
+ mListView.setItems((javafx.collections.ObservableList)mTableColumnMonitor.getTableView().getColumns());
+ mListView.setCellFactory(CheckBoxListCell.forListView(TableColumn::visibleProperty,
+ new StringConverter<>()
+ {
+ @Override
+ public String toString(TableColumn,?> column)
+ {
+ return column != null ? TableColumnMonitor.getName(column) : null;
+ }
+
+ @Override
+ public TableColumn,?> fromString(String string)
+ {
+ return null;
+ }
+ }));
+ mListView.setPrefHeight(300);
+ mListView.setPrefWidth(220);
+ mListView.getSelectionModel().selectedIndexProperty().addListener((observable, oldValue, newValue) -> updateButtons());
+ mListView.getItems().addListener((ListChangeListener>)c -> updateButtons());
+
+ mMoveUpButton = new Button("Move Up");
+ mMoveUpButton.setTooltip(new Tooltip("Move the selected column toward the left side of the table"));
+ mMoveUpButton.setOnAction(event -> move(-1));
+ mMoveDownButton = new Button("Move Down");
+ mMoveDownButton.setTooltip(new Tooltip("Move the selected column toward the right side of the table"));
+ mMoveDownButton.setOnAction(event -> move(1));
+ mResetButton = new Button("Reset");
+ mResetButton.setTooltip(new Tooltip("Restore the default columns and column order"));
+ mResetButton.setOnAction(event -> {
+ mTableColumnMonitor.reset();
+ mListView.getSelectionModel().clearSelection();
+ updateButtons();
+ });
+
+ for(Button button: new Button[]{mMoveUpButton, mMoveDownButton, mResetButton})
+ {
+ button.setMaxWidth(Double.MAX_VALUE);
+ }
+
+ Region spacer = new Region();
+ VBox.setVgrow(spacer, Priority.ALWAYS);
+ Button closeButton = new Button("Close");
+ closeButton.setMaxWidth(Double.MAX_VALUE);
+ closeButton.setOnAction(event -> stage.hide());
+ VBox buttonBox = new VBox(5, mMoveUpButton, mMoveDownButton, mResetButton, spacer, closeButton);
+
+ HBox listBox = new HBox(10, mListView, buttonBox);
+ HBox.setHgrow(mListView, Priority.ALWAYS);
+ VBox.setVgrow(listBox, Priority.ALWAYS);
+
+ Label instructions = new Label("Check the columns to show. You can also drag column headers in the " +
+ "table to change the column order.");
+ instructions.setWrapText(true);
+ instructions.setMaxWidth(320);
+
+ VBox content = new VBox(10, instructions, listBox);
+ content.setPadding(new Insets(10));
+ content.setAlignment(Pos.TOP_LEFT);
+
+ Scene scene = new Scene(content);
+ ThemeManager.getInstance().register(scene);
+ stage.setScene(scene);
+ stage.setOnShown(event -> updateButtons());
+
+ //Reset tracks whether the table has the default columns, so update it when visibility changes too
+ for(TableColumn,?> column: mTableColumnMonitor.getDefaultOrder())
+ {
+ column.visibleProperty().addListener((observable, oldValue, newValue) -> updateButtons());
+ }
+
+ return stage;
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private void move(int direction)
+ {
+ TableColumn column = mListView.getSelectionModel().getSelectedItem();
+
+ if(column != null && ((TableColumnMonitor)mTableColumnMonitor).move(column, direction))
+ {
+ mListView.getSelectionModel().select(column);
+ mListView.scrollTo(column);
+ }
+ }
+
+ private void updateButtons()
+ {
+ int index = mListView.getSelectionModel().getSelectedIndex();
+ mMoveUpButton.setDisable(index <= 0);
+ mMoveDownButton.setDisable(index < 0 || index >= mListView.getItems().size() - 1);
+ mResetButton.setDisable(mTableColumnMonitor.isDefault());
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java b/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java
new file mode 100644
index 00000000..ada34c42
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java
@@ -0,0 +1,402 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.table;
+
+import io.github.dsheirer.preference.swing.SwingPreference;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.prefs.Preferences;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+
+/**
+ * Stores and restores the order, visibility and width of the columns of a JavaFX table across application sessions,
+ * and restores the default column order and visibility on request.
+ *
+ * Each column must have a unique, stable ID (TableColumn.setId()), which is the key for its stored settings. The
+ * order and visibility of the columns when this monitor is constructed are the defaults. A column that is added to
+ * the table in a later version of the application is shown at its default position.
+ *
+ * Use this class only on the JavaFX application thread.
+ */
+public class TableColumnMonitor
+{
+ /**
+ * Column property that holds the name shown for the column in the column configuration editor, when the
+ * column header text doesn't identify the column by itself (e.g. two columns titled 'Alias').
+ */
+ public static final String CONFIGURATION_NAME = "column.configuration.name";
+ private static final String ORDER = ".column.order";
+ private static final String HIDDEN = ".column.hidden";
+ private static final String WIDTH = ".column.width.";
+ private static final String SEPARATOR = ",";
+
+ private final TableView mTableView;
+ private final String mKey;
+ private final Preferences mPreferences;
+ private final List> mDefaultOrder;
+ private final Map,Boolean> mDefaultVisibility = new HashMap<>();
+ private boolean mRestoring;
+
+ /**
+ * Constructs an instance and applies the stored column settings to the table.
+ * @param tableView to monitor
+ * @param key that uniquely identifies the table, used as the prefix for its stored settings
+ */
+ public TableColumnMonitor(TableView tableView, String key)
+ {
+ this(tableView, key, Preferences.userNodeForPackage(TableColumnMonitor.class), null, null);
+ }
+
+ /**
+ * Constructs an instance and applies the stored column settings to the table. Columns that don't have a stored
+ * width yet use the width stored by the Swing version of the table, so that users keep their column widths.
+ * @param tableView to monitor
+ * @param key that uniquely identifies the table, used as the prefix for its stored settings
+ * @param swingPreference that holds the column widths of the Swing table
+ * @param swingKey of the Swing table. The Swing column number is the default index of the column.
+ */
+ public TableColumnMonitor(TableView tableView, String key, SwingPreference swingPreference, String swingKey)
+ {
+ this(tableView, key, Preferences.userNodeForPackage(TableColumnMonitor.class), swingPreference, swingKey);
+ }
+
+ /**
+ * Constructs an instance and applies the stored column settings to the table.
+ * @param tableView to monitor
+ * @param key that uniquely identifies the table, used as the prefix for its stored settings
+ * @param preferences node for storing the settings
+ * @param swingPreference that holds legacy Swing column widths, or null
+ * @param swingKey for the legacy Swing column widths, or null
+ */
+ TableColumnMonitor(TableView tableView, String key, Preferences preferences, SwingPreference swingPreference,
+ String swingKey)
+ {
+ mTableView = tableView;
+ mKey = key;
+ mPreferences = preferences;
+ mDefaultOrder = List.copyOf(tableView.getColumns());
+
+ Set ids = new HashSet<>();
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ if(column.getId() == null || column.getId().isEmpty() || column.getId().contains(SEPARATOR) ||
+ !ids.add(column.getId()))
+ {
+ throw new IllegalArgumentException("Each table column needs a unique ID without commas - column [" +
+ column.getText() + "] has ID [" + column.getId() + "]");
+ }
+
+ mDefaultVisibility.put(column, column.isVisible());
+ }
+
+ restore(swingPreference, swingKey);
+
+ mTableView.getColumns().addListener((ListChangeListener>)c -> {
+ if(!mRestoring)
+ {
+ storeOrder();
+ }
+ });
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ column.visibleProperty().addListener((observable, wasVisible, visible) -> {
+ if(!mRestoring)
+ {
+ //Don't let the user hide every column, since that leaves no column header for getting them back
+ if(!visible && getVisibleColumnCount() == 0)
+ {
+ column.setVisible(true);
+ return;
+ }
+
+ storeVisibility();
+ }
+ });
+
+ column.widthProperty().addListener((observable, oldWidth, width) -> {
+ if(!mRestoring && column.isVisible() && width.doubleValue() > 0)
+ {
+ mPreferences.putDouble(getWidthKey(column), width.doubleValue());
+ }
+ });
+ }
+ }
+
+ /**
+ * Table that is monitored.
+ */
+ public TableView getTableView()
+ {
+ return mTableView;
+ }
+
+ /**
+ * Columns in their default order.
+ */
+ public List> getDefaultOrder()
+ {
+ return mDefaultOrder;
+ }
+
+ /**
+ * Name of the column for display in the column configuration editor.
+ * @param column to name
+ * @return configuration name for the column, or the column header text.
+ */
+ public static String getName(TableColumn,?> column)
+ {
+ Object name = column.getProperties().get(CONFIGURATION_NAME);
+ return name != null ? name.toString() : column.getText();
+ }
+
+ /**
+ * Sets the name of the column for display in the column configuration editor
+ * @param column to name
+ * @param name to show in the configuration editor
+ */
+ public static void setName(TableColumn,?> column, String name)
+ {
+ column.getProperties().put(CONFIGURATION_NAME, name);
+ }
+
+ /**
+ * Restores the default column order and visibility and removes the stored order and visibility settings. The
+ * column widths aren't changed.
+ */
+ public void reset()
+ {
+ mRestoring = true;
+
+ try
+ {
+ if(!mTableView.getColumns().equals(mDefaultOrder))
+ {
+ mTableView.getColumns().setAll(mDefaultOrder);
+ }
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ column.setVisible(mDefaultVisibility.get(column));
+ }
+ }
+ finally
+ {
+ mRestoring = false;
+ }
+
+ mPreferences.remove(mKey + ORDER);
+ mPreferences.remove(mKey + HIDDEN);
+ }
+
+ /**
+ * Indicates if the column order and visibility are the defaults.
+ */
+ public boolean isDefault()
+ {
+ if(!mTableView.getColumns().equals(mDefaultOrder))
+ {
+ return false;
+ }
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ if(column.isVisible() != mDefaultVisibility.get(column))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Moves the column one position toward the start (-1) or the end (+1) of the table.
+ * @param column to move
+ * @param direction -1 or +1
+ * @return true if the column moved
+ */
+ public boolean move(TableColumn column, int direction)
+ {
+ ObservableList> columns = mTableView.getColumns();
+ int index = columns.indexOf(column);
+ int target = index + Integer.signum(direction);
+
+ if(index < 0 || direction == 0 || target < 0 || target >= columns.size())
+ {
+ return false;
+ }
+
+ //Rearrange a copy and apply it in one change, since a table view can't hold the same column twice
+ List> reordered = new ArrayList<>(columns);
+ Collections.swap(reordered, index, target);
+ columns.setAll(reordered);
+ return true;
+ }
+
+ /**
+ * Applies the stored column settings to the table.
+ */
+ private void restore(SwingPreference swingPreference, String swingKey)
+ {
+ mRestoring = true;
+
+ try
+ {
+ Map> columnMap = new LinkedHashMap<>();
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ columnMap.put(column.getId(), column);
+ }
+
+ String order = mPreferences.get(mKey + ORDER, null);
+
+ if(order != null)
+ {
+ List> ordered = new ArrayList<>();
+
+ for(String id: order.split(SEPARATOR))
+ {
+ TableColumn column = columnMap.get(id);
+
+ if(column != null && !ordered.contains(column))
+ {
+ ordered.add(column);
+ }
+ }
+
+ //Columns that aren't in the stored order are new, so show them at their default position
+ for(int x = 0; x < mDefaultOrder.size(); x++)
+ {
+ TableColumn column = mDefaultOrder.get(x);
+
+ if(!ordered.contains(column))
+ {
+ ordered.add(Math.min(x, ordered.size()), column);
+ }
+ }
+
+ mTableView.getColumns().setAll(ordered);
+ }
+
+ String hidden = mPreferences.get(mKey + HIDDEN, null);
+
+ if(hidden != null)
+ {
+ Set hiddenIds = Set.of(hidden.isEmpty() ? new String[0] : hidden.split(SEPARATOR));
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ column.setVisible(!hiddenIds.contains(column.getId()));
+ }
+
+ if(getVisibleColumnCount() == 0)
+ {
+ for(TableColumn column: mDefaultOrder)
+ {
+ column.setVisible(mDefaultVisibility.get(column));
+ }
+ }
+ }
+
+ for(int x = 0; x < mDefaultOrder.size(); x++)
+ {
+ TableColumn column = mDefaultOrder.get(x);
+ double width = mPreferences.getDouble(getWidthKey(column), Double.NaN);
+
+ if(Double.isNaN(width) && swingPreference != null && swingKey != null)
+ {
+ int swingWidth = swingPreference.getInt(swingKey + ".column." + x, Integer.MAX_VALUE);
+
+ if(swingWidth != Integer.MAX_VALUE && swingWidth > 0)
+ {
+ width = swingWidth;
+ }
+ }
+
+ if(!Double.isNaN(width) && width > 0)
+ {
+ column.setPrefWidth(width);
+ }
+ }
+ }
+ finally
+ {
+ mRestoring = false;
+ }
+ }
+
+ private int getVisibleColumnCount()
+ {
+ int count = 0;
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ if(column.isVisible())
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ private void storeOrder()
+ {
+ List ids = new ArrayList<>();
+
+ for(TableColumn column: mTableView.getColumns())
+ {
+ ids.add(column.getId());
+ }
+
+ mPreferences.put(mKey + ORDER, String.join(SEPARATOR, ids));
+ }
+
+ private void storeVisibility()
+ {
+ List ids = new ArrayList<>();
+
+ for(TableColumn column: mDefaultOrder)
+ {
+ if(!column.isVisible())
+ {
+ ids.add(column.getId());
+ }
+ }
+
+ mPreferences.put(mKey + HIDDEN, String.join(SEPARATOR, ids));
+ }
+
+ private String getWidthKey(TableColumn column)
+ {
+ return mKey + WIDTH + column.getId();
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/ClearableHistoryModel.java b/src/main/java/io/github/dsheirer/module/decode/event/ClearableHistoryModel.java
deleted file mode 100644
index bc72df27..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/event/ClearableHistoryModel.java
+++ /dev/null
@@ -1,126 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-
-package io.github.dsheirer.module.decode.event;
-
-import java.awt.EventQueue;
-import java.util.LinkedList;
-import java.util.List;
-
-import javax.swing.table.AbstractTableModel;
-
-/**
- * AbstractTableModel implementation supporting clearable method options.
- */
-public abstract class ClearableHistoryModel extends AbstractTableModel
-{
- public static final int DEFAULT_HISTORY_SIZE = 200;
- private LinkedList mItems = new LinkedList<>();
- private int mHistorySize = DEFAULT_HISTORY_SIZE;
-
- /**
- * Access an item/row by the model index value.
- * @param index to retrieve
- * @return item or null
- */
- public T getItem(int index)
- {
- if(index < mItems.size())
- {
- return mItems.get(index);
- }
-
- return null;
- }
-
- /**
- * Adds the item to the top of the item list and removes any tail items while the item list size exceeds the
- * maximum history size for this model.
- * @param item to add
- */
- public void add(T item)
- {
- if(mItems.contains(item))
- {
- int itemRow = mItems.indexOf(item);
- fireTableRowsUpdated(itemRow, itemRow);
- }
- else
- {
- mItems.addFirst(item);
- fireTableRowsInserted(0, 0);
-
- while(mItems.size() > mHistorySize)
- {
- mItems.removeLast();
- fireTableRowsDeleted(mItems.size() - 1, mItems.size() - 1);
- }
- }
- }
-
- /**
- * Clears all messages from history
- */
- public void clear()
- {
- EventQueue.invokeLater(() -> {
- mItems.clear();
- fireTableDataChanged();
- });
- }
-
- /**
- * Clears the current messages and loads the messages argument
- */
- public void clearAndSet(List items)
- {
- EventQueue.invokeLater(() -> {
- mItems.clear();
- fireTableDataChanged();
- for(T item: items)
- {
- add(item);
- }
- });
- }
-
- /**
- * Current history size
- * @return history size
- */
- public int getHistorySize()
- {
- return mHistorySize;
- }
-
- /**
- * Sets the history size
- * @param historySize
- */
- public void setHistorySize(int historySize)
- {
- mHistorySize = historySize;
- }
-
- @Override
- public int getRowCount()
- {
- return mItems.size();
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventModel.java b/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventModel.java
deleted file mode 100644
index 0d54ce7d..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventModel.java
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2026 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-package io.github.dsheirer.module.decode.event;
-
-import com.google.common.eventbus.Subscribe;
-import io.github.dsheirer.channel.IChannelDescriptor;
-import io.github.dsheirer.eventbus.MyEventBus;
-import io.github.dsheirer.identifier.IdentifierCollection;
-import io.github.dsheirer.preference.PreferenceType;
-import io.github.dsheirer.sample.Listener;
-import java.awt.EventQueue;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Decode event table model
- */
-public class DecodeEventModel extends ClearableHistoryModel implements Listener
-{
- private static final long serialVersionUID = 1L;
- private final static Logger mLog = LoggerFactory.getLogger(DecodeEventModel.class);
- public static final int COLUMN_TIME = 0;
- public static final int COLUMN_DURATION = 1;
- public static final int COLUMN_EVENT = 2;
- public static final int COLUMN_FROM_ID = 3;
- public static final int COLUMN_FROM_ALIAS = 4;
- public static final int COLUMN_TO_ID = 5;
- public static final int COLUMN_TO_ALIAS = 6;
- public static final int COLUMN_CHANNEL = 7;
- public static final int COLUMN_FREQUENCY = 8;
- public static final int COLUMN_DETAILS = 9;
- protected String[] mHeaders = new String[]{"Time", "Duration", "Event", "From", "Alias", "To", "Alias", "Channel", "Frequency", "Details"};
-
- public DecodeEventModel()
- {
- MyEventBus.getGlobalEventBus().register(this);
- }
-
- /**
- * Receives preference update notifications via the event bus
- * @param preferenceType that was updated
- */
- @Subscribe
- public void preferenceUpdated(PreferenceType preferenceType)
- {
- if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT)
- {
- fireTableDataChanged();
- }
- }
-
- /**
- * Adds, updates or deletes the event from the model. Producers can send
- * the same call event multiple times to indicate that information in the
- * event is updated. Producers can also mark the event as invalid and the
- * event will be removed from the model.
- */
- public void receive(final IDecodeEvent event)
- {
- EventQueue.invokeLater(() -> add(event));
- }
-
- @Override
- public int getColumnCount()
- {
- return mHeaders.length;
- }
-
- public String getColumnName(int column)
- {
- return mHeaders[column];
- }
-
- @Override
- public Object getValueAt(int rowIndex, int columnIndex)
- {
- IDecodeEvent event = getItem(rowIndex);
-
- if(event != null)
- {
- switch(columnIndex)
- {
- case COLUMN_TIME:
- return event.getTimeStart();
- case COLUMN_DURATION:
- return event.getDuration();
- case COLUMN_EVENT:
- return event.getEventType().getLabel();
- case COLUMN_FROM_ID:
- return event.getIdentifierCollection();
- case COLUMN_FROM_ALIAS:
- return event.getIdentifierCollection();
- case COLUMN_TO_ID:
- return event.getIdentifierCollection();
- case COLUMN_TO_ALIAS:
- return event.getIdentifierCollection();
- case COLUMN_CHANNEL:
- IChannelDescriptor channelDescriptor = event.getChannelDescriptor();
-
- if(channelDescriptor != null)
- {
- if(event.hasTimeslot() && !event.toString().contains("TS"))
- {
- return channelDescriptor + " TS" + event.getTimeslot();
- }
- else
- {
- return channelDescriptor.toString();
- }
- }
- else
- {
- if(event.hasTimeslot() && !event.toString().contains("TS"))
- {
- return "TS" + event.getTimeslot();
- }
- else
- {
- return null;
- }
- }
- case COLUMN_FREQUENCY:
- return event.getChannelDescriptor();
- case COLUMN_DETAILS:
- return event.getDetails();
- }
- }
-
- return null;
- }
-
- @Override
- public Class> getColumnClass(int columnIndex)
- {
- switch(columnIndex)
- {
- case COLUMN_DURATION:
- case COLUMN_TIME:
- return Long.class;
- case COLUMN_DETAILS:
- case COLUMN_EVENT:
- return String.class;
- case COLUMN_FREQUENCY:
- case COLUMN_FROM_ALIAS:
- case COLUMN_FROM_ID:
- case COLUMN_TO_ALIAS:
- case COLUMN_TO_ID:
- return IdentifierCollection.class;
- case COLUMN_CHANNEL:
- return String.class;
- }
-
- return super.getColumnClass(columnIndex);
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java b/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java
deleted file mode 100644
index 245d7528..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java
+++ /dev/null
@@ -1,440 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2026 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-package io.github.dsheirer.module.decode.event;
-
-import com.google.common.base.Joiner;
-import com.google.common.eventbus.Subscribe;
-import io.github.dsheirer.alias.Alias;
-import io.github.dsheirer.alias.AliasList;
-import io.github.dsheirer.alias.AliasModel;
-import io.github.dsheirer.channel.IChannelDescriptor;
-import io.github.dsheirer.eventbus.MyEventBus;
-import io.github.dsheirer.filter.FilterSet;
-import io.github.dsheirer.gui.theme.ThemeManager;
-import io.github.dsheirer.icon.IconModel;
-import io.github.dsheirer.identifier.Form;
-import io.github.dsheirer.identifier.Identifier;
-import io.github.dsheirer.identifier.IdentifierCollection;
-import io.github.dsheirer.identifier.Role;
-import io.github.dsheirer.module.ProcessingChain;
-import io.github.dsheirer.module.decode.event.filter.DecodeEventFilterSet;
-import io.github.dsheirer.preference.PreferenceType;
-import io.github.dsheirer.preference.UserPreferences;
-import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
-import io.github.dsheirer.sample.Listener;
-import java.awt.Color;
-import java.awt.Component;
-import java.awt.EventQueue;
-import java.text.DecimalFormat;
-import java.text.SimpleDateFormat;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import net.miginfocom.swing.MigLayout;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.swing.ImageIcon;
-import javax.swing.JLabel;
-import javax.swing.JPanel;
-import javax.swing.JScrollPane;
-import javax.swing.JTable;
-import javax.swing.RowFilter;
-import javax.swing.table.DefaultTableCellRenderer;
-import javax.swing.table.TableModel;
-import javax.swing.table.TableRowSorter;
-
-/**
- * Decode event table view
- */
-public class DecodeEventPanel extends JPanel implements Listener
-{
- private static final long serialVersionUID = 1L;
- private final static Logger mLog = LoggerFactory.getLogger(DecodeEventPanel.class);
- private static final String TABLE_PREFERENCE_KEY = "decode.event.panel";
-
- private final JTable mTable;
- private final DecodeEventModel mEventModel = new DecodeEventModel();
- private DecodeEventHistory mCurrentEventHistory;
- private final IconModel mIconModel;
- private final AliasModel mAliasModel;
- private final UserPreferences mUserPreferences;
- private final TimestampCellRenderer mTimestampCellRenderer;
- private final FilterSet mFilterSet = new DecodeEventFilterSet();
- private final HistoryManagementPanel mHistoryManagementPanel;
-
-
- /**
- * View for call event table
- * @param iconModel to display alias icons in table rows
- */
- public DecodeEventPanel(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel)
- {
- MyEventBus.getGlobalEventBus().register(this);
-
- setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[][grow,fill]"));
- mIconModel = iconModel;
- mAliasModel = aliasModel;
- mUserPreferences = userPreferences;
- mTimestampCellRenderer = new TimestampCellRenderer();
- mTable = new JTable(mEventModel);
- TableRowSorter tableRowSorter = new TableRowSorter<>(mEventModel);
- tableRowSorter.setRowFilter(new EventRowFilter());
- mTable.setRowSorter(tableRowSorter);
- JTableColumnWidthMonitor tableColumnWidthMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
- updateCellRenderers();
- mHistoryManagementPanel = new HistoryManagementPanel<>(mEventModel, "Event Filter Editor");
- mHistoryManagementPanel.updateFilterSet(mFilterSet);
- add(mHistoryManagementPanel, "span,growx");
- JScrollPane emptyScroller = new JScrollPane(mTable);
- add(emptyScroller);
-
- //Register filter change listener to refresh the table any time the event filters are changed.
- mFilterSet.register(mEventModel::fireTableDataChanged);
- }
-
- public void dispose()
- {
- MyEventBus.getGlobalEventBus().unregister(this);
- }
-
- /**
- * Receives preference update notifications via the event bus
- * @param preferenceType that was updated
- */
- @Subscribe
- public void preferenceUpdated(PreferenceType preferenceType)
- {
- if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT)
- {
- EventQueue.invokeLater(mTimestampCellRenderer::updatePreferences);
- }
- }
-
- private void updateCellRenderers()
- {
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_TIME).setCellRenderer(mTimestampCellRenderer);
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_DURATION).setCellRenderer(new DurationCellRenderer());
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_FROM_ID).setCellRenderer(new IdentifierCellRenderer(Role.FROM));
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_FROM_ALIAS).setCellRenderer(new AliasedIdentifierCellRenderer(Role.FROM));
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_TO_ID).setCellRenderer(new IdentifierCellRenderer(Role.TO));
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_TO_ALIAS).setCellRenderer(new AliasedIdentifierCellRenderer(Role.TO));
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_CHANNEL).setCellRenderer(new ChannelDescriptorCellRenderer());
- mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_FREQUENCY).setCellRenderer(new FrequencyCellRenderer());
- }
-
- @Override
- public void receive(final ProcessingChain processingChain)
- {
- if(mCurrentEventHistory != null)
- {
- mCurrentEventHistory.removeListener(mEventModel);
- }
-
- EventQueue.invokeLater(() -> {
- if(processingChain != null)
- {
- mCurrentEventHistory = processingChain.getDecodeEventHistory();
- mEventModel.clearAndSet(mCurrentEventHistory.getItems());
- processingChain.getDecodeEventHistory().addListener(mEventModel);
- mHistoryManagementPanel.setEnabled(true);
- }
- else
- {
- mCurrentEventHistory = null;
- mEventModel.clearAndSet(Collections.emptyList());
- mHistoryManagementPanel.setEnabled(false);
- }
- });
- }
-
- /**
- * Custom cell renderer for displaying identifiers from an identifier collection
- */
- public class IdentifierCellRenderer extends DefaultTableCellRenderer
- {
- protected Role mRole;
-
- /**
- * Constructs an instance of the cell renderer.
- *
- * @param role of the identifier
- */
- public IdentifierCellRenderer(Role role)
- {
- mRole = role;
- setHorizontalAlignment(JLabel.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- if(value instanceof IdentifierCollection)
- {
- List identifiers = ((IdentifierCollection)value).getIdentifiers(mRole);
- label.setText(format(identifiers));
- }
- else
- {
- label.setText(null);
- }
-
- return label;
- }
-
- /**
- * Formats a list of identifiers as a comma separated list of values
- * @param identifiers to format
- * @return formatted list or null
- */
- protected String format(List identifiers)
- {
- if(identifiers == null || identifiers.isEmpty())
- {
- return null;
- }
-
- StringBuilder sb = new StringBuilder();
-
- for(Identifier identifier: identifiers)
- {
- if(!sb.isEmpty())
- {
- sb.append(",");
- }
-
- if(identifier.getForm() == Form.TALKGROUP || identifier.getForm() == Form.RADIO || identifier.getForm() == Form.PATCH_GROUP)
- {
- sb.append(mUserPreferences.getTalkgroupFormatPreference().format(identifier));
- }
- else
- {
- sb.append(identifier);
- }
-
- }
-
- return sb.toString();
- }
- }
-
- /**
- * Cell renderer for identifier aliases
- */
- public class AliasedIdentifierCellRenderer extends DefaultTableCellRenderer
- {
- private Role mRole;
-
- /**
- * Constructs an instance of the cell renderer.
- *
- * @param role of the identifier
- */
- public AliasedIdentifierCellRenderer(Role role)
- {
- mRole = role;
- setHorizontalAlignment(JLabel.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- Color color = mTable.getForeground();
- ImageIcon icon = null;
- String text = null;
-
- if(value instanceof IdentifierCollection identifierCollection)
- {
- List identifiers = identifierCollection.getIdentifiers(mRole);
-
- if(identifiers != null && !identifiers.isEmpty())
- {
- AliasList aliasList = mAliasModel.getAliasList(identifierCollection);
-
- if(aliasList != null)
- {
- StringBuilder sb = new StringBuilder();
-
- for(Identifier identifier: identifiers)
- {
- List aliases = aliasList.getAliases(identifier);
-
- if(!aliases.isEmpty())
- {
- if(!sb.isEmpty())
- {
- sb.append(",");
- }
- sb.append(Joiner.on(", ").skipNulls().join(aliases));
- color = ThemeManager.getInstance().getReadableColor(
- aliases.getFirst().getDisplayColor(), label.getBackground());
- icon = mIconModel.getIcon(aliases.getFirst().getIconName(), IconModel.DEFAULT_ICON_SIZE);
- }
- }
-
- text = sb.toString();
- }
- }
- }
-
- label.setText(text);
- label.setForeground(color);
- label.setIcon(icon);
-
- return label;
- }
- }
-
- public class TimestampCellRenderer extends DefaultTableCellRenderer
- {
- private SimpleDateFormat mTimestampFormatter;
-
- public TimestampCellRenderer()
- {
- setHorizontalAlignment(JLabel.CENTER);
- updatePreferences();
- }
-
- public void updatePreferences()
- {
- mTimestampFormatter = mUserPreferences.getDecodeEventPreference().getTimestampFormat().getFormatter();
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- if(value instanceof Long)
- {
- label.setText(mTimestampFormatter.format(new Date((long)value)));
- }
- else
- {
- label.setText(null);
- }
-
- return label;
- }
- }
-
- public static class DurationCellRenderer extends DefaultTableCellRenderer
- {
- private final DecimalFormat mDecimalFormat = new DecimalFormat("0.0");
-
- public DurationCellRenderer()
- {
- setHorizontalAlignment(JLabel.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- String formatted = null;
-
- if(value instanceof Long)
- {
- long duration = (long)value;
-
- if(duration > 0)
- {
- formatted = mDecimalFormat.format((double)duration / 1e3d);
- }
- }
-
- label.setText(formatted);
-
- return label;
- }
- }
-
- /**
- * Frequency value cell renderer
- */
- public static class FrequencyCellRenderer extends DefaultTableCellRenderer
- {
- private final DecimalFormat mFrequencyFormatter = new DecimalFormat("0.00000");
-
- public FrequencyCellRenderer()
- {
- setHorizontalAlignment(JLabel.CENTER);
- }
-
- @Override
- public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
- {
- JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
-
- String formatted = null;
-
- if(value instanceof IChannelDescriptor channelDescriptor)
- {
- long frequency = channelDescriptor.getDownlinkFrequency();
-
- if(frequency > 0)
- {
- formatted = mFrequencyFormatter.format(frequency / 1e6d);
- }
- }
-
- label.setText(formatted);
-
- return label;
- }
- }
-
- /**
- * Channel descriptor value cell renderer
- */
- public static class ChannelDescriptorCellRenderer extends DefaultTableCellRenderer
- {
- public ChannelDescriptorCellRenderer()
- {
- setHorizontalAlignment(JLabel.CENTER);
- }
- }
-
- /**
- * Row filter for decode events
- */
- public class EventRowFilter extends RowFilter
- {
- @Override
- public boolean include(Entry extends TableModel, ? extends Integer> entry)
- {
- if(entry.getModel() instanceof DecodeEventModel model)
- {
- IDecodeEvent event = model.getItem(entry.getIdentifier());
-
- if(event != null)
- {
- return mFilterSet.canProcess(event) && mFilterSet.passes(event);
- }
- }
-
- return false;
- }
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/HistoryManagementPanel.java b/src/main/java/io/github/dsheirer/module/decode/event/HistoryManagementPanel.java
deleted file mode 100644
index ad76b60d..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/event/HistoryManagementPanel.java
+++ /dev/null
@@ -1,212 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-
-package io.github.dsheirer.module.decode.event;
-
-import io.github.dsheirer.filter.FilterEditor;
-import io.github.dsheirer.filter.FilterSet;
-import java.awt.EventQueue;
-import java.awt.event.MouseEvent;
-import java.awt.event.MouseListener;
-import net.miginfocom.swing.MigLayout;
-
-import javax.swing.JButton;
-import javax.swing.JLabel;
-import javax.swing.JPanel;
-import javax.swing.JSlider;
-import javax.swing.SwingUtilities;
-
-/**
- * History management panel with controls for managing item histories.
- */
-public class HistoryManagementPanel extends JPanel
-{
- private ClearableHistoryModel mModel;
- private FilterSet mFilterSet;
- private FilterEditor mFilterEditor;
- private JButton mClearButton;
- private JButton mFilterButton;
- private JSlider mHistorySlider;
- private JLabel mHistoryTitleLabel;
- private JLabel mHistoryValueLabel;
- private String mFilterEditorTitle;
-
- /**
- * Constructs an instance
- * @param model to manage
- */
- public HistoryManagementPanel(ClearableHistoryModel model, String filterEditorTitle)
- {
- mModel = model;
- mFilterEditorTitle = filterEditorTitle;
- setLayout(new MigLayout("insets 6 1 5 5", "[]5[]10[]5[]5[][grow]", ""));
- add(getFilterButton());
- add(getClearButton());
- add(getHistoryTitleLabel());
- add(getHistorySlider());
- add(getHistoryValueLabel());
- setEnabled(false);
- }
-
- /**
- * Updates the filter set
- * @param filterSet to use
- */
- public void updateFilterSet(FilterSet filterSet)
- {
- mFilterSet = filterSet;
-
- if(mFilterEditor != null)
- {
- getFilterEditor().updateFilterSet(filterSet);
- }
- }
-
- /**
- * Overrides the panel method to also set the enabled state for the child controls.
- * @param enabled true if this component should be enabled, false otherwise
- */
- @Override
- public void setEnabled(boolean enabled)
- {
- super.setEnabled(enabled);
- getClearButton().setEnabled(enabled);
- getFilterButton().setEnabled(enabled);
- getHistoryValueLabel().setEnabled(enabled);
- getHistoryTitleLabel().setEnabled(enabled);
- getHistorySlider().setEnabled(enabled);
- }
-
- /**
- * Filter editor
- * @return editor lazily constructed
- */
- private FilterEditor getFilterEditor()
- {
- if(mFilterEditor == null)
- {
- mFilterEditor = new FilterEditor<>(mFilterEditorTitle, getFilterButton(), mFilterSet);
- }
-
- return mFilterEditor;
- }
-
- /**
- * Filter button for accessing the filter editor
- * @return filter button
- */
- private JButton getFilterButton()
- {
- if(mFilterButton == null)
- {
- mFilterButton = new JButton("Filters");
- mFilterButton.setToolTipText("Edit filters");
- mFilterButton.addActionListener(arg0 -> EventQueue.invokeLater(() -> getFilterEditor().setVisible(true)));
- }
-
- return mFilterButton;
- }
-
- /**
- * Clear button
- * @return clear button
- */
- private JButton getClearButton()
- {
- if(mClearButton == null)
- {
- mClearButton = new JButton("Clear");
- mClearButton.addActionListener(e -> mModel.clear());
- mClearButton.setToolTipText("Clears the history");
- }
-
- return mClearButton;
- }
-
- /**
- * History value label.
- * @return label
- */
- private JLabel getHistoryValueLabel()
- {
- if(mHistoryValueLabel == null)
- {
- mHistoryValueLabel = new JLabel(String.valueOf(ClearableHistoryModel.DEFAULT_HISTORY_SIZE));
- }
-
- return mHistoryValueLabel;
- }
-
- /**
- * History title label
- * @return label
- */
- private JLabel getHistoryTitleLabel()
- {
- if(mHistoryTitleLabel == null)
- {
- mHistoryTitleLabel = new JLabel("History:");
- }
-
- return mHistoryTitleLabel;
- }
-
- /**
- * History value slider control
- * @return slider
- */
- private JSlider getHistorySlider()
- {
- if(mHistorySlider == null)
- {
- mHistorySlider = new JSlider();
- mHistorySlider.setToolTipText("Adjust history size. Double-click to reset to default 200");
- mHistorySlider.setMinimum(0);
- mHistorySlider.setMaximum(2000);
- mHistorySlider.setMinorTickSpacing(25);
- mHistorySlider.setMajorTickSpacing(500);
- mHistorySlider.setPaintTicks(false);
- mHistorySlider.setPaintLabels(false);
- mHistorySlider.addMouseListener(new MouseListener()
- {
- @Override
- public void mouseClicked(MouseEvent arg0)
- {
- if(SwingUtilities.isLeftMouseButton(arg0) && arg0.getClickCount() == 2)
- {
- mHistorySlider.setValue(ClearableHistoryModel.DEFAULT_HISTORY_SIZE);
- }
- }
-
- public void mouseEntered(MouseEvent arg0) {}
- public void mouseExited(MouseEvent arg0) {}
- public void mousePressed(MouseEvent arg0) {}
- public void mouseReleased(MouseEvent arg0) {}
- });
-
- mHistorySlider.setValue(mModel.getHistorySize());
- mHistorySlider.addChangeListener(e -> {
- mModel.setHistorySize(mHistorySlider.getValue());
- getHistoryValueLabel().setText(String.valueOf(mHistorySlider.getValue()));
- });
- }
-
- return mHistorySlider;
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/MessageActivityModel.java b/src/main/java/io/github/dsheirer/module/decode/event/MessageActivityModel.java
deleted file mode 100644
index f5ecd4f2..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/event/MessageActivityModel.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-package io.github.dsheirer.module.decode.event;
-
-import io.github.dsheirer.message.IMessage;
-import io.github.dsheirer.message.StuffBitsMessage;
-import io.github.dsheirer.sample.Listener;
-import java.awt.EventQueue;
-import java.text.SimpleDateFormat;
-
-/**
- * Table Model for decoded IMessages.
- */
-public class MessageActivityModel extends ClearableHistoryModel implements Listener
-{
- private static final long serialVersionUID = 1L;
- private static final int TIME = 0;
- private static final int PROTOCOL = 1;
- private static final int TIMESLOT = 2;
- private static final int MESSAGE = 3;
-
- private String[] mHeaders = new String[]{"Time", "Protocol", "Timeslot", "Message"};
- private SimpleDateFormat mSDFTime = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
-
- /**
- * Constructor
- */
- public MessageActivityModel()
- {
- }
-
- /**
- * Implements the listener interface and wraps the IMessage in table-compatible message item wrapper.
- * @param message to add to the model
- */
- public void receive(final IMessage message)
- {
- //Don't process tail bits or stuff bits message fragments
- if(message instanceof StuffBitsMessage)
- {
- return;
- }
-
- EventQueue.invokeLater(() -> add(new MessageItem(message)));
- }
-
- @Override
- public int getColumnCount()
- {
- return mHeaders.length;
- }
-
- public String getColumnName(int column)
- {
- return mHeaders[column];
- }
-
- @Override
- public Object getValueAt(int rowIndex, int columnIndex)
- {
- MessageItem item = getItem(rowIndex);
-
- if(item != null)
- {
- switch(columnIndex)
- {
- case TIME:
- return item.getTimestamp(mSDFTime);
- case PROTOCOL:
- return item.getProtocol();
- case TIMESLOT:
- return item.getTimeslot();
- case MESSAGE:
- return item.getText();
- default:
- break;
- }
- }
-
- return null;
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/MessageActivityPanel.java b/src/main/java/io/github/dsheirer/module/decode/event/MessageActivityPanel.java
deleted file mode 100644
index 479c1d79..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/event/MessageActivityPanel.java
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- * *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * ****************************************************************************
- */
-package io.github.dsheirer.module.decode.event;
-
-import io.github.dsheirer.filter.FilterSet;
-import io.github.dsheirer.message.IMessage;
-import io.github.dsheirer.message.MessageHistory;
-import io.github.dsheirer.module.ProcessingChain;
-import io.github.dsheirer.module.decode.DecoderFactory;
-import io.github.dsheirer.preference.UserPreferences;
-import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
-import io.github.dsheirer.sample.Listener;
-import java.util.ArrayList;
-import java.util.List;
-import net.miginfocom.swing.MigLayout;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.swing.JPanel;
-import javax.swing.JScrollPane;
-import javax.swing.JTable;
-import javax.swing.RowFilter;
-import javax.swing.table.TableModel;
-import javax.swing.table.TableRowSorter;
-
-/**
- * Panel to display decoded messages/activity.
- */
-public class MessageActivityPanel extends JPanel implements Listener
-{
- private static final long serialVersionUID = 1L;
- private static final Logger mLog = LoggerFactory.getLogger(MessageActivityPanel.class);
- private final String TABLE_PREFERENCE_KEY = "message.activity.panel";
- private MessageActivityModel mMessageModel = new MessageActivityModel();
- private MessageHistory mCurrentMessageHistory;
- private JTable mTable = new JTable(mMessageModel);
- private TableRowSorter mTableRowSorter;
- private JTableColumnWidthMonitor mTableColumnWidthMonitor;
- private UserPreferences mUserPreferences;
- private FilterSet mMessageFilterSet;
- private HistoryManagementPanel mHistoryManagementPanel;
-
- /**
- * Constructs an instance
- * @param userPreferences
- */
- public MessageActivityPanel(UserPreferences userPreferences)
- {
- mUserPreferences = userPreferences;
- mTableRowSorter = new TableRowSorter<>(mMessageModel);
- mTableRowSorter.setRowFilter(new MessageRowFilter());
- mTable.setRowSorter(mTableRowSorter);
- mTableColumnWidthMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
- setLayout(new MigLayout("insets 0 0 0 0", "[][grow,fill]", "[]0[grow,fill]"));
- mHistoryManagementPanel = new HistoryManagementPanel<>(mMessageModel, "Message Filter Editor");
- add(mHistoryManagementPanel, "span,growx");
- add(new JScrollPane(mTable), "span,grow");
- }
-
- /**
- * Updates the message activity model with message history from the specified processing chain
- */
- @Override
- public void receive(ProcessingChain processingChain)
- {
- if(mCurrentMessageHistory != null)
- {
- mCurrentMessageHistory.removeListener(mMessageModel);
- }
-
- //Unregister from changes made to the filter set
- if(mMessageFilterSet != null)
- {
- mMessageFilterSet.register(null);
- }
-
- if(processingChain != null)
- {
- mCurrentMessageHistory = processingChain.getMessageHistory();
- mMessageFilterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
- //Register filter change listener to refresh the table any time the event filters are changed.
- mMessageFilterSet.register(() -> mMessageModel.fireTableDataChanged());
- if(mHistoryManagementPanel != null)
- {
- mHistoryManagementPanel.updateFilterSet(mMessageFilterSet);
- }
-
- List currentHistory = new ArrayList<>();
- for(IMessage message: mCurrentMessageHistory.getItems())
- {
- currentHistory.add(new MessageItem(message));
- }
-
- mMessageModel.clearAndSet(currentHistory);
- mCurrentMessageHistory.addListener(mMessageModel);
- mHistoryManagementPanel.setEnabled(true);
- }
- else
- {
- mCurrentMessageHistory = null;
- mMessageFilterSet = null;
- mMessageModel.clear();
- mHistoryManagementPanel.setEnabled(false);
- }
- }
-
- /**
- * Row visibility filter for messages
- */
- public class MessageRowFilter extends RowFilter
- {
- @Override
- public boolean include(Entry extends TableModel, ? extends Integer> entry)
- {
- if(entry.getModel() instanceof MessageActivityModel model)
- {
- MessageItem item = model.getItem(entry.getIdentifier());
-
- if(mMessageFilterSet != null && item != null && item.getMessage() != null)
- {
- IMessage message = item.getMessage();
- return mMessageFilterSet.canProcess(message) && mMessageFilterSet.passes(message);
- }
- }
-
- return false;
- }
- }
-}
diff --git a/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java b/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java
new file mode 100644
index 00000000..17af2316
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java
@@ -0,0 +1,215 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.gui.table;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.prefs.BackingStoreException;
+import java.util.prefs.Preferences;
+import javafx.application.Platform;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests storing, restoring and resetting table column order and visibility.
+ */
+public class TableColumnMonitorTest
+{
+ private static final String KEY = "test.table";
+ private Preferences mPreferences;
+
+ @BeforeAll
+ public static void startToolkit() throws Exception
+ {
+ //Run without a display
+ System.setProperty("glass.platform", "Headless");
+ System.setProperty("prism.order", "sw");
+ CompletableFuture started = new CompletableFuture<>();
+
+ try
+ {
+ Platform.startup(() -> started.complete(null));
+ }
+ catch(IllegalStateException e)
+ {
+ started.complete(null); //Already started
+ }
+
+ started.get(10, TimeUnit.SECONDS);
+ }
+
+ @BeforeEach
+ public void createPreferences()
+ {
+ mPreferences = Preferences.userRoot().node("sdrtrunk-test/" + UUID.randomUUID());
+ }
+
+ @AfterEach
+ public void removePreferences() throws BackingStoreException
+ {
+ mPreferences.removeNode();
+ }
+
+ /**
+ * Runs the test on the JavaFX application thread.
+ */
+ private static void onFx(Runnable runnable) throws Exception
+ {
+ CompletableFuture result = new CompletableFuture<>();
+ Platform.runLater(() -> {
+ try
+ {
+ runnable.run();
+ result.complete(null);
+ }
+ catch(Throwable t)
+ {
+ result.completeExceptionally(t);
+ }
+ });
+ result.get(10, TimeUnit.SECONDS);
+ }
+
+ private static TableView createTable(String... ids)
+ {
+ TableView tableView = new TableView<>();
+
+ for(String id: ids)
+ {
+ TableColumn column = new TableColumn<>(id.toUpperCase());
+ column.setId(id);
+ tableView.getColumns().add(column);
+ }
+
+ return tableView;
+ }
+
+ private static List ids(TableView tableView)
+ {
+ return tableView.getColumns().stream().map(TableColumn::getId).toList();
+ }
+
+ private static List visibleIds(TableView tableView)
+ {
+ return tableView.getColumns().stream().filter(TableColumn::isVisible).map(TableColumn::getId).toList();
+ }
+
+ private TableColumnMonitor monitor(TableView tableView)
+ {
+ return new TableColumnMonitor<>(tableView, KEY, mPreferences, null, null);
+ }
+
+ @Test
+ public void defaultsWhenNothingIsStored() throws Exception
+ {
+ onFx(() -> {
+ TableView table = createTable("a", "b", "c");
+ TableColumnMonitor monitor = monitor(table);
+ assertEquals(List.of("a", "b", "c"), ids(table));
+ assertEquals(List.of("a", "b", "c"), visibleIds(table));
+ assertTrue(monitor.isDefault());
+ });
+ }
+
+ @Test
+ public void orderAndVisibilityAreRestored() throws Exception
+ {
+ onFx(() -> {
+ TableView table = createTable("a", "b", "c", "d");
+ TableColumnMonitor monitor = monitor(table);
+ assertTrue(monitor.move(table.getColumns().get(0), 1));
+ table.getColumns().get(3).setVisible(false);
+ assertEquals(List.of("b", "a", "c", "d"), ids(table));
+ assertFalse(monitor.isDefault());
+
+ //A new session
+ TableView restored = createTable("a", "b", "c", "d");
+ monitor(restored);
+ assertEquals(List.of("b", "a", "c", "d"), ids(restored));
+ assertEquals(List.of("b", "a", "c"), visibleIds(restored));
+ });
+ }
+
+ @Test
+ public void resetRestoresDefaultsAndKeepsWidths() throws Exception
+ {
+ onFx(() -> {
+ TableView table = createTable("a", "b", "c");
+ TableColumnMonitor monitor = monitor(table);
+ monitor.move(table.getColumns().get(2), -1);
+ table.getColumns().get(0).setVisible(false);
+ mPreferences.putDouble(KEY + ".column.width.b", 123.0);
+
+ monitor.reset();
+ assertEquals(List.of("a", "b", "c"), ids(table));
+ assertEquals(List.of("a", "b", "c"), visibleIds(table));
+ assertTrue(monitor.isDefault());
+ assertNull(mPreferences.get(KEY + ".column.order", null));
+ assertNull(mPreferences.get(KEY + ".column.hidden", null));
+
+ TableView restored = createTable("a", "b", "c");
+ monitor(restored);
+ assertEquals(List.of("a", "b", "c"), ids(restored));
+ assertEquals(123.0, restored.getColumns().get(1).getPrefWidth());
+ });
+ }
+
+ @Test
+ public void newColumnIsShownAtItsDefaultPosition() throws Exception
+ {
+ onFx(() -> {
+ mPreferences.put(KEY + ".column.order", "c,a,removed");
+ mPreferences.put(KEY + ".column.hidden", "a");
+ TableView table = createTable("a", "b", "c");
+ monitor(table);
+ assertEquals(List.of("c", "b", "a"), ids(table));
+ assertEquals(List.of("c", "b"), visibleIds(table));
+ });
+ }
+
+ @Test
+ public void lastVisibleColumnCantBeHidden() throws Exception
+ {
+ onFx(() -> {
+ TableView table = createTable("a", "b");
+ monitor(table);
+ table.getColumns().get(0).setVisible(false);
+ table.getColumns().get(1).setVisible(false);
+ assertEquals(List.of("b"), visibleIds(table));
+ });
+ }
+
+ @Test
+ public void columnsNeedUniqueIds() throws Exception
+ {
+ onFx(() -> assertThrows(IllegalArgumentException.class, () -> monitor(createTable("a", "a"))));
+ }
+}