/*
 * $Id: PropertySheet.java 7725 2011-07-31 18:51:43Z uckelman $
 *
 * Copyright (c) 2000-2003 by Rodney Kinney, Jim Urbas
 *
 * 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.counters;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.Graphics;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.InputEvent;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.swing.DefaultCellEditor;
import javax.swing.JButton;
import javax.swing.JColorChooser;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JLayeredPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTable;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableCellEditor;
import javax.swing.table.TableModel;
import javax.swing.text.JTextComponent;

import VASSAL.build.GameModule;
import VASSAL.build.module.documentation.HelpFile;
import VASSAL.command.ChangePiece;
import VASSAL.command.Command;
import VASSAL.configure.NamedHotKeyConfigurer;
import VASSAL.i18n.PieceI18nData;
import VASSAL.i18n.TranslatablePiece;
import VASSAL.tools.NamedKeyStroke;
import VASSAL.tools.ScrollPane;
import VASSAL.tools.SequenceEncoder;

/**
 * A Decorator class that endows a GamePiece with a dialog.
 */
public class PropertySheet extends Decorator implements TranslatablePiece {
  public static final String ID = "propertysheet;";

  protected String oldState;

  // properties
  protected String menuName;
  protected NamedKeyStroke launchKeyStroke;
  protected KeyCommand launch;
  protected Color backgroundColor;
  protected String m_definition;

  protected PropertySheetDialog frame;
  protected JButton applyButton;

  // Commit type definitions
  final static String[] COMMIT_VALUES = {"Every Keystroke", "Apply Button or Enter Key", "Close Window or Enter Key"};
  static final int COMMIT_IMMEDIATELY = 0;
  static final int COMMIT_ON_APPLY = 1;
  static final int COMMIT_ON_CLOSE = 2;
  static final int COMMIT_DEFAULT = COMMIT_IMMEDIATELY;

  // Field type definitions
  static final String[] TYPE_VALUES = {"Text", "Multi-line text", "Label Only", "Tick Marks", "Tick Marks with Max Field", "Tick Marks with Value Field", "Tick Marks with Value & Max", "Spinner"};
  static final int TEXT_FIELD = 0;
  static final int TEXT_AREA = 1;
  static final int LABEL_ONLY = 2;
  static final int TICKS = 3;
  static final int TICKS_MAX = 4;
  static final int TICKS_VAL = 5;
  static final int TICKS_VALMAX = 6;
  static final int SPINNER = 7;

  protected int commitStyle = COMMIT_DEFAULT;

  protected boolean isUpdating;

  protected String state;
  protected Map<String,Object> properties = new HashMap<String,Object>();
  protected List<JComponent> m_fields;


  static final char TYPE_DELIMITOR = ';';
  static final char DEF_DELIMITOR = '~';
  static final char STATE_DELIMITOR = '~';
  static final char LINE_DELIMINATOR = '|';
  static final char VALUE_DELIMINATOR = '/';

  class PropertySheetDialog extends JDialog implements ActionListener {
    private static final long serialVersionUID = 1L;

    public PropertySheetDialog(Frame owner) {
      super(owner, false);
    }

    public void actionPerformed(ActionEvent event) {
      if (applyButton != null) {
        applyButton.setEnabled(false);
      }
      updateStateFromFields();
    }
  }


  public PropertySheet() {
    // format is propertysheet;menu-name;keystroke;commitStyle;backgroundRed;backgroundGreen;backgroundBlue
    this(ID + ";Properties;P;;;;", null);
  }


  public PropertySheet(String type, GamePiece p) {
    mySetType(type);
    setInner(p);
  }


  /** Changes the "type" definition this decoration, which discards all value data and structures.
   *  Format: definition; name; keystroke
   */
  public void mySetType(String s) {

    s = s.substring(ID.length());
    SequenceEncoder.Decoder st = new SequenceEncoder.Decoder(s, TYPE_DELIMITOR);

    m_definition = st.nextToken();
    menuName = st.nextToken();
    final String launchKeyToken = st.nextToken("");
    commitStyle = st.nextInt(COMMIT_DEFAULT);
    String red = st.hasMoreTokens() ? st.nextToken() : "";
    String green = st.hasMoreTokens() ? st.nextToken() : "";
    String blue = st.hasMoreTokens() ? st.nextToken() : "";
    final String launchKeyStrokeToken = st.nextToken("");

    backgroundColor = red.equals("") ? null : new Color(atoi(red), atoi(green), atoi(blue));
    frame = null;

    // Handle conversion from old character only key
    if (launchKeyStrokeToken.length() > 0) {
      launchKeyStroke = NamedHotKeyConfigurer.decode(launchKeyStrokeToken);
    }
    else if (launchKeyToken.length() > 0) {
      launchKeyStroke = new NamedKeyStroke(launchKeyToken.charAt(0), InputEvent.CTRL_MASK);
    }
    else {
      launchKeyStroke = new NamedKeyStroke('P', InputEvent.CTRL_MASK);
    }
  }

  public void draw(java.awt.Graphics g, int x, int y, java.awt.Component obs, double zoom) {
    piece.draw(g, x, y, obs, zoom);
  }

  public String getName() {
    return piece.getName();
  }

  public java.awt.Rectangle boundingBox() {
    return piece.boundingBox();
  }

