Find in chat with Ctrl+F
A find bar above the conversations highlights matches and steps through them, or filters the selected tab down to matching messages. New lines no longer pull the view away while it is open. Ctrl+F opens it from anywhere the tree doesn't claim the shortcut. Note tabs are now found by their scroll pane like other tabs, so sending checks for a target instead of assuming every tab has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,11 @@ package com.ts3client.ui;
|
|||||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
import com.ts3client.text.BBCode;
|
import com.ts3client.text.BBCode;
|
||||||
|
import com.ts3client.text.HtmlText;
|
||||||
|
import com.ts3client.text.TextQuery;
|
||||||
import com.ts3client.text.TsLink;
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
|
import javax.swing.AbstractAction;
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.Icon;
|
import javax.swing.Icon;
|
||||||
import javax.swing.JEditorPane;
|
import javax.swing.JEditorPane;
|
||||||
@@ -13,6 +16,7 @@ import javax.swing.JPanel;
|
|||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
import javax.swing.JTabbedPane;
|
import javax.swing.JTabbedPane;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.KeyStroke;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.event.HyperlinkEvent;
|
import javax.swing.event.HyperlinkEvent;
|
||||||
import javax.swing.text.BadLocationException;
|
import javax.swing.text.BadLocationException;
|
||||||
@@ -24,15 +28,18 @@ import java.awt.Dimension;
|
|||||||
import java.awt.FlowLayout;
|
import java.awt.FlowLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
import java.awt.Point;
|
import java.awt.Point;
|
||||||
|
import java.awt.event.ActionEvent;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
import java.awt.event.MouseWheelEvent;
|
import java.awt.event.MouseWheelEvent;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat area with one tab per conversation (server, current channel and one per
|
* 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 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 SendHandler sendHandler;
|
||||||
private LinkHandler linkHandler;
|
private LinkHandler linkHandler;
|
||||||
|
|
||||||
@@ -93,10 +114,24 @@ public final class ChatPanel extends JPanel {
|
|||||||
tabs.addChangeListener(e -> {
|
tabs.addChangeListener(e -> {
|
||||||
Tab t = selectedTab();
|
Tab t = selectedTab();
|
||||||
if (t != null) t.setUnread(false);
|
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);
|
tabs.addMouseWheelListener(this::onWheel);
|
||||||
dragReorder.attach(tabs);
|
dragReorder.attach(tabs);
|
||||||
add(tabs, BorderLayout.CENTER);
|
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));
|
JPanel bottom = new JPanel(new BorderLayout(UIScale.scale(4), 0));
|
||||||
bottom.setBorder(new ScaledEmptyBorder(4, 0, 0, 0));
|
bottom.setBorder(new ScaledEmptyBorder(4, 0, 0, 0));
|
||||||
@@ -116,6 +151,11 @@ public final class ChatPanel extends JPanel {
|
|||||||
this.linkHandler = h;
|
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. */
|
/** Appends text to the input line, e.g. a client link dropped onto the chat. */
|
||||||
public void appendToInput(String text) {
|
public void appendToInput(String text) {
|
||||||
edt(() -> {
|
edt(() -> {
|
||||||
@@ -211,7 +251,7 @@ public final class ChatPanel extends JPanel {
|
|||||||
String text = input.getText().trim();
|
String text = input.getText().trim();
|
||||||
if (text.isEmpty() || sendHandler == null) return;
|
if (text.isEmpty() || sendHandler == null) return;
|
||||||
Tab tab = selectedTab();
|
Tab tab = selectedTab();
|
||||||
if (tab == null) return;
|
if (tab == null || tab.target == null) return;
|
||||||
sendHandler.send(tab.target, tab.clientId, text);
|
sendHandler.send(tab.target, tab.clientId, text);
|
||||||
input.setText("");
|
input.setText("");
|
||||||
}
|
}
|
||||||
@@ -332,9 +372,21 @@ public final class ChatPanel extends JPanel {
|
|||||||
for (Tab t : privateTabs.values()) {
|
for (Tab t : privateTabs.values()) {
|
||||||
if (t.scroll == c) return t;
|
if (t.scroll == c) return t;
|
||||||
}
|
}
|
||||||
|
for (Tab t : noteTabs.values()) {
|
||||||
|
if (t.scroll == c) return t;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Tab> allTabs() {
|
||||||
|
List<Tab> all = new ArrayList<>();
|
||||||
|
all.add(serverTab);
|
||||||
|
all.add(channelTab);
|
||||||
|
all.addAll(privateTabs.values());
|
||||||
|
all.addAll(noteTabs.values());
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
private void edt(Runnable r) {
|
private void edt(Runnable r) {
|
||||||
if (SwingUtilities.isEventDispatchThread()) r.run();
|
if (SwingUtilities.isEventDispatchThread()) r.run();
|
||||||
else SwingUtilities.invokeLater(r);
|
else SwingUtilities.invokeLater(r);
|
||||||
@@ -355,6 +407,10 @@ public final class ChatPanel extends JPanel {
|
|||||||
final Runnable onClose;
|
final Runnable onClose;
|
||||||
private final Icon icon;
|
private final Icon icon;
|
||||||
private JLabel titleLabel;
|
private JLabel titleLabel;
|
||||||
|
/** Everything this conversation has shown, whether or not a search currently hides it. */
|
||||||
|
private final List<String> 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) {
|
Tab(Target target, int clientId, String title, Icon icon) {
|
||||||
this(target, clientId, "", title, icon, null, null);
|
this(target, clientId, "", title, icon, null, null);
|
||||||
@@ -474,18 +530,37 @@ public final class ChatPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setHistory(List<String> htmlEntries) {
|
void setHistory(List<String> htmlEntries) {
|
||||||
StringBuilder html = new StringBuilder();
|
entries.clear();
|
||||||
if (htmlEntries != null) {
|
if (htmlEntries != null) {
|
||||||
for (String entry : htmlEntries) html.append(normalizeTs3LogEntry(entry));
|
for (String entry : htmlEntries) entries.add(normalizeTs3LogEntry(entry));
|
||||||
}
|
}
|
||||||
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
|
render();
|
||||||
log.setCaretPosition(log.getDocument().getLength());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the whole log with fixed content; used by static-content tabs. */
|
/** Replaces the whole log with fixed content; used by static-content tabs. */
|
||||||
void setStaticContent(String html) {
|
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><body><div id=\"chatlog\">" + html + "</div></body></html>");
|
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
|
||||||
log.setCaretPosition(0);
|
log.setCaretPosition(noteKey != null ? 0 : log.getDocument().getLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
void appendLine(String html) {
|
void appendLine(String html) {
|
||||||
@@ -493,11 +568,16 @@ public final class ChatPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void appendRawHtml(String html) {
|
void appendRawHtml(String html) {
|
||||||
try {
|
String entry = normalizeTs3LogEntry(html);
|
||||||
HTMLDocument doc = (HTMLDocument) log.getDocument();
|
entries.add(entry);
|
||||||
doc.insertBeforeEnd(doc.getElement("chatlog"), normalizeTs3LogEntry(html));
|
if (shows(entry)) {
|
||||||
log.setCaretPosition(doc.getLength());
|
try {
|
||||||
} catch (BadLocationException | IOException ignored) {
|
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);
|
if (tabs.getSelectedComponent() != scroll) setUnread(true);
|
||||||
}
|
}
|
||||||
|
|||||||
264
ts3-client/swing/src/main/java/com/ts3client/ui/ChatSearch.java
Normal file
264
ts3-client/swing/src/main/java/com/ts3client/ui/ChatSearch.java
Normal file
@@ -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<Object> highlightTags = new ArrayList<>();
|
||||||
|
/** Start and end offsets of each match in the log document, oldest first. */
|
||||||
|
private final List<int[]> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
379
ts3-client/swing/src/main/java/com/ts3client/ui/FindBar.java
Normal file
379
ts3-client/swing/src/main/java/com/ts3client/ui/FindBar.java
Normal file
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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("<html><u>ab</u></html>", "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<BooleanSupplier> optionDefaults = new ArrayList<>();
|
||||||
|
private final List<Runnable> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import com.ts3client.net.ChannelNode;
|
|||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
import com.ts3client.sound.SoundPlayer;
|
import com.ts3client.sound.SoundPlayer;
|
||||||
|
|
||||||
|
import javax.swing.AbstractAction;
|
||||||
import javax.swing.JCheckBox;
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JCheckBoxMenuItem;
|
import javax.swing.JCheckBoxMenuItem;
|
||||||
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JFrame;
|
import javax.swing.JFrame;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JMenuItem;
|
import javax.swing.JMenuItem;
|
||||||
@@ -24,10 +26,12 @@ import javax.swing.JOptionPane;
|
|||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JPopupMenu;
|
import javax.swing.JPopupMenu;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.KeyStroke;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.GridLayout;
|
import java.awt.GridLayout;
|
||||||
|
import java.awt.event.ActionEvent;
|
||||||
import java.awt.event.WindowAdapter;
|
import java.awt.event.WindowAdapter;
|
||||||
import java.awt.event.WindowEvent;
|
import java.awt.event.WindowEvent;
|
||||||
import java.util.ArrayList;
|
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.
|
// Switching the icon pack in the options dialog re-decorates the whole window.
|
||||||
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
|
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
|
||||||
add(tabPane, BorderLayout.CENTER);
|
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 = new StatusBar();
|
||||||
statusBar.setVisible(settings.showStatusBar);
|
statusBar.setVisible(settings.showStatusBar);
|
||||||
add(statusBar, BorderLayout.SOUTH);
|
add(statusBar, BorderLayout.SOUTH);
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ public final class Theme {
|
|||||||
return ui("TextPane.foreground", 0x202020, 0xE0E0E0);
|
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 ----
|
// ---- status colours ----
|
||||||
|
|
||||||
public static Color channelText() {
|
public static Color channelText() {
|
||||||
|
|||||||
Reference in New Issue
Block a user