diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index b274786..c1d79f9 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -3,8 +3,11 @@ package com.ts3client.ui; import com.formdev.flatlaf.util.ScaledEmptyBorder; import com.formdev.flatlaf.util.UIScale; import com.ts3client.text.BBCode; +import com.ts3client.text.HtmlText; +import com.ts3client.text.TextQuery; import com.ts3client.text.TsLink; +import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.Icon; import javax.swing.JEditorPane; @@ -13,6 +16,7 @@ import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTabbedPane; import javax.swing.JTextField; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import javax.swing.event.HyperlinkEvent; import javax.swing.text.BadLocationException; @@ -24,15 +28,18 @@ import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.Insets; import java.awt.Point; +import java.awt.event.ActionEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseWheelEvent; import java.io.IOException; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * Chat area with one tab per conversation (server, current channel and one per @@ -76,6 +83,20 @@ public final class ChatPanel extends JPanel { private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab); + private final ChatSearch search = new ChatSearch(new ChatSearch.Host() { + @Override + public JEditorPane selectedLog() { + Tab t = selectedTab(); + return t == null ? null : t.log; + } + + @Override + public void showMatching(TextQuery filter) { + Tab t = selectedTab(); + if (t != null) t.showMatching(filter); + } + }); + private SendHandler sendHandler; private LinkHandler linkHandler; @@ -93,10 +114,24 @@ public final class ChatPanel extends JPanel { tabs.addChangeListener(e -> { Tab t = selectedTab(); if (t != null) t.setUnread(false); + // A search filters only the tab it is in; the one left behind shows everything again. + for (Tab other : allTabs()) { + if (other != t) other.showMatching(null); + } + search.retarget(); }); tabs.addMouseWheelListener(this::onWheel); dragReorder.attach(tabs); add(tabs, BorderLayout.CENTER); + add(search.bar(), BorderLayout.NORTH); + String find = "ts3-chat-find"; + getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke("control F"), find); + getActionMap().put(find, new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + openFind(); + } + }); JPanel bottom = new JPanel(new BorderLayout(UIScale.scale(4), 0)); bottom.setBorder(new ScaledEmptyBorder(4, 0, 0, 0)); @@ -116,6 +151,11 @@ public final class ChatPanel extends JPanel { this.linkHandler = h; } + /** Opens the find bar on the selected conversation. */ + public void openFind() { + edt(search::open); + } + /** Appends text to the input line, e.g. a client link dropped onto the chat. */ public void appendToInput(String text) { edt(() -> { @@ -211,7 +251,7 @@ public final class ChatPanel extends JPanel { String text = input.getText().trim(); if (text.isEmpty() || sendHandler == null) return; Tab tab = selectedTab(); - if (tab == null) return; + if (tab == null || tab.target == null) return; sendHandler.send(tab.target, tab.clientId, text); input.setText(""); } @@ -332,9 +372,21 @@ public final class ChatPanel extends JPanel { for (Tab t : privateTabs.values()) { if (t.scroll == c) return t; } + for (Tab t : noteTabs.values()) { + if (t.scroll == c) return t; + } return null; } + private List allTabs() { + List all = new ArrayList<>(); + all.add(serverTab); + all.add(channelTab); + all.addAll(privateTabs.values()); + all.addAll(noteTabs.values()); + return all; + } + private void edt(Runnable r) { if (SwingUtilities.isEventDispatchThread()) r.run(); else SwingUtilities.invokeLater(r); @@ -355,6 +407,10 @@ public final class ChatPanel extends JPanel { final Runnable onClose; private final Icon icon; private JLabel titleLabel; + /** Everything this conversation has shown, whether or not a search currently hides it. */ + private final List entries = new ArrayList<>(); + /** Only messages matching this are on screen; {@code null} shows all. */ + private TextQuery filter; Tab(Target target, int clientId, String title, Icon icon) { this(target, clientId, "", title, icon, null, null); @@ -474,18 +530,37 @@ public final class ChatPanel extends JPanel { } void setHistory(List htmlEntries) { - StringBuilder html = new StringBuilder(); + entries.clear(); if (htmlEntries != null) { - for (String entry : htmlEntries) html.append(normalizeTs3LogEntry(entry)); + for (String entry : htmlEntries) entries.add(normalizeTs3LogEntry(entry)); } - log.setText("
" + html + "
"); - log.setCaretPosition(log.getDocument().getLength()); + render(); } /** Replaces the whole log with fixed content; used by static-content tabs. */ void setStaticContent(String html) { + entries.clear(); + entries.add(html); + render(); + } + + void showMatching(TextQuery filter) { + if (Objects.equals(this.filter, filter)) return; + this.filter = filter; + render(); + } + + private boolean shows(String entry) { + return filter == null || filter.matches(HtmlText.plain(entry)); + } + + private void render() { + StringBuilder html = new StringBuilder(); + for (String entry : entries) { + if (shows(entry)) html.append(entry); + } log.setText("
" + html + "
"); - log.setCaretPosition(0); + log.setCaretPosition(noteKey != null ? 0 : log.getDocument().getLength()); } void appendLine(String html) { @@ -493,11 +568,16 @@ public final class ChatPanel extends JPanel { } void appendRawHtml(String html) { - try { - HTMLDocument doc = (HTMLDocument) log.getDocument(); - doc.insertBeforeEnd(doc.getElement("chatlog"), normalizeTs3LogEntry(html)); - log.setCaretPosition(doc.getLength()); - } catch (BadLocationException | IOException ignored) { + String entry = normalizeTs3LogEntry(html); + entries.add(entry); + if (shows(entry)) { + try { + HTMLDocument doc = (HTMLDocument) log.getDocument(); + doc.insertBeforeEnd(doc.getElement("chatlog"), entry); + // Following new lines would pull the view away from the match being read. + if (!search.isOpen()) log.setCaretPosition(doc.getLength()); + } catch (BadLocationException | IOException ignored) { + } } if (tabs.getSelectedComponent() != scroll) setUnread(true); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatSearch.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatSearch.java new file mode 100644 index 0000000..26a5b52 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatSearch.java @@ -0,0 +1,264 @@ +package com.ts3client.ui; + +import com.ts3client.text.TextQuery; + +import javax.swing.JEditorPane; +import javax.swing.JToggleButton; +import javax.swing.JViewport; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultHighlighter; +import javax.swing.text.Document; +import javax.swing.text.Highlighter; +import java.awt.Color; +import java.awt.Point; +import java.awt.Rectangle; +import java.beans.PropertyChangeListener; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; + +/** + * Find in the chat: highlights the query's matches in the selected conversation and + * steps through them, newest first. Optionally shows only the messages that match. + */ +final class ChatSearch implements FindBar.Listener { + + /** The conversation being searched: whichever chat tab is selected. */ + interface Host { + /** The selected tab's log, or {@code null} when there is none. */ + JEditorPane selectedLog(); + + /** Shows only the selected tab's messages matching {@code filter}; {@code null} shows all again. */ + void showMatching(TextQuery filter); + } + + /** More highlights than this slow painting down, and nobody steps through them one by one. */ + private static final int MAX_HIGHLIGHTS = 5000; + + private final Host host; + private final FindBar bar = new FindBar(this); + private final JToggleButton onlyMatching = bar.addFilterToggle("Show only matching messages"); + + private final Highlighter.HighlightPainter matchPainter = new ThemedPainter(false); + private final Highlighter.HighlightPainter currentPainter = new ThemedPainter(true); + private final List highlightTags = new ArrayList<>(); + /** Start and end offsets of each match in the log document, oldest first. */ + private final List matches = new ArrayList<>(); + private int current = -1; + /** The next re-highlight follows a new query, so it starts again from the newest match. */ + private boolean freshQuery; + private boolean rehighlightQueued; + + private JEditorPane log; + private final DocumentListener docListener = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + queueRehighlight(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + queueRehighlight(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + } + }; + /** A theme change re-installs the log's document. */ + private final PropertyChangeListener docSwap = e -> { + if (e.getOldValue() instanceof Document old) old.removeDocumentListener(docListener); + if (e.getNewValue() instanceof Document now) now.addDocumentListener(docListener); + queueRehighlight(); + }; + + ChatSearch(Host host) { + this.host = host; + } + + FindBar bar() { + return bar; + } + + boolean isOpen() { + return bar.isOpen(); + } + + void open() { + retarget(); + bar.open(); + } + + /** The selected tab changed: carry the search over to it. */ + void retarget() { + JEditorPane now = host.selectedLog(); + if (now != log) { + detach(); + log = now; + if (log != null) { + log.getDocument().addDocumentListener(docListener); + log.addPropertyChangeListener("document", docSwap); + } + } + if (bar.isOpen()) queryChanged(bar.query()); + } + + @Override + public void queryChanged(TextQuery query) { + freshQuery = true; + boolean usable = !query.isEmpty() && !query.isInvalid(); + host.showMatching(onlyMatching.isSelected() && usable ? query : null); + rehighlight(); + } + + @Override + public void next() { + step(1); + } + + @Override + public void previous() { + step(-1); + } + + @Override + public void closed() { + clearHighlights(); + matches.clear(); + host.showMatching(null); + if (log != null) { + log.setCaretPosition(log.getDocument().getLength()); + log.requestFocusInWindow(); + } + } + + private void step(int delta) { + if (matches.isEmpty()) return; + current = Math.floorMod(current + delta, matches.size()); + paintHighlights(); + scrollToCurrent(); + updateStatus(); + } + + private void queueRehighlight() { + if (!bar.isOpen() || rehighlightQueued) return; + rehighlightQueued = true; + SwingUtilities.invokeLater(() -> { + rehighlightQueued = false; + if (bar.isOpen()) rehighlight(); + }); + } + + /** Finds the matches in the log as it now reads, keeping the current one where it can. */ + private void rehighlight() { + int keepStart = !freshQuery && current >= 0 && current < matches.size() ? matches.get(current)[0] : -1; + matches.clear(); + current = -1; + TextQuery query = bar.query(); + Matcher m = null; + if (log != null) { + try { + Document doc = log.getDocument(); + m = query.matcher(doc.getText(0, doc.getLength())); + } catch (BadLocationException ignored) { + } + } + if (m != null) { + while (m.find()) { + if (m.end() > m.start()) matches.add(new int[]{m.start(), m.end()}); + } + } + if (matches.size() > MAX_HIGHLIGHTS) matches.subList(0, matches.size() - MAX_HIGHLIGHTS).clear(); + + if (!matches.isEmpty()) { + current = matches.size() - 1; + if (keepStart >= 0) { + for (int i = 0; i < matches.size(); i++) { + if (matches.get(i)[0] >= keepStart) { + current = i; + break; + } + } + } + } + paintHighlights(); + if (freshQuery) scrollToCurrent(); + freshQuery = false; + updateStatus(); + } + + private void updateStatus() { + TextQuery query = bar.query(); + if (query.isInvalid()) return; + bar.setNavigable(matches.size() > 1); + if (query.isEmpty()) bar.clearCount(); + else bar.setCount(current + 1, matches.size(), matches.size() == MAX_HIGHLIGHTS); + } + + private void paintHighlights() { + clearHighlights(); + if (log == null) return; + Highlighter h = log.getHighlighter(); + for (int i = 0; i < matches.size(); i++) { + int[] r = matches.get(i); + try { + highlightTags.add(h.addHighlight(r[0], r[1], i == current ? currentPainter : matchPainter)); + } catch (BadLocationException ignored) { + } + } + } + + private void clearHighlights() { + if (log != null) { + Highlighter h = log.getHighlighter(); + for (Object tag : highlightTags) h.removeHighlight(tag); + } + highlightTags.clear(); + } + + /** Brings the current match to the middle of the view, once the log has been laid out. */ + private void scrollToCurrent() { + if (current < 0 || log == null) return; + int start = matches.get(current)[0]; + JEditorPane pane = log; + SwingUtilities.invokeLater(() -> { + if (pane != log || !(pane.getParent() instanceof JViewport viewport)) return; + try { + Rectangle r = pane.modelToView2D(start).getBounds(); + Rectangle view = viewport.getViewRect(); + if (view.contains(r)) return; + int maxY = Math.max(0, pane.getHeight() - view.height); + int y = Math.max(0, Math.min(maxY, r.y - (view.height - r.height) / 2)); + viewport.setViewPosition(new Point(view.x, y)); + } catch (BadLocationException ignored) { + } + }); + } + + private void detach() { + clearHighlights(); + matches.clear(); + current = -1; + if (log == null) return; + log.getDocument().removeDocumentListener(docListener); + log.removePropertyChangeListener("document", docSwap); + } + + /** Reads the theme when painting, so highlights follow a light/dark switch. */ + private static final class ThemedPainter extends DefaultHighlighter.DefaultHighlightPainter { + private final boolean current; + + ThemedPainter(boolean current) { + super(null); + this.current = current; + } + + @Override + public Color getColor() { + return current ? Theme.searchCurrent() : Theme.searchMatch(); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/FindBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/FindBar.java new file mode 100644 index 0000000..7885b62 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/FindBar.java @@ -0,0 +1,379 @@ +package com.ts3client.ui; + +import com.formdev.flatlaf.FlatClientProperties; +import com.formdev.flatlaf.icons.FlatAbstractIcon; +import com.formdev.flatlaf.icons.FlatSearchIcon; +import com.formdev.flatlaf.util.ScaledEmptyBorder; +import com.formdev.flatlaf.util.UIScale; +import com.ts3client.text.TextQuery; + +import javax.swing.AbstractAction; +import javax.swing.AbstractButton; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.JToggleButton; +import javax.swing.JToolBar; +import javax.swing.KeyStroke; +import javax.swing.Timer; +import javax.swing.UIManager; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.Graphics2D; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.awt.geom.Path2D; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BooleanSupplier; + +/** + * A single-row find bar for a view: the search field with its match-case, whole-word + * and regex toggles, the match count, previous/next and close. A view puts its own + * controls (what to search, whether to filter) in front of the field via + * {@link #addLeading} and {@link #addFilterToggle}. + * + *

Typing is debounced; a toggle takes effect at once. Enter / F3 go to the next + * match, Shift+Enter / Shift+F3 to the previous one. Escape first resets the bar (text + * and every option) and closes it only once there is nothing left to reset. + */ +final class FindBar extends JPanel { + + interface Listener { + void queryChanged(TextQuery query); + + void next(); + + void previous(); + + void closed(); + } + + private static final int TYPING_DELAY_MS = 150; + + private final Listener listener; + private final JTextField field = new JTextField(); + private final JToggleButton matchCase = fieldToggle("Aa", "Match case"); + private final JToggleButton wholeWord = fieldToggle("ab", "Whole words"); + private final JToggleButton regex = fieldToggle(".*", "Regular expression"); + /** Wide enough for "00 / 00" whatever it shows, so the field beside it keeps its width. */ + private final JLabel status = new JLabel("", JLabel.TRAILING) { + @Override + public Dimension getPreferredSize() { + Dimension d = super.getPreferredSize(); + Insets in = getInsets(); + int reserved = getFontMetrics(getFont()).stringWidth("00 / 00") + in.left + in.right; + return new Dimension(Math.max(d.width, reserved), d.height); + } + + @Override + public Dimension getMinimumSize() { + return getPreferredSize(); + } + + @Override + public Dimension getMaximumSize() { + return getPreferredSize(); + } + }; + private final JButton previous = navButton(new Glyph(Glyph.UP), "Previous match (Shift+Enter)"); + private final JButton next = navButton(new Glyph(Glyph.DOWN), "Next match (Enter)"); + private final JPanel leading = new JPanel(); + private final Timer typing = new Timer(TYPING_DELAY_MS, e -> fireQueryChanged()); + /** Options beyond the built-in toggles: whether each is at its default, and how to put it back. */ + private final List optionDefaults = new ArrayList<>(); + private final List optionResets = new ArrayList<>(); + + FindBar(Listener listener) { + super(new BorderLayout(UIScale.scale(2), 0)); + this.listener = listener; + setBorder(new ScaledEmptyBorder(4, 4, 4, 4)); + setVisible(false); + + typing.setRepeats(false); + field.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, "Find"); + field.putClientProperty(FlatClientProperties.TEXT_FIELD_LEADING_ICON, new FlatSearchIcon()); + field.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); + JToolBar toggles = new JToolBar(); + toggles.setFloatable(false); + toggles.setBorder(null); + toggles.setOpaque(false); + for (JToggleButton b : new JToggleButton[]{matchCase, wholeWord, regex}) { + b.addActionListener(e -> fireQueryChanged()); + toggles.add(b); + } + field.putClientProperty(FlatClientProperties.TEXT_FIELD_TRAILING_COMPONENT, toggles); + field.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + typing.restart(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + typing.restart(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + } + }); + + status.setFont(Theme.uiSmall()); + status.setForeground(Theme.chatSystem()); + JButton close = navButton(new Glyph(Glyph.CLOSE), "Close (Escape)"); + previous.addActionListener(e -> goPrevious()); + next.addActionListener(e -> goNext()); + close.addActionListener(e -> close()); + + JPanel controls = new JPanel(); + controls.setLayout(new BoxLayout(controls, BoxLayout.LINE_AXIS)); + controls.setBorder(new ScaledEmptyBorder(0, 6, 0, 0)); + controls.add(status); + controls.add(Box.createHorizontalStrut(UIScale.scale(4))); + controls.add(previous); + controls.add(next); + controls.add(close); + + leading.setLayout(new BoxLayout(leading, BoxLayout.LINE_AXIS)); + leading.setVisible(false); + add(leading, BorderLayout.WEST); + add(field, BorderLayout.CENTER); + add(controls, BorderLayout.EAST); + + bind(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), this::goNext); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), this::goNext); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, KeyEvent.SHIFT_DOWN_MASK), this::goPrevious); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_F3, KeyEvent.SHIFT_DOWN_MASK), this::goPrevious); + bind(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), this::resetOrClose); + } + + /** Adds a view-specific control in front of the field; one that changes the query should call {@link #fireQueryChanged()}. */ + void addLeading(JComponent c) { + if (c instanceof AbstractButton b) { + b.setFocusable(false); + b.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON); + } + leading.add(c); + leading.setVisible(true); + } + + /** Adds the "show only matches" toggle: a funnel that turns from grey to the accent colour when on. */ + JToggleButton addFilterToggle(String tooltip) { + JToggleButton filter = new JToggleButton(new Glyph(Glyph.FILTER)); + filter.setToolTipText(tooltip); + filter.addActionListener(e -> fireQueryChanged()); + addLeading(filter); + addResettable(() -> !filter.isSelected(), () -> filter.setSelected(false)); + return filter; + } + + /** + * Lets Escape reset a view-specific option. {@code reset} only puts the option back; + * the bar re-runs the query itself afterwards. + */ + void addResettable(BooleanSupplier isDefault, Runnable reset) { + optionDefaults.add(isDefault); + optionResets.add(reset); + } + + private boolean isDefault() { + if (!field.getText().isEmpty() || matchCase.isSelected() || wholeWord.isSelected() || regex.isSelected()) { + return false; + } + for (BooleanSupplier d : optionDefaults) { + if (!d.getAsBoolean()) return false; + } + return true; + } + + private void resetOrClose() { + if (isDefault()) { + close(); + return; + } + field.setText(""); + matchCase.setSelected(false); + wholeWord.setSelected(false); + regex.setSelected(false); + for (Runnable r : optionResets) r.run(); + fireQueryChanged(); + } + + void setPlaceholder(String text) { + field.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, text); + } + + /** Shows the bar (if hidden) and puts the cursor in the field with its text selected. */ + void open() { + if (!isVisible()) { + setVisible(true); + revalidate(); + } + field.requestFocusInWindow(); + field.selectAll(); + if (!field.getText().isEmpty()) fireQueryChanged(); + } + + void close() { + if (!isVisible()) return; + typing.stop(); + setVisible(false); + revalidate(); + listener.closed(); + } + + boolean isOpen() { + return isVisible(); + } + + TextQuery query() { + return isVisible() + ? new TextQuery(field.getText(), matchCase.isSelected(), wholeWord.isSelected(), regex.isSelected()) + : TextQuery.EMPTY; + } + + /** + * Shows where the view is among the matches, as "3 / 17". It keeps that shape even + * with no match or none selected (" / 0"), so the field does not change width as + * the count comes and goes while typing. + * + * @param position the 1-based match the view is on, or 0 for none + * @param more whether there are more matches than {@code total} + */ + void setCount(int position, int total, boolean more) { + showStatus((position > 0 ? String.valueOf(position) : " ") + " / " + total + (more ? "+" : ""), total == 0); + } + + /** No query, nothing to count. */ + void clearCount() { + showStatus("", false); + } + + private void showStatus(String text, boolean noMatch) { + status.setText(text); + status.setToolTipText(null); + field.putClientProperty(FlatClientProperties.OUTLINE, noMatch ? FlatClientProperties.OUTLINE_ERROR : null); + } + + void setNavigable(boolean navigable) { + previous.setEnabled(navigable); + next.setEnabled(navigable); + } + + /** Re-runs the query now, e.g. after an option changed. */ + void fireQueryChanged() { + typing.stop(); + TextQuery q = query(); + if (q.isInvalid()) { + setCount(0, 0, false); + status.setToolTipText(q.error()); + setNavigable(false); + } + listener.queryChanged(q); + } + + /** Stepping through matches acts on what is typed, even if the pause that would run it is still pending. */ + private void goNext() { + if (typing.isRunning()) fireQueryChanged(); + listener.next(); + } + + private void goPrevious() { + if (typing.isRunning()) fireQueryChanged(); + listener.previous(); + } + + private void bind(KeyStroke key, Runnable action) { + String name = "ts3-find-" + key; + getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(key, name); + getActionMap().put(name, new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + action.run(); + } + }); + } + + private static JToggleButton fieldToggle(String text, String tooltip) { + JToggleButton b = new JToggleButton(text); + b.setToolTipText(tooltip); + b.setFont(Theme.uiSmall()); + b.setFocusable(false); + b.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON); + b.setMargin(new Insets(1, 3, 1, 3)); + return b; + } + + private static JButton navButton(Glyph icon, String tooltip) { + JButton b = new JButton(icon); + b.setToolTipText(tooltip); + b.setFocusable(false); + b.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON); + return b; + } + + /** The bar's line icons, drawn in the button's own text colour so they follow the theme. */ + private static final class Glyph extends FlatAbstractIcon { + static final int UP = 0; + static final int DOWN = 1; + static final int CLOSE = 2; + static final int FILTER = 3; + + private final int shape; + + Glyph(int shape) { + super(shape == FILTER ? 14 : 12, shape == FILTER ? 14 : 12, null); + this.shape = shape; + } + + @Override + protected void paintIcon(Component c, Graphics2D g) { + if (!c.isEnabled()) g.setColor(UIManager.getColor("Label.disabledForeground")); + else if (shape == FILTER) g.setColor(((AbstractButton) c).isSelected() ? Theme.accent() : Theme.chatSystem()); + else g.setColor(c.getForeground()); + if (shape == FILTER) { + Path2D.Float funnel = new Path2D.Float(); + funnel.moveTo(1.5f, 2.5f); + funnel.lineTo(12.5f, 2.5f); + funnel.lineTo(8.25f, 7.5f); + funnel.lineTo(8.25f, 12f); + funnel.lineTo(5.75f, 10.75f); + funnel.lineTo(5.75f, 7.5f); + funnel.closePath(); + g.fill(funnel); + return; + } + g.setStroke(new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + Path2D.Float p = new Path2D.Float(); + switch (shape) { + case UP -> { + p.moveTo(2.5f, 8f); + p.lineTo(6f, 4.5f); + p.lineTo(9.5f, 8f); + } + case DOWN -> { + p.moveTo(2.5f, 4.5f); + p.lineTo(6f, 8f); + p.lineTo(9.5f, 4.5f); + } + default -> { + p.moveTo(3f, 3f); + p.lineTo(9f, 9f); + p.moveTo(9f, 3f); + p.lineTo(3f, 9f); + } + } + g.draw(p); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 787e95d..b26350b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -15,8 +15,10 @@ import com.ts3client.net.ChannelNode; import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPlayer; +import javax.swing.AbstractAction; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; +import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JMenuItem; @@ -24,10 +26,12 @@ import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JTextField; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.GridLayout; +import java.awt.event.ActionEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.util.ArrayList; @@ -115,6 +119,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { // Switching the icon pack in the options dialog re-decorates the whole window. IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons)); add(tabPane, BorderLayout.CENTER); + // The tree and the chat each claim Ctrl+F for themselves while they have the focus; + // from anywhere else it searches the chat. + getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + .put(KeyStroke.getKeyStroke("control F"), "ts3-find"); + getRootPane().getActionMap().put("ts3-find", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + if (selected != null) selected.chat().openFind(); + } + }); statusBar = new StatusBar(); statusBar.setVisible(settings.showStatusBar); add(statusBar, BorderLayout.SOUTH); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java index 05a58f9..58f6d2c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java @@ -98,6 +98,16 @@ public final class Theme { return ui("TextPane.foreground", 0x202020, 0xE0E0E0); } + /** Behind every match of a find bar's query. */ + public static Color searchMatch() { + return pick(0xFFEBA3, 0x5C4E1A); + } + + /** Behind the match a find bar is on. */ + public static Color searchCurrent() { + return pick(0xFFB84D, 0x9C6A12); + } + // ---- status colours ---- public static Color channelText() {