package nodebox.ui; import javax.imageio.ImageIO; import javax.swing.*; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import java.awt.*; import java.awt.event.*; import java.io.IOException; import java.text.NumberFormat; import java.util.Locale; /** * DraggableNumber represents a number that can be edited in a variety of interesting ways: * by dragging, selecting the arrow buttons, or double-clicking to do direct input. */ public class DraggableNumber extends JComponent implements MouseListener, MouseMotionListener, ComponentListener, FocusListener { private static Image draggerLeft, draggerRight, draggerBackground; private static int draggerLeftWidth, draggerRightWidth, draggerHeight; private static Cursor dragCursor; static { Image dragCursorImage; try { draggerLeft = ImageIO.read(DraggableNumber.class.getResourceAsStream("/dragger-left.png")); draggerRight = ImageIO.read(DraggableNumber.class.getResourceAsStream("/dragger-right.png")); draggerBackground = ImageIO.read(DraggableNumber.class.getResourceAsStream("/dragger-background.png")); draggerLeftWidth = draggerLeft.getWidth(null); draggerRightWidth = draggerRight.getWidth(null); draggerHeight = draggerBackground.getHeight(null); dragCursorImage = ImageIO.read(DraggableNumber.class.getResourceAsStream("/dragger-cursor.png")); Toolkit toolkit = Toolkit.getDefaultToolkit(); dragCursor = toolkit.createCustomCursor(dragCursorImage, new Point(16, 17), "DragCursor"); } catch (IOException e) { throw new RuntimeException(e); } } // todo: could use something like BoundedRangeModel (but then for floats) for checking bounds. /** * Only one <code>ChangeEvent</code> is needed per slider instance since the * event's only (read-only) state is the source property. The source * of events generated here is always "this". The event is lazily * created the first time that an event notification is fired. * * @see #fireStateChanged */ protected transient ChangeEvent changeEvent = null; private JTextField numberField; private double oldValue, value; private int previousX; private Double minimumValue; private Double maximumValue; private NumberFormat numberFormat; private boolean isDragging = false; public DraggableNumber() { setLayout(null); setCursor(dragCursor); addMouseListener(this); addMouseMotionListener(this); addComponentListener(this); setFocusable(true); addFocusListener(this); Dimension d = new Dimension(87, 20); setPreferredSize(d); numberField = new JTextField(); numberField.putClientProperty("JComponent.sizeVariant", "small"); numberField.setFont(Theme.SMALL_BOLD_FONT); numberField.setHorizontalAlignment(JTextField.CENTER); numberField.setVisible(false); numberField.addKeyListener(new EscapeListener()); numberField.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { breakFocusCycle(); commitNumberField(); } }); numberField.addFocusListener(new FocusAdapter() { public void focusLost(FocusEvent e) { if (numberField.isVisible()) commitNumberField(); setFocusable(true); } }); add(numberField); numberFormat = NumberFormat.getNumberInstance(Locale.US); numberFormat.setMinimumFractionDigits(2); numberFormat.setMaximumFractionDigits(2); setValue(0); // Set the correct size for the numberField. componentResized(null); } public static void main(String[] args) { JFrame frame = new JFrame(); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.getContentPane().add(new DraggableNumber()); frame.pack(); frame.setVisible(true); } //// Value ranges //// @Override public void setEnabled(boolean enabled) { super.setEnabled(enabled); if (!enabled) cancelNumberField(); } public Double getMinimumValue() { return minimumValue; } public void setMinimumValue(Double minimumValue) { this.minimumValue = minimumValue; } public boolean hasMinimumValue() { return minimumValue == null; } public void clearMinimumValue() { this.minimumValue = null; } public Double getMaximumValue() { return maximumValue; } public void setMaximumValue(Double maximumValue) { this.maximumValue = maximumValue; } public boolean hasMaximumValue() { return maximumValue == null; } //// Value //// public void clearMaximumValue() { this.maximumValue = null; } public double getValue() { return value; } public void setValue(double value) { this.value = clampValue(value); repaint(); } public double clampValue(double value) { if (minimumValue != null && value < minimumValue) value = minimumValue; if (maximumValue != null && value > maximumValue) value = maximumValue; return value; } public void setValueFromString(String s) throws NumberFormatException { setValue(Double.parseDouble(s)); } //// Number formatting //// public String valueAsString() { return numberFormat.format(value); } public NumberFormat getNumberFormat() { return numberFormat; } public void setNumberFormat(NumberFormat numberFormat) { this.numberFormat = numberFormat; // Refresh the label setValue(getValue()); } private void commitNumberField() { numberField.setVisible(false); String s = numberField.getText(); try { setValueFromString(s); fireStateChanged(); } catch (NumberFormatException e) { Toolkit.getDefaultToolkit().beep(); } } //// Component paint //// private void cancelNumberField() { numberField.setVisible(false); } private Rectangle getLeftButtonRect() { return new Rectangle(0, 0, draggerLeftWidth, draggerHeight); } private Rectangle getRightButtonRect() { Rectangle r = getBounds(); return new Rectangle(r.width - draggerRightWidth, r.y, draggerRightWidth, draggerHeight); } private boolean inDraggableArea(Point pt) { return !getLeftButtonRect().contains(pt) && !getRightButtonRect().contains(pt); } public void focusGained(FocusEvent e) { showNumberField(); setFocusable(false); } public void focusLost(FocusEvent e) { } // We want to move focus to a sibling focusable control using TAB only, not by hitting // Enter or Escape. In these cases we need to break out of the current focus cycle. private void breakFocusCycle() { Container o = getParent(); while (o != null) { if (o != null && o.isFocusable()) break; o = o.getParent(); } if (o != null) o.requestFocus(); } //// Component size //// @Override public void paintComponent(Graphics g) { Graphics2D g2 = (Graphics2D) g; // g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); Rectangle r = getBounds(); int centerWidth = r.width - draggerLeftWidth - draggerRightWidth; g2.drawImage(draggerLeft, 0, 0, null); g2.drawImage(draggerRight, r.width - draggerRightWidth, 0, null); g2.drawImage(draggerBackground, draggerLeftWidth, 0, centerWidth, draggerHeight, null); g2.setFont(Theme.SMALL_BOLD_FONT); if (isEnabled()) { g2.setColor(Theme.TEXT_NORMAL_COLOR); } else { g2.setColor(Theme.TEXT_DISABLED_COLOR); } SwingUtils.drawCenteredShadowText(g2, valueAsString(), r.width / 2, 14, Theme.DRAGGABLE_NUMBER_HIGHLIGHT_COLOR); } //// Component listeners @Override public Dimension getPreferredSize() { // The control is actually 20 pixels high, but setting the height to 30 will leave a nice margin. return new Dimension(120, 30); } public void componentResized(ComponentEvent e) { numberField.setBounds(draggerLeftWidth, 1, getWidth() - draggerLeftWidth - draggerRightWidth, draggerHeight - 2); } public void componentMoved(ComponentEvent e) { } public void componentShown(ComponentEvent e) { } //// Mouse listeners //// public void componentHidden(ComponentEvent e) { } public void mousePressed(MouseEvent e) { if (!isEnabled()) return; if (!inDraggableArea(e.getPoint())) return; if (e.getButton() == MouseEvent.BUTTON1) { isDragging = true; oldValue = getValue(); previousX = e.getX(); SwingUtilities.getRootPane(this).setCursor(dragCursor); } } public void mouseClicked(MouseEvent e) { if (!isEnabled()) return; double dx = 1.0F; if ((e.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) > 0) { dx = 10F; } else if ((e.getModifiersEx() & MouseEvent.ALT_DOWN_MASK) > 0) { dx = 0.01F; } if (getLeftButtonRect().contains(e.getPoint())) { setValue(getValue() - dx); fireStateChanged(); } else if (getRightButtonRect().contains(e.getPoint())) { setValue(getValue() + dx); fireStateChanged(); } else if (e.getClickCount() >= 2) { showNumberField(); } } private void showNumberField() { numberField.setText(valueAsString()); numberField.setVisible(true); numberField.requestFocus(); numberField.selectAll(); componentResized(null); repaint(); } public void mouseReleased(MouseEvent e) { if (!isEnabled()) return; isDragging = false; SwingUtilities.getRootPane(this).setCursor(Cursor.getDefaultCursor()); if (oldValue != value) fireStateChanged(); } public void mouseEntered(MouseEvent e) { } public void mouseExited(MouseEvent e) { } public void mouseMoved(MouseEvent e) { } public void mouseDragged(MouseEvent e) { if (!isEnabled()) return; if (!isDragging) return; double deltaX = e.getX() - previousX; if (deltaX == 0F) return; if ((e.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) > 0) { deltaX *= 10; } else if ((e.getModifiersEx() & MouseEvent.ALT_DOWN_MASK) > 0) { deltaX *= 0.01; } setValue(getValue() + deltaX); previousX = e.getX(); fireStateChanged(); } /** * Adds a ChangeListener to the slider. * * @param l the ChangeListener to add * @see #fireStateChanged * @see #removeChangeListener */ public void addChangeListener(ChangeListener l) { listenerList.add(ChangeListener.class, l); } /** * Removes a ChangeListener from the slider. * * @param l the ChangeListener to remove * @see #fireStateChanged * @see #addChangeListener */ public void removeChangeListener(ChangeListener l) { listenerList.remove(ChangeListener.class, l); } /** * Send a ChangeEvent, whose source is this Slider, to * each listener. This method method is called each time * a ChangeEvent is received from the model. * * @see #addChangeListener * @see javax.swing.event.EventListenerList */ protected void fireStateChanged() { Object[] listeners = listenerList.getListenerList(); for (int i = listeners.length - 2; i >= 0; i -= 2) { if (listeners[i] == ChangeListener.class) { if (changeEvent == null) { changeEvent = new ChangeEvent(this); } ((ChangeListener) listeners[i + 1]).stateChanged(changeEvent); } } } /** * When the escape key is pressed in the numberField, ignore the change and "close" the field. */ private class EscapeListener extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { breakFocusCycle(); numberField.setVisible(false); } } } }