/*
* $Id$
*
* Copyright (c) 2000-2008 by Rodney Kinney, Joel Uckelman
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public
* License (LGPL) as published by the Free Software Foundation.
*
* This library 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
* Library General Public License for more details.
*
* You should have received a copy of the GNU Library General Public
* License along with this library; if not, copies are available
* at http://www.opensource.org.
*/
package VASSAL.build.module.map;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import javax.swing.AbstractListModel;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JRadioButtonMenuItem;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.JTextField;
import javax.swing.ListModel;
import javax.swing.ListSelectionModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import VASSAL.build.AbstractConfigurable;
import VASSAL.build.AutoConfigurable;
import VASSAL.build.Buildable;
import VASSAL.build.GameModule;
import VASSAL.build.module.GameComponent;
import VASSAL.build.module.Map;
import VASSAL.build.module.documentation.HelpFile;
import VASSAL.command.Command;
import VASSAL.configure.Configurer;
import VASSAL.configure.ConfigurerFactory;
import VASSAL.configure.IconConfigurer;
import VASSAL.configure.SingleChildInstance;
import VASSAL.configure.StringArrayConfigurer;
import VASSAL.i18n.Resources;
import VASSAL.tools.ErrorDialog;
import VASSAL.tools.LaunchButton;
import VASSAL.tools.NamedKeyStroke;
/**
* Controls the zooming in/out of a {@link Map} window.
*
* @author Joel Uckelman
*/
public class Zoomer extends AbstractConfigurable implements GameComponent {
protected Map map;
@Deprecated protected double zoom = 1.0;
@Deprecated protected int zoomLevel = 0;
@Deprecated protected int zoomStart = 1;
@Deprecated protected double[] zoomFactor;
@Deprecated protected int maxZoom = 4;
protected LaunchButton zoomInButton;
protected LaunchButton zoomPickButton;
protected LaunchButton zoomOutButton;
protected ZoomMenu zoomMenu;
protected State state;
// the default zoom levels are powers of 1.6
protected static final double[] defaultZoomLevels = new double[] {
1.0/1.6/1.6,
1.0/1.6,
1.0,
1.6
};
protected static final int defaultInitialZoomLevel = 2;
/**
* Stores the state information for the {@link Zoomer}. This class
* exists to keep the <code>Zoomer</code> data separate from the
* <code>Zoomer</code> GUI.
*
* <p>Predefined zoom levels are stored in <code>levels</code>. If we are
* in a predefined zoom level, then <code>custom == -1</code> and
* <code>levels[cur]</code> is the current zoom factor. If we are in
* a user-defined zoom level, then <code>custom</code> is the current
* zoom factor and <code>cur</code> is the greatest value such that
* {@code custom < level[cur]}}.</p>
*
* @author Joel Uckelman
* @since 3.1.0
*/
protected static class State {
private double custom;
private final double[] levels;
private int cur;
private final int initial;
public State(double[] levels, int initial) {
this.levels = levels;
Arrays.sort(this.levels);
cur = this.initial = initial;
custom = -1;
}
public State(Collection<Double> l, int initial) {
levels = new double[l.size()];
int i = 0;
for (Double d : l) levels[i++] = d;
Arrays.sort(levels);
cur = this.initial = initial;
custom = -1;
}
public double getZoom() {
return custom < 0 ? levels[cur] : custom;
}
public void setZoom(double z) {
if (z <= 0.0) {
// This should never happen, it's just a kludge to make sure that
// we continue having valid data even if our caller is wrong.
z = Double.MIN_VALUE;
}
cur = Arrays.binarySearch(levels, z);
if (cur < 0) {
// if z is not a level, set cur to the next level > z
cur = -cur-1;
// check whether we are close to a level
if (cur < levels.length && Math.abs(z - levels[cur]) < 0.005) {
custom = -1;
}
else if (cur > 0 && Math.abs(z - levels[cur-1]) < 0.005) {
--cur;
custom = -1;
}
else {
custom = z;
}
}
else {
// custom is negative when we are in a predefined zoom level
custom = -1;
}
}
public int getLevel() {
return cur;
}
public void setLevel(int l) {
cur = l;
custom = -1;
}
public int getInitialLevel() {
return initial;
}
public int getLevelCount() {
return levels.length;
}
public boolean atLevel() {
return custom < 0;
}
public void lowerLevel() {
if (custom >= 0) custom = -1;
--cur;
}
public void higherLevel() {
if (custom < 0) ++cur;
else custom = -1;
}
public boolean hasLowerLevel() {
return cur > 0;
}
public boolean hasHigherLevel() {
return custom < 0 ? cur < levels.length-1 : cur < levels.length;
}
public List<Double> getLevels() {
final ArrayList<Double> l = new ArrayList<Double>(levels.length);
for (double d : levels) l.add(d);
return l;
}
}
public Zoomer() {
state = new State(defaultZoomLevels, defaultInitialZoomLevel);
ActionListener zoomIn = new ActionListener() {
public void actionPerformed(ActionEvent e) {
zoomIn();
}
};
ActionListener zoomOut = new ActionListener() {
public void actionPerformed(ActionEvent e) {
zoomOut();
}
};
zoomMenu = new ZoomMenu();
ActionListener zoomPick = new ActionListener() {
public void actionPerformed(ActionEvent e) {
if (zoomPickButton.isShowing()) {
zoomMenu.show(zoomPickButton, 0, zoomPickButton.getHeight());
}
}
};
zoomPickButton = new LaunchButton(null, PICK_TOOLTIP, PICK_BUTTON_TEXT,
ZOOM_PICK, PICK_ICON_NAME, zoomPick);
zoomPickButton.setAttribute(PICK_TOOLTIP,
Resources.getString("Zoomer.zoom_select")); //$NON-NLS-1$
zoomPickButton.setAttribute(PICK_ICON_NAME, PICK_DEFAULT_ICON);
zoomInButton = new LaunchButton(null, IN_TOOLTIP, IN_BUTTON_TEXT,
ZOOM_IN, IN_ICON_NAME, zoomIn);
zoomInButton.setAttribute(IN_TOOLTIP,
Resources.getString("Zoomer.zoom_in")); //$NON-NLS-1$
zoomInButton.setAttribute(IN_ICON_NAME, IN_DEFAULT_ICON);
zoomOutButton = new LaunchButton(null, OUT_TOOLTIP, OUT_BUTTON_TEXT,
ZOOM_OUT, OUT_ICON_NAME, zoomOut);
zoomOutButton.setAttribute(OUT_TOOLTIP,
Resources.getString("Zoomer.zoom_out")); //$NON-NLS-1$
zoomOutButton.setAttribute(OUT_ICON_NAME, OUT_DEFAULT_ICON);
setConfigureName(null);
init();
}
protected void init() {
zoomInButton.setEnabled(state.hasHigherLevel());
zoomPickButton.setEnabled(true);
zoomOutButton.setEnabled(state.hasLowerLevel());
zoomMenu.initZoomItems();
}
public static String getConfigureTypeName() {
return Resources.getString("Editor.Zoom.component_type"); //$NON-NLS-1$
}
public String[] getAttributeNames() {
return new String[]{
ZOOM_START,
ZOOM_LEVELS,
IN_TOOLTIP,
IN_BUTTON_TEXT,
IN_ICON_NAME,
ZOOM_IN,
PICK_TOOLTIP,
PICK_BUTTON_TEXT,
PICK_ICON_NAME,
ZOOM_PICK,
OUT_TOOLTIP,
OUT_BUTTON_TEXT,
OUT_ICON_NAME,
ZOOM_OUT
};
}
public String[] getAttributeDescriptions() {
return new String[]{
"", //$NON-NLS-1$
Resources.getString("Editor.Zoom.preset"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.in_tooltip"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.in_button"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.in_icon"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.in_key"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.select_tooltip"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.select_button"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.select_icon"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.select_key"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.out_tooltip"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.out_button"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.out_icon"), //$NON-NLS-1$
Resources.getString("Editor.Zoom.out_key"), //$NON-NLS-1$
};
}
public Class<?>[] getAttributeTypes() {
return new Class<?>[]{
null, // ZOOM_START is handled by the LevelConfigurer
LevelConfig.class,
String.class,
String.class,
InIconConfig.class,
NamedKeyStroke.class,
String.class,
String.class,
PickIconConfig.class,
NamedKeyStroke.class,
String.class,
String.class,
OutIconConfig.class,
NamedKeyStroke.class
};
}
public static class InIconConfig implements ConfigurerFactory {
public Configurer getConfigurer(AutoConfigurable c,
String key, String name) {
return new IconConfigurer(key, name, IN_DEFAULT_ICON);
}
}
public static class PickIconConfig implements ConfigurerFactory {
public Configurer getConfigurer(AutoConfigurable c,
String key, String name) {
return new IconConfigurer(key, name, PICK_DEFAULT_ICON);
}
}
public static class OutIconConfig implements ConfigurerFactory {
public Configurer getConfigurer(AutoConfigurable c,
String key, String name) {
return new IconConfigurer(key, name, OUT_DEFAULT_ICON);
}
}
public static class LevelConfig implements ConfigurerFactory {
public Configurer getConfigurer(AutoConfigurable c,
String key, String name) {
return new LevelConfigurer((Zoomer) c, key, name);
}
}
/**
* The {@link Configurer} for {@link #ZOOM_LEVELS} and {@link #ZOOM_START}.
*
* @author Joel Uckelman
* @since 3.1.0
*/
protected static class LevelConfigurer extends Configurer {
private Zoomer z;
private JPanel panel;
private LevelModel model;
private JList levelList;
private JButton addButton;
private JButton removeButton;
private JButton initialButton;
private JTextField levelField;
public LevelConfigurer(final Zoomer z, String key, String name) {
super(key, name);
this.z = z;
panel = new JPanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.X_AXIS));
final Box leftBox = Box.createVerticalBox();
final Box addBox = Box.createHorizontalBox();
// Add button
addButton = new JButton(Resources.getString(Resources.ADD));
addButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) { addLevel(); }
});
addButton.setEnabled(false);
addBox.add(addButton);
levelField = new JTextField(8);
levelField.setMaximumSize(new Dimension(
Integer.MAX_VALUE, levelField.getPreferredSize().height));
// validator for the level entry field
levelField.getDocument().addDocumentListener(new DocumentListener() {
public void changedUpdate(DocumentEvent e) { }
public void insertUpdate(DocumentEvent e) { validate(); }
public void removeUpdate(DocumentEvent e) { validate(); }
private static final String pattern =
"^(\\d*[1-9]\\d*(/\\d*[1-9]\\d*|\\.\\d*)?|0*\\.\\d*[1-9]\\d*)$"; //$NON-NLS-1$
private void validate() {
// valid entries match the pattern and aren't already in the list
final String text = levelField.getText();
addButton.setEnabled(text.matches(pattern) &&
!z.state.getLevels().contains(parseLevel(text)));
}
});
// rely on addButton to do the validation
levelField.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
if (addButton.isEnabled()) addLevel();
}
});
addBox.add(levelField);
leftBox.add(addBox);
final Box buttonBox = Box.createHorizontalBox();
// Remove button
removeButton = new JButton(Resources.getString(Resources.REMOVE));
removeButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
// get the zoom level index to be removed
final int rm_level = levelList.getSelectedIndex();
final List<Double> l = z.state.getLevels();
final int new_init;
if (rm_level == z.state.getInitialLevel()) {
// we're deleting the initial level; keep it the same position
new_init = Math.min(rm_level, z.state.getLevelCount()-2);
l.remove(rm_level);
}
else {
// find the new index of the old initial level
final Double old_init_val = l.get(z.state.getInitialLevel());
l.remove(rm_level);
new_init = l.indexOf(old_init_val);
}
// adjust the state
z.state = new State(l, new_init);
z.init();
model.updateModel();
// adjust the selection
levelList.setSelectedIndex(
Math.max(Math.min(rm_level, l.size()-1), 0));
updateButtons();
}
});
buttonBox.add(removeButton);
// Set Initial button
initialButton = new JButton(Resources.getString("Editor.zoom.set_initial")); //$NON-NLS-1$
initialButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
// set the new initial scale level
final int i = levelList.getSelectedIndex();
z.state = new State(z.state.getLevels(), i);
z.init();
model.updateModel();
updateButtons();
}
});
buttonBox.add(initialButton);
leftBox.add(buttonBox);
final JLabel explanation =
new JLabel(Resources.getString("Editor.zoom.initial_zoom")); //$NON-NLS-1$
explanation.setAlignmentX(JLabel.CENTER_ALIGNMENT);
leftBox.add(
Box.createVerticalStrut(explanation.getPreferredSize().height));
leftBox.add(explanation);
leftBox.add(
Box.createVerticalStrut(explanation.getPreferredSize().height));
// level list
model = new LevelModel();
levelList = new JList(model);
levelList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
levelList.setSelectedIndex(0);
levelList.addListSelectionListener(new ListSelectionListener() {
public void valueChanged(ListSelectionEvent e) { updateButtons(); }
});
final JSplitPane pane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
pane.setLeftComponent(leftBox);
pane.setRightComponent(new JScrollPane(levelList));
panel.add(pane);
panel.setBorder(new TitledBorder(name));
updateButtons();
}
/**
* Parse a <code>String</code> to a <code>double</code>.
* Accepts fractions as "n/d".
*/
protected double parseLevel(String text) {
final String[] s = text.split("/"); //$NON-NLS-1$
try {
return s.length > 1 ?
Double.parseDouble(s[0])/Double.parseDouble(s[1]) :
Double.parseDouble(s[0]);
}
catch (NumberFormatException ex) {
// should not happen, text already validated
ErrorDialog.bug(ex);
}
return 0.0;
}
/**
* Add a level to the level list. This method expects that the
* intput has already been validated.
*/
protected void addLevel() {
// get the initial scale level
final List<Double> l = z.state.getLevels();
final Double old_init_val = l.get(z.state.getInitialLevel());
// add the new scale level
final double new_level_val = parseLevel(levelField.getText());
l.add(new_level_val);
Collections.sort(l);
// find the initial scale index
final int new_init = l.indexOf(old_init_val);
// adjust the state
z.state = new State(l, new_init);
z.init();
model.updateModel();
// adjust the selection
final int new_level = l.indexOf(new_level_val);
levelList.setSelectedIndex(new_level);
levelField.setText("");
updateButtons();
}
/**
* Ensures that the buttons are properly en- or disabled.
*/
protected void updateButtons() {
removeButton.setEnabled(z.state.getLevelCount() > 1);
initialButton.setEnabled(
levelList.getSelectedIndex() != z.state.getInitialLevel());
}
/**
* A {@link ListModel} built on the {@link State}.
*/
protected class LevelModel extends AbstractListModel {
private static final long serialVersionUID = 1L;
public void updateModel() {
fireContentsChanged(this, 0, z.state.getLevelCount()-1);
}
public Object getElementAt(int i) {
return z.state.getLevels().get(i) +
(z.state.getInitialLevel() == i ? " *" : ""); //$NON-NLS-1$ //$NON-NLS-2$
}
public int getSize() {
return z.state.getLevelCount();
}
}
@Override
public Component getControls() {
return panel;
}
@Override
public void setValue(Object o) {
}
@Override
public void setValue(String s) {
}
@Override
public String getValueString() {
return null;
}
}
protected static final String ZOOM_START = "zoomStart"; //$NON-NLS-1$
protected static final String ZOOM_LEVELS = "zoomLevels"; //$NON-NLS-1$
protected static final String ZOOM_IN = "zoomInKey"; //$NON-NLS-1$
protected static final String IN_TOOLTIP = "inTooltip"; //$NON-NLS-1$
protected static final String IN_BUTTON_TEXT = "inButtonText"; //$NON-NLS-1$
protected static final String IN_ICON_NAME = "inIconName"; //$NON-NLS-1$
protected static final String IN_DEFAULT_ICON = "/images/zoomIn.gif"; //$NON-NLS-1$
protected static final String ZOOM_PICK = "zoomPickKey"; //$NON-NLS-1$
protected static final String PICK_TOOLTIP = "pickTooltip"; //$NON-NLS-1$
protected static final String PICK_BUTTON_TEXT = "pickButtonText"; //$NON-NLS-1$
protected static final String PICK_ICON_NAME = "pickIconName"; //$NON-NLS-1$
protected static final String PICK_DEFAULT_ICON = "/images/zoom.png"; //$NON-NLS-1$
protected static final String ZOOM_OUT = "zoomOutKey"; //$NON-NLS-1$
protected static final String OUT_TOOLTIP = "outTooltip"; //$NON-NLS-1$
protected static final String OUT_BUTTON_TEXT = "outButtonText"; //$NON-NLS-1$
protected static final String OUT_ICON_NAME = "outIconName"; //$NON-NLS-1$
protected static final String OUT_DEFAULT_ICON = "/images/zoomOut.gif"; //$NON-NLS-1$
public void addTo(Buildable b) {
GameModule.getGameModule().getGameState().addGameComponent(this);
map = (Map) b;
validator = new SingleChildInstance(map, getClass());
map.setZoomer(this);
map.getToolBar().add(zoomInButton);
map.getToolBar().add(zoomPickButton);
map.getToolBar().add(zoomOutButton);
}
public String getAttributeValueString(String key) {
if (ZOOM_START.equals(key)) {
// Notes:
//
// 1. ZOOM_START is one-based, not zero-based.
// 2. The levels in state run from zoomed out to zoomed in,
// while the levels coming from outside Zoomer run from
// zoomed in to zoomed out. Hence we reverse the initial
// zoom level being returned here.
//
return String.valueOf(state.getLevelCount() - state.getInitialLevel());
}
else if (ZOOM_LEVELS.equals(key)) {
final List<Double> levels = state.getLevels();
final String[] s = new String[levels.size()];
for (int i = 0; i < s.length; ++i) {
s[i] = levels.get(i).toString();
}
return StringArrayConfigurer.arrayToString(s);
}
else if (zoomInButton.getAttributeValueString(key) != null) {
return zoomInButton.getAttributeValueString(key);
}
else if (zoomPickButton.getAttributeValueString(key) != null) {
return zoomPickButton.getAttributeValueString(key);
}
else {
return zoomOutButton.getAttributeValueString(key);
}
}
public void setAttribute(String key, Object val) {
if (ZOOM_START.equals(key)) {
if (val instanceof String) {
val = Integer.valueOf((String) val);
}
if (val != null) {
// Notes:
//
// 1. ZOOM_START is one-based, not zero-based.
// 2. The levels in state run from zoomed out to zoomed in,
// while the levels coming from outside Zoomer run from
// zoomed in to zoomed out. Hence we reverse the initial
// zoom level being set here.
//
final List<Double> levels = state.getLevels();
final int initial =
Math.max(0, Math.min(levels.size()-1, levels.size()-(Integer) val));
state = new State(levels, initial);
if (deprecatedFactor > 0 && deprecatedMax > 0) {
// zero these to prevent further adjustments due to old properties
deprecatedFactor = 0.0;
deprecatedMax = 0;
}
init();
}
}
else if (ZOOM_LEVELS.equals(key)) {
if (val instanceof String) {
val = StringArrayConfigurer.stringToArray((String) val);
}
if (val != null) {
// dump into a set to remove duplicates
final HashSet<Double> levels = new HashSet<Double>();
for (String s : (String[]) val) {
levels.add(Double.valueOf(s));
}
state = new State(levels,
Math.min(state.getInitialLevel(), levels.size()-1));
init();
}
}
else if (FACTOR.equals(key)) { // deprecated key
if (val instanceof String) {
val = Double.valueOf((String) val);
}
if (val != null) {
deprecatedFactor = (Double) val;
if (deprecatedFactor > 0 && deprecatedMax > 0) {
adjustStateForFactorAndMax();
}
}
}
else if (MAX.equals(key)) { // deprecated key
if (val instanceof String) {
val = Integer.valueOf((String) val);
}
if (val != null) {
deprecatedMax = (Integer) val;
if (deprecatedFactor > 0 && deprecatedMax > 0) {
adjustStateForFactorAndMax();
}
}
}
else {
// FIXME: does having this as an extremal case cause weird behavior for
// unrecognized keys?
zoomInButton.setAttribute(key, val);
zoomPickButton.setAttribute(key, val);
zoomOutButton.setAttribute(key, val);
}
}
// begin deprecated keys
private static final String FACTOR = "factor"; //$NON-NLS-1$
private static final String MAX = "max"; //$NON-NLS-1$
private int deprecatedMax = -1;
private double deprecatedFactor = -1.0;
private void adjustStateForFactorAndMax() {
final double[] levels = new double[deprecatedMax+1];
for (int i = 0; i < levels.length; ++i)
levels[i] = Math.pow(deprecatedFactor, -(i-1));
final int initial = Math.min(state.getInitialLevel(), levels.length-1);
state = new State(levels, initial);
init();
}
// end deprecated keys
public Class<?>[] getAllowableConfigureComponents() {
return new Class<?>[0];
}
public void removeFrom(Buildable b) {
map = (Map) b;
map.setZoomer(null);
map.getToolBar().remove(zoomInButton);
map.getToolBar().remove(zoomPickButton);
map.getToolBar().remove(zoomOutButton);
}
public double getZoomFactor() {
return state.getZoom();
}
protected Point getMapCenter() {
final Rectangle r = map.getView().getVisibleRect();
return map.mapCoordinates(new Point(r.x + r.width/2, r.y + r.height/2));
}
protected void updateZoomer(Point center) {
zoomInButton.setEnabled(state.hasHigherLevel());
zoomOutButton.setEnabled(state.hasLowerLevel());
zoomMenu.updateZoom();
final Dimension d = map.getPreferredSize();
map.getView().setBounds(0,0,d.width,d.height); // calls revalidate()
map.centerAt(center);
map.repaint(true);
}
public void setZoomLevel(int l) {
final Point center = getMapCenter();
state.setLevel(l);
updateZoomer(center);
}
public void setZoomFactor(double z) {
final Point center = getMapCenter();
state.setZoom(z);
updateZoomer(center);
}
public void zoomIn() {
if (state.hasHigherLevel()) {
final Point center = getMapCenter();
state.higherLevel();
updateZoomer(center);
}
}
public void zoomOut() {
if (state.hasLowerLevel()) {
final Point center = getMapCenter();
state.lowerLevel();
updateZoomer(center);
}
}
public HelpFile getHelpFile() {
return HelpFile.getReferenceManualPage("Map.htm", "Zoom"); //$NON-NLS-1$ //$NON-NLS-2$
}
public void setup(boolean gameStarting) {
if (!gameStarting) {
zoomInButton.setEnabled(state.hasHigherLevel());
zoomOutButton.setEnabled(state.hasLowerLevel());
}
zoomPickButton.setEnabled(gameStarting);
}
public Command getRestoreCommand() {
return null;
}
/**
* The menu which displays zoom levels.
*
* @author Joel Uckelman
* @since 3.1.0
*/
protected class ZoomMenu extends JPopupMenu implements ActionListener {
protected final JRadioButtonMenuItem other;
protected final JPopupMenu.Separator sep;
protected final ButtonGroup bg;
private static final String OTHER = "Other..."; //$NON-NLS-1$
private static final String FIT_WIDTH = "Fit Width"; //$NON-NLS-1$
private static final String FIT_HEIGHT = "Fit Height"; //$NON-NLS-1$
private static final String FIT_VISIBLE = "Fit Visible"; //$NON-NLS-1$
private static final long serialVersionUID = 1L;
public ZoomMenu() {
super();
sep = new JPopupMenu.Separator();
add(sep);
bg = new ButtonGroup();
other = new JRadioButtonMenuItem(
Resources.getString("Zoomer.ZoomMenu.other")); //$NON-NLS-1$
other.setActionCommand(OTHER);
other.addActionListener(this);
bg.add(other);
add(other);
addSeparator();
final JMenuItem fw = new JMenuItem(
Resources.getString("Zoomer.ZoomMenu.fit_width")); //$NON-NLS-1$
fw.setActionCommand(FIT_WIDTH);
fw.addActionListener(this);
add(fw);
final JMenuItem fh = new JMenuItem(
Resources.getString("Zoomer.ZoomMenu.fit_height")); //$NON-NLS-1$
fh.setActionCommand(FIT_HEIGHT);
fh.addActionListener(this);
add(fh);
final JMenuItem fv = new JMenuItem(
Resources.getString("Zoomer.ZoomMenu.fit_visible")); //$NON-NLS-1$
fv.setActionCommand(FIT_VISIBLE);
fv.addActionListener(this);
add(fv);
}
public void initZoomItems() {
while (getComponent(0) != sep) remove(0);
final List<Double> levels = state.getLevels();
for (int i = 0; i < levels.size(); ++i) {
final String zs = Long.toString(Math.round(levels.get(i)*100)) + "%"; //$NON-NLS-1$
final JMenuItem item = new JRadioButtonMenuItem(zs);
item.setActionCommand(Integer.toString(i));
item.addActionListener(this);
bg.add(item);
insert(item, 0);
}
((JRadioButtonMenuItem) getComponent(
state.getLevelCount() - state.getLevel() - 1)).setSelected(true);
}
public void actionPerformed(ActionEvent a) {
try {
setZoomLevel(Integer.parseInt(a.getActionCommand()));
return;
}
catch (NumberFormatException e) {
}
final String cmd = a.getActionCommand();
if (OTHER.equals(cmd)) {
final ZoomDialog dialog = new ZoomDialog((Frame)
SwingUtilities.getAncestorOfClass(Frame.class, map.getView()),
Resources.getString("Zoomer.ZoomDialog.title"), true); //$NON-NLS-1$
dialog.setVisible(true);
final double z = dialog.getResult()/100.0;
if (z > 0 && z != state.getZoom()) {
setZoomFactor(z);
}
}
// FIXME: should be map.getSize() for consistency?
else if (FIT_WIDTH.equals(cmd)) {
final Dimension vd = map.getView().getVisibleRect().getSize();
final Dimension md = map.mapSize();
setZoomFactor(vd.getWidth()/md.getWidth());
}
else if (FIT_HEIGHT.equals(cmd)) {
final Dimension vd = map.getView().getVisibleRect().getSize();
final Dimension md = map.mapSize();
setZoomFactor(vd.getHeight()/md.getHeight());
}
else if (FIT_VISIBLE.equals(cmd)) {
final Dimension vd = map.getView().getVisibleRect().getSize();
final Dimension md = map.mapSize();
setZoomFactor(Math.min(vd.getWidth()/md.getWidth(),
vd.getHeight()/md.getHeight()));
}
else {
// this should not happen!
assert false;
}
}
public void updateZoom() {
if (state.atLevel()) {
((JRadioButtonMenuItem) getComponent(
state.getLevelCount() - state.getLevel() - 1)).setSelected(true);
}
else {
other.setSelected(true);
}
}
}
/**
* The dialog for setting custom zoom levels.
*
* @author Joel Uckelman
* @since 3.1.0
*/
protected class ZoomDialog extends JDialog
implements ActionListener,
ChangeListener {
protected double result;
protected final JSpinner ratioNumeratorSpinner;
protected final JSpinner ratioDenominatorSpinner;
protected final JSpinner percentSpinner;
protected final SpinnerNumberModel ratioNumeratorModel;
protected final SpinnerNumberModel ratioDenominatorModel;
protected final SpinnerNumberModel percentModel;
protected final JButton okButton;
private static final long serialVersionUID = 1L;
public ZoomDialog(Frame owner, String title, boolean modal) {
super(owner, title, modal);
final int hsep = 5;
final JPanel controlsPane = new JPanel(new GridBagLayout());
final GridBagConstraints c = new GridBagConstraints();
c.fill = GridBagConstraints.HORIZONTAL;
final Insets linset = new Insets(0, 0, 11, 11);
final Insets dinset = new Insets(0, 0, 0, 0);
final JLabel ratioLabel = new JLabel(
Resources.getString("Zoomer.ZoomDialog.zoom_ratio")); //$NON-NLS-1$
c.gridx = 0;
c.gridy = 0;
c.weightx = 0;
c.weighty = 0;
c.insets = linset;
c.anchor = GridBagConstraints.LINE_START;
controlsPane.add(ratioLabel, c);
final Box ratioBox = new Box(BoxLayout.X_AXIS);
ratioLabel.setLabelFor(ratioBox);
c.gridx = 1;
c.gridy = 0;
c.weightx = 1;
c.weighty = 0;
c.insets = dinset;
c.anchor = GridBagConstraints.LINE_START;
controlsPane.add(ratioBox, c);
ratioNumeratorModel = new SpinnerNumberModel(1, 1, 256, 1);
ratioNumeratorSpinner = new JSpinner(ratioNumeratorModel);
ratioNumeratorSpinner.addChangeListener(this);
ratioBox.add(ratioNumeratorSpinner);
ratioBox.add(Box.createHorizontalStrut(hsep));
final JLabel ratioColon = new JLabel(":"); //$NON-NLS-1$
ratioBox.add(ratioColon);
ratioBox.add(Box.createHorizontalStrut(hsep));
ratioDenominatorModel = new SpinnerNumberModel(1, 1, 256, 1);
ratioDenominatorSpinner = new JSpinner(ratioDenominatorModel);
ratioDenominatorSpinner.addChangeListener(this);
ratioBox.add(ratioDenominatorSpinner);
final JLabel percentLabel = new JLabel(
Resources.getString("Zoomer.ZoomDialog.zoom_percent")); //$NON-NLS-1$
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
c.weighty = 0;
c.insets = linset;
c.anchor = GridBagConstraints.LINE_START;
controlsPane.add(percentLabel, c);
final Box percentBox = new Box(BoxLayout.X_AXIS);
c.gridx = 1;
c.gridy = 1;
c.weightx = 1;
c.weighty = 0;
c.insets = dinset;
c.anchor = GridBagConstraints.LINE_START;
controlsPane.add(percentBox, c);
percentModel =
new SpinnerNumberModel(state.getZoom()*100.0, 0.39, 25600.0, 10.0);
percentSpinner = new JSpinner(percentModel);
percentLabel.setLabelFor(percentSpinner);
percentSpinner.addChangeListener(this);
percentBox.add(percentSpinner);
updateRatio();
percentBox.add(Box.createHorizontalStrut(hsep));
final JLabel percentSign = new JLabel("%"); //$NON-NLS-1$
percentBox.add(percentSign);
// buttons
final Box buttonBox = new Box(BoxLayout.X_AXIS);
buttonBox.add(Box.createHorizontalGlue());
okButton = new JButton(Resources.getString(Resources.OK));
okButton.addActionListener(this);
getRootPane().setDefaultButton(okButton);
buttonBox.add(okButton);
buttonBox.add(Box.createHorizontalStrut(hsep));
final JButton cancelButton = new JButton(
Resources.getString(Resources.CANCEL));
cancelButton.addActionListener(this);
buttonBox.add(cancelButton);
final Dimension okDim = okButton.getPreferredSize();
final Dimension cancelDim = cancelButton.getPreferredSize();
final Dimension buttonDimension = new Dimension(
Math.max(okDim.width, cancelDim.width),
Math.max(okDim.height, cancelDim.height));
okButton.setPreferredSize(buttonDimension);
cancelButton.setPreferredSize(buttonDimension);
final JComponent contentPane = (JComponent) getContentPane();
contentPane.setBorder(new EmptyBorder(12, 12, 11, 11));
contentPane.setLayout(new BorderLayout(0, 11));
contentPane.add(controlsPane, BorderLayout.CENTER);
contentPane.add(buttonBox, BorderLayout.PAGE_END);
setResizable(false);
pack();
}
public double getResult() {
return result;
}
public void actionPerformed(ActionEvent e) {
result = e.getSource() == okButton ?
percentModel.getNumber().doubleValue() : 0.0;
setVisible(false);
}
public void stateChanged(ChangeEvent e) {
if (e.getSource() == ratioNumeratorSpinner ||
e.getSource() == ratioDenominatorSpinner) {
updatePercent();
}
else if (e.getSource() == percentSpinner) {
updateRatio();
}
}
private void updatePercent() {
// disconnect listener to prevent circularity
percentSpinner.removeChangeListener(this);
percentModel.setValue(
ratioNumeratorModel.getNumber().doubleValue() /
ratioDenominatorModel.getNumber().doubleValue() * 100.0
);
percentSpinner.addChangeListener(this);
}
private void updateRatio() {
// Warning: Heavy maths ahead!
//
// This algorithm borrowed from gimpzoommodel.c in LIBGIMP:
// http://svn.gnome.org/viewcvs/gimp/trunk/libgimpwidgets/gimpzoommodel.c
//
// See also http://www.virtualdub.org/blog/pivot/entry.php?id=81
// for a discussion of calculating continued fractions by convergeants.
double z = percentModel.getNumber().doubleValue() / 100.0;
// we want symmetric behavior, so find reciprocal when zooming out
boolean swapped = false;
if (z < 1.0) {
z = 1.0/z;
swapped = true;
}
// calculate convergeants
int p0 = 1;
int q0 = 0;
int p1 = (int)Math.floor(z);
int q1 = 1;
int p2;
int q2;
double r = z - p1;
double next_cf;
while (Math.abs(r) >= 0.0001 &&
Math.abs((double)p1/q1 - z) > 0.0001) {
r = 1.0/r;
next_cf = Math.floor(r);
p2 = (int)(next_cf * p1 + p0);
q2 = (int)(next_cf * q1 + q0);
// We limit the numerator and denominator to be 256 or less,
// and also exclude absurd ratios like 170:171.
if (p2 > 256 || q2 > 256 || (p2 > 1 && q2 > 1 && p2 * q2 > 200))
break;
// remember the last two fractions
p0 = p1;
p1 = p2;
q0 = q1;
q1 = q2;
r -= next_cf;
}
z = (double)p1/q1;
// hard upper and lower bounds for zoom ratio
if (z > 256.0) {
p1 = 256;
q1 = 1;
}
else if (z < 1.0/256.0) {
p1 = 1;
q1 = 256;
}
if (swapped) {
final int tmp = p1;
p1 = q1;
q1 = tmp;
}
// disconnect listeners to prevent circularity
ratioNumeratorSpinner.removeChangeListener(this);
ratioDenominatorSpinner.removeChangeListener(this);
ratioNumeratorModel.setValue(p1);
ratioDenominatorModel.setValue(q1);
ratioNumeratorSpinner.addChangeListener(this);
ratioDenominatorSpinner.addChangeListener(this);
}
}
}