From d35b299f1acd43d3114b1248df521f8c04b4aba5 Mon Sep 17 00:00:00 2001 From: Hazeybyte Date: Sun, 11 Oct 2026 11:00:16 +0100 Subject: [PATCH 1/2] feat(ui): remember the open console and sidebar tab across restarts The toggleable console's open state and the selected sidebar tab are persisted (clientConsoleOpen, and clientSidebarTab keyed by the nav button's tooltip) and restored on startup - the console in show(), the tab in addNavigation as plugins register their panels. Inserting the first sidebar tab makes JTabbedPane select index 0, which looks like the user picking a tab and unset the tab just restored, so saving is suppressed while navigation is being inserted or removed. That is what the old removingNavigation flag already did for removal, hence the rename to updatingNavigation. Co-Authored-By: Claude Opus 5 --- .../java/net/runelite/client/ui/ClientUI.java | 81 ++++++++++++++++--- 1 file changed, 69 insertions(+), 12 deletions(-) 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 1572437f144..276716b99a1 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 @@ -103,6 +103,8 @@ public class ClientUI private static final String CONFIG_CLIENT_BOUNDS = "clientBounds"; private static final String CONFIG_CLIENT_MAXIMIZED = "clientMaximized"; private static final String CONFIG_CLIENT_SIDEBAR_CLOSED = "clientSidebarClosed"; + private static final String CONFIG_CLIENT_SIDEBAR_TAB = "clientSidebarTab"; + private static final String CONFIG_CLIENT_CONSOLE_OPEN = "clientConsoleOpen"; public static final BufferedImage ICON_128 = ImageUtil.loadImageResource(Microbot.class, "microbot_logo.png"); public static final BufferedImage ICON_16 = ImageUtil.loadImageResource(Microbot.class, "microbot_logo.png"); public static String proxyMessage = ""; @@ -164,6 +166,8 @@ public class ClientUI private BufferedImage consoleIconOpen; private BufferedImage consoleIconClosed; private boolean consoleVisible; + private boolean sidebarTabRestored; + private boolean updatingNavigation; @RequiredArgsConstructor private static class HistoryEntry @@ -202,7 +206,9 @@ private void onConfigChanged(ConfigChanged event) { if (!event.getGroup().equals(CONFIG_GROUP) || event.getKey().equals(CONFIG_CLIENT_MAXIMIZED) || - event.getKey().equals(CONFIG_CLIENT_BOUNDS)) + event.getKey().equals(CONFIG_CLIENT_BOUNDS) || + event.getKey().equals(CONFIG_CLIENT_SIDEBAR_TAB) || + event.getKey().equals(CONFIG_CLIENT_CONSOLE_OPEN)) { return; } @@ -226,12 +232,28 @@ void addNavigation(NavigationButton navBtn) final int TAB_SIZE = 16; Icon icon = new ImageIcon(ImageUtil.resizeImage(navBtn.getIcon(), TAB_SIZE, TAB_SIZE)); - sidebar.insertTab(null, icon, navBtn.getPanel().getWrappedPanel(), navBtn.getTooltip(), - sidebarEntries.headSet(navBtn).size()); - // insertTab changes the selected index when the first tab is inserted, avoid this - if (sidebar.getTabCount() == 1) + updatingNavigation = true; + try { - sidebar.setSelectedIndex(-1); + sidebar.insertTab(null, icon, navBtn.getPanel().getWrappedPanel(), navBtn.getTooltip(), + sidebarEntries.headSet(navBtn).size()); + // insertTab changes the selected index when the first tab is inserted, avoid this + if (sidebar.getTabCount() == 1) + { + sidebar.setSelectedIndex(-1); + } + + if (!sidebarTabRestored + && sidebar.getSelectedIndex() < 0 + && navBtn.getTooltip().equals(configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB))) + { + sidebarTabRestored = true; + sidebar.setSelectedIndex(sidebarEntries.headSet(navBtn).size()); + } + } + finally + { + updatingNavigation = false; } } @@ -245,14 +267,22 @@ void removeNavigation(NavigationButton navBtn) { boolean closingOpenTab = !selectedTabHistory.isEmpty() && selectedTabHistory.getLast().navBtn == navBtn; selectedTabHistory.removeIf(it -> it.navBtn == navBtn); - sidebar.remove(navBtn.getPanel().getWrappedPanel()); - if (closingOpenTab) + updatingNavigation = true; + try { - HistoryEntry entry = selectedTabHistory.isEmpty() - ? new HistoryEntry(true, null) - : selectedTabHistory.removeLast(); + sidebar.remove(navBtn.getPanel().getWrappedPanel()); + if (closingOpenTab) + { + HistoryEntry entry = selectedTabHistory.isEmpty() + ? new HistoryEntry(true, null) + : selectedTabHistory.removeLast(); - openPanel(entry.navBtn, entry.sidebarOpen); + openPanel(entry.navBtn, entry.sidebarOpen); + } + } + finally + { + updatingNavigation = false; } } @@ -420,6 +450,19 @@ public void componentMoved(ComponentEvent e) selectedTab = newSelectedTab; + 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()); + } + } + if (sidebar.isVisible()) { pushHistory(); @@ -630,6 +673,12 @@ else if (OSType.getOSType() == OSType.MacOS && SystemInfo.isMacFullWindowContent // Update config updateFrameConfig(false); + // Reopen the console if it was open when the client was last closed + if (configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN, Boolean.class) == Boolean.TRUE) + { + setConsoleVisible(true); + } + // Close sidebar if the config closed state is set if (configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_CLOSED, Boolean.class) == Boolean.TRUE) { @@ -1187,6 +1236,14 @@ private void setConsoleVisible(boolean visible) } consoleVisible = visible; + if (visible) + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN, true); + } + else + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN); + } clientPanel.setConsoleVisible(visible); updateConsoleToggleButton(); From 6e3684705110dc85c0c7d0223c5a8cfab842a819 Mon Sep 17 00:00:00 2001 From: Hazeybyte Date: Sun, 11 Oct 2026 11:00:16 +0100 Subject: [PATCH 2/2] feat(ui): auto-scroll the console to the latest logs The console opens on the latest log line and follows new ones as they arrive. Scrolling up stops it, so output can be read while a script keeps logging, and scrolling back to the bottom resumes following. Whether to follow is decided by what the user does, never by measuring the scroll position: under a flood the view trails the document, and reading that as "the user scrolled up" leaves the console parked while lines keep arriving. Appends are buffered into one insert per event queue pass, since a line per pass schedules a layout and repaint each, and a busy script logs faster than the queue can run them. Co-Authored-By: Claude Opus 5 --- .../runelite/client/ui/LogConsolePanel.java | 207 +++++++++++++++++- 1 file changed, 196 insertions(+), 11 deletions(-) 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 f6fe67040d3..0f191175d78 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 @@ -5,12 +5,19 @@ package net.runelite.client.ui; import java.awt.*; +import java.awt.event.HierarchyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.awt.geom.Rectangle2D; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import javax.swing.JPanel; +import javax.swing.JScrollBar; import javax.swing.JScrollPane; +import javax.swing.JViewport; import javax.swing.JTextArea; import javax.swing.SwingUtilities; import javax.swing.border.MatteBorder; @@ -22,9 +29,22 @@ final class LogConsolePanel extends JPanel { private static final int MAX_CHARACTERS = 100_000; + // Trimming shifts the view, so it is deferred while the user is scrolled up - but not forever. + private static final int MAX_CHARACTERS_SCROLLED_UP = 400_000; private static final int PREFERRED_HEIGHT = 160; + private static final int BOTTOM_TOLERANCE_PX = 4; + // enough passes for a starved layout to catch up, few enough to give up on an off-screen console + private static final int MAX_SNAP_ATTEMPTS = 20; private final JTextArea textArea = new JTextArea(); + private final JScrollPane scrollPane = new JScrollPane(textArea); + + private final StringBuilder pending = new StringBuilder(); + private boolean flushScheduled; + + private boolean followTail = true; + private boolean snapQueued; + private int snapAttempts; LogConsolePanel() { @@ -42,13 +62,117 @@ final class LogConsolePanel extends JPanel textArea.setBorder(null); DefaultCaret caret = (DefaultCaret) textArea.getCaret(); - caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE); + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); - JScrollPane scrollPane = new JScrollPane(textArea); 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()); + + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent ev) + { + followTail = false; + } + + @Override + public void mouseReleased(MouseEvent ev) + { + reconsiderFollowingTail(); + } + }); + vertical.addMouseMotionListener(new MouseMotionAdapter() + { + @Override + public void mouseDragged(MouseEvent ev) + { + followTail = false; + } + }); + + // opening the console lands on the newest line, wherever the view was left + addHierarchyListener(ev -> + { + if ((ev.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && isShowing()) + { + scrollToBottom(); + } + }); + } + + void scrollToBottom() + { + followTail = true; + snapAttempts = 0; + queueSnapToBottom(); + } + + // Snapping waits for the next pass: until the insert's revalidation has run the viewport still + // reports the pre-insert view size and would snap a line short of the newest one. + private void queueSnapToBottom() + { + if (snapQueued) + { + return; + } + + snapQueued = true; + SwingUtilities.invokeLater(() -> + { + snapQueued = false; + if (followTail) + { + snapToBottom(); + } + }); + } + + private void snapToBottom() + { + int end = textArea.getDocument().getLength(); + textArea.setCaretPosition(end); + + Rectangle2D newestLine; + try + { + // the viewport's laid-out size trails the document, so ask the text view, which reads the + // newest line's position from the document itself + newestLine = textArea.modelToView2D(end); + } + catch (BadLocationException ex) + { + return; + } + + if (newestLine == null) + { + return; + } + + textArea.scrollRectToVisible(newestLine.getBounds()); + + JViewport viewport = scrollPane.getViewport(); + + // The scroll is clamped to the size the text area was last laid out at, so under load it can + // land short, and nothing else would correct that - keep trying while the layout catches up. + boolean reachedNewestLine = newestLine.getMaxY() + <= viewport.getViewPosition().y + viewport.getExtentSize().height + 1; + if (reachedNewestLine || ++snapAttempts > MAX_SNAP_ATTEMPTS) + { + snapAttempts = 0; + return; + } + + queueSnapToBottom(); } + // One batch per event queue pass. A line per pass is what breaks the console under load: each + // insert schedules its own layout, and a busy script logs faster than the queue can run them. void append(String text) { if (text == null || text.isEmpty()) @@ -56,19 +180,52 @@ void append(String text) return; } - String sanitized = text.replace("\r", ""); - if (SwingUtilities.isEventDispatchThread()) + synchronized (pending) { - appendOnEdt(sanitized); + pending.append(text, 0, text.length()); + + int excess = pending.length() - MAX_CHARACTERS_SCROLLED_UP; + if (excess > 0) + { + // the oldest buffered lines would be trimmed out of the document on arrival anyway + pending.delete(0, excess); + } + + if (flushScheduled) + { + return; + } + flushScheduled = true; } - else + + SwingUtilities.invokeLater(this::flushPending); + } + + private void flushPending() + { + String text; + synchronized (pending) { - SwingUtilities.invokeLater(() -> appendOnEdt(sanitized)); + flushScheduled = false; + if (pending.length() == 0) + { + return; + } + + text = pending.toString(); + pending.setLength(0); } + + appendOnEdt(text.replace("\r", "")); } void clear() { + synchronized (pending) + { + pending.setLength(0); + } + if (SwingUtilities.isEventDispatchThread()) { textArea.setText(""); @@ -89,10 +246,18 @@ private void appendOnEdt(String text) Document document = textArea.getDocument(); try { + boolean atBottom = followTail; int length = document.getLength(); document.insertString(length, text, null); - trimIfNecessary(); - textArea.setCaretPosition(document.getLength()); + trimIfNecessary(atBottom); + if (atBottom) + { + textArea.setCaretPosition(document.getLength()); + // 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; + queueSnapToBottom(); + } } catch (BadLocationException ex) { @@ -100,10 +265,30 @@ private void appendOnEdt(String text) } } - private void trimIfNecessary() + // Deferred so the user's gesture has reached the scrollbar before it is read. + private void reconsiderFollowingTail() + { + SwingUtilities.invokeLater(() -> + { + followTail = isScrolledToBottom(); + if (followTail) + { + snapAttempts = 0; + queueSnapToBottom(); + } + }); + } + + private boolean isScrolledToBottom() + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + return vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - BOTTOM_TOLERANCE_PX; + } + + private void trimIfNecessary(boolean atBottom) { Document document = textArea.getDocument(); - int excess = document.getLength() - MAX_CHARACTERS; + int excess = document.getLength() - (atBottom ? MAX_CHARACTERS : MAX_CHARACTERS_SCROLLED_UP); if (excess <= 0) { return;