/* ****************************************************************************** * Copyright (c) 2014 - 2015 Fabian Prasser. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Fabian Prasser - initial API and implementation ******************************************************************************/ package de.linearbits.swt.widgets; import java.awt.GraphicsDevice; import java.awt.GraphicsEnvironment; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; import org.eclipse.swt.SWT; import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.ControlEvent; import org.eclipse.swt.events.DisposeEvent; import org.eclipse.swt.events.DisposeListener; import org.eclipse.swt.events.FocusEvent; import org.eclipse.swt.events.FocusListener; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.MouseAdapter; import org.eclipse.swt.events.MouseEvent; import org.eclipse.swt.events.MouseMoveListener; import org.eclipse.swt.events.PaintEvent; import org.eclipse.swt.events.PaintListener; import org.eclipse.swt.events.SelectionEvent; import org.eclipse.swt.events.SelectionListener; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Cursor; import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.ImageData; import org.eclipse.swt.graphics.PaletteData; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.RGB; import org.eclipse.swt.graphics.Transform; import org.eclipse.swt.widgets.Canvas; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Event; import org.eclipse.swt.widgets.Listener; /** * This class implements a knob widget for SWT * * @author Fabian Prasser * * @param <T> */ public class Knob<T> extends Canvas { /** Design */ private static final int CUT_OFF = 90; /** Design */ private static final int SCALE_DOWN = 20; /** * Checks the style * * @param style */ private static int checkStyle(int style) { return style; } /** Design */ private final Cursor defaultCursor = getDefaultCursor(); /** Design */ private final Cursor hiddenCursor = getHiddenCursor(); /** Language profile */ private KnobDialogProfile dialogProfile = KnobDialogProfile.createEnglishProfile(); /** Default color profile */ private KnobColorProfile standardDefaultProfile; /** Focused color profile */ private KnobColorProfile standardFocusedProfile; /** Default color profile */ private KnobColorProfile defaultProfile; /** Focused color profile */ private KnobColorProfile focusedProfile; /** Pre-rendered default background */ private Image defaultBackground = null; /** Pre-rendered focused background */ private Image focusedBackground = null; /** Retina factor (OSX fix) */ private int scaleFactor = isRetina() ? 2 : 1; /** Dragging */ private boolean drag = false; /** Dragging */ private int dragY = 0; /** Dragging */ private int dragOffset = 0; /** Dragging */ private int screenX = 0; /** Dragging */ private int screenY = 0; /** Dragging */ private double dragValue = 0; /** Dragging */ private double sensitivity = 0d; /** Value handling */ private double value = 0d; /** Value handling */ private KnobRange<T> range = null; /** Listeners */ private List<SelectionListener> listeners = new ArrayList<SelectionListener>(); /** * Creates a new instance * * @param parent * @param style * @param range */ public Knob(Composite parent, int style, KnobRange<T> range) { super(parent, checkStyle(style) | SWT.DOUBLE_BUFFERED); // Init this.range = range; this.setBackground(getDisplay().getSystemColor(SWT.COLOR_WIDGET_BACKGROUND)); this.standardDefaultProfile = KnobColorProfile.createDefaultSystemProfile(parent.getDisplay()); this.standardFocusedProfile = KnobColorProfile.createFocusedSystemProfile(parent.getDisplay()); this.defaultProfile = standardDefaultProfile; this.focusedProfile = standardFocusedProfile; this.sensitivity = range.getSensitivity(); // Add listeners this.addDisposeListener(createDiposeHandler()); this.addControlListener(createResizeHandler()); this.addPaintListener(createPaintHandler()); this.addMouseListener(createMouseButtonHandler()); this.addMouseMoveListener(createMouseMoveHandler()); this.addKeyListener(createKeyHandler()); this.addFocusListener(createFocusHandler()); this.addListener(SWT.Traverse, createTraverseHandler()); } /** * Adds the given selection listener * * @param listener */ public void addSelectionListener(SelectionListener listener) { checkWidget(); this.listeners.add(listener); } /** * Returns the scale * * @return */ public KnobRange<T> getRange() { checkWidget(); return this.range; } /** * Returns the value * * @return */ public T getValue() { checkWidget(); return this.range.toExternal(value); } /** * Removes the listener * * @param listener */ public void removeSelectionListener(SelectionListener listener) { this.listeners.remove(listener); } @Override public void setBackground(Color arg0) { super.setBackground(arg0); checkWidget(); if (defaultBackground != null) defaultBackground.dispose(); if (focusedBackground != null) focusedBackground.dispose(); defaultBackground = null; focusedBackground = null; redraw(); } /** * Sets the default color profile */ public void setDefaultColorProfile(KnobColorProfile profile) { checkWidget(); profile.check(); if (this.standardDefaultProfile != null && !this.standardDefaultProfile.isDisposed()) { this.standardDefaultProfile.dispose(); this.standardDefaultProfile = null; } this.defaultProfile = profile; if (defaultBackground != null) defaultBackground.dispose(); defaultBackground = null; redraw(); } /** * Sets the dialog language profile * @param profile */ public void setDialogProfile(KnobDialogProfile profile){ checkWidget(); profile.check(); this.dialogProfile = profile; } /** * Sets the default focused color profile */ public void setFocusedColorProfile(KnobColorProfile profile) { checkWidget(); profile.check(); if (this.standardFocusedProfile != null && !this.standardFocusedProfile.isDisposed()) { this.standardFocusedProfile.dispose(); this.standardFocusedProfile = null; } this.focusedProfile = profile; if (focusedBackground != null) focusedBackground.dispose(); focusedBackground = null; redraw(); } /** * Sets the range. This resets the knob. * * @param range */ public void setRange(KnobRange<T> range) { checkWidget(); this.range = range; this.value = 0d; this.sensitivity = range.getSensitivity(); if (defaultBackground != null) defaultBackground.dispose(); if (focusedBackground != null) focusedBackground.dispose(); defaultBackground = null; focusedBackground = null; this.fireSelectionEvent(); this.redraw(); } /** * Sets the sensitivity * * @param sensitivity */ public void setSensitivity(double sensitivity) { checkWidget(); if (sensitivity <= 0d) { throw new IllegalArgumentException("Sensitivity must be > 0"); } this.sensitivity = sensitivity; } /** * Sets the value * * @param value */ public void setValue(T value) { setValue(value, true); } /** * Sets the value * * @param value * @param fireSelectionEvent */ public void setValue(T value, boolean fireSelectionEvent) { checkWidget(); double val = this.range.toInternal(value); if (val != this.value) { this.value = val; this.redraw(); if (fireSelectionEvent) { this.fireSelectionEvent(); } } } /** * Handle dispose events * * @return */ private DisposeListener createDiposeHandler() { return new DisposeListener() { @Override public void widgetDisposed(DisposeEvent arg0) { if (defaultBackground != null && !defaultBackground.isDisposed()) defaultBackground.dispose(); if (focusedBackground != null && !focusedBackground.isDisposed()) focusedBackground.dispose(); if (standardDefaultProfile != null && !standardDefaultProfile.isDisposed()) standardDefaultProfile.dispose(); if (standardFocusedProfile != null && !standardFocusedProfile.isDisposed()) standardFocusedProfile.dispose(); } }; } /** * Handle focus events * * @return */ private FocusListener createFocusHandler() { return new FocusListener() { @Override public void focusGained(FocusEvent arg0) { redraw(); } @Override public void focusLost(FocusEvent arg0) { redraw(); } }; } /** * Handle key events * * @return */ private KeyAdapter createKeyHandler() { return new KeyAdapter() { @Override public void keyPressed(KeyEvent arg0) { double newValue = value; // React on key press if (arg0.character == '0') newValue = 0.0d; else if (arg0.character == '1') newValue = 0.1d; else if (arg0.character == '2') newValue = 0.2d; else if (arg0.character == '3') newValue = 0.3d; else if (arg0.character == '4') newValue = 0.4d; else if (arg0.character == '5') newValue = 0.5d; else if (arg0.character == '6') newValue = 0.6d; else if (arg0.character == '7') newValue = 0.7d; else if (arg0.character == '8') newValue = 0.8d; else if (arg0.character == '9') newValue = 0.9d; else if (arg0.character == '-') newValue -= 0.1d; else if (arg0.character == '+') newValue += 0.1d; else if (arg0.keyCode == SWT.ARROW_UP) newValue += 1d / sensitivity; else if (arg0.keyCode == SWT.ARROW_DOWN) newValue -= 1d / sensitivity; // Adjust if (newValue < 0d) newValue = 0d; if (newValue > 1d) newValue = 1d; // Change if (value != newValue) { value = newValue; fireSelectionEvent(); redraw(); } } }; } /** * Handle mouse buttons * * @return */ private MouseAdapter createMouseButtonHandler() { return new MouseAdapter() { @Override public void mouseDoubleClick(MouseEvent arg0) { if (drag) { drag = false; getDisplay().setCursorLocation(screenX, screenY); Knob.this.setCursor(defaultCursor); } KnobInputDialog<T> dialog = new KnobInputDialog<T>(getShell(), dialogProfile, range, range.toExternal(value)); T result = dialog.open(); if (result != null) { value = range.toInternal(result); fireSelectionEvent(); redraw(); } } @Override public void mouseDown(MouseEvent arg0) { dragY = arg0.y; Point point = Knob.this.toDisplay(arg0.x, arg0.y); screenX = point.x; screenY = point.y; dragValue = value; dragOffset = 0; drag = true; Knob.this.setCursor(hiddenCursor); Knob.this.setFocus(); } @Override public void mouseUp(MouseEvent arg0) { if (drag) { drag = false; getDisplay().setCursorLocation(screenX, screenY); Knob.this.setCursor(defaultCursor); } } }; } /** * Handle mouse moves * * @return */ private MouseMoveListener createMouseMoveHandler() { return new MouseMoveListener() { @Override public void mouseMove(MouseEvent me) { if (drag) { dragOffset += me.y - dragY; double newValue = dragValue - dragOffset / sensitivity; if (newValue < 0d) { dragOffset = (int) (dragValue * sensitivity); newValue = 0d; } else if (newValue > 1d) { dragOffset = (int) (dragValue * sensitivity - sensitivity); newValue = 1d; } if (value != newValue) { value = newValue; fireSelectionEvent(); redraw(); } getDisplay().setCursorLocation(screenX, screenY); } } }; } /** * Handle paint events * * @return */ private PaintListener createPaintHandler() { return new PaintListener() { @Override public void paintControl(PaintEvent arg0) { paint(arg0.gc); } }; } /** * Handle resizes * * @return */ private ControlAdapter createResizeHandler() { return new ControlAdapter() { @Override public void controlResized(ControlEvent arg0) { if (defaultBackground != null) defaultBackground.dispose(); if (focusedBackground != null) focusedBackground.dispose(); defaultBackground = null; focusedBackground = null; redraw(); } }; } /** * Handle traverse events * * @return */ private Listener createTraverseHandler() { return new Listener() { public void handleEvent(Event e) { switch (e.detail) { case SWT.TRAVERSE_ESCAPE: case SWT.TRAVERSE_RETURN: case SWT.TRAVERSE_TAB_NEXT: case SWT.TRAVERSE_TAB_PREVIOUS: case SWT.TRAVERSE_PAGE_NEXT: case SWT.TRAVERSE_PAGE_PREVIOUS: e.doit = true; break; } } }; } /** * Fires a selection event */ private void fireSelectionEvent() { Event event = new Event(); event.widget = this; SelectionEvent sevent = new SelectionEvent(event); sevent.widget = this; sevent.data = range.toExternal(this.value); for (SelectionListener listener : listeners) { listener.widgetSelected(sevent); listener.widgetDefaultSelected(sevent); } } /** * Returns the current default cursor of the widget * * @return */ private Cursor getDefaultCursor() { return this.getCursor(); } /** * Returns an invisible cursor * * @return */ private Cursor getHiddenCursor() { Display display = getDisplay(); Color white = display.getSystemColor(SWT.COLOR_WHITE); Color black = display.getSystemColor(SWT.COLOR_BLACK); PaletteData palette = new PaletteData(new RGB[] { white.getRGB(), black.getRGB() }); ImageData sourceData = new ImageData(16, 16, 1, palette); sourceData.transparentPixel = 0; final Cursor cursor = new Cursor(display, sourceData, 0, 0); this.addDisposeListener(new DisposeListener(){ @Override public void widgetDisposed(DisposeEvent arg0) { if (cursor != null && !cursor.isDisposed()) { cursor.dispose(); } } }); return cursor; } /** * Calculate the x, y coordinates of end of a line from the center to the * edge of the knob for the given value * * @return */ private Point getLineCoordinates(int centerX, int centerY, int size, double value) { value *= 1 - CUT_OFF / 360d; value += CUT_OFF / 720d; double r = (double) size; double x = r * Math.sin(-value * 2d * Math.PI); double y = r * Math.cos(-value * 2d * Math.PI); return new Point((int) Math.round(x + centerX), (int) Math.round(y + centerY)); } /** * Returns whether this is a retina device. * http://lubosplavucha.com/java/2013/09/02/retina-support-in-java-for-awt-swing/ * @return */ private boolean isRetina() { boolean isRetina = false; GraphicsDevice graphicsDevice = GraphicsEnvironment. getLocalGraphicsEnvironment(). getDefaultScreenDevice(); try { Field field = graphicsDevice.getClass().getDeclaredField("scale"); if (field != null) { field.setAccessible(true); Object scale = field.get(graphicsDevice); if(scale instanceof Integer && ((Integer) scale).intValue() == 2) { isRetina = true; } } } catch (Exception e) { /* Ignore*/ } return isRetina; } /** * Paint routine * * @param gc */ private void paint(GC gc) { Point gcsize = this.getSize(); // Directly paint to the canvas if (gcsize.x >= SCALE_DOWN && gcsize.y >= SCALE_DOWN) { paint(gc, gcsize); // Paint to an image and scale down for better results } else { Image image = new Image(getDisplay(), SCALE_DOWN * scaleFactor, SCALE_DOWN * scaleFactor); GC gc2 = new GC(image); paint(gc2, new Point(SCALE_DOWN * scaleFactor, SCALE_DOWN * scaleFactor)); gc2.dispose(); int size = Math.min(gcsize.x, gcsize.y); gc.setAdvanced(true); gc.setAntialias(SWT.ON); gc.drawImage(image, 0, 0, SCALE_DOWN * scaleFactor, SCALE_DOWN * scaleFactor, 0, 0, size, size); image.dispose(); } } /** * Actually paints the know to the given canvas * * @param gc * @param gcsize */ private void paint(GC gc, Point gcsize) { KnobColorProfile profile = this.defaultProfile; if (this.isFocusControl()) profile = this.focusedProfile; // Determine size double min = (double) Math.min(gcsize.x, gcsize.y) * scaleFactor; int imageSize = (int) Math.round(min); if (defaultBackground == null || defaultBackground.isDisposed()) { defaultBackground = paintBackground(imageSize, imageSize, this.defaultProfile); } if (focusedBackground == null || focusedBackground.isDisposed()) { focusedBackground = paintBackground(imageSize, imageSize, this.focusedProfile); } // Activate anti-aliasing gc.setAdvanced(true); gc.setAntialias(SWT.ON); // Scale to adjust for retina displays if (scaleFactor != 1) { Transform transform = new Transform(getDisplay()); transform.scale(1f/(float)scaleFactor, 1f/(float)scaleFactor); gc.setTransform(transform); } // Draw background Image background = this.isFocusControl() ? focusedBackground : defaultBackground; gc.drawImage(background, 0, 0, imageSize, imageSize, 0, 0, imageSize, imageSize); // Compute parameters double tick = min * 0.3d; double inner = min * 0.4d; double outer = min * 0.1d; double plateau = min * 0.075d; double indicatorWidth = min / 20d; double focusWidth = indicatorWidth / 1.5d; if (indicatorWidth < 1d) indicatorWidth = 1d; double tickWidth = indicatorWidth / 3d; if (tickWidth < 1d) tickWidth = 1d; // Convert to ints int iTick = (int) Math.round(tick); int iInner = (int) Math.round(inner) - (int) Math.round(inner) % 2; int iOuter = (int) Math.round(outer); int iCenterX = iOuter + iInner; int iCenterY = iOuter + iInner; int iIndicatorWidth = (int) Math.round(indicatorWidth); int iFocusWidth = (int) Math.round(focusWidth); iIndicatorWidth -= 1 - iIndicatorWidth % 2; if (iIndicatorWidth < 1) iIndicatorWidth = 1; iFocusWidth -= 1 - iFocusWidth % 2; if (iFocusWidth < 1) iFocusWidth = 1; int iPlateau = (int) Math.round(plateau); // Draw plateau gc.setForeground(profile.getPlateauInner()); gc.setBackground(profile.getPlateauInner()); gc.fillOval(iCenterX - iPlateau, iCenterY - iPlateau, iPlateau * 2, iPlateau * 2); // Draw the value indicator Point line = getLineCoordinates(iCenterX, iCenterY, iTick, range.toNearestInternal(value)); gc.setForeground(profile.getIndicatorOuter()); gc.setForeground(profile.getIndicatorOuter()); gc.setLineCap(SWT.CAP_ROUND); gc.setLineWidth(iIndicatorWidth); gc.drawLine(line.x, line.y, iCenterX, iCenterY); gc.setForeground(profile.getIndicatorInner()); gc.setForeground(profile.getIndicatorInner()); gc.setLineCap(SWT.CAP_ROUND); gc.setLineWidth(iFocusWidth); gc.drawLine(line.x, line.y, iCenterX, iCenterY); } /** * Paint the background image * * @param width * @param height * @param profile */ private Image paintBackground(int width, int height, KnobColorProfile profile) { Display display = getDisplay(); Image background = new Image(display, width, height); GC gc = new GC(background); gc.setBackground(getBackground()); gc.fillRectangle(0, 0, width, height); // Compute parameters double min = width; double inner = min * 0.4d; double outer = min * 0.1d; double plateau = min * 0.1d; double indicatorWidth = min / 20d; double stepping = range.getStepping(); if (indicatorWidth < 1d) indicatorWidth = 1d; double tickWidth = indicatorWidth / 3d; if (tickWidth < 1d) tickWidth = 1d; // Convert to ints int iInner = (int) Math.round(inner) - (int) Math.round(inner) % 2; int iOuter = (int) Math.round(outer); int iPlateau = (int) Math.round(plateau); int iCenterX = iOuter + iInner; int iCenterY = iOuter + iInner; int iIndicatorWidth = (int) Math.round(indicatorWidth); int iTickWidth = (int) Math.round(tickWidth); iIndicatorWidth -= 1 - iIndicatorWidth % 2; iTickWidth -= 1 - iTickWidth % 2; if (iTickWidth < 1d) iTickWidth = 1; if (iIndicatorWidth < 1d) iIndicatorWidth = 1; if ((iCenterX + iPlateau) % 2 == 0) iPlateau++; // Compute lines for ticks List<Point> ticks11 = new ArrayList<Point>(); // Adapt Point ap1 = getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0d); Point ap2 = getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, stepping); int dX = Math.abs(ap1.x - ap2.x); int dY = Math.abs(ap1.y - ap2.y); if (1d / stepping <= 72 && (dX > 5 || dY > 5)) { // Ticks matching scale for (double v = 0d; v < 1d; v += stepping) { double tick = range.toNearestInternal(v); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, tick)); } double tick = range.toNearestInternal(1d); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, tick)); } else { // Default ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.125d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.25d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.375d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.5d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.625d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.75d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 0.875d)); ticks11.add(getLineCoordinates(iCenterX, iCenterY, iInner + iOuter / 2 + 1, 1.0d)); } // Activate anti-aliasing gc.setAdvanced(true); gc.setAntialias(SWT.ON); // Draw the ticks gc.setForeground(profile.getTick()); gc.setBackground(profile.getTick()); gc.setLineCap(SWT.CAP_FLAT); gc.setLineWidth(iTickWidth); for (int i = 0; i < ticks11.size(); i++) { Point p1 = ticks11.get(i); gc.drawLine(p1.x, p1.y, iCenterX, iCenterY); } // Draw the background KnobRenderer renderer = new KnobRenderer(); int transparent = profile.getTransparentByte(); Color transparentColor = new Color(getDisplay(), transparent, transparent, transparent); Image image = renderer.render(getDisplay(), transparentColor, profile, iInner * 2, iInner * 2); gc.drawImage(image, 0, 0, iInner * 2, iInner * 2, iOuter, iOuter, iInner * 2, iInner * 2); transparentColor.dispose(); image.dispose(); // Draw circle gc.setForeground(profile.getBorder()); gc.setBackground(profile.getBorder()); gc.setLineWidth(1); gc.drawOval(iOuter, iOuter, iInner * 2, iInner * 2); // Draw plateau gc.setForeground(profile.getPlateauOuter()); gc.setBackground(profile.getPlateauOuter()); gc.fillOval(iCenterX - iPlateau, iCenterY - iPlateau, iPlateau * 2, iPlateau * 2); gc.dispose(); // Return return background; } }