  public Shape getShape() {
    return piece.getShape();
  }

  public String myGetState() {
    return state;
  }

  public void mySetState(String state) {
    this.state = state;
    updateFieldsFromState();
  }

  /** returns string defining the field types */
  public String myGetType() {
    SequenceEncoder se = new SequenceEncoder(TYPE_DELIMITOR);

    String red = backgroundColor == null ? "" : Integer.toString(backgroundColor.getRed());
    String green = backgroundColor == null ? "" : Integer.toString(backgroundColor.getGreen());
    String blue = backgroundColor == null ? "" : Integer.toString(backgroundColor.getBlue());
    String commit = Integer.toString(commitStyle);

    se.append(m_definition).append(menuName).append("").append(commit).
        append(red).append(green).append(blue).append(launchKeyStroke);


    return ID + se.getValue();
  }

  protected KeyCommand[] myGetKeyCommands() {
    launch = new KeyCommand(menuName, launchKeyStroke, Decorator.getOutermost(this), this);
    return new KeyCommand[]{launch};
  }

  /** parses leading integer from string */
  int atoi(String s) {
    int value = 0;
    if (s != null) {
      for (int i = 0; i < s.length() && Character.isDigit(s.charAt(i)); ++i) {
        value = value * 10 + s.charAt(i) - '0';
      }
    }
    return value;
  }

  /** parses trailing integer from string */
  int atoiRight(String s) {
    int value = 0;
    if (s != null) {
      int base = 1;
      for (int i = s.length() - 1; i >= 0 && Character.isDigit(s.charAt(i)); --i, base *= 10) {
        value = value + base * (s.charAt(i) - '0');
      }
    }
    return value;
  }


  // stores field values
  private void updateStateFromFields() {
    SequenceEncoder encoder = new SequenceEncoder(STATE_DELIMITOR);

    for (Object field : m_fields) {
      if (field instanceof JTextComponent) {
        encoder.append(((JTextComponent) field).getText()
               .replace('\n', LINE_DELIMINATOR));
      }
      else if (field instanceof TickPanel) {
        encoder.append(((TickPanel) field).getValue());
      }
      else {
        encoder.append("Unknown");
      }
    }

    if (encoder.getValue() != null && !encoder.getValue().equals(state)) {
      mySetState(encoder.getValue());

      GamePiece outer = Decorator.getOutermost(PropertySheet.this);
      if (outer.getId() != null) {
        GameModule.getGameModule().sendAndLog(
          new ChangePiece(outer.getId(), oldState, outer.getState()));
      }
    }
  }

  private void updateFieldsFromState() {
    isUpdating = true;
    properties.clear();

    final SequenceEncoder.Decoder defDecoder =
      new SequenceEncoder.Decoder(m_definition, DEF_DELIMITOR);
    final SequenceEncoder.Decoder stateDecoder =
      new SequenceEncoder.Decoder(state, STATE_DELIMITOR);

    for (int iField = 0; defDecoder.hasMoreTokens(); ++iField) {
      String name = defDecoder.nextToken();
      if (name.length() == 0) {
        continue;
      }

      final int type = name.charAt(0) - '0';
      name = name.substring(1);
      String value = stateDecoder.nextToken("");
      switch (type) {
      case TICKS:
      case TICKS_VAL:
      case TICKS_MAX:
      case TICKS_VALMAX:
        final int index = value.indexOf('/');
        properties.put(name, index > 0 ? value.substring(0, index) : value);
        break;
      default:
        properties.put(name, value);
      }

      value = value.replace(LINE_DELIMINATOR, '\n');

      if (frame != null) {
        final Object field = m_fields.get(iField);
        if (field instanceof JTextComponent) {
          final JTextComponent tf = (JTextComponent) field;
          final int pos = Math.min(tf.getCaretPosition(), value.length());
          tf.setText(value);
          tf.setCaretPosition(pos);
        }
        else if (field instanceof TickPanel) {
          ((TickPanel) field).updateValue(value);
        }
      }
    }

    if (applyButton != null) {
      applyButton.setEnabled(false);
    }

    isUpdating = false;
  }

