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 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 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")))); + } +}