Merge remote-tracking branch 'origin/bug/JAL-3049colourCellTooltip' into
authorgmungoc <g.m.carstairs@dundee.ac.uk>
Tue, 5 Mar 2019 09:01:48 +0000 (09:01 +0000)
committergmungoc <g.m.carstairs@dundee.ac.uk>
Tue, 5 Mar 2019 09:01:48 +0000 (09:01 +0000)
merge/JAL-3049

Conflicts:
src/jalview/api/FeatureColourI.java
src/jalview/schemes/FeatureColour.java
test/jalview/schemes/FeatureColourTest.java

1  2 
src/jalview/api/FeatureColourI.java
src/jalview/gui/FeatureSettings.java
src/jalview/schemes/FeatureColour.java
test/jalview/gui/FeatureSettingsTest.java
test/jalview/schemes/FeatureColourTest.java

@@@ -194,14 -194,10 +194,22 @@@ public interface FeatureColour
    void setAttributeName(String... name);
  
    /**
 +   * Answers true if colour has a threshold set, and the feature score (or other
 +   * attribute selected for colouring) is outwith the threshold.
 +   * <p>
 +   * Answers false if not a graduated colour, or no threshold is set, or value
 +   * is not outwith the threshold, or value is null or non-numeric.
 +   * 
 +   * @param sf
 +   * @return
 +   */
 +  boolean isOutwithThreshold(SequenceFeature sf);
++
++  /*
+    * Answers a human-readable text description of the colour, suitable for
+    * display as a tooltip, possibly internationalised for the user's locale.
+    * 
+    * @return
+    */
+   String getDescription();
  }
@@@ -24,20 -24,20 +24,21 @@@ import jalview.api.FeatureColourI
  import jalview.api.FeatureSettingsControllerI;
  import jalview.datamodel.AlignmentI;
  import jalview.datamodel.SequenceI;
+ import jalview.datamodel.features.FeatureMatcher;
  import jalview.datamodel.features.FeatureMatcherI;
  import jalview.datamodel.features.FeatureMatcherSet;
  import jalview.datamodel.features.FeatureMatcherSetI;
  import jalview.gui.Help.HelpId;
  import jalview.io.JalviewFileChooser;
  import jalview.io.JalviewFileView;
 -import jalview.schemabinding.version2.Filter;
 -import jalview.schemabinding.version2.JalviewUserColours;
 -import jalview.schemabinding.version2.MatcherSet;
  import jalview.schemes.FeatureColour;
  import jalview.util.MessageManager;
  import jalview.util.Platform;
  import jalview.viewmodel.seqfeatures.FeatureRendererModel.FeatureSettingsBean;
 +import jalview.xml.binding.jalview.JalviewUserColours;
 +import jalview.xml.binding.jalview.JalviewUserColours.Colour;
 +import jalview.xml.binding.jalview.JalviewUserColours.Filter;
 +import jalview.xml.binding.jalview.ObjectFactory;
  
  import java.awt.BorderLayout;
  import java.awt.Color;
@@@ -79,6 -79,7 +80,6 @@@ import javax.swing.BorderFactory
  import javax.swing.Icon;
  import javax.swing.JButton;
  import javax.swing.JCheckBox;
 -import javax.swing.JCheckBoxMenuItem;
  import javax.swing.JColorChooser;
  import javax.swing.JDialog;
  import javax.swing.JInternalFrame;
@@@ -92,18 -93,13 +93,19 @@@ import javax.swing.JSlider
  import javax.swing.JTable;
  import javax.swing.ListSelectionModel;
  import javax.swing.SwingConstants;
+ import javax.swing.border.Border;
  import javax.swing.event.ChangeEvent;
  import javax.swing.event.ChangeListener;
  import javax.swing.table.AbstractTableModel;
 +import javax.swing.table.JTableHeader;
  import javax.swing.table.TableCellEditor;
  import javax.swing.table.TableCellRenderer;
  import javax.swing.table.TableColumn;
 +import javax.xml.bind.JAXBContext;
 +import javax.xml.bind.JAXBElement;
 +import javax.xml.bind.Marshaller;
 +import javax.xml.stream.XMLInputFactory;
 +import javax.xml.stream.XMLStreamReader;
  
  public class FeatureSettings extends JPanel
          implements FeatureSettingsControllerI
  
    private static final int MIN_HEIGHT = 400;
  