  public Command myKeyEvent(KeyStroke stroke) {
    myGetKeyCommands();

    if (launch.matches(stroke)) {
      if (frame == null) {
        m_fields = new ArrayList<JComponent>();
        VASSAL.build.module.Map map = piece.getMap();
        Frame parent = null;
        if (map != null && map.getView() != null) {
          Container topWin = map.getView().getTopLevelAncestor();
          if (topWin instanceof JFrame) {
            parent = (Frame) topWin;
          }
        }
        else {
          parent = GameModule.getGameModule().getFrame();
        }

        frame = new PropertySheetDialog(parent);

        JPanel pane = new JPanel();
        JScrollPane scroll =
          new JScrollPane(pane, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
                                JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
        frame.add(scroll);

        // set up Apply button
        if (commitStyle == COMMIT_ON_APPLY) {
          applyButton = new JButton("Apply");

          applyButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent event) {
              if (applyButton != null) {
                applyButton.setEnabled(false);
              }
              updateStateFromFields();
            }
          });

          applyButton.setMnemonic(java.awt.event.KeyEvent.VK_A); // respond to Alt+A
          applyButton.setEnabled(false);
        }

        // ... enable APPLY button when field changes
        DocumentListener changeListener = new DocumentListener() {
          public void insertUpdate(DocumentEvent e) {
            update(e);
          }

          public void removeUpdate(DocumentEvent e) {
            update(e);
          }

          public void changedUpdate(DocumentEvent e) {
            update(e);
          }

          public void update(DocumentEvent e) {
            if (!isUpdating) {
              switch (commitStyle) {
              case COMMIT_IMMEDIATELY:
                // queue commit operation because it could do something
                // unsafe in a an event update
                SwingUtilities.invokeLater(new Runnable() {
                  public void run() {
                    updateStateFromFields();
                  }
                });
                break;
              case COMMIT_ON_APPLY:
                applyButton.setEnabled(true);
                break;
              case COMMIT_ON_CLOSE:
                break;
              default:
                throw new IllegalStateException();
              }
            }
          }
        };

        pane.setLayout(new GridBagLayout());
        GridBagConstraints c = new GridBagConstraints();
        c.fill = GridBagConstraints.BOTH;
        c.insets = new Insets(1, 3, 1, 3);
        c.gridx = 0;
        c.gridy = 0;
        c.fill = GridBagConstraints.BOTH;
        SequenceEncoder.Decoder defDecoder =
          new SequenceEncoder.Decoder(m_definition, DEF_DELIMITOR);
        SequenceEncoder.Decoder stateDecoder =
          new SequenceEncoder.Decoder(state, STATE_DELIMITOR);

        while (defDecoder.hasMoreTokens()) {
          String code = defDecoder.nextToken();
          if (code.length() == 0) {
            break;
          }
          int type = code.charAt(0) - '0';
          String name = code.substring(1);
          JComponent field;
          switch (type) {
            case TEXT_FIELD:
              field = new JTextField(stateDecoder.nextToken(""));
              ((JTextComponent) field).getDocument()
                                      .addDocumentListener(changeListener);
              ((JTextField) field).addActionListener(frame);
              m_fields.add(field);
              break;
            case TEXT_AREA:
              field = new JTextArea(
                stateDecoder.nextToken("").replace(LINE_DELIMINATOR, '\n'));
              ((JTextComponent) field).getDocument()
                                      .addDocumentListener(changeListener);
              m_fields.add(field);
              field = new ScrollPane(field);
              break;
            case TICKS:
            case TICKS_VAL:
            case TICKS_MAX:
            case TICKS_VALMAX:
              field = new TickPanel(stateDecoder.nextToken(""), type);
              ((TickPanel) field).addDocumentListener(changeListener);
              ((TickPanel) field).addActionListener(frame);
              if (backgroundColor != null)
                field.setBackground(backgroundColor);
              m_fields.add(field);
              break;
            case SPINNER:
              JSpinner spinner = new JSpinner();
              JTextField textField =
                ((JSpinner.DefaultEditor) spinner.getEditor()).getTextField();
              textField.setText(stateDecoder.nextToken(""));
              textField.getDocument().addDocumentListener(changeListener);
              m_fields.add(textField);
              field = spinner;
              break;
            case LABEL_ONLY:
            default :
              stateDecoder.nextToken("");
              field = null;
              m_fields.add(field);
              break;
          }
          c.gridwidth = type == TEXT_AREA || type == LABEL_ONLY ? 2 : 1;

          if (name != null && !name.equals("")) {
            c.gridx = 0;
            c.weighty = 0.0;
            c.weightx = c.gridwidth == 2 ? 1.0 : 0.0;
            pane.add(new JLabel(getTranslation(name)), c);
            if (c.gridwidth == 2) {
              ++c.gridy;
            }
          }

          if (field != null) {
            c.weightx = 1.0;
            c.weighty = type == TEXT_AREA ? 1.0 : 0.0;
            c.gridx = type == TEXT_AREA ? 0 : 1;
            pane.add(field, c);
            ++c.gridy;
          }
        }

        if (backgroundColor != null) {
          pane.setBackground(backgroundColor);
        }

        if (commitStyle == COMMIT_ON_APPLY) {
          // setup Close button
          JButton closeButton = new JButton("Close");
          closeButton.setMnemonic(java.awt.event.KeyEvent.VK_C); // respond to Alt+C // key event cannot be resolved

          closeButton.addActionListener(new java.awt.event.ActionListener() {
            public void actionPerformed(java.awt.event.ActionEvent e) {
              updateStateFromFields();
              frame.setVisible(false);
            }
          });

          c.gridwidth = 1;
          c.weighty = 0.0;
          c.anchor = GridBagConstraints.SOUTHEAST;
          c.gridwidth = 2; // use the whole row
          c.gridx = 0;
          c.weightx = 0.0;

          JPanel buttonRow = new JPanel();
          buttonRow.add(applyButton);
          buttonRow.add(closeButton);
          if (backgroundColor != null) {
            applyButton.setBackground(backgroundColor);
            closeButton.setBackground(backgroundColor);
            buttonRow.setBackground(backgroundColor);
          }
          pane.add(buttonRow, c);
        }


        // move window
        Point p = GameModule.getGameModule().getFrame().getLocation();
        if (getMap() != null) {
          p = getMap().getView().getLocationOnScreen();
          Point p2 = getMap().componentCoordinates(getPosition());
          p.translate(p2.x, p2.y);
        }
        frame.setLocation(p.x, p.y);

        // watch for window closing - save state
        frame.addWindowListener(new WindowAdapter() {
          public void windowClosing(WindowEvent evt) {
            updateStateFromFields();
          }
        });
        frame.pack();
      }

