diff --git a/client/src/main/java/com/mirth/connect/client/ui/components/tag/MirthTagField.java b/client/src/main/java/com/mirth/connect/client/ui/components/tag/MirthTagField.java index 2c46de163..81648258a 100644 --- a/client/src/main/java/com/mirth/connect/client/ui/components/tag/MirthTagField.java +++ b/client/src/main/java/com/mirth/connect/client/ui/components/tag/MirthTagField.java @@ -4,11 +4,18 @@ package com.mirth.connect.client.ui.components.tag; +import java.awt.AWTEvent; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; -import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Insets; +import java.awt.Point; import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.event.AWTEventListener; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.FocusAdapter; @@ -17,6 +24,8 @@ import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -27,16 +36,21 @@ import java.util.TreeMap; import javax.swing.AbstractAction; +import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTextField; +import javax.swing.JViewport; import javax.swing.KeyStroke; import javax.swing.Scrollable; import javax.swing.SwingUtilities; import javax.swing.UIManager; +import javax.swing.border.AbstractBorder; +import javax.swing.border.Border; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; +import javax.swing.plaf.metal.MetalLookAndFeel; import net.miginfocom.swing.MigLayout; @@ -60,6 +74,11 @@ * A single-line field holding a row of tag chips plus a text editor for the tag being typed. * Completions are supplied through {@link #update} and offered by an autocomplete popup installed * on the editor. + *

+ * The editor sits between two chips, or before or after all of them. Typing puts it in edit mode, + * shown by an outline, where the arrow, Home, End, Backspace and Delete keys edit its text. Outside + * edit mode, those keys move the editor between the chips and delete the chips next to it instead. + * Committing the text or pressing Escape leaves edit mode, as does losing focus with no text. */ public class MirthTagField extends JPanel { private static final String TAG_TYPE = "tag"; @@ -70,8 +89,20 @@ public class MirthTagField extends JPanel { private static final int MAX_TAGS = 10; private static final int MAX_TAG_LENGTH = 24; private static final Color DEFAULT_BACKGROUND = new Color(0xEE, 0xEE, 0xEE); - private static final Font FONT = new Font("Tahoma", Font.PLAIN, 11); private static final int SCROLL_INCREMENT = 20; + /** Sized to its text, so the editor grows as the tag is typed. */ + private static final String EDITOR_CONSTRAINTS = ""; + /** While it shows its prompt, the editor takes up the rest of the row. */ + private static final String PROMPT_EDITOR_CONSTRAINTS = "growx, pushx, w 60::"; + /** The scroll amount the toolkits report for one step of the wheel. */ + private static final int WHEEL_SCROLL_AMOUNT = 3; + /** + * The X11 toolkit reports horizontal scrolling (X buttons 6 and 7) as presses of mouse buttons 4 + * and 5 instead of as wheel events. On other toolkits those buttons are back and forward. + */ + private static final boolean X11_HORIZONTAL_SCROLL_BUTTONS = "sun.awt.X11.XToolkit".equals(Toolkit.getDefaultToolkit().getClass().getName()); + /** Room after the text for the caret. */ + private static final int CARET_ROOM = 2; /** * Lays the chips out in a single row that fills the viewport while they fit and scrolls @@ -108,6 +139,30 @@ public boolean getScrollableTracksViewportHeight() { } } + /** The edit mode outline: a thin rounded rectangle with the same corners as the chips. */ + private static class OutlineBorder extends AbstractBorder { + private final Color color; + + OutlineBorder(Color color) { + this.color = color; + } + + @Override + public void paintBorder(Component c, Graphics g, int x, int y, int width, int height) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(color); + g2.drawRoundRect(x, y, width - 1, height - 1, TagChip.ARC, TagChip.ARC); + g2.dispose(); + } + + @Override + public Insets getBorderInsets(Component c, Insets insets) { + insets.set(1, 1, 1, 1); + return insets; + } + } + /** A tag currently shown in the field. */ private static class Token { private final String type; @@ -132,13 +187,36 @@ private static class Token { private JScrollPane scrollPane; private ChipRow chipRow; private JTextField editor; + private Border idleBorder; + private Border editingBorder; private IconButton clearButton; private MouseAdapter focusEditorAdapter; + /** Installed while the field is displayable, on X11 only. */ + private final AWTEventListener horizontalScrollButtonListener = new AWTEventListener() { + @Override + public void eventDispatched(AWTEvent event) { + MouseEvent evt = (MouseEvent) event; + int button = evt.getButton(); + + if (evt.getID() == MouseEvent.MOUSE_PRESSED && (button == 4 || button == 5) && SwingUtilities.isDescendingFrom(evt.getComponent(), scrollPane)) { + // Only marks the press as handled; Swing's mouse listeners still receive it. + evt.consume(); + scrollChipsBy((button == 4 ? -1 : 1) * WHEEL_SCROLL_AMOUNT); + } + } + }; private AutoCompletionProvider provider; private AutoCompletionDelegate completionDelegate; private final List tokens = new ArrayList(); + /** The editor's position among the chips, from 0 (before the first) to tokens.size(). */ + private int editorIndex; + /** + * Set by typing and cleared by committing, Escape, {@link #clear} or losing focus with no text, + * but not by backspacing the text away, so holding Backspace can't go on to delete chips. + */ + private boolean editing; /** Keyed by type and name, since a channel and a tag may go by the same name. */ private final Map completions = new TreeMap(String.CASE_INSENSITIVE_ORDER); private final Map tagColorMap = new HashMap(); @@ -167,6 +245,7 @@ public MirthTagField(String preferencePrefix, boolean channelContext, Set moveEditor(editorIndex - 1)); + bindChipKey(KeyEvent.VK_KP_LEFT, "Tag.keypadLeft", () -> moveEditor(editorIndex - 1)); + bindChipKey(KeyEvent.VK_RIGHT, "Tag.right", () -> moveEditor(editorIndex + 1)); + bindChipKey(KeyEvent.VK_KP_RIGHT, "Tag.keypadRight", () -> moveEditor(editorIndex + 1)); + bindChipKey(KeyEvent.VK_HOME, "Tag.first", () -> moveEditor(0)); + bindChipKey(KeyEvent.VK_END, "Tag.last", () -> moveEditor(tokens.size())); + bindChipKey(KeyEvent.VK_BACK_SPACE, "Tag.deletePrevious", () -> { + if (editorIndex > 0) { + removeToken(editorIndex - 1, true); + } + }); + bindChipKey(KeyEvent.VK_DELETE, "Tag.deleteNext", () -> { + if (editorIndex < tokens.size()) { + removeToken(editorIndex, true); + } + }); + + editor.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "Tag.cancel"); + editor.getActionMap().put("Tag.cancel", new AbstractAction() { + // Disabled outside edit mode, so that Escape still reaches the enclosing window. + @Override + public boolean isEnabled() { + return editing; + } + + @Override + public void actionPerformed(ActionEvent evt) { + editor.setText(""); + setEditing(false); + } + }); + editor.addKeyListener(new KeyAdapter() { @Override public void keyTyped(KeyEvent evt) { @@ -247,36 +392,44 @@ public void keyTyped(KeyEvent evt) { commitEditorText(); } } + }); + editor.addFocusListener(new FocusAdapter() { @Override - public void keyPressed(KeyEvent evt) { - if (evt.getKeyCode() == KeyEvent.VK_BACK_SPACE && StringUtils.isEmpty(editor.getText())) { - evt.consume(); - closePopupWindow(); - - if (!tokens.isEmpty()) { - removeToken(tokens.size() - 1); - } - } + public void focusGained(FocusEvent evt) { + updateEditorConstraints(); } - }); - editor.addFocusListener(new FocusAdapter() { @Override public void focusLost(FocusEvent evt) { closePopupWindow(); + + if (!evt.isTemporary()) { + // With its text gone there is nothing left to edit. + if (editing && editor.getText().isEmpty()) { + setEditing(false); + } + + // Back at the end, the prompt shows again. Uncommitted text stays where it is. + if (!editing) { + moveEditor(tokens.size()); + } + } + + updateEditorConstraints(); } }); editor.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent evt) { - updatePopup(); + setEditing(true); + editorTextChanged(); } @Override public void removeUpdate(DocumentEvent evt) { - updatePopup(); + editorTextChanged(); } @Override @@ -284,6 +437,58 @@ public void changedUpdate(DocumentEvent evt) {} }); } + /** + * Binds a key that runs chipAction outside edit mode and keeps its usual text editing action in + * edit mode. The autocomplete popup shadows some of these keys while it is showing. + */ + private void bindChipKey(int keyCode, String name, final Runnable chipAction) { + KeyStroke keyStroke = KeyStroke.getKeyStroke(keyCode, 0); + final Object textActionKey = editor.getInputMap().get(keyStroke); + + editor.getInputMap().put(keyStroke, name); + editor.getActionMap().put(name, new AbstractAction() { + @Override + public void actionPerformed(ActionEvent evt) { + if (!editing) { + chipAction.run(); + return; + } + + Action textAction = textActionKey != null ? editor.getActionMap().get(textActionKey) : null; + if (textAction != null) { + textAction.actionPerformed(evt); + } + } + }); + } + + private void setEditing(boolean editing) { + if (this.editing != editing) { + this.editing = editing; + editor.setBorder(editing ? editingBorder : idleBorder); + updateEditorConstraints(); + } + } + + /** The prompt shows only in an empty, unfocused editor, which is kept at the end. */ + private boolean isShowingPrompt() { + return !editing && editorIndex == tokens.size() && !editor.isFocusOwner(); + } + + /** Only the editor showing its prompt fills the row, so that the prompt has room. */ + private void updateEditorConstraints() { + ((MigLayout) chipRow.getLayout()).setComponentConstraints(editor, isShowingPrompt() ? PROMPT_EDITOR_CONSTRAINTS : EDITOR_CONSTRAINTS); + chipRow.revalidate(); + } + + private void editorTextChanged() { + updatePopup(); + + // A JTextField is its own validate root, so the row has to be told its width changed. + chipRow.revalidate(); + scrollEditorToVisible(); + } + private void initAutoCompletion() { provider = new AutoCompletionProvider(); provider.setListCellRenderer(new TagCompletionRenderer()); @@ -303,7 +508,7 @@ private void initLayout() { } } - /** Rebuilds the chips from {@link #tokens}, keeping the editor last. */ + /** Rebuilds the chips from {@link #tokens} around the editor, which stays at editorIndex. */ private void rebuild() { for (Component component : chipRow.getComponents()) { if (component instanceof TagChip) { @@ -312,9 +517,11 @@ private void rebuild() { } for (int i = 0; i < tokens.size(); i++) { - chipRow.add(createChip(tokens.get(i), i), "", i); + chipRow.add(createChip(tokens.get(i), i), "", i < editorIndex ? i : i + 1); } + updateEditorConstraints(); + clearButton.setEnabled(isEnabled() && !tokens.isEmpty()); chipRow.revalidate(); @@ -325,7 +532,8 @@ private TagChip createChip(Token token, final int index) { TagChip chip = new TagChip(token.name, token.background, token.foreground, new Runnable() { @Override public void run() { - removeToken(index); + // Leave the row where it is, so the next chip slides under the pointer. + removeToken(index, false); } }); chip.setToolTipText(getToolTipText()); @@ -367,6 +575,7 @@ private void setTokens(List newTokens, boolean fireEvent) { tokens.add(resolve(token.type, token.name)); } + editorIndex = Math.min(editorIndex, tokens.size()); rebuild(); if (fireEvent) { @@ -376,12 +585,13 @@ private void setTokens(List newTokens, boolean fireEvent) { private void addToken(String type, String name) { editor.setText(""); + setEditing(false); if (StringUtils.isBlank(name) || tokens.size() >= MAX_TAGS || isDuplicate(name)) { return; } - tokens.add(resolve(type, name)); + tokens.add(editorIndex++, resolve(type, name)); rebuild(); scrollEditorToVisible(); @@ -389,9 +599,16 @@ private void addToken(String type, String name) { updateSearchPerformed(); } - private void removeToken(int index) { + private void removeToken(int index, boolean scrollToEditor) { + closePopupWindow(); tokens.remove(index); + if (index < editorIndex) { + editorIndex--; + } rebuild(); + if (scrollToEditor) { + scrollEditorToVisible(); + } deleteTagActionPerformed(); } @@ -427,6 +644,59 @@ public void run() { }); } + /** The editor position nearest to x in the row: after every chip whose middle is left of x. */ + private int editorIndexAt(int x) { + int index = 0; + + for (Component component : chipRow.getComponents()) { + if (component instanceof TagChip && component.getX() + component.getWidth() / 2 < x) { + index++; + } + } + + return index; + } + + private void moveEditor(int index) { + index = Math.max(0, Math.min(index, tokens.size())); + + if (index != editorIndex) { + closePopupWindow(); + editorIndex = index; + rebuild(); + } + + scrollEditorToVisible(); + } + + /** + * With no scrollbar to show, the wheel (or a sideways trackpad swipe, which arrives with Shift + * held) scrolls the chips horizontally. While they fit, the event goes on to the components + * around the field, so an enclosing scroll pane still scrolls. + */ + private void scrollChips(MouseWheelEvent evt) { + if (scrollChipsBy(evt.getPreciseWheelRotation() * evt.getScrollAmount())) { + evt.consume(); + } else { + dispatchEvent(SwingUtilities.convertMouseEvent(scrollPane, evt, this)); + } + } + + /** Scrolls the chips by a number of scroll units, returning false when they all fit. */ + private boolean scrollChipsBy(double units) { + JViewport viewport = scrollPane.getViewport(); + int overflow = chipRow.getWidth() - viewport.getWidth(); + + if (overflow <= 0) { + return false; + } + + Point position = viewport.getViewPosition(); + position.x = (int) Math.max(0, Math.min(overflow, Math.round(position.x + units * SCROLL_INCREMENT))); + viewport.setViewPosition(position); + return true; + } + private void scrollEditorToVisible() { SwingUtilities.invokeLater(new Runnable() { @Override @@ -487,6 +757,21 @@ private void setProviderCompletions(Set tags) { } } + @Override + public void addNotify() { + super.addNotify(); + + if (X11_HORIZONTAL_SCROLL_BUTTONS) { + Toolkit.getDefaultToolkit().addAWTEventListener(horizontalScrollButtonListener, AWTEvent.MOUSE_EVENT_MASK); + } + } + + @Override + public void removeNotify() { + Toolkit.getDefaultToolkit().removeAWTEventListener(horizontalScrollButtonListener); + super.removeNotify(); + } + @Override public void setEnabled(boolean enabled) { super.setEnabled(enabled); @@ -541,11 +826,13 @@ public void setChannelTags(List tags) { channelTokens.add(new Token(TAG_TYPE, tag.getName(), null, null)); } + editorIndex = channelTokens.size(); setTokens(channelTokens, true); } public void clear() { editor.setText(""); + setEditing(false); tagColorMap.clear(); setTokens(new ArrayList(), true); } @@ -588,6 +875,7 @@ public void update(Set tags, boolean channelContext, boolean u } public void setUserPreferenceTags() { + editorIndex = cachedUserPreferenceTags.size(); setTokens(cachedUserPreferenceTags, true); } diff --git a/client/src/main/java/com/mirth/connect/client/ui/components/tag/TagChip.java b/client/src/main/java/com/mirth/connect/client/ui/components/tag/TagChip.java index 2957ae6ea..6c21e289e 100644 --- a/client/src/main/java/com/mirth/connect/client/ui/components/tag/TagChip.java +++ b/client/src/main/java/com/mirth/connect/client/ui/components/tag/TagChip.java @@ -16,17 +16,23 @@ import javax.swing.JLabel; import javax.swing.JPanel; +import javax.swing.SwingUtilities; import net.miginfocom.swing.MigLayout; /** * A single rounded tag "chip" with a delete affordance. Used by {@link MirthTagField}. + *

+ * Like a browser tab, a chip is deleted by a left click on its "×" or a middle click anywhere on + * it. Either acts on release, and only if the pointer is still over what was pressed. */ public class TagChip extends JPanel { - private static final Font FONT = new Font("Tahoma", Font.PLAIN, 11); + /** Also used by {@link MirthTagField}'s editor, so typed text looks like the chips. */ + static final Font FONT = new Font("Tahoma", Font.PLAIN, 11); private static final Font CLOSE_FONT = new Font("Tahoma", Font.BOLD, 12); private static final int HEIGHT = 20; - private static final int ARC = 6; + /** Also used by {@link MirthTagField}'s edit mode outline. */ + static final int ARC = 6; private static final float OPACITY = 0.9f; private static final Color BORDER_COLOR = new Color(0xD9, 0xD9, 0xD9); @@ -44,14 +50,31 @@ public TagChip(String name, Color background, Color foreground, final Runnable o nameLabel.setForeground(foreground); add(nameLabel); + // The name label has no listeners of its own, so presses on it land here too. + addMouseListener(new MouseAdapter() { + @Override + public void mouseReleased(MouseEvent e) { + if (isEnabled() && SwingUtilities.isMiddleMouseButton(e) && contains(e.getPoint())) { + onDelete.run(); + } + } + }); + closeLabel = new JLabel("×"); closeLabel.setFont(CLOSE_FONT); closeLabel.setForeground(foreground); closeLabel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); closeLabel.addMouseListener(new MouseAdapter() { + /* + * Released rather than clicked: a click is dropped if the pointer moves at all while the + * button is down, which a trackpoint nearly always does. Other buttons are ignored; they + * include horizontal scrolling on X11, which arrives as buttons 4 and 5. + */ @Override - public void mouseClicked(MouseEvent e) { - if (isEnabled()) { + public void mouseReleased(MouseEvent e) { + boolean deleteButton = SwingUtilities.isLeftMouseButton(e) || SwingUtilities.isMiddleMouseButton(e); + + if (isEnabled() && deleteButton && closeLabel.contains(e.getPoint())) { onDelete.run(); } } diff --git a/server/docs/thirdparty/THIRD-PARTY-README.txt b/server/docs/thirdparty/THIRD-PARTY-README.txt index e8a549a15..3d2868086 100644 --- a/server/docs/thirdparty/THIRD-PARTY-README.txt +++ b/server/docs/thirdparty/THIRD-PARTY-README.txt @@ -408,37 +408,6 @@ THE SOFTWARE. -------------------------------------------------------------------------------- -The license below pertains to the bootstrap-tokenfield library. - -=================== Beginning of License Information =================== - -Copyright (c) 2013 by Sliptree -https://github.com/sliptree/bootstrap-tokenfield - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -=================== End of License Information =================== - - --------------------------------------------------------------------------------- - - The license information below pertains to Flying Saucer version 9.0.1. This library is covered by the LGPL version 2.1 (see LGPL-2.1.txt) and its source code is available at