| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to you under the Apache License, |
| * Version 2.0 (the "License"); you may not use this file except in |
| * compliance with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package org.apache.pivot.wtk; |
| |
| import java.util.Iterator; |
| |
| import org.apache.pivot.collections.ArrayList; |
| import org.apache.pivot.collections.Group; |
| import org.apache.pivot.collections.List; |
| import org.apache.pivot.collections.Sequence; |
| import org.apache.pivot.util.Filter; |
| import org.apache.pivot.util.ImmutableIterator; |
| import org.apache.pivot.wtk.Keyboard.KeyCode; |
| import org.apache.pivot.wtk.Keyboard.KeyLocation; |
| import org.apache.pivot.wtk.Keyboard.Modifier; |
| |
| /** |
| * Extension of {@link ButtonGroup} providing keyboard navigation within the |
| * group and modified focus navigation that treats the group as a single |
| * focusable entity.<br><br> {@link KeyCode#UP UP} & {@link KeyCode#LEFT LEFT} |
| * Select the previous button<br> {@link KeyCode#DOWN DOWN} & |
| * {@link KeyCode#RIGHT RIGHT} Select the next button<br> {@link KeyCode#HOME |
| * HOME} Select the first button<br> {@link KeyCode#END END} Select the last |
| * button<br><br> (Note that only {@link Component#isFocusable() focusable} |
| * buttons are considered when searching for a Button to select)<br><br> When |
| * a button within the group is focused and key is typed, an attempt is made to |
| * find the next button (default) or previous button (when the SHIFT modifier is |
| * pressed) whose renderer text starts with the typed character. This search |
| * will always behave as if the <code>circular</code> property were |
| * set.<br><br> By default, {@link KeyCode#TAB TAB} and {@link KeyCode#TAB |
| * SHIFT+TAB} key presses will transfer focus out of the group (forwards or |
| * backwards respectively). This is managed by the |
| * {@link #setIntraGroupFocusTransferEnabled(boolean) |
| * intraGroupFocusTransferEnabled} property.<br><br> The |
| * {@link #setCircular(boolean) circular} property can be enabled to allow the |
| * selection to transfer seamlessly from one end of the group to the other. |
| * (i.e. holding down an arrow key will cycle through all focusable buttons) |
| * <br><br> Note that due to the conflicting return types of the |
| * <code>add(T)</code> and <code>remove(T)</code> methods in the |
| * {@link Group#add(Object) Group} and {@link Sequence#add(Object) Sequence} |
| * interfaces, this class cannot actually implement |
| * <code>Sequence<Button></code>, although most of the same methods are |
| * implemented.<br> |
| */ |
| public class RadioButtonGroup extends ButtonGroup { |
| |
| /** |
| * Filter used to determine selectable buttons whose rendered data starts |
| * with the target character. |
| */ |
| private class FirstCharacterFilter implements Filter<Integer> { |
| private char target = '\0'; |
| |
| public void setTarget(char target) { |
| this.target = Character.toUpperCase(target); |
| } |
| |
| @Override |
| public boolean include(Integer index) { |
| boolean include = defaultFilter.include(index); |
| if (include) { |
| Button button = buttonOrder.get(index.intValue()); |
| String rendered = button.getDataRenderer().toString(button.getButtonData()); |
| if (rendered != null && rendered.length() > 0) { |
| char first = Character.toUpperCase(rendered.charAt(0)); |
| if (first != target) { |
| include = false; |
| } |
| } |
| } |
| return include; |
| } |
| } |
| |
| /** |
| * ComponentKeyListener to be applied to all buttons as they are added to |
| * the group.<br><br> At least one button in the group must be focused for |
| * this listener to be executed, but that won't necessarily be a selected |
| * button.<br> This also means that the group will not be empty, although |
| * some of the buttons contained within may not be focusable, or even |
| * visible. |
| */ |
| private final ComponentKeyListener componentKeyListener = new ComponentKeyListener.Adapter() { |
| /** |
| * Handle TAB & SHIFT+TAB focus traversal, HOME, END & arrow keys |
| */ |
| @Override |
| public boolean keyPressed(Component component, int keyCode, KeyLocation keyLocation) { |
| int modifiers = Keyboard.getModifiers(); |
| boolean handled = false; |
| |
| /* |
| * Potentially transfer focus away from the buttons in this group. |
| * |
| * At this point we know that at least one button is focused, so we |
| * just need to find the first or last (and possibly only) focusable |
| * button depending on the focus transfer direction and then |
| * transfer away from it. |
| */ |
| if (!intraGroupFocusTransferEnabled) { |
| if (keyCode == KeyCode.TAB) { |
| if (modifiers == 0) { |
| Button lastFocusableButton = get(findPrevious(buttonOrder.getLength())); |
| lastFocusableButton.transferFocus(FocusTraversalDirection.FORWARD); |
| handled = true; |
| } else if (modifiers == Modifier.SHIFT.getMask()) { |
| Button firstFocusableButton = get(findNext(NO_SELECTION_INDEX)); |
| firstFocusableButton.transferFocus(FocusTraversalDirection.BACKWARD); |
| handled = true; |
| } |
| } |
| } |
| |
| // Navigation/selection within the group |
| if (!handled && modifiers == 0) { |
| RadioButtonGroup radioButtonGroup = RadioButtonGroup.this; |
| Button selectedButton = radioButtonGroup.getSelection(); |
| handled = true; |
| if (keyCode == Keyboard.KeyCode.HOME) { |
| radioButtonGroup.selectFirstButton(); |
| } else if (keyCode == Keyboard.KeyCode.END) { |
| radioButtonGroup.selectLastButton(); |
| } else if (keyCode == Keyboard.KeyCode.LEFT || keyCode == Keyboard.KeyCode.UP) { |
| radioButtonGroup.selectPreviousButton(selectedButton); |
| } else if (keyCode == Keyboard.KeyCode.RIGHT || keyCode == Keyboard.KeyCode.DOWN) { |
| radioButtonGroup.selectNextButton(selectedButton); |
| } else { |
| handled = false; |
| } |
| } |
| |
| return handled; |
| } |
| |
| /** |
| * Attempt to jump to the button whose rendered text begins with the |
| * typed character.<br> |
| */ |
| @Override |
| public boolean keyTyped(Component component, char character) { |
| int modifiers = Keyboard.getModifiers(); |
| boolean handled = false; |
| |
| // We are only interested when a key is typed with no modifier, or |
| // just SHIFT (which is used to reverse the search direction) |
| boolean noModifiersPressed = (modifiers == 0); |
| boolean shiftPressed = (modifiers == Modifier.SHIFT.getMask()); |
| if (noModifiersPressed || shiftPressed) { |
| RadioButtonGroup radioButtonGroup = RadioButtonGroup.this; |
| Button selectedButton = radioButtonGroup.getSelection(); |
| |
| firstCharacterFilter.setTarget(character); |
| |
| // Determine the starting point for the search |
| int searchStartIndex; |
| if (selectedButton != null) { |
| searchStartIndex = radioButtonGroup.indexOf(selectedButton); |
| } else { |
| if (noModifiersPressed) { |
| searchStartIndex = NO_SELECTION_INDEX; |
| } else { |
| searchStartIndex = buttonOrder.getLength(); |
| } |
| } |
| |
| int result = NOT_FOUND_INDEX; |
| if (noModifiersPressed) { |
| result = radioButtonGroup.findNext(searchStartIndex, firstCharacterFilter, true); |
| } else if (shiftPressed) { |
| result = radioButtonGroup.findPrevious(searchStartIndex, firstCharacterFilter, |
| true); |
| } |
| |
| // Consider the event to have been handled if a different |
| // button end up being selected |
| if (result != NOT_FOUND_INDEX && result != searchStartIndex) { |
| radioButtonGroup.setSelection(result); |
| handled = true; |
| } |
| } |
| return handled; |
| } |
| }; |
| |
| /** |
| * Ensure that all buttons in this group have the custom |
| * ComponentKeyListener.<br> This relies on the logic within ButtonGroup to |
| * prevent duplicates. |
| */ |
| private final ButtonGroupListener buttonGroupListener = new ButtonGroupListener.Adapter() { |
| @Override |
| public void buttonAdded(ButtonGroup buttonGroup, Button button) { |
| button.getComponentKeyListeners().add(componentKeyListener); |
| } |
| |
| @Override |
| public void buttonRemoved(ButtonGroup buttonGroup, Button button) { |
| button.getComponentKeyListeners().remove(componentKeyListener); |
| } |
| }; |
| |
| /** |
| * Filter used to determine selectable buttons within the group |
| */ |
| private final Filter<Integer> defaultFilter = new Filter<Integer>() { |
| @Override |
| public boolean include(Integer index) { |
| Button button = buttonOrder.get(index.intValue()); |
| boolean focusable = button.isFocusable(); |
| return focusable; |
| } |
| }; |
| |
| private final FirstCharacterFilter firstCharacterFilter = new FirstCharacterFilter(); |
| private final List<Button> buttonOrder = new ArrayList<>(); |
| private boolean circular = false; |
| private boolean intraGroupFocusTransferEnabled = false; |
| |
| private static final int NOT_FOUND_INDEX = -1; |
| private static final int NO_SELECTION_INDEX = -1; |
| |
| public RadioButtonGroup() { |
| getButtonGroupListeners().add(buttonGroupListener); |
| } |
| |
| /** |
| * When enabled, a search for the |
| * {@link RadioButtonGroup#selectPreviousButton(Button) previous} or |
| * {@link RadioButtonGroup#selectNextButton(Button) next} focusable button |
| * will not stop when the group's lower or upper bounds (respectively) are |
| * reached.<br> Instead, the search will 'wrap' and continue from the |
| * opposite bound until each button in the entire group has been tested for |
| * inclusion. <br><br> Defaults to <code>false</code> |
| * |
| * @return Whether the group is circular. |
| */ |
| public boolean isCircular() { |
| return circular; |
| } |
| |
| /** |
| * When enabled, a search for the |
| * {@link RadioButtonGroup#selectPreviousButton(Button) previous} or |
| * {@link RadioButtonGroup#selectNextButton(Button) next} focusable button |
| * will not stop when the group's lower or upper bounds (respectively) are |
| * reached.<br> Instead, the search will 'wrap' and continue from the |
| * opposite bound until each button in the entire group has been tested for |
| * inclusion. |
| * |
| * @param circular New setting for the group. |
| */ |
| public void setCircular(boolean circular) { |
| this.circular = circular; |
| } |
| |
| /** |
| * When true, TAB and SHIFT+TAB transfer focus out of the RadioButtonGroup. |
| * <br> Defaults to <code>false</code> |
| * |
| * @return Whether focus transfers out of the group on TAB. |
| */ |
| public boolean isIntraGroupFocusTransferEnabled() { |
| return intraGroupFocusTransferEnabled; |
| } |
| |
| /** |
| * Controls whether TAB and SHIFT+TAB will transfer focus out of the |
| * RadioButtonGroup, or simply maintain their default behaviour. |
| * |
| * @param intraGroupFocusTransferEnabled New setting for this group. |
| */ |
| public void setIntraGroupFocusTransferEnabled(boolean intraGroupFocusTransferEnabled) { |
| this.intraGroupFocusTransferEnabled = intraGroupFocusTransferEnabled; |
| } |
| |
| /** |
| * Add a button to the group. |
| * |
| * @param button The button to add. |
| * @see Group#add(Object) |
| * @see Sequence#add(Object) |
| * @throws IllegalArgumentException if the button parameter is {@code null}. |
| */ |
| @Override |
| public boolean add(Button button) { |
| if (button == null) { |
| throw new IllegalArgumentException("Button cannot be null"); |
| } |
| boolean result = super.add(button); |
| if (result) { |
| buttonOrder.add(button); |
| } |
| return result; |
| } |
| |
| /** |
| * @return The button at the specified index. |
| * |
| * @param index The location to retrieve the button from. |
| * @see Sequence#get(int) |
| */ |
| public Button get(int index) { |
| return buttonOrder.get(index); |
| } |
| |
| /** |
| * @return The number of buttons in the group. |
| * |
| * @see Sequence#getLength() |
| */ |
| public int getLength() { |
| return buttonOrder.getLength(); |
| } |
| |
| /** |
| * Return the index (order) of the button within the group. |
| * |
| * @param button The button to search for in this group. |
| * @return The index or {@link #NOT_FOUND_INDEX} if the button does not belong to this |
| * RadioButtonGroup. |
| * @see Sequence#indexOf(Object) |
| */ |
| public int indexOf(Button button) { |
| return buttonOrder.indexOf(button); |
| } |
| |
| /** |
| * Insert a button at the specified index. |
| * |
| * @param button The button to be inserted. |
| * @param index Where to insert the button. |
| * @see Sequence#insert(Object, int) |
| * @throws IllegalArgumentException if the button parameter is {@code null}. |
| */ |
| public void insert(Button button, int index) { |
| if (button == null) { |
| throw new IllegalArgumentException("Button cannot be null"); |
| } |
| boolean result = super.add(button); |
| if (result) { |
| buttonOrder.insert(button, index); |
| } |
| } |
| |
| /** |
| * Remove the button from the group. |
| * |
| * @param button The button to remove from this group. |
| * @return Success indicator. |
| * @see Group#remove(Object) |
| * @see Sequence#remove(Object) |
| */ |
| @Override |
| public boolean remove(Button button) { |
| boolean result = false; |
| if (button != null) { |
| result = super.remove(button); |
| if (result) { |
| buttonOrder.remove(button); |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Remove <code>count</code> buttons from the group starting at |
| * <code>index</code>. |
| * |
| * @param index Starting point for the removal. |
| * @param count Number of buttons to remove. |
| * @return The sequence of buttons actually removed (will not be |
| * {@code null}, but could be an empty sequence). |
| * @see Sequence#remove(int, int) |
| */ |
| public Sequence<Button> remove(int index, int count) { |
| int countMutable = count; |
| Sequence<Button> removed = new ArrayList<>(); |
| while (countMutable-- > 0) { |
| Button button = get(index); |
| boolean result = this.remove(button); |
| if (result) { |
| removed.add(button); |
| } |
| } |
| return removed; |
| } |
| |
| /** |
| * @return An iterator for the <strong>ordered</strong> list of buttons |
| */ |
| @Override |
| public Iterator<Button> iterator() { |
| return new ImmutableIterator<>(buttonOrder.iterator()); |
| } |
| |
| /** |
| * Select and <strong>focus</strong> the specified button. |
| * |
| * @see ButtonGroup#setSelection(Button) |
| */ |
| @Override |
| public void setSelection(Button button) { |
| super.setSelection(button); |
| if (button != null) { |
| button.requestFocus(); |
| } |
| } |
| |
| /** |
| * Select and <strong>focus</strong> the button at the specified index, |
| * unless the index is NOT_FOUND_INDEX. |
| * |
| * @param index The new selection index for the group. |
| */ |
| public void setSelection(int index) { |
| if (index != NOT_FOUND_INDEX) { |
| this.setSelection(buttonOrder.get(index)); |
| } |
| } |
| |
| /** |
| * Select the first focusable button in the group. |
| */ |
| public void selectFirstButton() { |
| setSelection(findNext(NO_SELECTION_INDEX)); |
| } |
| |
| /** |
| * Select the last focusable button in the group. |
| */ |
| public void selectLastButton() { |
| setSelection(findPrevious(buttonOrder.getLength())); |
| } |
| |
| /** |
| * Working forwards from the specified button, select the first focusable |
| * button. |
| * |
| * @param button If null, the first available button will be selected, |
| * unless the group contains a selected or focused button, in which case |
| * that button will be used as the starting point for the search. |
| */ |
| public void selectNextButton(final Button button) { |
| Button buttonWithDefault = button; |
| |
| // No explicit starting point was supplied |
| if (buttonWithDefault == null) { |
| // If there is a selected button in this group, we will try to use |
| // it as the starting point. |
| buttonWithDefault = getSelection(); |
| if (buttonWithDefault == null) { |
| // No selection, but perhaps one of the buttons has focus? |
| Component focusedComponent = Component.getFocusedComponent(); |
| if (focusedComponent instanceof Button) { |
| int index = this.indexOf((Button) focusedComponent); |
| if (index != NOT_FOUND_INDEX) { |
| buttonWithDefault = this.get(index); |
| } |
| } |
| } |
| // Try again, using new starting point if one was determined |
| if (buttonWithDefault != null) { |
| selectNextButton(buttonWithDefault); |
| } else { |
| selectFirstButton(); |
| } |
| } else { |
| int index = indexOf(buttonWithDefault); |
| if (index == NOT_FOUND_INDEX) { |
| throw new IllegalArgumentException( |
| "Button does not belong to this RadioButtonGroup."); |
| } |
| index = findNext(index); |
| if (index != NOT_FOUND_INDEX) { |
| setSelection(index); |
| } |
| } |
| } |
| |
| /** |
| * Working backwards from the specified button, select the first focusable |
| * button. |
| * |
| * @param button If null, the last available button will be selected, unless |
| * the group contains a selected or focused button, in which case that |
| * button will be used as the starting point for the search. |
| */ |
| public void selectPreviousButton(final Button button) { |
| Button buttonWithDefault = button; |
| |
| // No explicit starting point was supplied |
| if (buttonWithDefault == null) { |
| // If there is a selected button in this group, we will try to use |
| // it as the starting point. |
| buttonWithDefault = getSelection(); |
| if (buttonWithDefault == null) { |
| // No selection, but perhaps one of the buttons has focus? |
| Component focusedComponent = Component.getFocusedComponent(); |
| if (focusedComponent instanceof Button) { |
| int index = this.indexOf((Button) focusedComponent); |
| if (index != NOT_FOUND_INDEX) { |
| buttonWithDefault = this.get(index); |
| } |
| } |
| } |
| |
| // Try again, using new starting point if one was determined |
| if (buttonWithDefault != null) { |
| selectPreviousButton(buttonWithDefault); |
| } else { |
| selectLastButton(); |
| } |
| } else { |
| int index = indexOf(buttonWithDefault); |
| if (index == NOT_FOUND_INDEX) { |
| throw new IllegalArgumentException( |
| "Button does not belong to this RadioButtonGroup."); |
| } |
| index = findPrevious(index); |
| if (index != NOT_FOUND_INDEX) { |
| setSelection(index); |
| } |
| } |
| } |
| |
| /** |
| * Iterate forwards over the buttons in the group, looping back to the start |
| * if the upper bound is reached and the <code>circular</code> parameter is |
| * true. |
| * |
| * @param index Index to which the 'next' is relative |
| * @param filter Alternative filter to use during the search. |
| * @param circularArgument Loop when upper bound is reached |
| * @return The first button found to satisfy the filter |
| * @see #setCircular(boolean) |
| */ |
| private int findNext(int index, final Filter<Integer> filter, boolean circularArgument) { |
| Filter<Integer> filterWithDefault = (filter == null ? defaultFilter : filter); |
| int result = NOT_FOUND_INDEX; |
| int length = buttonOrder.getLength(); |
| if (length > 0) { |
| // (index + 1) --> last index |
| for (int i = (index + 1); i < length; i++) { |
| if (filterWithDefault.include(Integer.valueOf(i))) { |
| result = i; |
| break; |
| } |
| } |
| if (circularArgument && result == NOT_FOUND_INDEX) { |
| // first index --> index |
| for (int i = 0; i <= index; i++) { |
| if (filterWithDefault.include(Integer.valueOf(i))) { |
| result = i; |
| break; |
| } |
| } |
| } |
| } |
| return result; |
| } |
| |
| private int findNext(int index) { |
| return findNext(index, defaultFilter, circular); |
| } |
| |
| /** |
| * Iterate backwards over the buttons in the group, looping back to the end |
| * if the lower bound is reached and the <code>circular</code> parameter is |
| * true. |
| * |
| * @param index Index to which the 'previous' is relative |
| * @param filter Alternative filter to use during the search. |
| * @param circularArgument Loop when lower bound is reached |
| * @return The first focusable button found |
| * @see #setCircular(boolean) |
| */ |
| private int findPrevious(int index, final Filter<Integer> filter, boolean circularArgument) { |
| Filter<Integer> filterWithDefault = (filter == null ? defaultFilter : filter); |
| int result = NOT_FOUND_INDEX; |
| int length = buttonOrder.getLength(); |
| if (length > 0) { |
| // (index - 1) --> first index |
| for (int i = (index - 1); i >= 0; i--) { |
| if (filterWithDefault.include(Integer.valueOf(i))) { |
| result = i; |
| break; |
| } |
| } |
| if (circularArgument && result == NOT_FOUND_INDEX) { |
| // last index --> index |
| for (int i = (length - 1); i >= index; i--) { |
| if (filterWithDefault.include(Integer.valueOf(i))) { |
| result = i; |
| break; |
| } |
| } |
| } |
| } |
| return result; |
| } |
| |
| private int findPrevious(int index) { |
| return findPrevious(index, defaultFilter, circular); |
| } |
| } |