Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/kmcaster.git
src/main/com/whitemagicsoftware/kmcaster/EventHandler.java
import java.beans.PropertyChangeListener;
import java.util.Map;
+import java.util.Optional;
import static com.whitemagicsoftware.kmcaster.HardwareState.SWITCH_PRESSED;
import static com.whitemagicsoftware.kmcaster.HardwareState.SWITCH_RELEASED;
+import static com.whitemagicsoftware.kmcaster.HardwareSwitch.*;
import static com.whitemagicsoftware.kmcaster.ui.Constants.*;
import static java.awt.Toolkit.getDefaultToolkit;
*/
public class EventHandler implements PropertyChangeListener {
+
+ /**
+ * Used for initializing the {@link AutofitLabel} instances.
+ */
+ private enum LabelConfig {
+ LABEL_SHIFT( KEY_SHIFT, CENTER, CENTER ),
+ LABEL_CTRL( KEY_CTRL, CENTER, CENTER ),
+ LABEL_ALT( KEY_ALT, CENTER, CENTER ),
+ LABEL_REGULAR( KEY_REGULAR, CENTER, CENTER ),
+ LABEL_REGULAR_NUM_MAIN( CENTER, CENTER ),
+ LABEL_REGULAR_NUM_SUPERSCRIPT( TOP, LEFT ),
+ LABEL_REGULAR_COUNTER( TOP, RIGHT );
+
+ private final HardwareSwitch mHardwareSwitch;
+ private final int mHorizontalAlign;
+ private final int mVerticalAlign;
+
+ LabelConfig( final int vAlign, final int hAlign ) {
+ this( null, vAlign, hAlign );
+ }
+
+ LabelConfig(
+ final HardwareSwitch hwSwitch, final int vAlign, final int hAlign ) {
+ mHardwareSwitch = hwSwitch;
+ mVerticalAlign = vAlign;
+ mHorizontalAlign = hAlign;
+ }
+
+ private Optional<HardwareSwitch> getHardwareSwitch() {
+ return Optional.ofNullable( mHardwareSwitch );
+ }
+
+ private int getHorizontalAlign() {
+ return mHorizontalAlign;
+ }
+
+ private int getVerticalAlign() {
+ return mVerticalAlign;
+ }
+
+ /**
+ * Returns a blank space when no {@link HardwareSwitch} is assigned.
+ *
+ * @return The title case version of the hardware switch, or a space if
+ * there is no direct correlation.
+ */
+ private String toTitleCase() {
+ return mHardwareSwitch == null ? " " : mHardwareSwitch.toTitleCase();
+ }
+
+ /**
+ * Returns the number of values in the enumeration.
+ *
+ * @return {@link #values()}.length.
+ */
+ private static int size() {
+ return values().length;
+ }
+ }
/**
private final HardwareImages mHardwareImages;
+ private final AutofitLabel[] mLabels = new AutofitLabel[ LabelConfig.size() ];
public EventHandler( final HardwareImages hardwareImages ) {
mHardwareImages = hardwareImages;
+
+ final var keyColour = KEY_COLOURS.get( SWITCH_PRESSED );
+
+ for( final var config : LabelConfig.values() ) {
+ final var label = new AutofitLabel( config.toTitleCase(), LABEL_FONT );
+
+ label.setVerticalAlignment( config.getVerticalAlign() );
+ label.setHorizontalAlignment( config.getHorizontalAlign() );
+ label.setForeground( keyColour );
+
+ mLabels[ config.ordinal() ] = label;
+
+ config.getHardwareSwitch().ifPresentOrElse(
+ s -> mHardwareImages.get( s ).add( label ),
+ () -> mHardwareImages.get( KEY_REGULAR ).add( label )
+ );
+ }
}
protected void updateSwitchLabel( final HardwareSwitchState state ) {
final var hwState = state.getHardwareState();
- final var keyColour = KEY_COLOURS.get( hwState );
+
+ getLabel( LabelConfig.LABEL_REGULAR ).setVisible( false );
+ getLabel( LabelConfig.LABEL_REGULAR_COUNTER ).setVisible( false );
if( state.isModifier() ) {
- updateLabel( state, keyColour );
+ updateLabel( state );
mKeyCounter.reset();
}
else {
final var component = getHardwareComponent( state );
final var keyValue = state.getValue();
+
+ final var sup = getLabel( LabelConfig.LABEL_REGULAR_NUM_SUPERSCRIPT );
+ final var main = getLabel( LabelConfig.LABEL_REGULAR_NUM_MAIN );
+ main.setVisible( false );
+ sup.setVisible( false );
// A non-modifier key has been pressed.
if( hwState == SWITCH_PRESSED ) {
// Determine whether there are separate parts for the key label.
final var index = keyValue.indexOf( ' ' );
final var bounds = BoundsCalculator.getBounds( component );
- final var compDimen = new ScalableDimension(
- bounds.width, bounds.height );
+ final var compDimen = new ScalableDimension( bounds );
// If there's a space in the name, the text before the space is
// Label for "Num", "Back", "Tab", and other dual-labelled keys.
- final var sup = new AutofitLabel(
- keyValue.substring( 0, index ), LABEL_FONT );
- sup.setVisible( false );
- sup.setForeground( keyColour );
- sup.setVerticalAlignment( TOP );
+ sup.setText( keyValue.substring( 0, index ) );
+ sup.transform( supSize );
// Label for number pad keys or icon glyphs.
- final var main = new AutofitLabel(
- keyValue.substring( index + 1 ), LABEL_FONT );
- main.setVisible( false );
- main.setForeground( keyColour );
- main.setHorizontalAlignment( CENTER );
-
- // Keep removeAll/add operations close together to minimize flicker.
- component.removeAll();
- component.add( main );
- component.add( sup );
- main.setSize( mainSize );
- sup.setSize( supSize );
-
- // Center-align the main text with respect to the container.
- final var location = main.getLocation();
- final var dx = (compDimen.getWidth() - main.getWidth()) / 2;
- final var dy = (compDimen.getHeight() - main.getHeight()) / 2;
+ main.setText( keyValue.substring( index + 1 ) );
+ main.transform( mainSize );
- // Shift the main text down a smidgen, relative to the superscript.
- final var my = (int) (location.getY() + dy) + sup.getHeight() / 4;
- final var mx = (int) (location.getX() + dx);
+ // Shift the main label down away from the superscript.
+ final var mainLoc = main.getLocation();
+ main.setLocation( mainLoc.x, mainLoc.y + (sup.getHeight() / 3) );
- main.setLocation( mx, my );
main.setVisible( true );
sup.setVisible( true );
}
else {
- component.removeAll();
- updateLabel( state, keyColour );
+ updateLabel( state );
}
// Track the consecutive key presses for this value.
if( mKeyCounter.apply( keyValue ) ) {
final var count = mKeyCounter.toString();
+ final var tally = getLabel( LabelConfig.LABEL_REGULAR_COUNTER );
final var tallySize = compDimen.scale( .25f );
-
- final var tally = new AutofitLabel( count, LABEL_FONT );
- tally.setVisible( false );
- component.add( tally );
-
- tally.setSize( tallySize );
- tally.setVerticalAlignment( TOP );
- tally.setHorizontalAlignment( RIGHT );
-
- // Get the upper-left point, accounting for padding and insets.
- final var tx = bounds.x + compDimen.getWidth() - tally.getWidth();
- final var ty = bounds.y;
- tally.setLocation( (int) tx, ty );
+ tally.setText( count );
+ tally.transform( tallySize );
tally.setVisible( true );
}
- }
- else {
- component.removeAll();
}
-
- component.paintImmediately( component.getBounds()) ;
}
}
/**
- * Creates the label if it does not already exist.
+ * Changes the text label and colour for the given state.
*
* @param state The state of the hardware switch to look up.
*/
- private void updateLabel(
- final HardwareSwitchState state,
- final Color keyColour ) {
+ private void updateLabel( final HardwareSwitchState state ) {
final var container = getHardwareComponent( state );
- final var value = state.getValue();
+ final var label = (AutofitLabel) container.getComponent( 0 );
- if( container.getComponentCount() == 0 ) {
- // Regular keys will have labels recreated each time to auto-fit the text.
- final var label = new AutofitLabel( value, LABEL_FONT );
- label.setVisible( false );
- label.setHorizontalAlignment( CENTER );
- label.setForeground( keyColour );
- container.add( label );
- label.setVisible( true );
- }
- else {
- // Modifier keys can reuse labels.
- final var label = (AutofitLabel) container.getComponent( 0 );
- label.setForeground( keyColour );
- label.setText( value );
- }
+ label.setVisible( false );
+ label.setForeground( KEY_COLOURS.get( state.getHardwareState() ) );
+ label.setText( state.getValue() );
+ label.transform();
+ label.setVisible( true );
}
private HardwareComponent<HardwareSwitchState, Image> getHardwareComponent(
final HardwareSwitchState state ) {
return getHardwareImages().get( state.getHardwareSwitch() );
}
private HardwareImages getHardwareImages() {
return mHardwareImages;
+ }
+
+ private AutofitLabel getLabel( final LabelConfig config ) {
+ return mLabels[ config.ordinal() ];
}
}
src/main/com/whitemagicsoftware/kmcaster/HardwareComponent.java
@Override
- protected void paintComponent( final Graphics graphics ) {
- super.paintComponent( graphics );
-
- ((Graphics2D) graphics).setComposite( AlphaComposite.Src );
- graphics.drawImage( getActiveImage(), 0, 0, this );
+ protected void paintComponent( final Graphics g ) {
+ g.drawImage( getActiveImage(), 0, 0, this );
}
src/main/com/whitemagicsoftware/kmcaster/HardwareImages.java
*/
private final static Map<HardwareSwitch, Insets> SWITCH_INSETS = Map.of(
- KEY_ALT, new Insets( 8, 11, 12, 11 ),
- KEY_CTRL, new Insets( 8, 11, 12, 11 ),
- KEY_SHIFT, new Insets( 5, 50, 9, 11 ),
+ KEY_ALT, new Insets( 10, 11, 12, 11 ),
+ KEY_CTRL, new Insets( 10, 11, 12, 11 ),
+ KEY_SHIFT, new Insets( 10, 50, 12, 11 ),
KEY_REGULAR, new Insets( 3, 7, 6, 7 )
);
src/main/com/whitemagicsoftware/kmcaster/HardwareSwitchState.java
/**
- * Delegates to
- * {@link #HardwareSwitchState(HardwareSwitch, HardwareState, String)} using
- * an empty string for the value.
+ * Calls {@link #HardwareSwitchState(HardwareSwitch, HardwareState, String)}
+ * using an empty string for the value.
*/
public HardwareSwitchState(
public boolean isModifier() {
return mHardwareSwitch.isModifier();
- }
-
- /**
- * Answers whether the {@link HardwareState} is in the given state.
- *
- * @param hardwareState The status to compare against the
- * {@link HardwareState}.
- * @return {@code true} when the given status matches the internal
- * {@link HardwareState} value.
- */
- public boolean isHardwareState( final HardwareState hardwareState ) {
- return mHardwareState == hardwareState;
}
@Override
public int hashCode() {
- int result = mHardwareSwitch.hashCode();
- result = 31 * result + mHardwareState.hashCode();
- return result;
+ return 31 * mHardwareSwitch.hashCode() + mHardwareState.hashCode();
}
}
src/main/com/whitemagicsoftware/kmcaster/listeners/KeyboardListener.java
import javax.swing.*;
import java.awt.event.ActionListener;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Stack;
-
-import static com.whitemagicsoftware.kmcaster.HardwareSwitch.*;
-import static java.util.Map.entry;
-import static java.util.Optional.ofNullable;
-import static org.jnativehook.keyboard.NativeKeyEvent.getKeyText;
-
-/**
- * Responsible for sending property change events for keyboard state changes.
- */
-public final class KeyboardListener
- extends PropertyDispatcher<HardwareSwitch>
- implements NativeKeyListener {
- private final static String KEY_SPACE = "Space";
- private final static String KEY_BACKSPACE = "Back ⌫";
- private final static String KEY_TAB = "Tab ↹";
- private final static String KEY_ENTER = "Enter ⏎";
-
- /**
- * The key is the raw key code return from the {@link NativeKeyEvent}, the
- * value is the human-readable text to display on screen.
- */
- @SuppressWarnings("JavacQuirks")
- private final static Map<Integer, String> KEY_CODES =
- Map.ofEntries(
- entry( 32, KEY_SPACE ),
- entry( 33, "!" ),
- entry( 34, "\"" ),
- entry( 35, "#" ),
- entry( 36, "$" ),
- entry( 37, "%" ),
- entry( 38, "&" ),
- entry( 39, "'" ),
- entry( 40, "(" ),
- entry( 41, ")" ),
- entry( 42, "*" ),
- entry( 43, "+" ),
- entry( 44, "," ),
- entry( 45, "-" ),
- entry( 46, "." ),
- entry( 47, "/" ),
- entry( 58, ":" ),
- entry( 59, ";" ),
- entry( 60, "<" ),
- entry( 61, "=" ),
- entry( 62, ">" ),
- entry( 63, "?" ),
- entry( 64, "@" ),
- entry( 91, "[" ),
- entry( 92, "\\" ),
- entry( 93, "]" ),
- entry( 94, "^" ),
- entry( 95, "_" ),
- entry( 96, "`" ),
- entry( 97, "a" ),
- entry( 98, "b" ),
- entry( 99, "c" ),
- entry( 100, "d" ),
- entry( 101, "e" ),
- entry( 102, "f" ),
- entry( 103, "g" ),
- entry( 104, "h" ),
- entry( 105, "i" ),
- entry( 106, "j" ),
- entry( 107, "k" ),
- entry( 108, "l" ),
- entry( 109, "m" ),
- entry( 110, "n" ),
- entry( 111, "o" ),
- entry( 112, "p" ),
- entry( 113, "q" ),
- entry( 114, "r" ),
- entry( 115, "s" ),
- entry( 116, "t" ),
- entry( 117, "u" ),
- entry( 118, "v" ),
- entry( 119, "w" ),
- entry( 120, "x" ),
- entry( 121, "y" ),
- entry( 122, "z" ),
- entry( 123, "{" ),
- entry( 124, "|" ),
- entry( 125, "}" ),
- entry( 126, "~" ),
- entry( 65056, KEY_TAB ),
- entry( 65289, KEY_TAB ),
- entry( 65293, KEY_ENTER ),
- entry( 65288, KEY_BACKSPACE ),
- entry( 65301, "SysRq" ),
- entry( 65377, "Print" ),
- entry( 65361, "←" ),
- entry( 65362, "↑" ),
- entry( 65363, "→" ),
- entry( 65364, "↓" ),
- entry( 65307, "Esc" ),
- entry( 65365, "PgUp" ),
- entry( 65366, "PgDn" ),
- entry( 65379, "Ins" ),
- entry( 65535, "Del" ),
- entry( 65506, "Shift" ),
- entry( 65407, "Num" ),
- entry( 65421, "Num ⏎" ),
- entry( 65430, "Num ←" ),
- entry( 65431, "Num ↑" ),
- entry( 65432, "Num →" ),
- entry( 65433, "Num ↓" ),
- entry( 65429, "Num Home" ),
- entry( 65434, "Num PgUp" ),
- entry( 65435, "Num PgDn" ),
- entry( 65436, "Num End" ),
- entry( 65437, "Num Clear" ),
- entry( 65438, "Num Ins" ),
- entry( 65439, "Num Del" ),
- entry( 65450, "Num *" ),
- entry( 65451, "Num +" ),
- entry( 65452, "Num Sep" ),
- entry( 65453, "Num -" ),
- entry( 65454, "Num ." ),
- entry( 65455, "Num /" ),
- entry( 65456, "Num 0" ),
- entry( 65457, "Num 1" ),
- entry( 65458, "Num 2" ),
- entry( 65459, "Num 3" ),
- entry( 65460, "Num 4" ),
- entry( 65461, "Num 5" ),
- entry( 65462, "Num 6" ),
- entry( 65463, "Num 7" ),
- entry( 65464, "Num 8" ),
- entry( 65465, "Num 9" ),
- entry( 65300, "Scrl" ),
- entry( 65509, "Caps" )
- );
-
- /**
- * Whether a modifier key state is pressed or released depends on the state
- * of multiple keys (left and right). This map assigns the left and right
- * key codes to the same modifier key so that the physical state can be
- * represented by a single on-screen button (the logical state).
- * <p>
- * The 65511, 65512 are shifted alt key codes (a.k.a. the meta key).
- * </p>
- */
- private final Map<Integer, HardwareSwitch> mModifierCodes =
- Map.ofEntries(
- entry( 65505, KEY_SHIFT ),
- entry( 65506, KEY_SHIFT ),
- entry( 65507, KEY_CTRL ),
- entry( 65508, KEY_CTRL ),
- entry( 65511, KEY_ALT ),
- entry( 65512, KEY_ALT ),
- entry( 65513, KEY_ALT ),
- entry( 65514, KEY_ALT )
- );
-
- /**
- * Most recently pressed non-modifier key value, empty signifies release.
- */
- private String mRegularHeld = "";
-
- /**
- * Stores the state of modifier keys. The contents of the map reflect the
- * state of each switch, so the reference can be final but not its contents.
- * An integer is used because keyboards usually have two separate keys for
- * each modifier, both can be pressed and released independently.
- */
- private final Map<HardwareSwitch, Integer> mModifiers = new HashMap<>();
-
- /**
- * Informing the application of a key release is delayed so that the user
- * interface will give the end user a momentary glance of what key was
- * pressed before it is released. Without this delay the keys disappear
- * as fast as a typist can type, which can be too quick to read as individual
- * keystrokes.
- * <p>
- * Track the number of key release timers are running so that they can
- * all be stopped to prevent releasing the key when another key has been
- * pressed in the mean time.
- * </p>
- */
- private final Stack<Timer> mTimerStack = new Stack<>();
-
- private final int mDelayRegular;
- private final int mDelayModifier;
-
- /**
- * Creates a keyboard listener that publishes events when keys are either
- * pressed or released. The constructor initializes all modifier keys to
- * the released state because the native keyboard hook API does not offer
- * a way to query what keys are currently pressed.
- *
- * @param delayRegular Milliseconds to wait before releasing a regular key.
- * @param delayModifier Milliseconds to wait before releasing a modifier key.
- */
- public KeyboardListener( final int delayRegular, final int delayModifier ) {
- mDelayRegular = delayRegular;
- mDelayModifier = delayModifier;
-
- for( final var key : modifierSwitches() ) {
- mModifiers.put( key, 0 );
- }
- }
-
- @Override
- public void nativeKeyPressed( final NativeKeyEvent e ) {
- getKey( e ).ifPresentOrElse(
- keyValue -> updateModifier( keyValue, 1 ),
- () -> {
- while( !mTimerStack.isEmpty() ) {
- mTimerStack.pop().stop();
- }
-
- updateRegular( mRegularHeld, getDisplayText( e ) );
- } );
- }
-
- @Override
- public void nativeKeyReleased( final NativeKeyEvent e ) {
- getKey( e ).ifPresentOrElse(
- keyValue -> delayedAction( mDelayModifier, ( action ) ->
- updateModifier( keyValue, -1 )
- ),
- () -> {
- final var timer = delayedAction( mDelayRegular, ( action ) ->
- updateRegular( getDisplayText( e ), "" ) );
-
- mTimerStack.push( timer );
- }
- );
- }
-
- /**
- * Convenience method to start a one-time action at a relative time in
- * the future.
- *
- * @param delay When to perform the action.
- * @param listener The listener that will perform some future action.
- * @return The {@link Timer} that will perform a one-time action.
- */
- @SuppressWarnings("SameParameterValue")
- private Timer delayedAction(
- final int delay, final ActionListener listener ) {
- final var timer = new Timer( delay, listener );
-
- timer.setRepeats( false );
- timer.start();
-
- return timer;
- }
-
- /**
- * Unused. Key up and key down are tracked separately from a typed key.
- *
- * @param e Ignored.
- */
- @Override
- public void nativeKeyTyped( final NativeKeyEvent e ) {
- }
-
- /**
- * Sets the initial state of the modifiers.
- */
- public void initModifiers() {
- for( final var key : mModifiers.keySet() ) {
- final var state = mModifiers.get( key );
-
- // All modifiers keys are "false" by default, so firing fake transition
- // events from "true" to "false" will cause the GUI to repaint with the
- // text label affixed to each key, drawn in the released state. This
- // happens before the frame is set to visible.
- tryFire( key, state == 0, state == 1 );
- }
- }
-
- /**
- * Notifies of any modifier state changes. There's a bug whereby this
- * method is never called by the native library when both Left/Right Ctrl
- * keys are pressed followed by pressing either Shift key. Similarly,
- * holding both Left/Right Shift keys followed by pressing either Ctrl key
- * fails to call this method.
- *
- * @param key Must be a modifier key.
- * @param increment {@code -1} means released, {@code 1} means pressed.
- */
- private void updateModifier( final HardwareSwitch key, final int increment ) {
- final var oldCount = mModifiers.get( key );
- final var newCount = oldCount + increment;
-
- // Only fire the event if the state has changed.
- tryFire( key, oldCount > 0, newCount > 0 );
- mModifiers.put( key, newCount );
- }
-
- /**
- * State for a regular (non-modifier) key has changed.
- *
- * @param o Previous key value.
- * @param n Current key value.
- */
- private void updateRegular( final String o, final String n ) {
- assert o != null;
- assert n != null;
-
- // Always fire the event, which permits double-key taps.
- fire( KEY_REGULAR, o, n );
- mRegularHeld = n;
- }
-
- /**
- * Looks up the key code for the given event. If the key code is not mapped,
- * this will return the default value from the native implementation.
- *
- * @param e The keyboard event that was triggered.
- * @return The human-readable name for the key relating to the event.
- */
- private String getDisplayText( final NativeKeyEvent e ) {
- return KEY_CODES.getOrDefault(
- e.getRawCode(), getKeyText( e.getKeyCode() )
- );
- }
-
- /**
- * Returns the modifier key that corresponds to the raw key code from
- * the given event. This is necessary to ensure that both left and right
- * modifier keys return the same {@link HardwareSwitch} value.
- *
- * @param e The event containing a raw key code to look up.
- * @return The switch matching the raw key code, or {@code null} if the
- * raw key code does not represent a modifier.
- */
- private Optional<HardwareSwitch> getKey( final NativeKeyEvent e ) {
- return ofNullable( mModifierCodes.get( e.getRawCode() ) );
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.Map;
+
+import static com.whitemagicsoftware.kmcaster.HardwareSwitch.*;
+import static java.util.Map.entry;
+import static org.jnativehook.keyboard.NativeKeyEvent.getKeyText;
+
+/**
+ * Responsible for sending property change events for keyboard state changes.
+ */
+public final class KeyboardListener
+ extends PropertyDispatcher<HardwareSwitch>
+ implements NativeKeyListener {
+ private final static String KEY_SPACE = "Space";
+ private final static String KEY_BACKSPACE = "Back ⌫";
+ private final static String KEY_TAB = "Tab ↹";
+ private final static String KEY_ENTER = "Enter ⏎";
+
+ /**
+ * The key is the raw key code return from the {@link NativeKeyEvent}, the
+ * value is the human-readable text to display on screen.
+ */
+ @SuppressWarnings("JavacQuirks")
+ private final static Map<Integer, String> KEY_CODES =
+ Map.ofEntries(
+ entry( 32, KEY_SPACE ),
+ entry( 33, "!" ),
+ entry( 34, "\"" ),
+ entry( 35, "#" ),
+ entry( 36, "$" ),
+ entry( 37, "%" ),
+ entry( 38, "&" ),
+ entry( 39, "'" ),
+ entry( 40, "(" ),
+ entry( 41, ")" ),
+ entry( 42, "*" ),
+ entry( 43, "+" ),
+ entry( 44, "," ),
+ entry( 45, "-" ),
+ entry( 46, "." ),
+ entry( 47, "/" ),
+ entry( 58, ":" ),
+ entry( 59, ";" ),
+ entry( 60, "<" ),
+ entry( 61, "=" ),
+ entry( 62, ">" ),
+ entry( 63, "?" ),
+ entry( 64, "@" ),
+ entry( 91, "[" ),
+ entry( 92, "\\" ),
+ entry( 93, "]" ),
+ entry( 94, "^" ),
+ entry( 95, "_" ),
+ entry( 96, "`" ),
+ entry( 97, "a" ),
+ entry( 98, "b" ),
+ entry( 99, "c" ),
+ entry( 100, "d" ),
+ entry( 101, "e" ),
+ entry( 102, "f" ),
+ entry( 103, "g" ),
+ entry( 104, "h" ),
+ entry( 105, "i" ),
+ entry( 106, "j" ),
+ entry( 107, "k" ),
+ entry( 108, "l" ),
+ entry( 109, "m" ),
+ entry( 110, "n" ),
+ entry( 111, "o" ),
+ entry( 112, "p" ),
+ entry( 113, "q" ),
+ entry( 114, "r" ),
+ entry( 115, "s" ),
+ entry( 116, "t" ),
+ entry( 117, "u" ),
+ entry( 118, "v" ),
+ entry( 119, "w" ),
+ entry( 120, "x" ),
+ entry( 121, "y" ),
+ entry( 122, "z" ),
+ entry( 123, "{" ),
+ entry( 124, "|" ),
+ entry( 125, "}" ),
+ entry( 126, "~" ),
+ entry( 65056, KEY_TAB ),
+ entry( 65289, KEY_TAB ),
+ entry( 65293, KEY_ENTER ),
+ entry( 65288, KEY_BACKSPACE ),
+ entry( 65301, "SysRq" ),
+ entry( 65377, "Print" ),
+ entry( 65361, "←" ),
+ entry( 65362, "↑" ),
+ entry( 65363, "→" ),
+ entry( 65364, "↓" ),
+ entry( 65307, "Esc" ),
+ entry( 65365, "PgUp" ),
+ entry( 65366, "PgDn" ),
+ entry( 65379, "Ins" ),
+ entry( 65535, "Del" ),
+ entry( 65506, "Shift" ),
+ entry( 65407, "Num" ),
+ entry( 65421, "Num ⏎" ),
+ entry( 65430, "Num ←" ),
+ entry( 65431, "Num ↑" ),
+ entry( 65432, "Num →" ),
+ entry( 65433, "Num ↓" ),
+ entry( 65429, "Num Home" ),
+ entry( 65434, "Num PgUp" ),
+ entry( 65435, "Num PgDn" ),
+ entry( 65436, "Num End" ),
+ entry( 65437, "Num Clear" ),
+ entry( 65438, "Num Ins" ),
+ entry( 65439, "Num Del" ),
+ entry( 65450, "Num *" ),
+ entry( 65451, "Num +" ),
+ entry( 65452, "Num Sep" ),
+ entry( 65453, "Num -" ),
+ entry( 65454, "Num ." ),
+ entry( 65455, "Num /" ),
+ entry( 65456, "Num 0" ),
+ entry( 65457, "Num 1" ),
+ entry( 65458, "Num 2" ),
+ entry( 65459, "Num 3" ),
+ entry( 65460, "Num 4" ),
+ entry( 65461, "Num 5" ),
+ entry( 65462, "Num 6" ),
+ entry( 65463, "Num 7" ),
+ entry( 65464, "Num 8" ),
+ entry( 65465, "Num 9" ),
+ entry( 65300, "Scrl" ),
+ entry( 65509, "Caps" )
+ );
+
+ /**
+ * Whether a modifier key state is pressed or released depends on the state
+ * of multiple keys (left and right). This map assigns the left and right
+ * key codes to the same modifier key so that the physical state can be
+ * represented by a single on-screen button (the logical state).
+ * <p>
+ * The 65511, 65512 are shifted alt key codes (a.k.a. the meta key).
+ * </p>
+ */
+ private final Map<Integer, HardwareSwitch> mModifierCodes =
+ Map.ofEntries(
+ entry( 65505, KEY_SHIFT ),
+ entry( 65506, KEY_SHIFT ),
+ entry( 65507, KEY_CTRL ),
+ entry( 65508, KEY_CTRL ),
+ entry( 65511, KEY_ALT ),
+ entry( 65512, KEY_ALT ),
+ entry( 65513, KEY_ALT ),
+ entry( 65514, KEY_ALT )
+ );
+
+ /**
+ * Most recently pressed non-modifier key value, empty signifies release.
+ */
+ private String mRegularHeld = "";
+
+ /**
+ * Stores the state of modifier keys. The contents of the map reflect the
+ * state of each switch, so the reference can be final but not its contents.
+ * An integer is used because keyboards usually have two separate keys for
+ * each modifier, both can be pressed and released independently.
+ */
+ private final Map<HardwareSwitch, Integer> mModifiers = new HashMap<>();
+
+ /**
+ * Informing the application of a key release is delayed so that the user
+ * interface will give the end user a momentary glance of what key was
+ * pressed before it is released. Without this delay the keys disappear
+ * as fast as a typist can type, which can be too quick to read as individual
+ * keystrokes.
+ * <p>
+ * Track the number of key release timers are running so that they can
+ * all be stopped to prevent releasing the key when another key has been
+ * pressed in the mean time.
+ * </p>
+ */
+ private final Deque<Timer> mTimers = new LinkedList<>();
+
+ private final int mDelayRegular;
+ private final int mDelayModifier;
+
+ /**
+ * Creates a keyboard listener that publishes events when keys are either
+ * pressed or released. The constructor initializes all modifier keys to
+ * the released state because the native keyboard hook API does not offer
+ * a way to query what keys are currently pressed.
+ *
+ * @param delayRegular Milliseconds to wait before releasing a regular key.
+ * @param delayModifier Milliseconds to wait before releasing a modifier key.
+ */
+ public KeyboardListener( final int delayRegular, final int delayModifier ) {
+ mDelayRegular = delayRegular;
+ mDelayModifier = delayModifier;
+
+ for( final var key : modifierSwitches() ) {
+ mModifiers.put( key, 0 );
+ }
+ }
+
+ @Override
+ public void nativeKeyPressed( final NativeKeyEvent e ) {
+ final var modifierKey = getModifierKey( e );
+
+ if( modifierKey == null ) {
+ while( !mTimers.isEmpty() ) {
+ mTimers.pop().stop();
+ }
+
+ updateRegular( mRegularHeld, getDisplayText( e ) );
+ }
+ else {
+ updateModifier( modifierKey, 1 );
+ }
+ }
+
+ @Override
+ public void nativeKeyReleased( final NativeKeyEvent e ) {
+ final var modifierKey = getModifierKey( e );
+
+ if( modifierKey == null ) {
+ final var timer = delayedAction( mDelayRegular, ( action ) ->
+ updateRegular( getDisplayText( e ), "" ) );
+
+ mTimers.push( timer );
+ }
+ else {
+ delayedAction( mDelayModifier, ( action ) ->
+ updateModifier( modifierKey, -1 )
+ );
+ }
+ }
+
+ /**
+ * Convenience method to start a one-time action at a relative time in
+ * the future.
+ *
+ * @param delay When to perform the action.
+ * @param listener The listener that will perform some future action.
+ * @return The {@link Timer} that will perform a one-time action.
+ */
+ @SuppressWarnings("SameParameterValue")
+ private Timer delayedAction(
+ final int delay, final ActionListener listener ) {
+ final var timer = new Timer( delay, listener );
+
+ timer.setRepeats( false );
+ timer.start();
+
+ return timer;
+ }
+
+ /**
+ * Sets the initial state of the modifiers.
+ */
+ public void initModifiers() {
+ for( final var key : mModifiers.keySet() ) {
+ final var state = mModifiers.get( key );
+
+ // All modifiers keys are "false" by default, so firing fake transition
+ // events from "true" to "false" will cause the GUI to repaint with the
+ // text label affixed to each key, drawn in the released state. This
+ // happens before the frame is set to visible.
+ tryFire( key, state == 0, state == 1 );
+ }
+ }
+
+ /**
+ * Notifies of any modifier state changes. There's a bug whereby this
+ * method is never called by the native library when both Left/Right Ctrl
+ * keys are pressed followed by pressing either Shift key. Similarly,
+ * holding both Left/Right Shift keys followed by pressing either Ctrl key
+ * fails to call this method.
+ *
+ * @param key Must be a modifier key.
+ * @param increment {@code -1} means released, {@code 1} means pressed.
+ */
+ private void updateModifier( final HardwareSwitch key, final int increment ) {
+ final var oldCount = mModifiers.get( key );
+ final var newCount = oldCount + increment;
+
+ // Only fire the event if the state has changed.
+ tryFire( key, oldCount > 0, newCount > 0 );
+ mModifiers.put( key, newCount );
+ }
+
+ /**
+ * State for a regular (non-modifier) key has changed.
+ *
+ * @param o Previous key value.
+ * @param n Current key value.
+ */
+ private void updateRegular( final String o, final String n ) {
+ assert o != null;
+ assert n != null;
+
+ // Always fire the event, which permits double-key taps.
+ fire( KEY_REGULAR, o, n );
+ mRegularHeld = n;
+ }
+
+ /**
+ * Looks up the key code for the given event. If the key code is not mapped,
+ * this will return the default value from the native implementation.
+ *
+ * @param e The keyboard event that was triggered.
+ * @return The human-readable name for the key relating to the event.
+ */
+ private String getDisplayText( final NativeKeyEvent e ) {
+ return KEY_CODES.getOrDefault(
+ e.getRawCode(), getKeyText( e.getKeyCode() )
+ );
+ }
+
+ /**
+ * Returns the modifier key that corresponds to the raw key code from
+ * the given event. This is necessary to ensure that both left and right
+ * modifier keys return the same {@link HardwareSwitch} value.
+ *
+ * @param e The event containing a raw key code to look up.
+ * @return The switch matching the raw key code, or {@code null} if the
+ * raw key code does not represent a modifier.
+ */
+ private HardwareSwitch getModifierKey( final NativeKeyEvent e ) {
+ return mModifierCodes.get( e.getRawCode() );
+ }
+
+ /**
+ * Unused. Key up and key down are tracked separately from a typed key.
+ *
+ * @param e Ignored.
+ */
+ @Override
+ public void nativeKeyTyped( final NativeKeyEvent e ) {
}
}
src/main/com/whitemagicsoftware/kmcaster/ui/AutofitLabel.java
import javax.swing.*;
import java.awt.*;
+import java.awt.geom.Rectangle2D;
-import static java.awt.event.HierarchyEvent.PARENT_CHANGED;
import static java.lang.Math.floor;
/**
- * Responsible for changing a {@link JLabel}'s font size, dynamically. This
- * requires a valid {@link Graphics} context in order to determine the ideal
- * font point size for this component's size dimensions. The {@link Graphics}
- * context is set when the component is added to another container.
+ * Responsible for changing a {@link JLabel}'s font size, dynamically.
*/
public final class AutofitLabel extends JLabel {
/**
- * Constructs an instance of {@link AutofitLabel} that will rescale itself
- * to the parent {@link Container}, automatically.
- * <p>
- * When this label is added to a container, it will appear immediately. If
- * additional scaling is required, then:
- * </p>
- * <ol>
- * <li>call {@link #setVisible(boolean)} with {@code false};</li>
- * <li>add the label to its container;</li>
- * <li>perform the necessary size or location computations;</li>
- * <li>call {@link #setSize(Dimension)} to update the label;</li>
- * <li>then make the label visible again.</li>
- * </ol>
- * <p>
- * Without following the prescribed steps, the label may display at an
- * unexpected size.
- * </p>
+ * Constructs an instance of {@link AutofitLabel} that can rescale itself
+ * relative to either the parent {@link Container} or a given dimension.
*
* @param text The text to write on the container's graphics context.
* @param font The font to use when writing the text.
*/
public AutofitLabel( final String text, final Font font ) {
super( text );
- setDoubleBuffered( true );
setFont( font );
+ }
- addHierarchyListener( e -> {
- final var parent = getParent();
+ /**
+ * Scales the dimensions of the label to fit within the given width and
+ * height, while maintaining the aspect ratio; relocates the label relative
+ * to the bounds of the container, honouring {@link #getVerticalAlignment()}
+ * and {@link #getHorizontalAlignment()}.
+ *
+ * @param width The maximum label width.
+ * @param height The maximum label height.
+ */
+ public void transform( final int width, final int height ) {
+ setSize( width, height );
+ setFont( computeScaledFont() );
- if( (e.getChangeFlags() & PARENT_CHANGED) != 0 &&
- (e.getChangedParent() == parent) ) {
- setBounds( BoundsCalculator.getBounds( parent ) );
- }
- } );
+ final var bounds = BoundsCalculator.getBounds( getParent() );
+
+ // LEFT by default.
+ int x = bounds.x;
+
+ // TOP by default.
+ int y = bounds.y;
+
+ switch( getHorizontalAlignment() ) {
+ case CENTER -> x += (bounds.getWidth() - getWidth()) / 2;
+ case RIGHT -> x += (bounds.getWidth() - getWidth());
+ }
+
+ switch( getVerticalAlignment() ) {
+ case CENTER -> y += (bounds.getHeight() - getHeight()) / 2;
+ case BOTTOM -> y += (bounds.getHeight() - getHeight());
+ }
+
+ setLocation( x, y );
}
/**
- * Note that {@link #setSize(Dimension)} eventually delegates to calling this
- * method, so there's no need to override both. The {@link Graphics} context
- * must be valid before calling this method.
- * <p>
- * Rescales the constructed font to fit within the label's dimensions,
- * governed by {@link #getWidth()} and {@link #getHeight()}. This must only
- * be called after a {@link Graphics} context is available to compute the
- * maximum {@link Font} size that will fit the label's {@link Rectangle}
- * bounds.
- * </p>
+ * Convenience method to scale to the given dimensions then relocate the
+ * label with respect to the vertical and horizontal alignment.
*
- * @param x The new horizontal position.
- * @param y The new horizontal position.
- * @param w The new width constraint.
- * @param h The new height constraint.
+ * @param dimension The maximum label width and height.
*/
- @Override
- public void setBounds( final int x, final int y, final int w, final int h ) {
- super.setBounds( x, y, w, h );
- setFont( computeScaledFont() );
- paintImmediately( getBounds() );
+ public void transform( final Dimension dimension ) {
+ transform( dimension.width, dimension.height );
+ }
+
+ /**
+ * Scales the dimensions of the label to fit its parent's boundaries, while
+ * maintaining the aspect ratio, then relocate the label with respect to
+ * the vertical and horizontal alignment.
+ */
+ public void transform() {
+ final var bounds = BoundsCalculator.getBounds( getParent() );
+ transform( bounds.width, bounds.height );
}
*/
private Font computeScaledFont() {
- final var text = getText();
- final var graphics = getGraphics();
+ final var g = getGraphics();
+
+ if( g == null ) {
+ return getFont();
+ }
+ final var text = getText();
final var dstWidthPx = getWidth();
final var dstHeightPx = getHeight();
- var minSizePt = 1;
- var maxSizePt = 200;
+ // Derived using a binary search algorithm to minimize text width lookups.
var scaledFont = getFont();
+
+ // Using the scaledPt as a relative max size reduces the iterations by two.
var scaledPt = scaledFont.getSize();
+ var minSizePt = 1;
+ var maxSizePt = scaledPt * 2;
while( maxSizePt - minSizePt > 1 ) {
scaledFont = scaledFont.deriveFont( (float) scaledPt );
- final var fm = getFontMetrics( scaledFont );
- final var bounds = fm.getStringBounds( text, graphics );
+ final var bounds = getBounds( text, scaledFont, g );
final var fontWidthPx = (int) bounds.getWidth();
final var fontHeightPx = (int) bounds.getHeight();
}
- graphics.dispose();
+ g.dispose();
// Round down to guarantee fit.
- return scaledFont.deriveFont( (float) floor( scaledPt ) );
+ scaledFont = scaledFont.deriveFont( (float) floor( scaledPt ) );
+
+ // Recompute the bounds of the label based on the text extents that fit.
+ final var bounds = getBounds( text, scaledFont, g );
+ setSize( (int) bounds.getWidth(), (int) bounds.getHeight() );
+
+ return scaledFont;
+ }
+
+ /**
+ * Helper method to determine the width and height of the text.
+ *
+ * @param text Text having a width and height to derive.
+ * @param font Font used to render the next.
+ * @param graphics Graphics context needed for calculating the text extents.
+ * @return Text width and height.
+ */
+ private Rectangle2D getBounds(
+ final String text, final Font font, final Graphics graphics ) {
+ return getFontMetrics( font ).getStringBounds( text, graphics );
}
}
src/main/com/whitemagicsoftware/kmcaster/ui/Constants.java
/**
- * DejaVu Sans is the only free, open, sans serif font that supports
- * all the Unicode blocks used by the application. The font size is
- * arbitrary, the font will be scaled dynamically to the window size.
+ * DejaVu Sans is the only free, open, sans serif font that supports all
+ * the Unicode blocks used by the application. The font size will be scaled
+ * dynamically to the window size, but should be sufficiently large for the
+ * autofit functionality to scale down as required.
*/
- public static final Font LABEL_FONT = new Font( "DejaVu Sans", BOLD, 32 );
+ public static final Font LABEL_FONT = new Font( "DejaVu Sans", BOLD, 100 );
/**
src/main/com/whitemagicsoftware/kmcaster/ui/ScalableDimension.java
import java.awt.*;
-import static java.lang.Math.*;
+import static java.lang.Math.ceil;
import static java.lang.Math.min;
/**
* Provides the ability to scale a dimension in relation to another
* dimension. The dimensions are unit-less.
*/
public final class ScalableDimension extends Dimension {
+
+ /**
+ * Delegates construction to the superclass.
+ *
+ * @param r The dimension's width and height.
+ */
+ public ScalableDimension( final Rectangle r ) {
+ super( r.width, r.height );
+ }
/**

Refactor autofit functionality for performance and clearer API intent

Author DaveJarvis <email>
Date 2020-07-29 13:54:10 GMT-0700
Commit c58f39357ca0ecff0205c5d5f2200ad7b1d8ed3b
Parent a413df5
Delta 557 lines added, 492 lines removed, 65-line increase