      // Name window and make it visible
      frame.setTitle(getLocalizedName());
      oldState = Decorator.getOutermost(this).getState();
      frame.setVisible(true);
      return null;
    }
    else {
      return null;
    }
  }

  @Override
  public Object getLocalizedProperty(Object key) {
    final Object value = properties.get(key);
    return value == null ? super.getLocalizedProperty(key) : value;
  }

  public Object getProperty(Object key) {
    final Object value = properties.get(key);
    return value == null ? super.getProperty(key) : value;
  }

  public String getDescription() {
    return "Property Sheet";
  }

  public VASSAL.build.module.documentation.HelpFile getHelpFile() {
    return HelpFile.getReferenceManualPage("PropertySheet.htm");
  }

  public PieceEditor getEditor() {
    return new Ed(this);
  }

  /**
   * A generic panel for editing unit traits.
   * Not directly related to "PropertySheets".
   */
  static class PropertyPanel extends JPanel implements FocusListener {
    private static final long serialVersionUID = 1L;

    GridBagConstraints c = new GridBagConstraints();

    public PropertyPanel() {
      super(new GridBagLayout());
      c.insets = new Insets(0, 4, 0, 4);
      //c.ipadx = 5;
      c.anchor = GridBagConstraints.WEST;
    }

    public NamedHotKeyConfigurer addKeyStrokeConfig(NamedKeyStroke value) {
      ++c.gridy;
      c.gridx = 0;
      c.weightx = 0.0;
      c.fill = GridBagConstraints.NONE;
      add(new JLabel("Keystroke:"), c);

      ++c.gridx;
      final NamedHotKeyConfigurer config = new NamedHotKeyConfigurer(null, "", value);
      final Component field = config.getControls();
      add(field, c);

      return config;
    }

    public JTextField addStringCtrl(String name, String value) {
      ++c.gridy;
      c.gridx = 0;
      c.weightx = 0.0;
      c.fill = GridBagConstraints.HORIZONTAL;
      add(new JLabel(name), c);

      c.weightx = 1.0;
      JTextField field = new JTextField(value);
      ++c.gridx;
      add(field, c);
      return field;
    }

    public JButton addColorCtrl(String name, Color value) {
      ++c.gridy;
      c.gridx = 0;
      c.weightx = 0.0;
      c.fill = GridBagConstraints.NONE;
      add(new JLabel(name), c);

      c.weightx = 0.0;
      JButton button = new JButton("Default");

      if (value != null) {
        button.setBackground(value);
        button.setText("sample");
      }
      button.addActionListener(new ActionListener() {
        public void actionPerformed(ActionEvent event) {
          JButton button = (JButton) event.getSource();
          Color value = button.getBackground();
          Color newColor = JColorChooser.showDialog(PropertyPanel.this, "Choose background color or CANCEL to use default color scheme", value);
          if (newColor != null) {
            button.setBackground(newColor);
            button.setText("sample");
          }
          else {
            button.setBackground(PropertyPanel.this.getBackground());
            button.setText("Default");
          }
        }
      });
      ++c.gridx;
      add(button, c);
      return button;
    }

    public JComboBox addComboBox(String name, String[] values, int initialRow) {
      JComboBox comboBox = new JComboBox(values);
      comboBox.setEditable(false);
      comboBox.setSelectedIndex(initialRow);

      addCtrl(name, comboBox);

      return comboBox;
    }


    public void addCtrl(String name, JComponent ctrl) {
      ++c.gridy;
      c.gridx = 0;
      c.weightx = 0.0;
      c.fill = GridBagConstraints.NONE;
      add(new JLabel(name), c);

      c.weightx = 0.0;

      ++c.gridx;
      add(ctrl, c);
    }


    DefaultTableModel tableModel;
    String[] defaultValues;


    public static class SmartTable extends JTable {
      private static final long serialVersionUID = 1L;

      SmartTable(TableModel m) {
        super(m);
        setSurrendersFocusOnKeystroke(true);
      }

      //Prepares the editor by querying the data model for the value and selection state of the cell at row, column.    }
      public Component prepareEditor(TableCellEditor editor, int row, int column) {
        if (row == getRowCount() - 1) {
          ((DefaultTableModel) getModel()).addRow(Ed.DEFAULT_ROW);
        }
        Component component = super.prepareEditor(editor, row, column);

        if (component instanceof JTextComponent) {
          ((JTextComponent) component).grabFocus();
          ((JTextComponent) component).selectAll();
        }
        return component;
      }
    }

    public JTable addTableCtrl(String name, DefaultTableModel theTableModel, String[] theDefaultValues) {
      tableModel = theTableModel;
      this.defaultValues = theDefaultValues;

      ++c.gridy;
      c.gridx = 0;
      c.weighty = 0.0;
      c.weightx = 1.0;
      c.gridwidth = 2;
      c.fill = GridBagConstraints.HORIZONTAL;
      add(new JLabel(name), c);

      ++c.gridy;
      c.weighty = 1.0;
      c.fill = GridBagConstraints.BOTH;
      final SmartTable table = new SmartTable(tableModel);
      add(new ScrollPane(table), c);

      c.gridwidth = 1;

      ++c.gridy;
      c.fill = GridBagConstraints.NONE;
      c.anchor = GridBagConstraints.EAST;
      c.gridwidth = 2;
      c.weighty = 0.0;
      c.weightx = 1.0;

      JPanel buttonPanel = new JPanel();

      // add button
      JButton addButton = new JButton("Insert Row");
      buttonPanel.add(addButton);
      addButton.addActionListener(new ActionListener() {
        public void actionPerformed(ActionEvent e) {

          if (table.isEditing()) {
            table.getCellEditor().stopCellEditing();
          }

          ListSelectionModel selection = table.getSelectionModel();
          int iSelection;
          if (selection.isSelectionEmpty()) {
            tableModel.addRow(defaultValues);
            iSelection = tableModel.getRowCount() - 1;
          }
          else {
            iSelection = selection.getMaxSelectionIndex();
            tableModel.insertRow(iSelection, defaultValues);
          }
          tableModel.fireTableDataChanged(); // BING BING BING
          selection.setSelectionInterval(iSelection, iSelection);
          table.grabFocus();
          table.editCellAt(iSelection, 0);
          Component comp = table.getCellEditor().getTableCellEditorComponent(table, null, true, iSelection, 0);
          if (comp instanceof JComponent) {
            ((JComponent) comp).grabFocus();
          }
        }
      });

      // delete button
      final JButton deleteButton = new JButton("Delete Row");
      deleteButton.setEnabled(false);
      buttonPanel.add(deleteButton);
      deleteButton.addActionListener(new ActionListener() {
        public void actionPerformed(ActionEvent e) {

          if (table.isEditing()) {
            table.getCellEditor().stopCellEditing();
          }

          ListSelectionModel selection = table.getSelectionModel();
          for (int i = selection.getMaxSelectionIndex(); i >= selection.getMinSelectionIndex(); --i) {
            if (selection.isSelectedIndex(i)) {
              tableModel.removeRow(i);
            }
          }
          tableModel.fireTableDataChanged(); // BING BING BING
        }
      });

      // Ask to be notified of selection changes.
      table.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
        public void valueChanged(ListSelectionEvent event) {
          //Ignore extra messages.
          if (!event.getValueIsAdjusting()) {
            ListSelectionModel lsm = (ListSelectionModel) event.getSource();
            deleteButton.setEnabled(!lsm.isSelectionEmpty());
          }
        }
      });

      add(buttonPanel, c);

      c.anchor = GridBagConstraints.WEST;
      c.weightx = 0.0;
      return table;
    }

    public void focusGained(FocusEvent event) {
    }

    // make sure we save user's changes
    public void focusLost(FocusEvent event) {
      if (event.getComponent() instanceof JTable) {
        JTable table = (JTable) event.getComponent();
        if (table.isEditing()) {
          table.getCellEditor().stopCellEditing();
        }
      }
    }
  }

  public PieceI18nData getI18nData() {
    final ArrayList<String> items = new ArrayList<String>();
    final SequenceEncoder.Decoder defDecoder =
      new SequenceEncoder.Decoder(m_definition, DEF_DELIMITOR);
    while (defDecoder.hasMoreTokens()) {
      final String item = defDecoder.nextToken();
      items.add(item.length() == 0 ? "" : item.substring(1));
    }

    final String[] menuNames = new String[items.size()+1];
    final String[] descriptions = new String[items.size()+1];
    menuNames[0]  = menuName;
    descriptions[0] = "Property Sheet command";
    int j = 1;
    for (String s : items) {
      menuNames[j] = s;
      descriptions[j] = "Property Sheet item " + j;
      j++;
    }
    return getI18nData(menuNames, descriptions);
  }

  /**
   * Return Property names exposed by this trait
   */
  public List<String> getPropertyNames() {
    ArrayList<String> l = new ArrayList<String>();
    for (String prop : properties.keySet()) {
      l.add(prop);
    }
    return l;
  }

  private static class Ed implements PieceEditor {

    private PropertyPanel m_panel;
    private JTextField menuNameCtrl;
    private NamedHotKeyConfigurer keyStrokeConfig;
    private JButton colorCtrl;
    private JTable propertyTable;
    private JComboBox commitCtrl;

    static final String[] COLUMN_NAMES = {"Name", "Type"};
    static final String[] DEFAULT_ROW = {"*new property*", "Text"};

    public Ed(PropertySheet propertySheet) {
      m_panel = new PropertyPanel();
      menuNameCtrl = m_panel.addStringCtrl("Menu Text:", propertySheet.menuName);
      keyStrokeConfig = m_panel.addKeyStrokeConfig(propertySheet.launchKeyStroke);
      commitCtrl = m_panel.addComboBox("Commit changes on:", COMMIT_VALUES, propertySheet.commitStyle);
      colorCtrl = m_panel.addColorCtrl("Background Color:", propertySheet.backgroundColor);
      DefaultTableModel dataModel = new DefaultTableModel(getTableData(propertySheet.m_definition), COLUMN_NAMES);
      AddCreateRow(dataModel);
      propertyTable = m_panel.addTableCtrl("Properties:", dataModel, DEFAULT_ROW);

      DefaultCellEditor typePicklist = new DefaultCellEditor(new JComboBox(TYPE_VALUES));
      propertyTable.getColumnModel().getColumn(1).setCellEditor(typePicklist);
    }

    protected void AddCreateRow(DefaultTableModel data) {
      data.addRow(DEFAULT_ROW);
    }

    protected String[][] getTableData(String definition) {
      SequenceEncoder.Decoder decoder = new SequenceEncoder.Decoder(definition, DEF_DELIMITOR);

      int numRows = !definition.equals("") && decoder.hasMoreTokens() ? 1 : 0;
      for (int iDef = -1; (iDef = definition.indexOf(DEF_DELIMITOR, iDef + 1)) >= 0;) {
        ++numRows;
      }

      String[][] rows = new String[numRows][2];

      for (int iRow = 0; decoder.hasMoreTokens() && iRow < numRows; ++iRow) {
        String token = decoder.nextToken();
        rows[iRow][0] = token.substring(1);
        rows[iRow][1] = TYPE_VALUES[token.charAt(0) - '0'];
      }

      return rows;
    }

    public java.awt.Component getControls() {
      return m_panel;
    }

    /** returns the type-definition in the format:
     definition, name, keystroke, commit, red, green, blue
     */
    public String getType() {

      if (propertyTable.isEditing()) {
        propertyTable.getCellEditor().stopCellEditing();
      }

      SequenceEncoder defEncoder = new SequenceEncoder(DEF_DELIMITOR);
//      StringBuilder definition = new StringBuilder();
//      int numRows = propertyTable.getRowCount();
      for (int iRow = 0; iRow < propertyTable.getRowCount(); ++iRow) {
        String typeString = (String) propertyTable.getValueAt(iRow, 1);
        for (int iType = 0; iType < TYPE_VALUES.length; ++iType) {
          if (typeString.matches(TYPE_VALUES[iType]) &&
              !DEFAULT_ROW[0].equals(propertyTable.getValueAt(iRow, 0))) {
            defEncoder.append(iType + (String) propertyTable.getValueAt(iRow, 0));
            break;
          }
        }
      }

      SequenceEncoder typeEncoder = new SequenceEncoder(TYPE_DELIMITOR);

      // calc color strings
      String red, green, blue;
      if (colorCtrl.getText().equals("Default")) {
        red = "";
        green = "";
        blue = "";
      }
      else {
        red = Integer.toString(colorCtrl.getBackground().getRed());
        green = Integer.toString(colorCtrl.getBackground().getGreen());
        blue = Integer.toString(colorCtrl.getBackground().getBlue());
      }

      String definitionString = defEncoder.getValue();
      typeEncoder.append(definitionString == null ? "" : definitionString).
          append(menuNameCtrl.getText()).
          append("").
          append(Integer.toString(commitCtrl.getSelectedIndex())).
          append(red).append(green).append(blue).append(keyStrokeConfig.getValueString());

      return ID + typeEncoder.getValue();
    }

    /** returns a default value-string for the given definition */
    public String getState() {
      final StringBuilder buf = new StringBuilder();
      for (int i = 0; i < propertyTable.getRowCount(); ++i) {
        buf.append(STATE_DELIMITOR);
      }

      return buf.toString();
    }
  }


  class TickPanel extends JPanel implements ActionListener, FocusListener, DocumentListener {
    private static final long serialVersionUID = 1L;

    private int numTicks;
    private int maxTicks;
    private int panelType;
    private JTextField valField;
    private JTextField maxField;
    private TickLabel ticks;
    private List<ActionListener> actionListeners =
      new ArrayList<ActionListener>();
    private List<DocumentListener> documentListeners =
      new ArrayList<DocumentListener>();

    public TickPanel(String value, int type) {
      super(new GridBagLayout());
      set(value);

      panelType = type;

      GridBagConstraints c = new GridBagConstraints();
      c.fill = GridBagConstraints.BOTH;
      c.ipadx = 1;
      c.weightx = 0.0;
      c.gridx = 0;
      c.gridy = 0;

      Dimension minSize;

      if (panelType == TICKS_VAL || panelType == TICKS_VALMAX) {
        valField = new JTextField(Integer.toString(numTicks));
        minSize = valField.getMinimumSize();
        minSize.width = 24;
        valField.setMinimumSize(minSize);
        valField.setPreferredSize(minSize);
        valField.addActionListener(this);
        valField.addFocusListener(this);
        add(valField, c);
        ++c.gridx;
      }
      if (panelType == TICKS_MAX || panelType == TICKS_VALMAX) {
        maxField = new JTextField(Integer.toString(maxTicks));
        minSize = maxField.getMinimumSize();
        minSize.width = 24;
        maxField.setMinimumSize(minSize);
        maxField.setPreferredSize(minSize);
        maxField.addActionListener(this);
        maxField.addFocusListener(this);
        if (panelType == TICKS_VALMAX) {
          add(new JLabel("/"), c);
          ++c.gridx;
        }
        add(maxField, c);
        ++c.gridx;
      }

      ticks = new TickLabel(numTicks, maxTicks, panelType);
      ticks.addActionListener(this);
      c.weightx = 1.0;
      add(ticks, c);
      doLayout();
    }

    public void updateValue(String value) {
      set(value);
      updateFields();
    }

    private void set(String value) {
      set(atoi(value), atoiRight(value));
    }

    private boolean set(int num, int max) {

      boolean changed = false;

      if (numTicks == 0 && maxTicks == 0 && num == 0 && max > 0) {
//        num = max;  // This causes a bug in which ticks set to zero go to max after save/reload of a game
        changed = true;
      }
      if (numTicks == 0 && maxTicks == 0 && max == 0 && num > 0) {
        max = num;
        changed = true;
      }
      numTicks = Math.min(max, num);
      maxTicks = max;

      return changed;
    }

    public String getValue() {
      commitTextFields();
      return Integer.toString(numTicks) + VALUE_DELIMINATOR + maxTicks;
    }


    private void commitTextFields() {
      if (valField != null || maxField != null) {
        if (set(valField != null ? atoi(valField.getText()) : numTicks,
                maxField != null ? atoi(maxField.getText()) : maxTicks)) {
          updateFields();
        }
      }
    }

    private void updateFields() {
      if (valField != null) {
        valField.setText(Integer.toString(numTicks));
      }
      if (maxField != null) {
        maxField.setText(Integer.toString(maxTicks));
      }
      ticks.set(numTicks, maxTicks);
    }

    private boolean areFieldValuesValid() {
      int max = maxField == null ? maxTicks : atoi(maxField.getText());
      int val = valField == null ? numTicks : atoi(valField.getText());
      return val < max && val >= 0;
    }

    // field changed
    public void actionPerformed(ActionEvent event) {

      if (event.getSource() == maxField || event.getSource() == valField) {
        commitTextFields();
        ticks.set(numTicks, maxTicks);
        fireActionEvent();
      }
      else if (event.getSource() == ticks) {
        commitTextFields();
        numTicks = ticks.getNumTicks();
        if (maxField == null) {
          maxTicks = ticks.getMaxTicks();
        }
        updateFields();
        fireDocumentEvent();
      }
    }

    public void addActionListener(ActionListener listener) {
      actionListeners.add(listener);
    }

    public void fireActionEvent() {
      for (ActionListener l : actionListeners) {
        l.actionPerformed(new ActionEvent(this, 0, null));
      }
    }


    void addDocumentListener(DocumentListener listener) {
      if (valField != null)
        valField.getDocument().addDocumentListener(this);
      if (maxField != null)
        maxField.getDocument().addDocumentListener(this);
      documentListeners.add(listener);
    }

    public void fireDocumentEvent() {
      for (DocumentListener l : documentListeners) {
        l.changedUpdate(null);
      }
    }

    // FocusListener Interface
    public void focusLost(FocusEvent event) {
      commitTextFields();
      ticks.set(numTicks, maxTicks);
    }

    public void focusGained(FocusEvent event) {
    }

    public void changedUpdate(DocumentEvent event) {
      // do not propagate events unless min/max is valid.  We don't want to trigger both
      // a state update. A state update might trigger a fix-min/max operation, which would
      // cause the text fields to update which will throw an IllegalStateException
      if (areFieldValuesValid()) {
        fireDocumentEvent();
      }
    }

    public void insertUpdate(DocumentEvent event) {
      changedUpdate(event);
    }

    public void removeUpdate(DocumentEvent event) {
      changedUpdate(event);
    }
  }


  class TickLabel extends JLabel implements MouseListener {
    private static final long serialVersionUID = 1L;

    private int numTicks = 0;
    private int maxTicks = 0;
    protected int panelType;
    private List<ActionListener> actionListeners =
      new ArrayList<ActionListener>();

    public int getNumTicks() {
      return numTicks;
    }

    public int getMaxTicks() {
      return maxTicks;
    }

    public void addActionListener(ActionListener listener) {
      actionListeners.add(listener);
    }

    public TickLabel(int numTicks, int maxTicks, int panelType) {
      super(" ");
      //Debug.trace("TickLabel( " + maxTicks + ", " + numTicks + " )");
      set(numTicks, maxTicks);
      this.panelType = panelType;
      addMouseListener(this);
    }

    protected int topMargin = 2;
    protected int leftMargin = 2;
    protected int numRows;
    protected int numCols;
    protected int dx = 1;

    public void paint(Graphics g) {

      //Debug.trace("TickLabcmdel.paint(" + numTicks + "/" + maxTicks + ")");
      //Debug.trace("  width=" + getWidth() + " height=" + getHeight());
      if (maxTicks > 0) {
        // prefered width is 10
        // min width before resize is 6
        int displayWidth = getWidth() - 2;

        dx = Math.min(displayWidth / maxTicks, 10);
        // if dx < 2, we have a problem

        numRows = dx < 3 ? 3 : dx < 6 ? 2 : 1;

        dx = Math.min(displayWidth * numRows / maxTicks, Math.min(getHeight() / numRows, 10));
        if (dx < 1) dx = 1;

        numCols = (maxTicks + numRows - 1) / numRows;

        int dy = dx;

        topMargin = (getHeight() - dy * numRows + 2) / 2;

        int tick = 0;
        int row, col;
        if (dx > 4) {
          for (; tick < maxTicks; ++tick) {
            row = tick / numCols;
            col = tick % numCols;
            g.setColor(Color.BLACK);
            g.drawRect(leftMargin + col * dx, topMargin + row * dy, dx - 3, dy - 3);
            g.setColor(tick < numTicks ? Color.BLACK : Color.WHITE);
            g.fillRect(leftMargin + 1 + col * dx, topMargin + 1 + row * dy, dx - 4, dy - 4);
          }
        }
        else {
          g.setColor(Color.GRAY);
          g.fillRect(0, topMargin - 2, numCols * dx + leftMargin * 2, numRows * dy + 4);
          for (; tick < maxTicks; ++tick) {
            row = tick / numCols;
            col = tick % numCols;
            g.setColor(tick < numTicks ? Color.BLACK : Color.WHITE);
            g.fillRect(leftMargin + col * dx, topMargin + row * dy, dx - 1, dy - 1);
          }
        }
      }
    }

    public void mouseClicked(MouseEvent event) {

      if ((event.isMetaDown() || event.isShiftDown()) && panelType != TICKS_VALMAX) {
        new EditTickLabelValueDialog(this);
        return;
      }
      int col = Math.min((event.getX() - leftMargin + 1) / dx, numCols - 1);
      int row = Math.min((event.getY() - topMargin + 1) / dx, numRows - 1);
      int num = row * numCols + col + 1;

      // Checkbox behavior; toggle the box clicked on
      // numTicks = num > numTicks ? num : num - 1;

      // Slider behavior; set the box clicked on and all to left and clear all boxes to right,
      // UNLESS user clicked on last set box (which would do nothing), in this case, toggle the
      // box clicked on.  This is the only way the user can clear the first box.

      numTicks = (num == numTicks) ? num - 1 : num;
      fireActionEvent();

      repaint();
    }

    public void fireActionEvent() {
      for (ActionListener l : actionListeners) {
        l.actionPerformed(new ActionEvent(this, 0, null));
      }
    }


    public void set(int newNumTicks, int newMaxTicks) {
      //Debug.trace("TickLabel.set( " + newNumTicks + "," + newMaxTicks + " ) was " + numTicks + "/" + maxTicks);
      numTicks = newNumTicks;
      maxTicks = newMaxTicks;

      String tip = numTicks + "/" + maxTicks;

      if (panelType != TICKS_VALMAX) {
        tip += " (right-click to edit)";
      }

      setToolTipText(tip);

      repaint();
    }

    public void mouseEntered(MouseEvent event) {
    }

    public void mouseExited(MouseEvent event) {
    }

    public void mousePressed(MouseEvent event) {
    }

    public void mouseReleased(MouseEvent event) {
    }

    public class EditTickLabelValueDialog extends JPanel implements ActionListener, DocumentListener, FocusListener {
      private static final long serialVersionUID = 1L;

      TickLabel theTickLabel;
      JLayeredPane editorParent;
      JTextField valueField;

      public EditTickLabelValueDialog(TickLabel owner) {
        super(new BorderLayout());

        theTickLabel = owner;

        // Find containing dialog
        Container theDialog = theTickLabel.getParent();
        while (!(theDialog instanceof JDialog) && theDialog != null) {
          theDialog = theDialog.getParent();
        }

        if (theDialog != null) {
          editorParent = ((JDialog) theDialog).getLayeredPane();
          Rectangle newBounds = SwingUtilities.convertRectangle(theTickLabel.getParent(), theTickLabel.getBounds(), editorParent);
          setBounds(newBounds);

          JButton okButton = new JButton("Ok");

          switch (panelType) {
            case TICKS_VAL:
              valueField = new JTextField(Integer.toString(owner.getMaxTicks()));
              valueField.setToolTipText("max value");
              break;
            case TICKS_MAX:
              valueField = new JTextField(Integer.toString(owner.getNumTicks()));
              valueField.setToolTipText("current value");
              break;
            case TICKS:
            default:
              valueField = new JTextField(owner.numTicks + "/" + owner.maxTicks);
              valueField.setToolTipText("current value / max value");
              break;
          }

          valueField.addActionListener(this);
          valueField.addFocusListener(this);
          valueField.getDocument().addDocumentListener(this);
          add(valueField, BorderLayout.CENTER);

          okButton.addActionListener(this);
          add(okButton, BorderLayout.EAST);

          editorParent.add(this, JLayeredPane.MODAL_LAYER);

          setVisible(true);
          valueField.grabFocus();
        }
      }

      public void storeValues() {
        String value = valueField.getText();
        switch (panelType) {
          case TICKS_VAL:
            theTickLabel.set(theTickLabel.getNumTicks(), atoi(value));
            break;
          case TICKS_MAX:
            theTickLabel.set(atoi(value), theTickLabel.getMaxTicks());
            break;
          case TICKS:
          default:
            theTickLabel.set(atoi(value), atoiRight(value));
            break;
        }
        theTickLabel.fireActionEvent();
      }

      public void actionPerformed(ActionEvent event) {
        storeValues();
        editorParent.remove(this);
      }

      public void changedUpdate(DocumentEvent event) {
        theTickLabel.fireActionEvent();
      }

      public void insertUpdate(DocumentEvent event) {
        theTickLabel.fireActionEvent();
      }

      public void removeUpdate(DocumentEvent event) {
        theTickLabel.fireActionEvent();
      }

      public void focusGained(FocusEvent event) {
      }

      public void focusLost(FocusEvent event) {
        storeValues();
      }

    }

  }
}