/*
 * $Id: Zoomer.java 4639 2008-12-06 14:24:28Z uckelman $
 *
 * 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.KeyStroke;
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;

/**
 * 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) {
      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 "Zoom capability";
  }

  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[]{
      "",
      "Preset zoom levels:  ",
      "Zoom in tooltip text:  ",
      "Zoom in button text:  ",
      "Zoom in Icon:  ",
      "Zoom in hotkey:  ",
      "Zoom select tooltip text",
      "Zoom select button text",
      "Zoom select Icon",
      "Zoom select hotkey",
      "Zoom out tooltip text:  ",
      "Zoom out button text:  ",
      "Zoom out Icon:  ",
      "Zoom out hotkey:  "
    };
  }

  public Class<?>[] getAttributeTypes() {
    return new Class<?>[]{
      null,   // ZOOM_START is handled by the LevelConfigurer
      LevelConfig.class,
      String.class,
      String.class,
      InIconConfig.class,
      KeyStroke.class,
      String.class,
      String.class,
      PickIconConfig.class,
      KeyStroke.class,
      String.class,
      String.class,
      OutIconConfig.class,
      KeyStroke.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 final String pattern =
          "^(\\d*[1-9]\\d*(/\\d*[1-9]\\d*|\\.\\d*)?|0*\\.\\d*[1-9]\\d*)$";

        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("Set Initial");
      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("An '*' indicates the initial zoom level.");
      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("/");
      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 ? " *" : "");
      }

      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 = new Integer((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) {
          state = new State(levels, initial-1);
          
          // zero these so this block is run only once
          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 = new Double((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 = new Integer((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...";
    private static final String FIT_WIDTH = "Fit Width";
    private static final String FIT_HEIGHT = "Fit Height";
    private static final String FIT_VISIBLE = "Fit Visible";

    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"));
      other.setActionCommand(OTHER);
      other.addActionListener(this);
      bg.add(other);
      add(other);

      addSeparator();

      final JMenuItem fw = new JMenuItem(
        Resources.getString("Zoomer.ZoomMenu.fit_width"));
      fw.setActionCommand(FIT_WIDTH);
      fw.addActionListener(this);
      add(fw);

      final JMenuItem fh = new JMenuItem(
        Resources.getString("Zoomer.ZoomMenu.fit_height"));
      fh.setActionCommand(FIT_HEIGHT);
      fh.addActionListener(this);
      add(fh);

      final JMenuItem fv = new JMenuItem(
        Resources.getString("Zoomer.ZoomMenu.fit_visible"));
      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)) + "%";
        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);
        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"));
      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(":");
      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"));
      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("%");
      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(new Double(
        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(new Integer(p1));
      ratioDenominatorModel.setValue(new Integer(q1));
  
      ratioNumeratorSpinner.addChangeListener(this);
      ratioDenominatorSpinner.addChangeListener(this);
    }
  }
}
