From ab646231fbbe12679e59602b682c90c196e52b3c Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:47:19 +0200 Subject: [PATCH] fix(console): show each log line once and stop following on user input Logback's STDOUT ConsoleAppender resolves System.out on every write, so after ClientUI swapped System.out for the console tee every log line reached the panel twice: once through the tee and once through ConsoleLogAppender. Bind System.out console appenders to the original stdout when the console is installed; plain System.out prints still go through the tee. Wheel-up, key presses and mouse presses/drags in the text area now stop following immediately; following resumes only when the view is back at the bottom with no selection. The caret moves made while following no longer scroll the view from their deferred visibility update, which could undo a wheel-up. The pending buffer is compacted only once it doubles and a flush inserts at most what the document keeps (100k chars while following). The saved sidebar tab is restored only during startup, and removing the open tab's plugin stores the tab that is shown instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../java/net/runelite/client/ui/ClientUI.java | 26 +- .../client/ui/ConsoleLogAppender.java | 24 ++ .../runelite/client/ui/LogConsolePanel.java | 77 ++++- .../client/ui/ConsoleLogAppenderTest.java | 103 ++++++ .../client/ui/LogConsolePanelTest.java | 307 ++++++++++++++++++ 5 files changed, 519 insertions(+), 18 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java 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(); + } +}