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(); 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;