+   private final static String BASE_TOOLTIP = "Click to edit, right-click for menu";
    final FeatureRenderer fr;
  
    public final AlignFrame af;
  
    int selectedRow = -1;
  
 -  JButton fetchDAS = new JButton();
 -
 -  JButton saveDAS = new JButton();
 -
 -  JButton cancelDAS = new JButton();
 -
    boolean resettingTable = false;
  
    /*
  
      table = new JTable()
      {
+       static final String tt = "Click to edit, right-click for menu"; // todo i18n
+       
        @Override
        public String getToolTipText(MouseEvent e)
        {
          String tip = null;
          int column = table.columnAtPoint(e.getPoint());
+         int row = table.rowAtPoint(e.getPoint());
          switch (column)
          {
          case TYPE_COLUMN:
            tip = JvSwingUtils.wrapTooltip(true, MessageManager
                    .getString("label.feature_settings_click_drag"));
            break;
+         case COLOUR_COLUMN:
+           FeatureColourI colour = (FeatureColourI) table.getValueAt(row,
+                   column);
+           tip = getColorTooltip(colour);
+           break;
          case FILTER_COLUMN:
            FeatureMatcherSet o = (FeatureMatcherSet) table.getValueAt(row,
                    column);
            tip = o.isEmpty()
 -                  ? MessageManager.getString("label.filters_tooltip")
 +                  ? MessageManager
 +                          .getString("label.configure_feature_tooltip")
                    : o.toString();
            break;
          default:
          }
          return tip;
        }
+       /**
+        * Position the tooltip at the bottom edge of, and half way across, the
+        * current cell
+        */
+       @Override
+       public Point getToolTipLocation(MouseEvent e)
+       {
+         Point point = e.getPoint();
+         int column = table.columnAtPoint(point);
+         int row = table.rowAtPoint(point);
+         Rectangle r = getCellRect(row, column, false);
+         Point loc = new Point(r.x + r.width / 2, r.y + r.height);
+         return loc;
+       }
      };
 -    table.getTableHeader().setFont(new Font("Verdana", Font.PLAIN, 12));
 +    JTableHeader tableHeader = table.getTableHeader();
 +    tableHeader.setFont(new Font("Verdana", Font.PLAIN, 12));
 +    tableHeader.setReorderingAllowed(false);
      table.setFont(new Font("Verdana", Font.PLAIN, 12));
  
      // table.setDefaultRenderer(Color.class, new ColorRenderer());
      });
      men.add(dens);
  
 -    /*
 -     * variable colour options include colour by label, by score,
 -     * by selected attribute text, or attribute value
 -     */
 -    final JCheckBoxMenuItem mxcol = new JCheckBoxMenuItem(
 -            MessageManager.getString("label.variable_colour"));
 -    mxcol.setSelected(!featureColour.isSimpleColour());
 -    men.add(mxcol);
 -    mxcol.addActionListener(new ActionListener()
 -    {
 -      JColorChooser colorChooser;
 -
 -      @Override
 -      public void actionPerformed(ActionEvent e)
 -      {
 -        if (e.getSource() == mxcol)
 -        {
 -          if (featureColour.isSimpleColour())
 -          {
 -            FeatureTypeSettings fc = new FeatureTypeSettings(me.fr, type);
 -            fc.addActionListener(this);
 -          }
 -          else
 -          {
 -            // bring up simple color chooser
 -            colorChooser = new JColorChooser();
 -            String title = MessageManager
 -                    .getString("label.select_colour");
 -            JDialog dialog = JColorChooser.createDialog(me,
 -                    title, true, // modal
 -                    colorChooser, this, // OK button handler
 -                    null); // no CANCEL button handler
 -            colorChooser.setColor(featureColour.getMaxColour());
 -            dialog.setVisible(true);
 -          }
 -        }
 -        else
 -        {
 -          if (e.getSource() instanceof FeatureTypeSettings)
 -          {
 -            /*
 -             * update after OK in feature colour dialog; the updated
 -             * colour will have already been set in the FeatureRenderer
 -             */
 -            FeatureColourI fci = fr.getFeatureColours().get(type);
 -            table.setValueAt(fci, rowSelected, 1);
 -            table.validate();
 -          }
 -          else
 -          {
 -            // probably the color chooser!
 -            table.setValueAt(new FeatureColour(colorChooser.getColor()),
 -                    rowSelected, 1);
 -            table.validate();
 -            me.updateFeatureRenderer(
 -                    ((FeatureTableModel) table.getModel()).getData(),
 -                    false);
 -          }
 -        }
 -      }
 -
 -    });
 -
      JMenuItem selCols = new JMenuItem(
              MessageManager.getString("label.select_columns_containing"));
      selCols.addActionListener(new ActionListener()
        InputStreamReader in = new InputStreamReader(
                new FileInputStream(file), "UTF-8");
  
 -      JalviewUserColours jucs = JalviewUserColours.unmarshal(in);
 +      JAXBContext jc = JAXBContext
 +              .newInstance("jalview.xml.binding.jalview");
 +      javax.xml.bind.Unmarshaller um = jc.createUnmarshaller();
 +      XMLStreamReader streamReader = XMLInputFactory.newInstance()
 +              .createXMLStreamReader(in);
 +      JAXBElement<JalviewUserColours> jbe = um.unmarshal(streamReader,
 +              JalviewUserColours.class);
 +      JalviewUserColours jucs = jbe.getValue();
 +
 +      // JalviewUserColours jucs = JalviewUserColours.unmarshal(in);
  
        /*
         * load feature colours
         */
 -      for (int i = jucs.getColourCount() - 1; i >= 0; i--)
 +      for (int i = jucs.getColour().size() - 1; i >= 0; i--)
        {
 -        jalview.schemabinding.version2.Colour newcol = jucs.getColour(i);
 -        FeatureColourI colour = Jalview2XML.unmarshalColour(newcol);
 +        Colour newcol = jucs.getColour().get(i);
 +        FeatureColourI colour = jalview.project.Jalview2XML
 +                .parseColour(newcol);
          fr.setColour(newcol.getName(), colour);
 -        fr.setOrder(newcol.getName(), i / (float) jucs.getColourCount());
 +        fr.setOrder(newcol.getName(), i / (float) jucs.getColour().size());
        }
  
        /*
         * load feature filters; loaded filters will replace any that are
         * currently defined, other defined filters are left unchanged 
         */
 -      for (int i = 0; i < jucs.getFilterCount(); i++)
 +      for (int i = 0; i < jucs.getFilter().size(); i++)
        {
 -        jalview.schemabinding.version2.Filter filterModel = jucs
 -                .getFilter(i);
 +        Filter filterModel = jucs.getFilter().get(i);
          String featureType = filterModel.getFeatureType();
 -        FeatureMatcherSetI filter = Jalview2XML.unmarshalFilter(featureType,
 -                filterModel.getMatcherSet());
 +        FeatureMatcherSetI filter = jalview.project.Jalview2XML
 +                .parseFilter(featureType, filterModel.getMatcherSet());
          if (!filter.isEmpty())
          {
            fr.setFeatureFilter(featureType, filter);
        for (String featureType : sortedTypes)
        {
          FeatureColourI fcol = fr.getFeatureStyle(featureType);
 -        jalview.schemabinding.version2.Colour col = Jalview2XML.marshalColour(
 -                featureType, fcol);
 -        ucs.addColour(col);
 +        Colour col = jalview.project.Jalview2XML.marshalColour(featureType,
 +                fcol);
 +        ucs.getColour().add(col);
        }
  
        /*
          {
            Iterator<FeatureMatcherI> iterator = filter.getMatchers().iterator();
            FeatureMatcherI firstMatcher = iterator.next();
 -          MatcherSet ms = Jalview2XML.marshalFilter(firstMatcher, iterator,
 +          jalview.xml.binding.jalview.FeatureMatcherSet ms = jalview.project.Jalview2XML
 +                  .marshalFilter(firstMatcher, iterator,
                    filter.isAnded());
            Filter filterModel = new Filter();
            filterModel.setFeatureType(featureType);
            filterModel.setMatcherSet(ms);
 -          ucs.addFilter(filterModel);
 +          ucs.getFilter().add(filterModel);
          }
        }
 +      JAXBContext jaxbContext = JAXBContext
 +              .newInstance(JalviewUserColours.class);
 +      Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
 +      jaxbMarshaller.marshal(
 +              new ObjectFactory().createJalviewUserColours(ucs), out);
  
 -      ucs.marshal(out);
 +      // jaxbMarshaller.marshal(object, pout);
 +      // marshaller.marshal(object);
 +      out.flush();
 +
 +      // ucs.marshal(out);
        out.close();
      } catch (Exception ex)
      {
      this.add(settingsPane);
    }
  
+   /**
+    * Answers a suitable tooltip to show on the colour cell of the table
+    * 
+    * @param fcol
+    * @return
+    */
+   public static String getColorTooltip(FeatureColourI fcol)
+   {
+     if (fcol == null)
+     {
+       return null;
+     }
+     if (fcol.isSimpleColour())
+     {
+       return BASE_TOOLTIP;
+     }
+     String description = fcol.getDescription();
+     description = description.replaceAll("<", "&lt;");
+     description = description.replaceAll(">", "&gt;");
+     StringBuilder tt = new StringBuilder(description);
+     tt.append("<br>").append(BASE_TOOLTIP).append("</br>");
+     return JvSwingUtils.wrapTooltip(true, tt.toString());
+   }
+   public static void renderGraduatedColor(JLabel comp, FeatureColourI gcol,
+           int w, int h)
+   {
+     boolean thr = false;
+     StringBuilder tx = new StringBuilder();
+   
+     if (gcol.isColourByAttribute())
+     {
+       tx.append(FeatureMatcher
+               .toAttributeDisplayName(gcol.getAttributeName()));
+     }
+     else if (!gcol.isColourByLabel())
+     {
+       tx.append(MessageManager.getString("label.score"));
+     }
+     tx.append(" ");
+     if (gcol.isAboveThreshold())
+     {
+       thr = true;
+       tx.append(">");
+     }
+     if (gcol.isBelowThreshold())
+     {
+       thr = true;
+       tx.append("<");
+     }
+     if (gcol.isColourByLabel())
+     {
+       if (thr)
+       {
+         tx.append(" ");
+       }
+       if (!gcol.isColourByAttribute())
+       {
+         tx.append("Label");
+       }
+       comp.setIcon(null);
+     }
+     else
+     {
+       Color newColor = gcol.getMaxColour();
+       comp.setBackground(newColor);
+       // System.err.println("Width is " + w / 2);
+       Icon ficon = new FeatureIcon(gcol, comp.getBackground(), w, h, thr);
+       comp.setIcon(ficon);
+       // tt+="RGB value: Max (" + newColor.getRed() + ", "
+       // + newColor.getGreen() + ", " + newColor.getBlue()
+       // + ")\nMin (" + minCol.getRed() + ", " + minCol.getGreen()
+       // + ", " + minCol.getBlue() + ")");
+     }
+     comp.setHorizontalAlignment(SwingConstants.CENTER);
+     comp.setText(tx.toString());
+   }
    // ///////////////////////////////////////////////////////////////////////
    // http://java.sun.com/docs/books/tutorial/uiswing/components/table.html
    // ///////////////////////////////////////////////////////////////////////
      private String[] columnNames = {
          MessageManager.getString("label.feature_type"),
          MessageManager.getString("action.colour"),
 -        MessageManager.getString("label.filter"),
 +        MessageManager.getString("label.configuration"),
          MessageManager.getString("label.show") };
  
      private Object[][] data;
  
    class ColorRenderer extends JLabel implements TableCellRenderer
    {
-     javax.swing.border.Border unselectedBorder = null;
+     Border unselectedBorder = null;
  
-     javax.swing.border.Border selectedBorder = null;
-     final String baseTT = "Click to edit, right/apple click for menu.";
+     Border selectedBorder = null;
  
      public ColorRenderer()
      {
      {
        FeatureColourI cellColour = (FeatureColourI) color;
        setOpaque(true);
-       setToolTipText(baseTT);
        setBackground(tbl.getBackground());
        if (!cellColour.isSimpleColour())
        {
      renderGraduatedColor(comp, gcol, w, h);
    }
  
-   public static void renderGraduatedColor(JLabel comp, FeatureColourI gcol,
-           int w, int h)
-   {
-     boolean thr = false;
-     StringBuilder tt = new StringBuilder();
-     StringBuilder tx = new StringBuilder();
-     if (gcol.isColourByAttribute())
-     {
-       tx.append(String.join(":", gcol.getAttributeName()));
-     }
-     else if (!gcol.isColourByLabel())
-     {
-       tx.append(MessageManager.getString("label.score"));
-     }
-     tx.append(" ");
-     if (gcol.isAboveThreshold())
-     {
-       thr = true;
-       tx.append(">");
-       tt.append("Thresholded (Above ").append(gcol.getThreshold())
-               .append(") ");
-     }
-     if (gcol.isBelowThreshold())
-     {
-       thr = true;
-       tx.append("<");
-       tt.append("Thresholded (Below ").append(gcol.getThreshold())
-               .append(") ");
-     }
-     if (gcol.isColourByLabel())
-     {
-       tt.append("Coloured by label text. ").append(tt);
-       if (thr)
-       {
-         tx.append(" ");
-       }
-       if (!gcol.isColourByAttribute())
-       {
-         tx.append("Label");
-       }
-       comp.setIcon(null);
-     }
-     else
-     {
-       Color newColor = gcol.getMaxColour();
-       comp.setBackground(newColor);
-       // System.err.println("Width is " + w / 2);
-       Icon ficon = new FeatureIcon(gcol, comp.getBackground(), w, h, thr);
-       comp.setIcon(ficon);
-       // tt+="RGB value: Max (" + newColor.getRed() + ", "
-       // + newColor.getGreen() + ", " + newColor.getBlue()
-       // + ")\nMin (" + minCol.getRed() + ", " + minCol.getGreen()
-       // + ", " + minCol.getBlue() + ")");
-     }
-     comp.setHorizontalAlignment(SwingConstants.CENTER);
-     comp.setText(tx.toString());
-     if (tt.length() > 0)
-     {
-       if (comp.getToolTipText() == null)
-       {
-         comp.setToolTipText(tt.toString());
-       }
-       else
-       {
-         comp.setToolTipText(
-                 tt.append(" ").append(comp.getToolTipText()).toString());
-       }
-     }
-   }
    class ColorEditor extends AbstractCellEditor
            implements TableCellEditor, ActionListener
    {
          {
            // bring up graduated chooser.
            chooser = new FeatureTypeSettings(me.fr, type);
 -          chooser.setRequestFocusEnabled(true);
 -          chooser.requestFocus();
 +          /**
 +           * @j2sNative
 +           */
 +          {
 +            chooser.setRequestFocusEnabled(true);
 +            chooser.requestFocus();
 +          }
            chooser.addActionListener(this);
 -          chooser.showTab(true);
 +          // Make the renderer reappear.
 +          fireEditingStopped();
          }
 -        // Make the renderer reappear.
 -        fireEditingStopped();
 -
        }
        else
        {
                    chooser.getWidth(), chooser.getHeight());
            chooser.validate();
          }
 -        chooser.showTab(false);
          fireEditingStopped();
        }
        else if (e.getSource() instanceof Component)
        button.setOpaque(true);
        button.setBackground(me.getBackground());
        button.setText(currentFilter.toString());
-       button.setToolTipText(currentFilter.toString());
        button.setIcon(null);
        return button;
      }
@@@ -25,6 -25,7 +25,7 @@@ import jalview.datamodel.SequenceFeatur
  import jalview.datamodel.features.FeatureMatcher;
  import jalview.util.ColorUtils;
  import jalview.util.Format;
+ import jalview.util.MessageManager;
  
  import java.awt.Color;
  import java.util.StringTokenizer;
   */
  public class FeatureColour implements FeatureColourI
  {
+   private static final String I18N_LABEL = MessageManager
+           .getString("label.label");
+   private static final String I18N_SCORE = MessageManager
+           .getString("label.score");
    private static final String ABSOLUTE = "abso";
  
    private static final String ABOVE = "above";
        } catch (Exception e)
        {
          throw new IllegalArgumentException(
 -                "Couldn't parse the minimum value for graduated colour ("
 -                        + descriptor + ")");
 +                "Couldn't parse the minimum value for graduated colour ('"
 +                        + minval + "')");
        }
        try
        {
        Color maxColour = ColorUtils.parseColourString(maxcol);
        Color noColour = noValueColour.equals(NO_VALUE_MAX) ? maxColour
                : (noValueColour.equals(NO_VALUE_NONE) ? null : minColour);
 -      featureColour = new FeatureColour(minColour, maxColour, noColour, min,
 -              max);
 +      featureColour = new FeatureColour(maxColour, minColour, maxColour,
 +              noColour, min, max);
        featureColour.setColourByLabel(minColour == null);
        featureColour.setAutoScaled(autoScaled);
        if (byAttribute)
    }
  
    /**
 -   * Constructor given a simple colour
 +   * Constructor given a simple colour. This also 'primes' a graduated colour
 +   * range, where the maximum colour is the given simple colour, and the minimum
 +   * colour a paler shade of it. This is for convenience when switching from a
 +   * simple colour to a graduated colour scheme.
     * 
     * @param c
     */
    public FeatureColour(Color c)
    {
 -    minColour = Color.WHITE;
 -    maxColour = Color.BLACK;
 -    noColour = DEFAULT_NO_COLOUR;
 -    minRed = 0f;
 -    minGreen = 0f;
 -    minBlue = 0f;
 -    deltaRed = 0f;
 -    deltaGreen = 0f;
 -    deltaBlue = 0f;
 -    colour = c;
 -  }
 +    /*
 +     * set max colour to the simple colour, min colour to a paler shade of it
 +     */
 +    this(c, c == null ? Color.white : ColorUtils.bleachColour(c, 0.9f),
 +            c == null ? Color.black : c, DEFAULT_NO_COLOUR, 0, 0);
  
 -  /**
 -   * Constructor given a colour range and a score range, defaulting 'no value
 -   * colour' to be the same as minimum colour
 -   * 
 -   * @param low
 -   * @param high
 -   * @param min
 -   * @param max
 -   */
 -  public FeatureColour(Color low, Color high, float min, float max)
 -  {
 -    this(low, high, low, min, max);
 +    /*
 +     * but enforce simple colour for now!
 +     */
 +    setGraduatedColour(false);
    }
  
    /**
    }
  
    /**
 -   * Copy constructor with new min/max ranges
 -   * 
 -   * @param fc
 -   * @param min
 -   * @param max
 -   */
 -  public FeatureColour(FeatureColour fc, float min, float max)
 -  {
 -    this(fc);
 -    updateBounds(min, max);
 -  }
 -
 -  /**
 -   * Constructor for a graduated colour
 +   * Constructor that sets both simple and graduated colour values. This allows
 +   * alternative colour schemes to be 'preserved' while switching between them
 +   * to explore their effects on the visualisation.
 +   * <p>
 +   * This sets the colour scheme to 'graduated' by default. Override this if
 +   * wanted by calling <code>setGraduatedColour(false)</code> for a simple
 +   * colour, or <code>setColourByLabel(true)</code> for colour by label.
     * 
 +   * @param myColour
     * @param low
     * @param high
     * @param noValueColour
     * @param min
     * @param max
     */
 -  public FeatureColour(Color low, Color high, Color noValueColour,
 -          float min, float max)
 +  public FeatureColour(Color myColour, Color low, Color high,
 +          Color noValueColour, float min, float max)
    {
      if (low == null)
      {
      {
        high = Color.black;
      }
 -    graduatedColour = true;
 -    colour = null;
 +    colour = myColour;
      minColour = low;
      maxColour = high;
 +    setGraduatedColour(true);
      noColour = noValueColour;
      threshold = Float.NaN;
      isHighToLow = min >= max;
     * Sets the 'graduated colour' flag. If true, also sets 'colour by label' to
     * false.
     */
 -  void setGraduatedColour(boolean b)
 +  public void setGraduatedColour(boolean b)
    {
      graduatedColour = b;
      if (b)
  
    /**
     * Returns the colour for the given instance of the feature. This may be a
 -   * simple colour, a colour generated from the feature description (if
 -   * isColourByLabel()), or a colour derived from the feature score (if
 -   * isGraduatedColour()).
 +   * simple colour, a colour generated from the feature description or other
 +   * attribute (if isColourByLabel()), or a colour derived from the feature
 +   * score or other attribute (if isGraduatedColour()).
 +   * <p>
 +   * Answers null if feature score (or attribute) value lies outside a
 +   * configured threshold.
     * 
     * @param feature
     * @return
          sb.append(BAR).append(Format.getHexString(getMinColour()))
                  .append(BAR);
          sb.append(Format.getHexString(getMaxColour())).append(BAR);
 -        String noValue = minColour.equals(noColour) ? NO_VALUE_MIN
 -                : (maxColour.equals(noColour) ? NO_VALUE_MAX
 -                        : NO_VALUE_NONE);
 +        
 +        /*
 +         * 'no value' colour should be null, min or max colour;
 +         * if none of these, coerce to minColour
 +         */
 +        String noValue = NO_VALUE_MIN;
 +        if (maxColour.equals(noColour))
 +        {
 +          noValue = NO_VALUE_MAX;
 +        }
 +        if (noColour == null)
 +        {
 +          noValue = NO_VALUE_NONE;
 +        }
          sb.append(noValue).append(BAR);
          if (!isAutoScaled())
          {
    }
  
    @Override
 +  public boolean isOutwithThreshold(SequenceFeature feature)
 +  {
 +    if (!isGraduatedColour())
 +    {
 +      return false;
 +    }
 +    float scr = feature.getScore();
 +    if (attributeName != null)
 +    {
 +      try
 +      {
 +        String attVal = feature.getValueAsString(attributeName);
 +        scr = Float.valueOf(attVal);
 +      } catch (Throwable e)
 +      {
 +        scr = Float.NaN;
 +      }
 +    }
 +    if (Float.isNaN(scr))
 +    {
 +      return false;
 +    }
 +
 +    return ((isAboveThreshold() && scr <= threshold)
 +            || (isBelowThreshold() && scr >= threshold));
 +  }
 +
++  @Override
+   public String getDescription()
+   {
+     if (isSimpleColour())
+     {
+       return "r=" + colour.getRed() + ",g=" + colour.getGreen() + ",b="
+               + colour.getBlue();
+     }
+     StringBuilder tt = new StringBuilder();
+     String by = null;
+     if (getAttributeName() != null)
+     {
+       by = FeatureMatcher.toAttributeDisplayName(getAttributeName());
+     }
+     else if (isColourByLabel())
+     {
+       by = I18N_LABEL;
+     }
+     else
+     {
+       by = I18N_SCORE;
+     }
+     tt.append(MessageManager.formatMessage("action.by_title_param", by));
+     /*
+      * add threshold if any
+      */
+     if (isAboveThreshold() || isBelowThreshold())
+     {
+       tt.append(" (");
+       if (isColourByLabel())
+       {
+         /*
+          * Jalview features file supports the combination of 
+          * colour by label or attribute text with score threshold
+          */
+         tt.append(I18N_SCORE).append(" ");
+       }
+       tt.append(isAboveThreshold() ? "> " : "< ");
+       tt.append(getThreshold()).append(")");
+     }
+     return tt.toString();
+   }
  }
@@@ -13,6 -13,7 +13,7 @@@ import jalview.datamodel.features.Featu
  import jalview.io.DataSourceType;
  import jalview.io.FileLoader;
  import jalview.schemes.FeatureColour;
+ import jalview.schemes.FeatureColourTest;
  import jalview.util.matcher.Condition;
  
  import java.awt.Color;
@@@ -60,8 -61,8 +61,8 @@@ public class FeatureSettingsTes
      fr.setColour("type2", byLabel);
  
      // type3: by score above threshold
 -    FeatureColourI byScore = new FeatureColour(Color.BLACK, Color.BLUE, 1,
 -            10);
 +    FeatureColourI byScore = new FeatureColour(null, Color.BLACK,
 +            Color.BLUE, null, 1, 10);
      byScore.setAboveThreshold(true);
      byScore.setThreshold(2f);
      fr.setColour("type3", byScore);
@@@ -73,8 -74,8 +74,8 @@@
      fr.setColour("type4", byAF);
  
      // type5: by attribute CSQ:PolyPhen below threshold
 -    FeatureColourI byPolyPhen = new FeatureColour(Color.BLACK, Color.BLUE,
 -            1, 10);
 +    FeatureColourI byPolyPhen = new FeatureColour(null, Color.BLACK,
 +            Color.BLUE, null, 1, 10);
      byPolyPhen.setBelowThreshold(true);
      byPolyPhen.setThreshold(3f);
      byPolyPhen.setAttributeName("CSQ", "PolyPhen");
      });
      seq.addSequenceFeature(sf);
    }
+   /**
+    * @see FeatureColourTest#testGetDescription()
+    * @throws IOException
+    */
+   @Test(groups = "Functional")
+   public void testGetColorTooltip() throws IOException
+   {
+     assertNull(FeatureSettings.getColorTooltip(null));
+     /*
+      * simple colour
+      */
+     FeatureColourI fc = new FeatureColour(Color.black);
+     String simpleTooltip = "Click to edit, right-click for menu";
+     assertEquals(FeatureSettings.getColorTooltip(fc), simpleTooltip);
+     /*
+      * graduated colour tooltip includes description of colour
+      */
+     fc.setColourByLabel(true);
+     assertEquals(FeatureSettings.getColorTooltip(fc),
+             "<html>By Label<br>" + simpleTooltip + "</br></html>");
+     /*
+      * graduated colour with threshold is html-encoded
+      */
 -    fc = new FeatureColour(Color.red, Color.blue, 2f, 10f);
++    fc = new FeatureColour(null, Color.red, Color.blue, null, 2f, 10f);
+     fc.setBelowThreshold(true);
+     fc.setThreshold(4f);
+     assertEquals(FeatureSettings.getColorTooltip(fc),
+             "<html>By Score (&lt; 4.0)<br>" + simpleTooltip
+                     + "</br></html>");
+     fc.setAboveThreshold(true);
+     assertEquals(FeatureSettings.getColorTooltip(fc),
+             "<html>By Score (&gt; 4.0)<br>" + simpleTooltip
+                     + "</br></html>");
+   }
  }
