diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index 276716b99a1..c4af522cd24 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -278,6 +278,7 @@ void removeNavigation(NavigationButton navBtn) : selectedTabHistory.removeLast(); openPanel(entry.navBtn, entry.sidebarOpen); + saveSidebarTab(); } } finally @@ -452,15 +453,7 @@ public void componentMoved(ComponentEvent e) if (!updatingNavigation) { - sidebarTabRestored = true; - if (newSelectedTab == null) - { - configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB); - } - else - { - configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB, newSelectedTab.getTooltip()); - } + saveSidebarTab(); } if (sidebar.isVisible()) @@ -764,6 +757,7 @@ public void show() frame.revalidateMinimumSize(); // this must run after the native window border is installed on the window frame.updateContainsInScreen(); + sidebarTabRestored = true; }); // Show out of date dialog if needed @@ -1125,6 +1119,19 @@ public GraphicsConfiguration getGraphicsConfiguration() return frame.getGraphicsConfiguration(); } + private void saveSidebarTab() + { + sidebarTabRestored = true; + if (selectedTab == null) + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB); + } + else + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB, selectedTab.getTooltip()); + } + } + void openPanel(NavigationButton navBtn, boolean showSidebar) { if (navBtn != null && !sidebarEntries.contains(navBtn)) @@ -1294,6 +1301,7 @@ private void initializeConsoleLogging() System.setOut(consolePrintStream); LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + ConsoleLogAppender.bindStdoutAppenders(loggerContext, originalOut); consoleLogAppender = new ConsoleLogAppender(consolePanel::append); consoleLogAppender.setContext(loggerContext); consoleLogAppender.start(); diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java index 95be2c60434..38f4b84ba01 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java @@ -4,9 +4,16 @@ */ package net.runelite.client.ui; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.ConsoleTarget; +import java.io.OutputStream; +import java.util.Iterator; import java.util.function.Consumer; final class ConsoleLogAppender extends AppenderBase @@ -19,6 +26,23 @@ final class ConsoleLogAppender extends AppenderBase this.logConsumer = logConsumer; } + static void bindStdoutAppenders(LoggerContext context, OutputStream stdout) + { + for (Logger logger : context.getLoggerList()) + { + Iterator> appenders = logger.iteratorForAppenders(); + while (appenders.hasNext()) + { + Appender appender = appenders.next(); + if (appender instanceof ConsoleAppender + && ConsoleTarget.SystemOut.getName().equals(((ConsoleAppender) appender).getTarget())) + { + ((ConsoleAppender) appender).setOutputStream(stdout); + } + } + } + } + @Override public void start() { diff --git a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java index 0f191175d78..c9519eb6c8d 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java @@ -6,6 +6,8 @@ import java.awt.*; import java.awt.event.HierarchyEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter; @@ -45,6 +47,7 @@ final class LogConsolePanel extends JPanel private boolean followTail = true; private boolean snapQueued; private int snapAttempts; + private int tailCaretDot = -1; LogConsolePanel() { @@ -61,15 +64,65 @@ final class LogConsolePanel extends JPanel textArea.setForeground(new Color(0, 255, 70)); textArea.setBorder(null); - DefaultCaret caret = (DefaultCaret) textArea.getCaret(); + DefaultCaret caret = new DefaultCaret() + { + @Override + protected void adjustVisibility(Rectangle nloc) + { + if (getDot() != tailCaretDot || getMark() != tailCaretDot) + { + super.adjustVisibility(nloc); + } + } + }; caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + textArea.setCaret(caret); scrollPane.setBorder(null); add(scrollPane, BorderLayout.CENTER); // Following is decided by what the user does, never by measuring the view: under a flood the // scroll position trails the document, which reads as "the user scrolled up". - scrollPane.addMouseWheelListener(ev -> reconsiderFollowingTail()); + scrollPane.addMouseWheelListener(ev -> + { + if (ev.getPreciseWheelRotation() < 0) + { + followTail = false; + } + reconsiderFollowingTail(); + }); + + textArea.addKeyListener(new KeyAdapter() + { + @Override + public void keyPressed(KeyEvent ev) + { + followTail = false; + SwingUtilities.invokeLater(LogConsolePanel.this::reconsiderFollowingTail); + } + }); + textArea.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent ev) + { + followTail = false; + } + + @Override + public void mouseReleased(MouseEvent ev) + { + reconsiderFollowingTail(); + } + }); + textArea.addMouseMotionListener(new MouseMotionAdapter() + { + @Override + public void mouseDragged(MouseEvent ev) + { + followTail = false; + } + }); JScrollBar vertical = scrollPane.getVerticalScrollBar(); vertical.addMouseListener(new MouseAdapter() @@ -135,7 +188,7 @@ private void queueSnapToBottom() private void snapToBottom() { int end = textArea.getDocument().getLength(); - textArea.setCaretPosition(end); + moveCaretToTail(); Rectangle2D newestLine; try @@ -184,11 +237,10 @@ void append(String text) { pending.append(text, 0, text.length()); - int excess = pending.length() - MAX_CHARACTERS_SCROLLED_UP; - if (excess > 0) + if (pending.length() > 2 * MAX_CHARACTERS_SCROLLED_UP) { // the oldest buffered lines would be trimmed out of the document on arrival anyway - pending.delete(0, excess); + pending.delete(0, pending.length() - MAX_CHARACTERS_SCROLLED_UP); } if (flushScheduled) @@ -212,7 +264,8 @@ private void flushPending() return; } - text = pending.toString(); + int cap = followTail ? MAX_CHARACTERS : MAX_CHARACTERS_SCROLLED_UP; + text = pending.substring(Math.max(0, pending.length() - cap)); pending.setLength(0); } @@ -252,7 +305,7 @@ private void appendOnEdt(String text) trimIfNecessary(atBottom); if (atBottom) { - textArea.setCaretPosition(document.getLength()); + moveCaretToTail(); // NEVER_UPDATE stops the caret dragging the view, so move it ourselves; the attempt // budget is per batch, or a flood that outruns the layout would exhaust it for good. snapAttempts = 0; @@ -265,12 +318,18 @@ private void appendOnEdt(String text) } } + private void moveCaretToTail() + { + tailCaretDot = textArea.getDocument().getLength(); + textArea.setCaretPosition(tailCaretDot); + } + // Deferred so the user's gesture has reached the scrollbar before it is read. private void reconsiderFollowingTail() { SwingUtilities.invokeLater(() -> { - followTail = isScrolledToBottom(); + followTail = isScrolledToBottom() && textArea.getSelectionStart() == textArea.getSelectionEnd(); if (followTail) { snapAttempts = 0; diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java new file mode 100644 index 00000000000..07d470ca283 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java @@ -0,0 +1,103 @@ +package net.runelite.client.ui; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class ConsoleLogAppenderTest +{ + private PrintStream originalOut; + private LoggerContext context; + + @Before + public void setUp() + { + originalOut = System.out; + context = new LoggerContext(); + } + + @After + public void tearDown() + { + System.setOut(originalOut); + context.stop(); + } + + @Test + public void logLineReachesTheConsoleOnceAndStdoutOnce() + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + ConsoleAppender stdout = new ConsoleAppender<>(); + stdout.setContext(context); + stdout.setEncoder(encoder); + stdout.start(); + root.addAppender(stdout); + + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + + ConsoleLogAppender.bindStdoutAppenders(context, terminal); + ConsoleLogAppender appender = new ConsoleLogAppender(console::append); + appender.setContext(context); + appender.start(); + root.addAppender(appender); + + root.info("flood line"); + System.out.println("plain print"); + + String panel = console + consolePanel.toString(StandardCharsets.UTF_8); + assertEquals(1, occurrences(panel, "flood line")); + assertEquals(1, occurrences(panel, "plain print")); + + String out = terminal.toString(StandardCharsets.UTF_8); + assertEquals(1, occurrences(out, "flood line")); + assertEquals(1, occurrences(out, "plain print")); + } + + private static int occurrences(String text, String needle) + { + int count = 0; + for (int i = text.indexOf(needle); i >= 0; i = text.indexOf(needle, i + 1)) + { + count++; + } + return count; + } + + private static final class Tee extends OutputStream + { + private final OutputStream first; + private final OutputStream second; + + private Tee(OutputStream first, OutputStream second) + { + this.first = first; + this.second = second; + } + + @Override + public void write(int b) throws IOException + { + first.write(b); + second.write(b); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java b/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java new file mode 100644 index 00000000000..ed4ed2eb62a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java @@ -0,0 +1,307 @@ +package net.runelite.client.ui; + +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import javax.swing.JScrollBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class LogConsolePanelTest +{ + private static final int DOCUMENT_CAP = 100_000; + + private LogConsolePanel panel; + private JScrollPane scrollPane; + private JTextArea textArea; + + @Before + public void setUp() throws Exception + { + onEdt(() -> + { + panel = new LogConsolePanel(); + scrollPane = (JScrollPane) panel.getComponent(0); + textArea = (JTextArea) scrollPane.getViewport().getView(); + panel.setSize(400, 160); + layOut(); + }); + panel.append(lines(0, 200)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void wheelUpWinsOverAnAlreadyQueuedSnap() throws Exception + { + onEdt(() -> + { + panel.append(lines(200, 50)); + SwingUtilities.invokeLater(() -> + { + layOut(); + MouseWheelEvent wheelUp = new MouseWheelEvent(scrollPane, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), + 0, 10, 10, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) + { + listener.mouseWheelMoved(wheelUp); + } + }); + }); + settle(); + assertFalse(isAtBottom()); + + panel.append(lines(250, 50)); + settle(); + assertFalse(isAtBottom()); + } + + @Test + public void pageUpStopsFollowing() throws Exception + { + press(KeyEvent.VK_PAGE_UP, 0); + settle(); + assertFalse(isAtBottom()); + + panel.append(lines(200, 50)); + settle(); + assertFalse(isAtBottom()); + } + + @Test + public void ctrlEndAfterPageUpResumesFollowing() throws Exception + { + press(KeyEvent.VK_PAGE_UP, 0); + settle(); + panel.append(lines(200, 50)); + settle(); + assertFalse(isAtBottom()); + + press(KeyEvent.VK_END, InputEvent.CTRL_DOWN_MASK); + settle(); + panel.append(lines(250, 50)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void dragSelectionSurvivesTheNextBatch() throws Exception + { + AtomicReference start = new AtomicReference<>(); + onEdt(() -> + { + Point view = scrollPane.getViewport().getViewPosition(); + start.set(new Point(5, view.y + 20)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_PRESSED, start.get().x, start.get().y)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_DRAGGED, start.get().x + 60, start.get().y + 20)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_RELEASED, start.get().x + 60, start.get().y + 20)); + }); + settle(); + String selected = onEdtGet(textArea::getSelectedText); + assertTrue(selected != null && !selected.isEmpty()); + + panel.append(lines(200, 50)); + settle(); + assertEquals(selected, onEdtGet(textArea::getSelectedText)); + } + + @Test + public void wheelDownToTheBottomResumesFollowing() throws Exception + { + onEdt(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum() / 2); + }); + wheel(-3); + settle(); + assertFalse(isAtBottom()); + + onEdt(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + }); + wheel(3); + settle(); + panel.append(lines(200, 50)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void starvedEdtInsertsOnlyWhatAFollowerKeeps() throws Exception + { + List inserts = new ArrayList<>(); + onEdt(() -> textArea.getDocument().addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent ev) + { + inserts.add(ev.getLength()); + } + + @Override + public void removeUpdate(DocumentEvent ev) + { + } + + @Override + public void changedUpdate(DocumentEvent ev) + { + } + })); + + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean released = new AtomicBoolean(); + SwingUtilities.invokeLater(() -> + { + blocked.countDown(); + try + { + released.set(release.await(30, TimeUnit.SECONDS)); + } + catch (InterruptedException ex) + { + Thread.currentThread().interrupt(); + } + }); + assertTrue(blocked.await(30, TimeUnit.SECONDS)); + + int next = 1_000; + String last = null; + while (next < 1_000 + 80_000) + { + panel.append(lines(next, 1_000)); + next += 1_000; + last = "line " + (next - 1) + "\n"; + } + release.countDown(); + settle(); + assertTrue(released.get()); + + int largest = inserts.stream().mapToInt(Integer::intValue).max().orElse(0); + assertTrue("largest insert " + largest, largest <= DOCUMENT_CAP); + String text = onEdtGet(() -> + { + try + { + return textArea.getDocument().getText(0, textArea.getDocument().getLength()); + } + catch (BadLocationException ex) + { + throw new IllegalStateException(ex); + } + }); + assertTrue(text.length() <= DOCUMENT_CAP); + assertTrue(text.endsWith(last)); + assertNotEquals(-1, text.indexOf("line " + (next - 2) + "\n")); + } + + private void press(int keyCode, int modifiers) throws Exception + { + onEdt(() -> + { + KeyEvent key = new KeyEvent(textArea, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), + modifiers, keyCode, KeyEvent.CHAR_UNDEFINED); + for (KeyListener listener : textArea.getKeyListeners()) + { + listener.keyPressed(key); + } + Object binding = textArea.getInputMap().get(KeyStroke.getKeyStrokeForEvent(key)); + textArea.getActionMap().get(binding).actionPerformed(new ActionEvent(textArea, ActionEvent.ACTION_PERFORMED, null)); + }); + } + + private void wheel(int rotation) throws Exception + { + onEdt(() -> + { + MouseWheelEvent event = new MouseWheelEvent(scrollPane, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), + 0, 10, 10, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, rotation); + for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) + { + listener.mouseWheelMoved(event); + } + }); + } + + private MouseEvent mouse(int id, int x, int y) + { + int modifiers = id == MouseEvent.MOUSE_RELEASED ? 0 : InputEvent.BUTTON1_DOWN_MASK; + return new MouseEvent(textArea, id, System.currentTimeMillis(), modifiers, x, y, 1, false, MouseEvent.BUTTON1); + } + + private void layOut() + { + for (int i = 0; i < 2; i++) + { + panel.doLayout(); + scrollPane.doLayout(); + scrollPane.getViewport().doLayout(); + } + } + + private void settle() throws Exception + { + for (int i = 0; i < 40; i++) + { + onEdt(this::layOut); + } + } + + private boolean isAtBottom() throws Exception + { + return onEdtGet(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + return vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - 4; + }); + } + + private static String lines(int from, int count) + { + StringBuilder text = new StringBuilder(); + for (int i = from; i < from + count; i++) + { + text.append("line ").append(i).append('\n'); + } + return text.toString(); + } + + private static void onEdt(Runnable runnable) throws Exception + { + SwingUtilities.invokeAndWait(runnable); + } + + private static T onEdtGet(Supplier supplier) throws Exception + { + AtomicReference result = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> result.set(supplier.get())); + return result.get(); + } +}