/* * Copyright 2011 Luke Usherwood. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package net.bettyluke.tracinstant.ui; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Container; import java.awt.Dimension; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.InputEvent; import java.awt.event.ItemEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.ConnectException; import java.net.MalformedURLException; import java.net.ProtocolException; import java.net.URL; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.regex.Pattern; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.Box; import javax.swing.Icon; import javax.swing.JButton; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JEditorPane; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.KeyStroke; import javax.swing.SwingConstants; import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.ToolTipManager; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.text.JTextComponent; import net.bettyluke.swing.StatusWidget; import net.bettyluke.tracinstant.data.SiteData; import net.bettyluke.tracinstant.data.Ticket; import net.bettyluke.tracinstant.data.TicketLoadTask; import net.bettyluke.tracinstant.data.TicketLoadTask.Update; import net.bettyluke.tracinstant.data.TicketTableModel; import net.bettyluke.tracinstant.download.DownloadDialog; import net.bettyluke.tracinstant.download.DownloadModel; import net.bettyluke.tracinstant.plugins.DummyPlugin; import net.bettyluke.tracinstant.plugins.TicketUpdater; import net.bettyluke.tracinstant.plugins.ToolPlugin; import net.bettyluke.tracinstant.prefs.TracInstantProperties; import net.bettyluke.util.DesktopUtils; import net.bettyluke.util.DocUtils; import net.bettyluke.util.FileUtils; public class TracInstantFrame extends JFrame { private static final String FRAME_STATE_PROPERTY = "MainFrame"; private static final Icon BUSY_IMAGE = StatusWidget.BUSY_IMAGE; private static final InputStream TIP = TracInstantFrame.class.getResourceAsStream("res/SearchTip.html"); private static final int GAP = 6; private final class TicketLoadListener implements PropertyChangeListener { private final TicketLoadTask task; private TicketLoadListener(TicketLoadTask task) { this.task = task; } @Override public void propertyChange(PropertyChangeEvent evt) { if (!evt.getPropertyName().equals("status")) { return; } removeWindowListener(m_OnActivationRefresher); Update update = (Update) evt.getNewValue(); if (update.ticketProvider != null) { mergeTickets(update.ticketProvider.getTickets()); return; } if (update.summaryMessage != null) { m_SlurpStatus.showBusy(update.summaryMessage, update.detailMessage); System.out.println(update.detailMessage); } else { task.removePropertyChangeListener(this); addWindowListener(m_OnActivationRefresher); // Retrieve any exceptions. (There is no "result" to collect, since // all data is processed on-the-fly via the publishing mechanism.) try { try { task.get(); } catch (CancellationException ex) { // Treat the same as complete. } m_SlurpStatus.hide(); } catch (InterruptedException e) { e.printStackTrace(); Thread.interrupted(); } catch (ExecutionException e) { Throwable why = e.getCause(); Runnable doSlurp = () -> slurpAction.slurpIncremental(); if (why instanceof ConnectException) { m_SlurpStatus.showRetryError( "Disconnected (click to retry)", e.getMessage(), doSlurp); } else if (why instanceof ProtocolException) { m_SlurpStatus.showError( "Not connected: Incorrect Password?", e.getMessage()); } else { m_SlurpStatus.showRetryError( "Not connected - hover for details", e.getMessage(), doSlurp); } e.printStackTrace(); } System.out.println("Slurp complete."); } updateRowFilter(); updateViews(); } private void mergeTickets(List<Ticket> tickets) { m_Table.getModel().mergeTickets(tickets); /* * Performance! When the major sort criteria have lots of repeating strings (like sort * by Priority then Severity then Resolution), performance can crumble. This 'tweak' * foregoes full locale-sensitive sorting to gain performance. (RowSorter defaults to * "Collator" as the comparator for String columns). This speeds up the sorting of the * given scenario 6-fold, measuring a 10,000 ticket sort 600ms -> 100ms. Good find this! * After all that work and multi-threading to get the text searching down to sub 50 ms * (typical) it was a real shame that this Swing table-sort had become the bottleneck! */ for (int col = 1; col < m_Table.getColumnCount(); col++) { m_Table.getRowSorter().setComparator(col, String.CASE_INSENSITIVE_ORDER); } } } private final class DownloadAction extends AbstractAction { private DownloadDialog downloadDialog; public DownloadAction() { super("Download..."); this.putValue(Action.MNEMONIC_KEY, KeyEvent.VK_D); } @Override public void actionPerformed(ActionEvent e) { if (downloadDialog == null) { if (m_Downloads.getBugsFolder() == null) { m_Downloads.setBugsFolder( TracInstantProperties.get().getFilePath( "LocalBugsDir", new File("C:\\bugs"))); } downloadDialog = new DownloadDialog(TracInstantFrame.this, m_Downloads); } downloadDialog.setVisible(true); } } private final class TicketSelectionListener implements ListSelectionListener { private final Timer m_Timer = new Timer(60, e2 -> updateViews()); public TicketSelectionListener() { m_Timer.setRepeats(false); } @Override public void valueChanged(ListSelectionEvent e) { /*- * IGNORE an "adjusting" event while typing in the Search field: * - this will always be zero rows, prior to re-sorting/re-filtering, * so we want to avoid any annoying flicker. * HONOUR an "adjusting" event during a mouse-drag, for better UI feedback */ if (m_RowFilterJustUpdated && e.getValueIsAdjusting()) { return; } m_RowFilterJustUpdated = false; // Add a slight delay for multiple rows. It could be due to mouse-drag, // SHIFT+arrow or updated row-filter. In any case, this is a simple trick // to reduce the frequency of description updates in it is expensive. m_Timer.setInitialDelay((m_Table.getSelectedRowCount() == 1) ? 5 : 60); m_Timer.restart(); } } private WindowAdapter m_OnActivationRefresher = new UpdateTicketsOnWindowActivated(); private class UpdateTicketsOnWindowActivated extends WindowAdapter { @Override public void windowActivated(WindowEvent e) { String problem = slurpAction.slurpIncremental(); if (problem != null) { m_SlurpStatus.showWarning(problem, null); } } } private final TicketTable m_Table; private final HtmlDescriptionPane m_DescriptionPane; private final JSplitPane m_ToolWindowSplit; private final SearchCombo m_FilterCombo; private final JComboBox<ToolPlugin> m_PluginCombo; private final JLabel m_Matches; private final StatusWidget m_SlurpStatus = new StatusWidget(); private final JLabel m_DownloadsNumber; private final Action m_DownloadAction = new DownloadAction(); private final Box m_StatusPanel; private SearchTerm[] m_SearchTerms = new SearchTerm[0]; /** One of those horrible flags you wish didn't need to exist. Just see the code. */ private boolean m_RowFilterJustUpdated = false; private final DownloadModel m_Downloads; private final TableRowFilterComputer m_FilterComputor = new TableRowFilterComputer(); /** * The set of selected tickets taken from the table and currently in display (by the description * panel, downloads util, and any other views plugins). These displays may update a short time * after the table itself has updated, for UI feedback responsiveness. * <p> * It is intended mainly to short-circuit evaluation (don't update if no work is needed); it is * particularly desirable to stop download-info requests hitting network resources * unnecessarily. */ private Ticket[] m_DisplayedTickets = new Ticket[0]; private Map<ToolPlugin, JComponent> m_Plugins = new HashMap<>(); private ToolPlugin m_ActivePlugin = null; private final SlurpAction slurpAction; private final Action performanceAction = new ShowPerformanceMonitorAction(this); private final JSplitPane m_MainArea; private TicketUpdater m_TableModelUpdater = new TicketUpdater() { @Override public void setTicketField(int ticketId, String field, String value) { Ticket t = m_Table.getModel().findTicketByID(ticketId); if (t == null) { System.out.println("Ticket ID not found: " + ticketId); return; } t.putField(field, value); } @Override public void identifyUserField(String fieldName, boolean isUserDefined) { if (isUserDefined) { m_Table.getModel().addUserField(fieldName); } else { m_Table.getModel().removeUserField(fieldName); } } }; public TracInstantFrame(SiteData site) { super("Trac Instant"); slurpAction = new SlurpAction(this, site); m_FilterCombo = createFilterBox(); JLabel filterLabel = createLabel("Filter: ", 'F', m_FilterCombo); m_Table = createTicketTable(site.getTableModel(), m_FilterCombo); m_DescriptionPane = new HtmlDescriptionPane(m_Table.getModel()); ToolTipManager.sharedInstance().registerComponent(m_DescriptionPane); ToolTipManager.sharedInstance().setDismissDelay(60000); m_Matches = new JLabel(); m_Matches.setPreferredSize(new Dimension(110, m_Matches.getPreferredSize().height)); m_PluginCombo = createPluginCombo(); JLabel pluginLabel = createLabel("Tools:", 'T', m_PluginCombo); Box toolPanel = createToolPanel(filterLabel, m_FilterCombo, m_Matches, createNewTicketButton(), pluginLabel, m_PluginCombo); m_DownloadsNumber = new JLabel("", null, SwingConstants.LEFT); m_DownloadsNumber.setHorizontalTextPosition(SwingConstants.LEFT); m_Downloads = createDownloadModel(); m_StatusPanel = createStatusPanel( m_DownloadsNumber, new JButton(m_DownloadAction), Box.createHorizontalGlue(), m_SlurpStatus.getComponent(), new JButton(slurpAction)); m_MainArea = createMainSplitArea(m_Table, m_DescriptionPane, m_StatusPanel); m_ToolWindowSplit = createToolSplit(); Container cp = getContentPane(); cp.setLayout(new BorderLayout()); cp.add(toolPanel, BorderLayout.NORTH); cp.add(m_MainArea); FrameStatePersister wsp = new FrameStatePersister(FRAME_STATE_PROPERTY, this); wsp.restoreFrameState(); wsp.startListening(); } private JButton createNewTicketButton() { String text = "New ticket"; String tooltip = "Create a new Trac ticket using an external web browser"; ActionListener action = e -> { String baseUrl = TracInstantProperties.getURL(); if (baseUrl != null) { try { DesktopUtils.browseTo(new URL(baseUrl + "/newticket")); } catch (MalformedURLException ex) { ex.printStackTrace(); } } }; JButton button = GuiUtilities.createHyperlinkButton(text, tooltip, action); GuiUtilities.makeMaxASmidgeWider(button, GAP); return button; } private JSplitPane createToolSplit() { JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true, null, null); split.setDividerLocation(1.0); split.setResizeWeight(1.0); return split; } private SearchCombo createFilterBox() { final SearchCombo result = new SearchCombo(); addFilterAccelerator(result); JTextComponent editorComp = result.getEditorComponent(); editorComp.getDocument().addDocumentListener( DocUtils.newOnAnyEventListener(() -> SwingUtilities.invokeLater(() -> updateRowFilter()))); editorComp.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isAltDown() && !e.isControlDown() && !e.isMetaDown() && !e.isShiftDown()) { m_Table.requestFocusInWindow(); if (m_Table.getRowCount() > 0) { m_Table.getSelectionModel().setSelectionInterval(0, 0); } } } }); try { result.setToolTipText(FileUtils.copyInputStreamToString(TIP, "ISO-8859-1")); } catch (IOException ex) { ex.printStackTrace(); } // Probably only suitable for some layout managers. Works with current: "Box". result.setMinimumSize(new Dimension(8, 8)); return result; } private void addFilterAccelerator(final SearchCombo result) { JComponent ancestor = (JComponent) getContentPane(); ancestor.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put( KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK), "Select Filter"); ancestor.getActionMap().put("Select Filter", new AbstractAction("Select Filter") { @Override public void actionPerformed(ActionEvent e) { result.requestFocusInWindow(); result.getEditorComponent().selectAll(); } }); } private JLabel createLabel(String name, char mnemonic, JComboBox<?> boundComponent) { JLabel result = new JLabel(name); result.setDisplayedMnemonic(mnemonic); result.setLabelFor(boundComponent); return result; } private JComboBox<ToolPlugin> createPluginCombo() { JComboBox<ToolPlugin> combo = new JComboBox<>(); combo.addItem(new DummyPlugin("None")); combo.addItemListener(e -> { if (e.getStateChange() == ItemEvent.SELECTED) { switchPlugin((ToolPlugin) e.getItem()); } }); GuiUtilities.makeMaxASmidgeWider(combo, GAP); return combo; } protected void switchPlugin(ToolPlugin plugin) { boolean activate = plugin != null && !(plugin instanceof DummyPlugin); if (activate) { if (m_ActivePlugin == null) { getContentPane().remove(m_MainArea); getContentPane().add(m_ToolWindowSplit); m_ToolWindowSplit.setLeftComponent(m_MainArea); } else { m_ActivePlugin.hidden(); } m_ToolWindowSplit.setRightComponent(m_Plugins.get(plugin)); m_ActivePlugin = plugin; m_ActivePlugin.shown(); updatePlugin(); } else { if (m_ActivePlugin != null) { m_ToolWindowSplit.setRightComponent(null); getContentPane().remove(m_ToolWindowSplit); getContentPane().add(m_MainArea); m_ActivePlugin.hidden(); m_ActivePlugin = null; } } validate(); } private TicketTable createTicketTable(TicketTableModel model, SearchCombo filter) { final TicketTable table = new TicketTable(model, filter); table.getSelectionModel().addListSelectionListener(new TicketSelectionListener()); table.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent evt) { if (SwingUtilities.isLeftMouseButton(evt) && evt.getClickCount() == 2) { browseToSelectedTickets(); evt.consume(); } } }); table.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent evt) { if (!KeyEvent.getKeyModifiersText(evt.getModifiers()).isEmpty()) { return; } switch (evt.getKeyCode()) { case KeyEvent.VK_ENTER: browseToSelectedTickets(); evt.consume(); break; case KeyEvent.VK_DELETE: removeSelectedTicketsFromTable(); evt.consume(); break; default: break; } } }); return table; } private DownloadModel createDownloadModel() { DownloadModel result = new DownloadModel(); result.addChangeListener(e -> { m_DownloadsNumber.setIcon(m_Downloads.isBusy() ? BUSY_IMAGE : null); m_DownloadsNumber.setText(m_Downloads.getDownloadSummary()); }); return result; } private static JSplitPane createMainSplitArea( TicketTable table, JEditorPane descriptionPane, Box statusPanel) { JPanel descriptionAndStatus = new JPanel(new BorderLayout()); descriptionAndStatus.add(new JScrollPane(descriptionPane)); descriptionAndStatus.add(statusPanel, BorderLayout.SOUTH); return createSplit(new JScrollPane(table), descriptionAndStatus); } private Box createToolPanel(JComponent... comps) { Box box = Box.createHorizontalBox(); for (JComponent comp : comps) { box.add(Box.createHorizontalStrut(GAP)); box.add(comp); } box.add(Box.createHorizontalStrut(GAP)); return box; } private Box createStatusPanel(JLabel downloadNumber, Component... comps) { final Box box = Box.createHorizontalBox(); box.add(Box.createHorizontalStrut(GAP)); box.add(new JLabel("Attachments: ")); box.add(downloadNumber); // Extra space here box.add(Box.createHorizontalStrut(GAP)); for (Component comp : comps) { box.add(Box.createHorizontalStrut(GAP)); box.add(comp); } box.add(createSneakyPerformanceMonitorButton(box, comps)); return box; } private JComponent createSneakyPerformanceMonitorButton(final Box box, Component... comps) { final JButton pi = GuiUtilities.createHyperlinkButton("", null, performanceAction); pi.setText(" \u03C0 "); pi.setVisible(false); MouseAdapter listener = new MouseAdapter() { @Override public void mouseMoved(MouseEvent e) { pi.setVisible(e.isControlDown() && e.isShiftDown()); } }; pi.addMouseMotionListener(listener); box.addMouseMotionListener(listener); for (Component comp : comps) { comp.addMouseMotionListener(listener); } return pi; } private static JSplitPane createSplit(JComponent top, JComponent bottom) { JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, true, top, bottom); // This is crap. Want the split bar to stay __where it is put__ even when // resized to a postage stamp size and back. Oh well, best we can do easily. split.setResizeWeight(0.3); split.setDividerLocation(0.3); return split; } protected void browseToSelectedTickets() { Ticket[] tickets = m_Table.getSelectedTickets(); try { HtmlDescriptionPane.browseToTickets(tickets); } catch (MalformedURLException ex) { ex.printStackTrace(); } } protected void removeSelectedTicketsFromTable() { Ticket[] tickets = m_Table.getSelectedTickets(); String oldSearch = m_FilterCombo.getEditorText().trim(); String newSearch = isLastTermADeletion(oldSearch) ? expandDeletionTerm(oldSearch, tickets) : appendDeletionTerm(oldSearch, tickets); // Attempt to retain selection even after deleting rows (and regenerating table) int oldRow = m_Table.getSelectionModel().getMaxSelectionIndex(); if (oldRow < m_Table.getRowCount() - 1) { m_Table.getSelectionModel().setSelectionInterval(oldRow + 1, oldRow + 1); } else if (oldRow > 0) { m_Table.getSelectionModel().setSelectionInterval(oldRow - 1, oldRow - 1); } // This will fire change events... m_FilterCombo.setEditorText(newSearch.trim()); } private static final Pattern DELETE_PATTERN = Pattern.compile( "\\-\\#\\:\\^\\(\\d+(\\|\\d+)*\\)\\$"); private boolean isLastTermADeletion(String search) { String[] split = search.split("\\s"); return split.length != 0 && DELETE_PATTERN.matcher(split[split.length - 1]).matches(); } private String expandDeletionTerm(String old, Ticket[] tickets) { assert old.length() > 6; StringBuilder sb = new StringBuilder(old.substring(0, old.length() - 2)); for (Ticket ticket : tickets) { sb.append("|").append(ticket.getNumber()); } sb.append(")$"); return sb.toString(); } private String appendDeletionTerm(String old, Ticket[] tickets) { StringBuilder sb = new StringBuilder(old); sb.append(" -#:^("); String pipe = ""; for (Ticket ticket : tickets) { sb.append(pipe).append(ticket.getNumber()); pipe = "|"; } sb.append(")$"); return sb.toString(); } public void monitorTask(final TicketLoadTask task) { task.addPropertyChangeListener(new TicketLoadListener(task)); } private void updateRowFilter() { // We set the search terms straight away, but don't fire any event so they won't // be applied until the view changes - typically when the search completes. It // could also happen earlier from a list-navigation event, but I think that's // fine; why not highlight matches in the description before the filtering is // complete anyway? m_SearchTerms = SearchTerm.parseSearchString(m_FilterCombo.getExpandedText()); Ticket[] tickets = m_Table.getModel().getTickets(); m_FilterComputor.computeFilter(tickets, m_SearchTerms, rowFilter -> { long t0 = System.nanoTime(); m_RowFilterJustUpdated = true; m_Table.getRowSorter().setRowFilter(rowFilter); updateMatches(); System.out.format("Sort rows: %.2f ms\n", (System.nanoTime() - t0) / 1000000f); }); } private void updateMatches() { int rows = m_Table.getRowCount(); m_Matches.setText(rows == 0 ? "" : "Matches: " + rows); } private void updateViews() { displaySelectedTickets(); updatePlugin(); } private void displaySelectedTickets() { Ticket[] selected = getSelectedTickets(); String text = HtmlFormatter.buildDescription(selected, m_SearchTerms); m_DescriptionPane.updateDescription(text); // Avoid updating downloads via this very simple check. (Could make more // sophisticated) if (!Arrays.equals(selected, m_DisplayedTickets)) { m_Downloads.count(selected); } m_DisplayedTickets = Arrays.copyOf(selected, selected.length); } private void updatePlugin() { if (m_ActivePlugin != null) { m_ActivePlugin.ticketViewUpdated(getViewedTickets(), getSelectedTickets()); } } private Ticket[] getViewedTickets() { TicketTableModel model = m_Table.getModel(); Ticket[] tickets = new Ticket[m_Table.getRowCount()]; for (int i = 0; i < tickets.length; i++) { int row = m_Table.convertRowIndexToModel(i); tickets[i] = model.getTicket(row); } return tickets; } private Ticket[] getSelectedTickets() { int[] rows = m_Table.getSelectedRows(); TicketTableModel model = m_Table.getModel(); Ticket[] tickets = new Ticket[rows.length]; for (int i = 0; i < rows.length; i++) { int row = m_Table.convertRowIndexToModel(rows[i]); tickets[i] = model.getTicket(row); } return tickets; } @Override public void dispose() { new FrameStatePersister(FRAME_STATE_PROPERTY, this).saveFrameState(); switchPlugin(null); m_FilterComputor.shutdown(); m_FilterCombo.saveSearches(); super.dispose(); } public void installToolPanel(ToolPlugin plugin) { m_Plugins.put(plugin, plugin.initialise(m_TableModelUpdater)); m_PluginCombo.addItem(plugin); GuiUtilities.makeMaxASmidgeWider(m_PluginCombo, GAP); } public TicketTableModel getTicketModel() { return m_Table.getModel(); } public SlurpAction getSlurpAction() { return slurpAction; } }