From a830a76bfe64e5e373f0b5cd0e342530fde3653a Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Sun, 25 Oct 2026 00:06:23 -0400 Subject: [PATCH 1/9] Navigate tag chips from the keyboard with an edit mode The editor can now sit between chips, and commits insert at its position. Typing enters an outlined edit mode, in which the keys act on the text instead of the chips: - Left / Right - Navigating: move the editor past a chip - Edit mode: move the caret - Home / End - Navigating: move the editor to the first or last slot - Edit mode: move the caret - Backspace / Delete - Navigating: remove the chip before or after the editor - Edit mode: edit the text - Enter / comma - Navigating: nothing - Edit mode: commit the text - Escape - Navigating: passed on to the window - Edit mode: clear the text and leave edit mode without committing Edit mode lasts until the text is committed, Escape is pressed, or the field loses focus with no text. Deleting the text doesn't end it, so holding Backspace to clear the text can't run on into the chips. A click on the row outside the chips moves the editor back to the end. Deleting a chip from the keyboard scrolls the editor into view, but deleting one with the mouse leaves the row where it is, so the next chip slides under the pointer. The editor is sized to its text and grows while typing; it fills the rest of the row only while showing its prompt. TagChip's font and corner arc are shared so the editor and its outline match the chips. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../ui/components/tag/MirthTagField.java | 241 ++++++++++++++++-- .../client/ui/components/tag/TagChip.java | 6 +- 2 files changed, 221 insertions(+), 26 deletions(-) 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..27e44cabb 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 @@ -7,8 +7,11 @@ 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.Rectangle; +import java.awt.RenderingHints; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.FocusAdapter; @@ -27,6 +30,7 @@ import java.util.TreeMap; import javax.swing.AbstractAction; +import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.JPanel; import javax.swing.JScrollPane; @@ -35,8 +39,11 @@ 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 +67,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 +82,13 @@ 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::"; + /** 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 +125,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,6 +173,8 @@ 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; @@ -139,6 +182,13 @@ private static class Token { 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 +217,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 +353,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 +398,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 +469,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 +478,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 +493,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 +536,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 +546,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 +560,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 +605,18 @@ public void run() { }); } + private void moveEditor(int index) { + index = Math.max(0, Math.min(index, tokens.size())); + + if (index != editorIndex) { + closePopupWindow(); + editorIndex = index; + rebuild(); + } + + scrollEditorToVisible(); + } + private void scrollEditorToVisible() { SwingUtilities.invokeLater(new Runnable() { @Override @@ -541,11 +731,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 +780,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..a382e332e 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 @@ -23,10 +23,12 @@ * A single rounded tag "chip" with a delete affordance. Used by {@link MirthTagField}. */ 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); From cecb3ba07c0cd876b1c0180b798c1bf39806d1eb Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Sun, 25 Oct 2026 00:11:20 -0400 Subject: [PATCH 2/9] Drop the bootstrap-tokenfield third-party notice The Swing rewrite of the tag field removed the last bundled copy of bootstrap-tokenfield, so its license notice no longer applies. The jQuery and Bootstrap notices stay, since server/public_api_html still ships both. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- server/docs/thirdparty/THIRD-PARTY-README.txt | 31 ------------------- 1 file changed, 31 deletions(-) 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 From c3e1eee7728903abee736de4a5bf4c1c4c6a7487 Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Sun, 25 Oct 2026 03:54:12 -0400 Subject: [PATCH 3/9] Scroll the tag chips with the mouse wheel The chip row has no scrollbar, so the scroll pane's own wheel handling did nothing, and tags scrolled off the left edge could not be reached with the mouse. The wheel, or a sideways trackpad swipe, now scrolls the row horizontally. While the chips fit, the event is passed on so an enclosing scroll pane still scrolls; before, the field swallowed it. On X11, horizontal scrolling arrives as presses of mouse buttons 4 and 5 rather than as wheel events, so a toolkit event listener scrolls the row for those while the field is displayable. Buttons other than the left one no longer focus the editor or move it to the end; otherwise each sideways scroll step would pull the row back to the editor. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../ui/components/tag/MirthTagField.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) 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 27e44cabb..f9f81a7b4 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,14 +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.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; @@ -20,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; @@ -35,6 +41,7 @@ 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; @@ -87,6 +94,13 @@ public class MirthTagField extends JPanel { 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; @@ -177,6 +191,20 @@ private static class Token { 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; @@ -227,6 +255,10 @@ private void initComponents() { focusEditorAdapter = new MouseAdapter() { @Override public void mousePressed(MouseEvent evt) { + if (!SwingUtilities.isLeftMouseButton(evt)) { + return; + } + // A click outside the chips goes back to adding tags at the end. if (!editing && !(evt.getComponent() instanceof TagChip)) { moveEditor(tokens.size()); @@ -250,6 +282,13 @@ public void mousePressed(MouseEvent evt) { scrollPane.setViewportBorder(null); scrollPane.getViewport().setBackground(UIConstants.BACKGROUND_COLOR); scrollPane.getViewport().addMouseListener(focusEditorAdapter); + scrollPane.setWheelScrollingEnabled(false); + scrollPane.addMouseWheelListener(new MouseWheelListener() { + @Override + public void mouseWheelMoved(MouseWheelEvent evt) { + scrollChips(evt); + } + }); clearButton = new IconButton(); clearButton.setIcon(UIConstants.ICON_X); @@ -617,6 +656,34 @@ private void moveEditor(int index) { 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 @@ -677,6 +744,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); From 5f267ac5479f40c3226c1397ff9459fa155e5309 Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Sun, 25 Oct 2026 03:54:12 -0400 Subject: [PATCH 4/9] Delete tag chips with a middle click and place the editor by clicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Like a browser tab, a middle click anywhere on a chip deletes it. The "×" deletes on a left or middle click and ignores other buttons, which on X11 include horizontal scrolling. Both act on release, and only if the pointer is still over what was pressed; a click event is dropped if the pointer moves while the button is down, which a trackpoint nearly always does. A left click in a gap between chips, or past the last one, puts the editor in that gap, so tags can be inserted with the mouse. Clicks on a chip itself don't move the editor, and neither does any click while text is being edited or the field is disabled. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../ui/components/tag/MirthTagField.java | 19 +++++++++++--- .../client/ui/components/tag/TagChip.java | 25 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) 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 f9f81a7b4..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 @@ -259,9 +259,9 @@ public void mousePressed(MouseEvent evt) { return; } - // A click outside the chips goes back to adding tags at the end. - if (!editing && !(evt.getComponent() instanceof TagChip)) { - moveEditor(tokens.size()); + // A click between chips, or past the last one, puts the editor in the nearest gap. + if (!editing && isEnabled() && !(evt.getComponent() instanceof TagChip)) { + moveEditor(editorIndexAt(SwingUtilities.convertPoint(evt.getComponent(), evt.getPoint(), chipRow).x)); } editor.requestFocusInWindow(); } @@ -644,6 +644,19 @@ 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())); 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 a382e332e..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,11 +16,15 @@ 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 { /** Also used by {@link MirthTagField}'s editor, so typed text looks like the chips. */ @@ -46,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(); } } From c7a7d15247d3c0eaeedd653f0c1279955022c7ca Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Wed, 28 Oct 2026 10:49:39 -0400 Subject: [PATCH 5/9] fixup! Navigate tag chips from the keyboard with an edit mode Clear uncommitted text and edit mode when another channel is loaded. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../mirth/connect/client/ui/components/tag/MirthTagField.java | 3 +++ 1 file changed, 3 insertions(+) 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 81648258a..f2702732f 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 @@ -826,6 +826,9 @@ public void setChannelTags(List tags) { channelTokens.add(new Token(TAG_TYPE, tag.getName(), null, null)); } + // Text left uncommitted in the previous channel doesn't belong to this one. + editor.setText(""); + setEditing(false); editorIndex = channelTokens.size(); setTokens(channelTokens, true); } From 4a08e133b18a20d304cde9d1155be6443050affc Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Wed, 28 Oct 2026 10:49:39 -0400 Subject: [PATCH 6/9] fixup! Navigate tag chips from the keyboard with an edit mode Move the editor back to the end when Save commits text while unfocused. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../connect/client/ui/components/tag/MirthTagField.java | 5 +++++ 1 file changed, 5 insertions(+) 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 f2702732f..db1a03f3b 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 @@ -804,6 +804,11 @@ public void addUpdateSearchListener(SearchFilterListener searchListener) { public void createTagOnFocusLost() { commitEditorText(); + + // Committed from outside the field, as by Save, the editor goes back to the end and its prompt. + if (!editor.isFocusOwner()) { + moveEditor(tokens.size()); + } } public void setFocus(boolean focus) { From a7cb5b2df8319876f8ff42e06bef84cbedd83252 Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Wed, 28 Oct 2026 10:49:39 -0400 Subject: [PATCH 7/9] fixup! Navigate tag chips from the keyboard with an edit mode Guard isShowingPrompt() against a null editor during construction. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../mirth/connect/client/ui/components/tag/MirthTagField.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 db1a03f3b..88f099297 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 @@ -472,7 +472,8 @@ private void setEditing(boolean editing) { /** 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(); + // The editor field is still null while the text field's own constructor runs. + return !editing && editorIndex == tokens.size() && (editor == null || !editor.isFocusOwner()); } /** Only the editor showing its prompt fills the row, so that the prompt has room. */ From cd42c1c18a8a9526a35daa5b82f9b3b9fa39f69f Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Wed, 28 Oct 2026 10:49:39 -0400 Subject: [PATCH 8/9] fixup! Navigate tag chips from the keyboard with an edit mode Move the editor with setComponentZOrder instead of rebuilding chips. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../connect/client/ui/components/tag/MirthTagField.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 88f099297..051a583b8 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 @@ -664,7 +664,10 @@ private void moveEditor(int index) { if (index != editorIndex) { closePopupWindow(); editorIndex = index; - rebuild(); + // Moved within its own container, the editor keeps focus, so the chips can stay put. + chipRow.setComponentZOrder(editor, editorIndex); + updateEditorConstraints(); + chipRow.repaint(); } scrollEditorToVisible(); From 35f20bb21a5d516e4660a274514450cf04ca116b Mon Sep 17 00:00:00 2001 From: Tony Germano Date: Wed, 28 Oct 2026 10:49:39 -0400 Subject: [PATCH 9/9] fixup! Navigate tag chips from the keyboard with an edit mode Read the outline color from UIManager on each paint, not Metal. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Tony Germano --- .../client/ui/components/tag/MirthTagField.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 051a583b8..04f74015d 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 @@ -50,7 +50,6 @@ 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; @@ -141,17 +140,13 @@ 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) { + // Looked up on each paint, so the outline follows the look and feel. + Color color = UIManager.getColor("Button.focus"); Graphics2D g2 = (Graphics2D) g.create(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2.setColor(color); + g2.setColor(color != null ? color : c.getForeground()); g2.drawRoundRect(x, y, width - 1, height - 1, TagChip.ARC, TagChip.ARC); g2.dispose(); } @@ -321,7 +316,7 @@ public Dimension getPreferredSize() { }; idleBorder = BorderFactory.createEmptyBorder(1, 3, 1, 3); // The same insets as the idle border, so the outline doesn't shift the chips after it. - editingBorder = BorderFactory.createCompoundBorder(new OutlineBorder(MetalLookAndFeel.getFocusColor()), BorderFactory.createEmptyBorder(0, 2, 0, 2)); + editingBorder = BorderFactory.createCompoundBorder(new OutlineBorder(), BorderFactory.createEmptyBorder(0, 2, 0, 2)); editor.setBorder(idleBorder); editor.setFont(TagChip.FONT); editor.setToolTipText(getToolTipText());