001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.history;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Component;
007import java.awt.Dimension;
008import java.awt.Point;
009import java.awt.Rectangle;
010import java.awt.event.ActionEvent;
011import java.awt.event.ItemEvent;
012import java.awt.event.ItemListener;
013import java.awt.event.KeyAdapter;
014import java.awt.event.KeyEvent;
015import java.awt.event.MouseEvent;
016import java.util.Objects;
017import java.util.stream.IntStream;
018
019import javax.swing.DefaultCellEditor;
020import javax.swing.JCheckBox;
021import javax.swing.JPopupMenu;
022import javax.swing.JRadioButton;
023import javax.swing.JTable;
024import javax.swing.SwingConstants;
025import javax.swing.UIManager;
026import javax.swing.event.ChangeEvent;
027import javax.swing.event.ChangeListener;
028import javax.swing.table.TableCellRenderer;
029
030import org.openstreetmap.josm.actions.AbstractInfoAction;
031import org.openstreetmap.josm.data.osm.User;
032import org.openstreetmap.josm.data.osm.history.History;
033import org.openstreetmap.josm.data.osm.history.HistoryOsmPrimitive;
034import org.openstreetmap.josm.gui.util.GuiHelper;
035import org.openstreetmap.josm.gui.util.TableHelper;
036import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
037import org.openstreetmap.josm.io.XmlWriter;
038import org.openstreetmap.josm.spi.preferences.Config;
039import org.openstreetmap.josm.tools.Destroyable;
040import org.openstreetmap.josm.tools.ImageProvider;
041import org.openstreetmap.josm.tools.OpenBrowser;
042
043/**
044 * VersionTable shows a list of version in a {@link org.openstreetmap.josm.data.osm.history.History}
045 * of an {@link org.openstreetmap.josm.data.osm.OsmPrimitive}.
046 * @since 1709
047 */
048public class VersionTable extends JTable implements ChangeListener, Destroyable {
049    private VersionTablePopupMenu popupMenu;
050    private final transient HistoryBrowserModel model;
051
052    /**
053     * Constructs a new {@code VersionTable}.
054     * @param model model used by the history browser
055     */
056    public VersionTable(HistoryBrowserModel model) {
057        super(model.getVersionTableModel(), new VersionTableColumnModel());
058        model.addChangeListener(this);
059        build();
060        this.model = model;
061    }
062
063    /**
064     * Builds the table.
065     */
066    protected void build() {
067        getTableHeader().setFont(getTableHeader().getFont().deriveFont(9f));
068        setRowSelectionAllowed(false);
069        setShowGrid(false);
070        setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
071        TableHelper.setFont(this, getClass());
072        GuiHelper.setBackgroundReadable(this, UIManager.getColor("Button.background"));
073        setIntercellSpacing(new Dimension(6, 0));
074        putClientProperty("terminateEditOnFocusLost", Boolean.TRUE);
075        popupMenu = new VersionTablePopupMenu();
076        addMouseListener(new MouseListener());
077        addKeyListener(new KeyAdapter() {
078            @Override
079            public void keyReleased(KeyEvent e) {
080                // navigate history down/up using the corresponding arrow keys.
081                long ref = model.getReferencePointInTime().getVersion();
082                long cur = model.getCurrentPointInTime().getVersion();
083                if (e.getKeyCode() == KeyEvent.VK_DOWN) {
084                    History refNext = model.getHistory().from(ref);
085                    History curNext = model.getHistory().from(cur);
086                    if (refNext.getNumVersions() > 1 && curNext.getNumVersions() > 1) {
087                        model.setReferencePointInTime(refNext.sortAscending().get(1));
088                        model.setCurrentPointInTime(curNext.sortAscending().get(1));
089                    }
090                } else if (e.getKeyCode() == KeyEvent.VK_UP) {
091                    History refNext = model.getHistory().until(ref);
092                    History curNext = model.getHistory().until(cur);
093                    if (refNext.getNumVersions() > 1 && curNext.getNumVersions() > 1) {
094                        model.setReferencePointInTime(refNext.sortDescending().get(1));
095                        model.setCurrentPointInTime(curNext.sortDescending().get(1));
096                    }
097                }
098            }
099        });
100        getModel().addTableModelListener(e ->
101                IntStream.range(0, model.getHistory().getNumVersions()).filter(model::isCurrentPointInTime).findFirst().ifPresent(row ->
102                        scrollRectToVisible(getCellRect(row, 0, true))));
103        getModel().addTableModelListener(e -> {
104            adjustColumnWidth(this, 0, 0);
105            adjustColumnWidth(this, 1, -8);
106            adjustColumnWidth(this, 2, -8);
107            adjustColumnWidth(this, 3, 0);
108            adjustColumnWidth(this, 4, 0);
109            adjustColumnWidth(this, 5, 0);
110        });
111    }
112
113    @Override
114    public void destroy() {
115        popupMenu.destroy();
116    }
117
118    // some kind of hack to prevent the table from scrolling to the
119    // right when clicking on the cells
120    @Override
121    public void scrollRectToVisible(Rectangle aRect) {
122        super.scrollRectToVisible(new Rectangle(0, aRect.y, aRect.width, aRect.height));
123    }
124
125    @Override
126    public void stateChanged(ChangeEvent e) {
127        repaint();
128    }
129
130    final class MouseListener extends PopupMenuLauncher {
131        private MouseListener() {
132            super(Objects.requireNonNull(popupMenu));
133        }
134
135        @Override
136        public void mousePressed(MouseEvent e) {
137            super.mousePressed(e);
138            if (!e.isPopupTrigger() && e.getButton() == MouseEvent.BUTTON1) {
139                int row = rowAtPoint(e.getPoint());
140                int col = columnAtPoint(e.getPoint());
141                if (row >= 0 && (col == VersionTableColumnModel.COL_DATE || col == VersionTableColumnModel.COL_USER)) {
142                    model.setCurrentPointInTime(row);
143                    model.setReferencePointInTime(Math.max(0, row - 1));
144                }
145            }
146        }
147
148        @Override
149        protected int checkTableSelection(JTable table, Point p) {
150            int row = rowAtPoint(p);
151            if (row > -1 && !model.isLatest(row)) {
152                popupMenu.prepare(model.getPrimitive(row));
153            }
154            return row;
155        }
156    }
157
158    static class ChangesetInfoAction extends AbstractInfoAction {
159        private transient HistoryOsmPrimitive primitive;
160
161        /**
162         * Constructs a new {@code ChangesetInfoAction}.
163         */
164        ChangesetInfoAction() {
165            super(true);
166            putValue(NAME, tr("Changeset info"));
167            putValue(SHORT_DESCRIPTION, tr("Launch browser with information about the changeset"));
168            new ImageProvider("help/internet").getResource().attachImageIcon(this, true);
169        }
170
171        @Override
172        protected String createInfoUrl(Object infoObject) {
173            if (infoObject instanceof HistoryOsmPrimitive) {
174                HistoryOsmPrimitive prim = (HistoryOsmPrimitive) infoObject;
175                return Config.getUrls().getBaseBrowseUrl() + "/changeset/" + prim.getChangesetId();
176            } else {
177                return null;
178            }
179        }
180
181        @Override
182        public void actionPerformed(ActionEvent e) {
183            if (!isEnabled())
184                return;
185            String url = createInfoUrl(primitive);
186            OpenBrowser.displayUrl(url);
187        }
188
189        public void prepare(HistoryOsmPrimitive primitive) {
190            putValue(NAME, tr("Show changeset {0}", primitive.getChangesetId()));
191            this.primitive = primitive;
192        }
193    }
194
195    static class UserInfoAction extends AbstractInfoAction {
196        private transient HistoryOsmPrimitive primitive;
197
198        /**
199         * Constructs a new {@code UserInfoAction}.
200         */
201        UserInfoAction() {
202            super(true);
203            putValue(NAME, tr("User info"));
204            putValue(SHORT_DESCRIPTION, tr("Launch browser with information about the user"));
205            new ImageProvider("data/user").getResource().attachImageIcon(this, true);
206        }
207
208        @Override
209        protected String createInfoUrl(Object infoObject) {
210            if (infoObject instanceof HistoryOsmPrimitive) {
211                HistoryOsmPrimitive hp = (HistoryOsmPrimitive) infoObject;
212                return hp.getUser() == null ? null : Config.getUrls().getBaseUserUrl() + '/' + hp.getUser().getName();
213            } else {
214                return null;
215            }
216        }
217
218        @Override
219        public void actionPerformed(ActionEvent e) {
220            if (!isEnabled())
221                return;
222            String url = createInfoUrl(primitive);
223            OpenBrowser.displayUrl(url);
224        }
225
226        public void prepare(HistoryOsmPrimitive primitive) {
227            final User user = primitive.getUser();
228            putValue(NAME, "<html>" + tr("Show user {0}", user == null ? "?" :
229                    XmlWriter.encode(user.getName(), true) + " <font color=gray>(" + user.getId() + ")</font>") + "</html>");
230            this.primitive = primitive;
231        }
232    }
233
234    static class VersionTablePopupMenu extends JPopupMenu implements Destroyable {
235
236        private ChangesetInfoAction changesetInfoAction;
237        private UserInfoAction userInfoAction;
238
239        /**
240         * Constructs a new {@code VersionTablePopupMenu}.
241         */
242        VersionTablePopupMenu() {
243            super();
244            build();
245        }
246
247        protected void build() {
248            changesetInfoAction = new ChangesetInfoAction();
249            add(changesetInfoAction);
250            userInfoAction = new UserInfoAction();
251            add(userInfoAction);
252        }
253
254        public void prepare(HistoryOsmPrimitive primitive) {
255            changesetInfoAction.prepare(primitive);
256            userInfoAction.prepare(primitive);
257            invalidate();
258        }
259
260        @Override
261        public void destroy() {
262            if (changesetInfoAction != null) {
263                changesetInfoAction.destroy();
264                changesetInfoAction = null;
265            }
266            if (userInfoAction != null) {
267                userInfoAction.destroy();
268                userInfoAction = null;
269            }
270        }
271    }
272
273    /**
274     * Renderer for history radio buttons in columns A and B.
275     */
276    public static class RadioButtonRenderer extends JRadioButton implements TableCellRenderer {
277
278        @Override
279        public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus,
280                int row, int column) {
281            setSelected(value != null && (Boolean) value);
282            setHorizontalAlignment(SwingConstants.CENTER);
283            return this;
284        }
285    }
286
287    /**
288     * Editor for history radio buttons in columns A and B.
289     */
290    public static class RadioButtonEditor extends DefaultCellEditor implements ItemListener {
291
292        private final JRadioButton btn;
293
294        /**
295         * Constructs a new {@code RadioButtonEditor}.
296         */
297        public RadioButtonEditor() {
298            super(new JCheckBox());
299            btn = new JRadioButton();
300            btn.setHorizontalAlignment(SwingConstants.CENTER);
301        }
302
303        @Override
304        public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
305            if (value == null)
306                return null;
307            boolean val = (Boolean) value;
308            btn.setSelected(val);
309            btn.addItemListener(this);
310            return btn;
311        }
312
313        @Override
314        public Object getCellEditorValue() {
315            btn.removeItemListener(this);
316            return btn.isSelected();
317        }
318
319        @Override
320        public void itemStateChanged(ItemEvent e) {
321            fireEditingStopped();
322        }
323    }
324
325    private static void adjustColumnWidth(JTable tbl, int col, int cellInset) {
326        int maxwidth = 0;
327
328        for (int row = 0; row < tbl.getRowCount(); row++) {
329            TableCellRenderer tcr = tbl.getCellRenderer(row, col);
330            Object val = tbl.getValueAt(row, col);
331            Component comp = tcr.getTableCellRendererComponent(tbl, val, false, false, row, col);
332            maxwidth = Math.max(comp.getPreferredSize().width + cellInset, maxwidth);
333        }
334        TableCellRenderer tcr = tbl.getTableHeader().getDefaultRenderer();
335        Object val = tbl.getColumnModel().getColumn(col).getHeaderValue();
336        Component comp = tcr.getTableCellRendererComponent(tbl, val, false, false, -1, col);
337        maxwidth = Math.max(comp.getPreferredSize().width + Config.getPref().getInt("table.header-inset", 0), maxwidth);
338
339        int spacing = tbl.getIntercellSpacing().width;
340        tbl.getColumnModel().getColumn(col).setPreferredWidth(maxwidth + spacing);
341    }
342}