@@@ -38,6 -38,8 +38,6 @@@ import java.awt.Color
  import org.testng.annotations.BeforeClass;
  import org.testng.annotations.Test;
  
 -import junit.extensions.PA;
 -
  public class FeatureColourTest
  {
  
    }
  
    @Test(groups = { "Functional" })
 +  public void testConstructors()
 +  {
 +    FeatureColourI fc = new FeatureColour();
 +    assertNull(fc.getColour());
 +    assertTrue(fc.isSimpleColour());
 +    assertFalse(fc.isColourByLabel());
 +    assertFalse(fc.isGraduatedColour());
 +    assertFalse(fc.isColourByAttribute());
 +    assertEquals(Color.white, fc.getMinColour());
 +    assertEquals(Color.black, fc.getMaxColour());
 +
 +    fc = new FeatureColour(Color.RED);
 +    assertEquals(Color.red, fc.getColour());
 +    assertTrue(fc.isSimpleColour());
 +    assertFalse(fc.isColourByLabel());
 +    assertFalse(fc.isGraduatedColour());
 +    assertFalse(fc.isColourByAttribute());
 +    assertEquals(ColorUtils.bleachColour(Color.RED, 0.9f),
 +            fc.getMinColour());
 +    assertEquals(Color.RED, fc.getMaxColour());
 +
 +  }
 +
 +  @Test(groups = { "Functional" })
    public void testCopyConstructor()
    {
      /*
@@@ -89,8 -67,7 +89,8 @@@
      /*
       * min-max colour
       */
 -    fc = new FeatureColour(Color.gray, Color.black, 10f, 20f);
 +    fc = new FeatureColour(null, Color.gray, Color.black, Color.gray, 10f,
 +            20f);
      fc.setAboveThreshold(true);
      fc.setThreshold(12f);
      fc1 = new FeatureColour(fc);
      /*
       * min-max-noValue colour
       */
 -    fc = new FeatureColour(Color.gray, Color.black, Color.green, 10f, 20f);
 +    fc = new FeatureColour(Color.red, Color.gray, Color.black, Color.green,
 +            10f, 20f);
      fc.setAboveThreshold(true);
      fc.setThreshold(12f);
      fc1 = new FeatureColour(fc);
      assertTrue(fc1.isGraduatedColour());
      assertFalse(fc1.isColourByLabel());
 +    assertFalse(fc1.isSimpleColour());
      assertFalse(fc1.isColourByAttribute());
      assertNull(fc1.getAttributeName());
      assertTrue(fc1.isAboveThreshold());
      assertEquals(Color.gray, fc1.getMinColour());
      assertEquals(Color.black, fc1.getMaxColour());
      assertEquals(Color.green, fc1.getNoColour());
 +    assertEquals(Color.red, fc1.getColour());
      assertEquals(10f, fc1.getMin());
      assertEquals(20f, fc1.getMax());
  
      /*
       * colour by attribute (value)
       */
 -    fc = new FeatureColour(Color.gray, Color.black, Color.green, 10f, 20f);
 +    fc = new FeatureColour(Color.yellow, Color.gray, Color.black,
 +            Color.green, 10f, 20f);
      fc.setAboveThreshold(true);
      fc.setThreshold(12f);
      fc.setAttributeName("AF");
      assertTrue(fc1.isGraduatedColour());
      assertFalse(fc1.isColourByLabel());
      assertTrue(fc1.isColourByAttribute());
 +    assertFalse(fc1.isSimpleColour());
      assertArrayEquals(new String[] { "AF" }, fc1.getAttributeName());
      assertTrue(fc1.isAboveThreshold());
      assertEquals(12f, fc1.getThreshold());
      assertEquals(Color.gray, fc1.getMinColour());
      assertEquals(Color.black, fc1.getMaxColour());
      assertEquals(Color.green, fc1.getNoColour());
 +    assertEquals(Color.yellow, fc1.getColour());
      assertEquals(10f, fc1.getMin());
      assertEquals(20f, fc1.getMax());
    }
  
    @Test(groups = { "Functional" })
 -  public void testCopyConstructor_minMax()
 -  {
 -    /*
 -     * graduated colour
 -     */
 -    FeatureColour fc = new FeatureColour(Color.BLUE, Color.RED, 1f, 5f);
 -    assertTrue(fc.isGraduatedColour());
 -    assertFalse(fc.isColourByLabel());
 -    assertFalse(fc.isColourByAttribute());
 -    assertNull(fc.getAttributeName());
 -    assertEquals(1f, fc.getMin());
 -    assertEquals(5f, fc.getMax());
 -
 -    /*
 -     * update min-max bounds
 -     */
 -    FeatureColour fc1 = new FeatureColour(fc, 2f, 6f);
 -    assertTrue(fc1.isGraduatedColour());
 -    assertFalse(fc1.isColourByLabel());
 -    assertFalse(fc1.isColourByAttribute());
 -    assertNull(fc1.getAttributeName());
 -    assertEquals(2f, fc1.getMin());
 -    assertEquals(6f, fc1.getMax());
 -    assertFalse((boolean) PA.getValue(fc1, "isHighToLow"));
 -
 -    /*
 -     * update min-max bounds - high to low
 -     */
 -    fc1 = new FeatureColour(fc, 23f, 16f);
 -    assertTrue(fc1.isGraduatedColour());
 -    assertFalse(fc1.isColourByLabel());
 -    assertFalse(fc1.isColourByAttribute());
 -    assertNull(fc1.getAttributeName());
 -    assertEquals(23f, fc1.getMin());
 -    assertEquals(16f, fc1.getMax());
 -    assertTrue((boolean) PA.getValue(fc1, "isHighToLow"));
 -
 -    /*
 -     * graduated colour by attribute
 -     */
 -    fc1.setAttributeName("AF");
 -    fc1 = new FeatureColour(fc1, 13f, 36f);
 -    assertTrue(fc1.isGraduatedColour());
 -    assertFalse(fc1.isColourByLabel());
 -    assertTrue(fc1.isColourByAttribute());
 -    assertArrayEquals(new String[] { "AF" }, fc1.getAttributeName());
 -    assertEquals(13f, fc1.getMin());
 -    assertEquals(36f, fc1.getMax());
 -    assertFalse((boolean) PA.getValue(fc1, "isHighToLow"));
 -
 -    /*
 -     * colour by label
 -     */
 -    fc = new FeatureColour(Color.BLUE, Color.RED, 1f, 5f);
 -    fc.setColourByLabel(true);
 -    assertFalse(fc.isGraduatedColour());
 -    assertTrue(fc.isColourByLabel());
 -    assertFalse(fc.isColourByAttribute());
 -    assertNull(fc.getAttributeName());
 -    assertEquals(1f, fc.getMin());
 -    assertEquals(5f, fc.getMax());
 -
 -    /*
 -     * update min-max bounds
 -     */
 -    fc1 = new FeatureColour(fc, 2f, 6f);
 -    assertFalse(fc1.isGraduatedColour());
 -    assertTrue(fc1.isColourByLabel());
 -    assertFalse(fc1.isColourByAttribute());
 -    assertNull(fc1.getAttributeName());
 -    assertEquals(2f, fc1.getMin());
 -    assertEquals(6f, fc1.getMax());
 -
 -    /*
 -     * colour by attribute text
 -     */
 -    fc1.setAttributeName("AC");
 -    fc1 = new FeatureColour(fc1, 13f, 36f);
 -    assertFalse(fc1.isGraduatedColour());
 -    assertTrue(fc1.isColourByLabel());
 -    assertTrue(fc1.isColourByAttribute());
 -    assertArrayEquals(new String[] { "AC" }, fc1.getAttributeName());
 -    assertEquals(13f, fc1.getMin());
 -    assertEquals(36f, fc1.getMax());
 -  }
 -
 -  @Test(groups = { "Functional" })
    public void testGetColor_simpleColour()
    {
      FeatureColour fc = new FeatureColour(Color.RED);
       * score 0 to 100
       * gray(128, 128, 128) to red(255, 0, 0)
       */
 -    FeatureColour fc = new FeatureColour(Color.GRAY, Color.RED, 0f, 100f);
 +    FeatureColour fc = new FeatureColour(null, Color.GRAY, Color.RED, null,
 +            0f, 100f);
      // feature score is 75 which is 3/4 of the way from GRAY to RED
      SequenceFeature sf = new SequenceFeature("type", "desc", 0, 20, 75f,
              null);
    public void testGetColor_aboveBelowThreshold()
    {
      // gradient from [50, 150] from WHITE(255, 255, 255) to BLACK(0, 0, 0)
 -    FeatureColour fc = new FeatureColour(Color.WHITE, Color.BLACK, 50f,
 -            150f);
 +    FeatureColour fc = new FeatureColour(null, Color.WHITE, Color.BLACK,
 +            Color.white, 50f, 150f);
      SequenceFeature sf = new SequenceFeature("type", "desc", 0, 20, 70f,
              null);
  
       * graduated colour by score, no threshold
       * - default constructor sets noValueColor = minColor
       */
 -    fc = new FeatureColour(Color.GREEN, Color.RED, 12f, 25f);
 +    fc = new FeatureColour(null, Color.GREEN, Color.RED, Color.GREEN, 12f,
 +            25f);
      String greenHex = Format.getHexString(Color.GREEN);
      String expected = String.format(
              "domain\tscore|%s|%s|noValueMin|abso|12.0|25.0|none", greenHex,
      /*
       * graduated colour by score, no threshold, no value gets min colour
       */
 -    fc = new FeatureColour(Color.GREEN, Color.RED, Color.GREEN, 12f, 25f);
 +    fc = new FeatureColour(Color.RED, Color.GREEN, Color.RED, Color.GREEN,
 +            12f, 25f);
      expected = String.format(
              "domain\tscore|%s|%s|noValueMin|abso|12.0|25.0|none", greenHex,
              redHex);
      /*
       * graduated colour by score, no threshold, no value gets max colour
       */
 -    fc = new FeatureColour(Color.GREEN, Color.RED, Color.RED, 12f, 25f);
 +    fc = new FeatureColour(Color.RED, Color.GREEN, Color.RED, Color.RED,
 +            12f, 25f);
      expected = String.format(
              "domain\tscore|%s|%s|noValueMax|abso|12.0|25.0|none", greenHex,
              redHex);
      assertFalse(fc.hasThreshold());
      assertEquals(Color.RED, fc.getMinColour());
      assertEquals(Color.GREEN, fc.getMaxColour());
 +    assertEquals(Color.RED, fc.getNoColour());
      assertEquals(10f, fc.getMin());
      assertEquals(20f, fc.getMax());
      assertTrue(fc.isAutoScaled());
  
      /*
 +     * the same, with 'no value colour' specified as max
 +     */
 +    fc = FeatureColour
 +            .parseJalviewFeatureColour("red|green|novaluemax|10.0|20.0");
 +    assertEquals(Color.RED, fc.getMinColour());
 +    assertEquals(Color.GREEN, fc.getMaxColour());
 +    assertEquals(Color.GREEN, fc.getNoColour());
 +    assertEquals(10f, fc.getMin());
 +    assertEquals(20f, fc.getMax());
 +
 +    /*
 +     * the same, with 'no value colour' specified as min
 +     */
 +    fc = FeatureColour
 +            .parseJalviewFeatureColour("red|green|novalueMin|10.0|20.0");
 +    assertEquals(Color.RED, fc.getMinColour());
 +    assertEquals(Color.GREEN, fc.getMaxColour());
 +    assertEquals(Color.RED, fc.getNoColour());
 +    assertEquals(10f, fc.getMin());
 +    assertEquals(20f, fc.getMax());
 +
 +    /*
 +     * the same, with 'no value colour' specified as none
 +     */
 +    fc = FeatureColour
 +            .parseJalviewFeatureColour("red|green|novaluenone|10.0|20.0");
 +    assertEquals(Color.RED, fc.getMinColour());
 +    assertEquals(Color.GREEN, fc.getMaxColour());
 +    assertNull(fc.getNoColour());
 +    assertEquals(10f, fc.getMin());
 +    assertEquals(20f, fc.getMax());
 +
 +    /*
 +     * the same, with invalid 'no value colour'
 +     */
 +    try
 +    {
 +      fc = FeatureColour
 +              .parseJalviewFeatureColour("red|green|blue|10.0|20.0");
 +      fail("expected exception");
 +    } catch (IllegalArgumentException e)
 +    {
 +      assertEquals(
 +              "Couldn't parse the minimum value for graduated colour ('blue')",
 +              e.getMessage());
 +    }
 +
 +    /*
       * graduated colour (explicitly by 'score') (no threshold)
       */
      fc = FeatureColour
       * graduated colour based on attribute value for AF
       * given a min-max range of 0-100
       */
 -    FeatureColour fc = new FeatureColour(new Color(50, 100, 150),
 -            new Color(150, 200, 250), Color.yellow, 0f, 100f);
 +    FeatureColour fc = new FeatureColour(Color.white,
 +            new Color(50, 100, 150), new Color(150, 200, 250), Color.yellow,
 +            0f, 100f);
      String attName = "AF";
      fc.setAttributeName(attName);
  
      assertEquals(expected, fc.getColor(sf));
    }
  
 +  @Test(groups = { "Functional" })
 +  public void testIsOutwithThreshold()
 +  {
 +    FeatureColourI fc = new FeatureColour(Color.red);
 +    SequenceFeature sf = new SequenceFeature("METAL", "desc", 10, 12, 1.2f, "grp");
 +    assertFalse(fc.isOutwithThreshold(null));
 +    assertFalse(fc.isOutwithThreshold(sf));
 +
 +    fc = new FeatureColour(null, Color.white, Color.black, Color.green, 0f,
 +            10f);
 +    assertFalse(fc.isOutwithThreshold(sf)); // no threshold
 +
 +    fc.setAboveThreshold(true);
 +    fc.setThreshold(1f);
 +    assertFalse(fc.isOutwithThreshold(sf)); // feature score 1.2 is above 1
 +
 +    fc.setThreshold(2f);
 +    assertTrue(fc.isOutwithThreshold(sf)); // feature score 1.2 is not above 2
 +
 +    fc.setBelowThreshold(true);
 +    assertFalse(fc.isOutwithThreshold(sf)); // feature score 1.2 is below 2
 +
 +    fc.setThreshold(1f);
 +    assertTrue(fc.isOutwithThreshold(sf)); // feature score 1.2 is not below 1
 +
 +    /*
 +     * with attribute value threshold
 +     */
 +    fc.setAttributeName("AC");
 +    assertFalse(fc.isOutwithThreshold(sf)); // missing attribute AC is ignored
 +
 +    sf.setValue("AC", "-1");
 +    assertFalse(fc.isOutwithThreshold(sf)); // value -1 is below 1
 +
 +    sf.setValue("AC", "1");
 +    assertTrue(fc.isOutwithThreshold(sf)); // value 1 is not below 1
 +
 +    sf.setValue("AC", "junk");
 +    assertFalse(fc.isOutwithThreshold(sf)); // bad value is ignored
 +  }
++
+   /**
+    * Test description of feature colour suitable for a tooltip
+    */
+   @Test(groups = { "Functional" })
+   public void testGetDescription()
+   {
+     /*
+      * plain colour
+      */
+     FeatureColour fc = new FeatureColour(Color.RED);
+     assertEquals(
+             String.format("r=%d,g=%d,b=%d", Color.RED.getRed(),
+                     Color.red.getGreen(), Color.red.getBlue()),
+             fc.getDescription());
+   
+     /*
+      * colour by label (no threshold)
+      */
+     fc = new FeatureColour();
+     fc.setColourByLabel(true);
+     assertEquals("By Label", fc.getDescription());
+   
+     /*
+      * colour by attribute text (no threshold)
+      */
+     fc = new FeatureColour();
+     fc.setColourByLabel(true);
+     fc.setAttributeName("CLIN_SIG");
+     assertEquals("By CLIN_SIG", fc.getDescription());
+   
+     /*
+      * colour by label (above score threshold) 
+      */
+     fc = new FeatureColour();
+     fc.setColourByLabel(true);
+     fc.setAutoScaled(false);
+     fc.setThreshold(12.5f);
+     fc.setAboveThreshold(true);
+     assertEquals("By Label (Score > 12.5)",
+             fc.getDescription());
+   
+     /*
+      * colour by label (below score threshold)
+      */
+     fc.setBelowThreshold(true);
+     assertEquals("By Label (Score < 12.5)",
+             fc.getDescription());
+   
+     /*
+      * colour by attributes text (below score threshold)
+      */
+     fc.setBelowThreshold(true);
+     fc.setAttributeName("CSQ", "Consequence");
+     assertEquals(
+             "By CSQ:Consequence (Score < 12.5)",
+             fc.getDescription());
+   
+     /*
+      * graduated colour by score, no threshold
+      */
 -    fc = new FeatureColour(Color.GREEN, Color.RED, 12f, 25f);
++    fc = new FeatureColour(null, Color.GREEN, Color.RED, null, 12f, 25f);
+     assertEquals("By Score", fc.getDescription());
+   
+     /*
+      * graduated colour by score, below threshold
+      */
+     fc.setThreshold(12.5f);
+     fc.setBelowThreshold(true);
+     assertEquals("By Score (< 12.5)",
+             fc.getDescription());
+   
+     /*
+      * graduated colour by score, above threshold
+      */
+     fc.setThreshold(12.5f);
+     fc.setAboveThreshold(true);
+     fc.setAutoScaled(false);
+     assertEquals("By Score (> 12.5)",
+             fc.getDescription());
+     /*
+      * graduated colour by attribute, no threshold
+      */
+     fc.setAttributeName("CSQ", "AF");
+     fc.setAboveThreshold(false);
+     fc.setAutoScaled(false);
+     assertEquals("By CSQ:AF", fc.getDescription());
+   
+     /*
+      * graduated colour by attribute, above threshold
+      */
+     fc.setAboveThreshold(true);
+     fc.setAutoScaled(false);
+     assertEquals("By CSQ:AF (> 12.5)",
+             fc.getDescription());
+   }
  }