From 1c9431f9824b35ba5cdcd784d605e4e73dfe588d Mon Sep 17 00:00:00 2001 From: ShadelessFox Date: Fri, 3 May 2024 17:31:48 +0200 Subject: [PATCH] Application: Support clickable tags in trees --- .../ui/controls/ColoredComponent.java | 65 +++++++++++++++++-- .../ui/controls/ColoredListCellRenderer.java | 6 +- .../ui/controls/ColoredTreeCellRenderer.java | 45 ++++++++++++- .../ui/controls/TagMouseListener.java | 36 ++++++++++ .../platform/ui/controls/TextAttributes.java | 2 + .../com/shade/platform/ui/util/UIUtils.java | 11 ++-- 6 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TagMouseListener.java diff --git a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredComponent.java b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredComponent.java index 59d89bbd1..9d2786861 100644 --- a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredComponent.java +++ b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredComponent.java @@ -7,6 +7,7 @@ import javax.swing.*; import java.awt.*; +import java.awt.event.MouseEvent; import java.awt.geom.Rectangle2D; import java.util.ArrayList; import java.util.List; @@ -29,18 +30,26 @@ public ColoredComponent() { updateUI(); } + public void append(@NotNull String fragment) { + append(fragment, TextAttributes.REGULAR_ATTRIBUTES); + } + public void append(@NotNull String fragment, @NotNull TextAttributes attributes) { + append(fragment, attributes, null); + } + + public void append(@NotNull String fragment, @NotNull TextAttributes attributes, @Nullable Tag tag) { synchronized (fragments) { ColoredFragment lastFragment; - if (fragments.isEmpty()) { + if (fragments.isEmpty() || tag != null) { lastFragment = null; } else { lastFragment = fragments.get(fragments.size() - 1); } if (lastFragment != null && lastFragment.attributes().equals(attributes)) { - fragments.set(fragments.size() - 1, new ColoredFragment(lastFragment.text() + fragment, attributes)); + fragments.set(fragments.size() - 1, new ColoredFragment(lastFragment.text() + fragment, attributes, null)); } else { - fragments.add(new ColoredFragment(fragment, attributes)); + fragments.add(new ColoredFragment(fragment, attributes, tag)); } } @@ -57,6 +66,46 @@ public void clear() { repaint(); } + @Nullable + public Tag getFragmentTag(int index) { + return fragments.get(index).tag; + } + + @Nullable + public Tag getFragmentTagAt(int x) { + final int index = getFragmentAt(x); + return index < 0 ? null : getFragmentTag(index); + } + + public int getFragmentAt(int x) { + float offset = 0; + boolean wasSmaller = false; + + if (leadingIcon != null) { + offset += padding.left + leadingIcon.getIconWidth() + iconTextGap; + } + + final Font baseFont = getBaseFont(); + + synchronized (fragments) { + for (int i = 0; i < fragments.size(); i++) { + final ColoredFragment fragment = fragments.get(i); + final TextAttributes attributes = fragment.attributes(); + final Font font = deriveFontFromAttributes(baseFont, attributes, wasSmaller); + final float fragmentWidth = computeFragmentWidth(fragment, font); + + if (x >= offset && x <= offset + fragmentWidth) { + return i; + } + + offset += fragmentWidth; + wasSmaller = attributes.isSmaller(); + } + } + + return -1; + } + @Nullable public Icon getLeadingIcon() { return leadingIcon; @@ -83,7 +132,6 @@ public void setTrailingIcon(@Nullable Icon trailingIcon) { repaint(); } - public int getIconTextGap() { return iconTextGap; } @@ -245,8 +293,8 @@ private void doPaintTextBackground(@NotNull Graphics2D g, int offset) { } } - @SuppressWarnings("MagicConstant") @NotNull + @SuppressWarnings("MagicConstant") private Font deriveFontFromAttributes(@NotNull Font font, @NotNull TextAttributes attributes, boolean wasSmaller) { final int style = attributes.fontStyle(); @@ -341,5 +389,10 @@ private Font getBaseFont() { return font; } - private record ColoredFragment(@NotNull String text, @NotNull TextAttributes attributes) {} + @FunctionalInterface + public interface Tag { + void run(@NotNull MouseEvent e); + } + + private record ColoredFragment(@NotNull String text, @NotNull TextAttributes attributes, @Nullable Tag tag) {} } diff --git a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredListCellRenderer.java b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredListCellRenderer.java index cf3d38a2c..188fa8391 100644 --- a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredListCellRenderer.java +++ b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredListCellRenderer.java @@ -46,11 +46,11 @@ public Component getListCellRendererComponent(JList list, T value, } @Override - public void append(@NotNull String fragment, @NotNull TextAttributes attributes) { + public void append(@NotNull String fragment, @NotNull TextAttributes attributes, @Nullable Tag tag) { if (selected) { - super.append(fragment, new TextAttributes(getForeground(), attributes.styles())); + super.append(fragment, new TextAttributes(getForeground(), attributes.styles()), tag); } else { - super.append(fragment, attributes); + super.append(fragment, attributes, tag); } } diff --git a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredTreeCellRenderer.java b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredTreeCellRenderer.java index 88b6fb890..095e35108 100644 --- a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredTreeCellRenderer.java +++ b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/ColoredTreeCellRenderer.java @@ -8,7 +8,10 @@ import javax.swing.*; import javax.swing.tree.TreeCellRenderer; +import javax.swing.tree.TreePath; import java.awt.*; +import java.awt.event.MouseEvent; +import java.lang.ref.WeakReference; import java.util.Objects; public abstract class ColoredTreeCellRenderer extends ColoredComponent implements TreeCellRenderer { @@ -25,6 +28,12 @@ public ColoredTreeCellRenderer() { setPadding(new Insets(2, 2, 2, 2)); } + @NotNull + public ColoredTreeCellRenderer withTags(@NotNull JTree tree) { + new TreeTagMouseListener().installOn(tree); + return this; + } + @Override public void updateUI() { super.updateUI(); @@ -64,11 +73,11 @@ public Component getTreeCellRendererComponent(JTree tree, Object value, boolean } @Override - public void append(@NotNull String fragment, @NotNull TextAttributes attributes) { + public void append(@NotNull String fragment, @NotNull TextAttributes attributes, @Nullable Tag tag) { if (selected && isFocused()) { - super.append(fragment, new TextAttributes(getForeground(), attributes.styles())); + super.append(fragment, new TextAttributes(getForeground(), attributes.styles()), tag); } else { - super.append(fragment, attributes); + super.append(fragment, attributes, tag); } } @@ -91,4 +100,34 @@ protected final boolean isFocused() { } protected abstract void customizeCellRenderer(@NotNull JTree tree, @NotNull T value, boolean selected, boolean expanded, boolean focused, boolean leaf, int row); + + private class TreeTagMouseListener extends TagMouseListener { + private WeakReference lastHitNode; + + @Nullable + @Override + protected Tag getTagAt(@NotNull MouseEvent e) { + final JTree tree = (JTree) e.getSource(); + final TreePath path = tree.getPathForLocation(e.getX(), e.getY()); + + if (path != null) { + final Object node = path.getLastPathComponent(); + + if (lastHitNode == null || lastHitNode.get() != node || e.getButton() != MouseEvent.NOBUTTON) { + lastHitNode = new WeakReference<>(node); + getTreeCellRendererComponent(tree, node, false, false, tree.getModel().isLeaf(node), tree.getRowForPath(path), false); + } + + return getFragmentTagAt(getRendererRelativeX(e, tree, path)); + } + + return null; + } + + private int getRendererRelativeX(@NotNull MouseEvent e, @NotNull JTree tree, @NotNull TreePath path) { + final Rectangle bounds = tree.getPathBounds(path); + assert bounds != null; + return e.getX() - bounds.x; + } + } } diff --git a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TagMouseListener.java b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TagMouseListener.java new file mode 100644 index 000000000..9d53c1b51 --- /dev/null +++ b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TagMouseListener.java @@ -0,0 +1,36 @@ +package com.shade.platform.ui.controls; + +import com.shade.platform.ui.util.UIUtils; +import com.shade.util.NotNull; +import com.shade.util.Nullable; + +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +public abstract class TagMouseListener extends MouseAdapter { + public void installOn(@NotNull Component component) { + component.addMouseListener(this); + component.addMouseMotionListener(this); + } + + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + final T tag = getTagAt(e); + if (tag != null) { + tag.run(e); + } + } + } + + @Override + public void mouseMoved(MouseEvent e) { + final Component component = (Component) e.getSource(); + final T tag = getTagAt(e); + UIUtils.setCursor(component, tag != null ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null); + } + + @Nullable + protected abstract T getTagAt(@NotNull MouseEvent e); +} diff --git a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TextAttributes.java b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TextAttributes.java index b155ea1f9..84adeeb76 100644 --- a/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TextAttributes.java +++ b/modules/platform-ui/src/main/java/com/shade/platform/ui/controls/TextAttributes.java @@ -19,6 +19,8 @@ public record TextAttributes(@Nullable Color foreground, @Nullable Color backgro public static final TextAttributes GRAYED_ITALIC_ATTRIBUTES = GRAYED_ATTRIBUTES.italic(); public static final TextAttributes GRAYED_SMALL_ATTRIBUTES = GRAYED_ATTRIBUTES.smaller(); + public static final TextAttributes LINK_ATTRIBUTES = new TextAttributes(UIColor.named("Component.linkColor"), EnumSet.of(Style.PLAIN)); + public TextAttributes(@Nullable Color foregroundColor, @Nullable Color backgroundColor, @NotNull Style style, @NotNull Style... rest) { this(foregroundColor, backgroundColor, EnumSet.of(style, rest)); } diff --git a/modules/platform-ui/src/main/java/com/shade/platform/ui/util/UIUtils.java b/modules/platform-ui/src/main/java/com/shade/platform/ui/util/UIUtils.java index 14c850545..c716736d9 100644 --- a/modules/platform-ui/src/main/java/com/shade/platform/ui/util/UIUtils.java +++ b/modules/platform-ui/src/main/java/com/shade/platform/ui/util/UIUtils.java @@ -26,11 +26,6 @@ public final class UIUtils { private UIUtils() { } - @NotNull - public static Color getInactiveTextColor() { - return UIManager.getColor("Label.disabledForeground"); - } - @NotNull public static Font getDefaultFont() { Font font = UIManager.getFont("defaultFont"); @@ -480,6 +475,12 @@ public static String getTextForAccelerator(@NotNull KeyStroke accelerator) { return sb.toString(); } + public static void setCursor(@NotNull Component component, @Nullable Cursor cursor) { + if (!component.isCursorSet() || component.getCursor() != cursor) { + component.setCursor(cursor); + } + } + public interface SelectionProvider { @Nullable U getSelection(@NotNull T component, @Nullable MouseEvent event);