From 250b1820603a4bfa489920b3a4ee76dbbf9c0144 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 29 May 2026 21:41:25 +0200 Subject: [PATCH] tabs --- .../java/com/gregor/jprototerm/AppConfig.java | 28 +- src/main/java/com/gregor/jprototerm/Main.java | 15 + src/main/java/com/gregor/jprototerm/Tab.java | 321 ++++++++++++++++++ .../gregor/jprototerm/TerminalWorkspace.java | 315 ++++------------- 4 files changed, 420 insertions(+), 259 deletions(-) create mode 100644 src/main/java/com/gregor/jprototerm/Tab.java diff --git a/src/main/java/com/gregor/jprototerm/AppConfig.java b/src/main/java/com/gregor/jprototerm/AppConfig.java index 33a7d02..2424662 100644 --- a/src/main/java/com/gregor/jprototerm/AppConfig.java +++ b/src/main/java/com/gregor/jprototerm/AppConfig.java @@ -39,6 +39,9 @@ public record AppConfig( "new_pane", "next_floating", "close_pane", + "new_tab", + "previous_tab", + "next_tab", "open_font_selector", "open_scrollback" ); @@ -86,17 +89,20 @@ public record AppConfig( true, defaultScrollbackEditorCommand(), Map.of(), - Map.of( - "navigate_left", KeyBinding.parse("ALT+H"), - "navigate_down", KeyBinding.parse("ALT+J"), - "navigate_up", KeyBinding.parse("ALT+K"), - "navigate_right", KeyBinding.parse("ALT+L"), - "toggle_floating", KeyBinding.parse("ALT+F"), - "new_pane", KeyBinding.parse("ALT+N"), - "next_floating", KeyBinding.parse("ALT+F12"), - "close_pane", KeyBinding.parse("ALT+X"), - "open_font_selector", KeyBinding.parse("ALT+T"), - "open_scrollback", KeyBinding.parse("ALT+S") + Map.ofEntries( + Map.entry("navigate_left", KeyBinding.parse("ALT+H")), + Map.entry("navigate_down", KeyBinding.parse("ALT+J")), + Map.entry("navigate_up", KeyBinding.parse("ALT+K")), + Map.entry("navigate_right", KeyBinding.parse("ALT+L")), + Map.entry("toggle_floating", KeyBinding.parse("ALT+F")), + Map.entry("new_pane", KeyBinding.parse("ALT+N")), + Map.entry("next_floating", KeyBinding.parse("ALT+F12")), + Map.entry("close_pane", KeyBinding.parse("ALT+X")), + Map.entry("new_tab", KeyBinding.parse("ALT+A")), + Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")), + Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")), + Map.entry("open_font_selector", KeyBinding.parse("ALT+T")), + Map.entry("open_scrollback", KeyBinding.parse("ALT+S")) ) ); } diff --git a/src/main/java/com/gregor/jprototerm/Main.java b/src/main/java/com/gregor/jprototerm/Main.java index d0bd7bd..690e894 100644 --- a/src/main/java/com/gregor/jprototerm/Main.java +++ b/src/main/java/com/gregor/jprototerm/Main.java @@ -2,6 +2,7 @@ package com.gregor.jprototerm; import javafx.animation.AnimationTimer; import javafx.application.Application; +import javafx.application.Platform; import javafx.scene.Scene; import javafx.scene.control.ButtonType; import javafx.scene.control.ComboBox; @@ -80,6 +81,20 @@ public final class Main extends Application { } else if (config.keybindings().get("close_pane").matches(event)) { workspace.closeActivePane(); event.consume(); + if (workspace.isEmpty()) { + // Closing the last pane quits the app. + workspace.close(); + Platform.exit(); + } + } else if (config.keybindings().get("new_tab").matches(event)) { + workspace.newTab(); + event.consume(); + } else if (config.keybindings().get("previous_tab").matches(event)) { + workspace.previousTab(); + event.consume(); + } else if (config.keybindings().get("next_tab").matches(event)) { + workspace.nextTab(); + event.consume(); } else if (config.keybindings().get("open_font_selector").matches(event)) { openFontSelector(); event.consume(); diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java new file mode 100644 index 0000000..1cbe39c --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -0,0 +1,321 @@ +package com.gregor.jprototerm; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * One tab: an isolated stack of panes (tiled + floating) with its own active pane and + * stashed-floating state. {@link TerminalWorkspace} owns the list of tabs and renders only + * the current one. Mutating methods return whether they actually changed anything so the + * workspace can bump its render version conditionally. + */ +final class Tab implements AutoCloseable { + private final AppConfig config; + private final List panes = new ArrayList<>(); + private int activeIndex; + private int hiddenFloatingFocusIndex = -1; + + Tab(AppConfig config) { + this.config = config; + panes.add(openPane(false)); + } + + TerminalPane activePane() { + return panes.get(activeIndex); + } + + boolean isEmpty() { + return panes.isEmpty(); + } + + List panes() { + if (panes.isEmpty()) { + return List.of(); + } + List visible = panes.stream().filter(TerminalPane::visible).toList(); + TerminalPane active = activePane(); + if (!active.visible() || !active.floating()) { + return visible; + } + + List ordered = new ArrayList<>(visible.size()); + visible.stream() + .filter(pane -> pane != active) + .forEach(ordered::add); + ordered.add(active); + return List.copyOf(ordered); + } + + boolean isActive(TerminalPane pane) { + return !panes.isEmpty() && activePane() == pane; + } + + boolean focus(TerminalPane pane) { + int index = panes.indexOf(pane); + if (index >= 0 && pane.visible() && activeIndex != index) { + activeIndex = index; + return true; + } + return false; + } + + void layout(double width, double height) { + List tiled = panes.stream() + .filter(TerminalPane::visible) + .filter(pane -> !pane.floating()) + .toList(); + int tileCount = Math.max(1, tiled.size()); + double tileWidth = width / tileCount; + for (int i = 0; i < tiled.size(); i++) { + tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height); + } + + List floating = panes.stream() + .filter(TerminalPane::visible) + .filter(TerminalPane::floating) + .toList(); + for (int i = 0; i < floating.size(); i++) { + TerminalPane pane = floating.get(i); + if (pane.visible() && pane.floating()) { + double floatingWidth = Math.max(420, width * 0.58); + double floatingHeight = Math.max(260, height * 0.58); + double offset = i * 28.0; + pane.bounds( + Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), + Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset), + floatingWidth, + floatingHeight + ); + } + } + } + + boolean navigate(Direction direction) { + TerminalPane current = activePane(); + if (current.floating() && navigateFloatingStack(direction)) { + return true; + } + + TerminalPane target = panes.stream() + .filter(TerminalPane::visible) + .filter(pane -> pane != current) + .filter(pane -> directionFilter(direction, current, pane)) + .min(Comparator.comparingDouble(pane -> distance(current, pane))) + .orElse(null); + if (target != null) { + activeIndex = panes.indexOf(target); + return true; + } + return false; + } + + void toggleFloating() { + List floating = panes.stream() + .filter(TerminalPane::floating) + .toList(); + if (floating.isEmpty()) { + createFloatingPane(); + return; + } + + boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible); + if (anyVisible) { + TerminalPane active = activePane(); + hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex(); + floating.forEach(pane -> pane.setVisible(false)); + activeIndex = firstVisibleNonFloatingIndex(); + } else { + floating.forEach(pane -> pane.setVisible(true)); + activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))); + hiddenFloatingFocusIndex = -1; + } + } + + /** + * "New pane": adds a floating pane while floating panes are shown, otherwise adds a + * tiled pane (the tiled row is redistributed equally by the layout). + */ + void createPane() { + if (anyFloatingVisible()) { + createFloatingPane(); + } else { + TerminalPane pane = openPane(false); + panes.add(pane); + activeIndex = panes.size() - 1; + } + } + + void nextFloatingPane() { + TerminalPane next = nextFloatingAfter(activeIndex); + next.setVisible(true); + activeIndex = panes.indexOf(next); + } + + void closeActivePane() { + TerminalPane active = activePane(); + int removed = activeIndex; + int previous = previousVisibleIndex(removed); + panes.remove(removed); + active.close(); + if (panes.isEmpty()) { + activeIndex = 0; + return; + } + activeIndex = adjustIndexAfterRemoval(previous, removed); + hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); + // If only hidden panes remained (e.g. closed the last tiled pane while floating + // panes were stashed), reveal the one we're focusing so the screen isn't blank. + if (!panes.get(activeIndex).visible()) { + panes.get(activeIndex).setVisible(true); + } + } + + private void createFloatingPane() { + TerminalPane pane = openPane(true); + panes.add(pane); + activeIndex = panes.size() - 1; + } + + private boolean anyFloatingVisible() { + return panes.stream().anyMatch(pane -> pane.floating() && pane.visible()); + } + + private TerminalPane nextFloatingAfter(int index) { + for (int i = index + 1; i < panes.size(); i++) { + TerminalPane pane = panes.get(i); + if (pane.floating()) { + return pane; + } + } + for (int i = 0; i <= index && i < panes.size(); i++) { + TerminalPane pane = panes.get(i); + if (pane.floating()) { + return pane; + } + } + return createAndReturnFloatingPane(); + } + + private TerminalPane createAndReturnFloatingPane() { + TerminalPane pane = openPane(true); + panes.add(pane); + return pane; + } + + private boolean navigateFloatingStack(Direction direction) { + List floating = panes.stream() + .filter(TerminalPane::visible) + .filter(TerminalPane::floating) + .toList(); + if (floating.size() < 2) { + return false; + } + + int current = floating.indexOf(activePane()); + if (current < 0) { + return false; + } + + int next = switch (direction) { + case LEFT, UP -> current - 1; + case DOWN, RIGHT -> current + 1; + }; + if (next < 0 || next >= floating.size()) { + return false; + } + + activeIndex = panes.indexOf(floating.get(next)); + return true; + } + + private int firstVisibleFloatingIndex() { + for (int i = 0; i < panes.size(); i++) { + TerminalPane pane = panes.get(i); + if (pane.visible() && pane.floating()) { + return i; + } + } + return -1; + } + + private int firstVisibleNonFloatingIndex() { + for (int i = 0; i < panes.size(); i++) { + TerminalPane pane = panes.get(i); + if (pane.visible() && !pane.floating()) { + return i; + } + } + return 0; + } + + private int previousVisibleIndex(int index) { + for (int i = index - 1; i >= 0; i--) { + if (panes.get(i).visible()) { + return i; + } + } + for (int i = index + 1; i < panes.size(); i++) { + if (panes.get(i).visible()) { + return i; + } + } + return firstVisibleNonFloatingIndex(); + } + + private int visibleIndexOrFallback(int index, int fallback) { + if (index >= 0 && index < panes.size() && panes.get(index).visible()) { + return index; + } + return fallback; + } + + private static int adjustIndexAfterRemoval(int index, int removedIndex) { + if (index < 0) { + return 0; + } + return index > removedIndex ? index - 1 : index; + } + + private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) { + if (index < 0 || index == removedIndex) { + return -1; + } + return index > removedIndex ? index - 1 : index; + } + + private TerminalPane openPane(boolean floating) { + TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback()); + pane.setFloating(floating); + pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, config.columns(), config.rows())); + return pane; + } + + private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { + double currentCenterX = current.x() + current.width() / 2.0; + double currentCenterY = current.y() + current.height() / 2.0; + double candidateCenterX = candidate.x() + candidate.width() / 2.0; + double candidateCenterY = candidate.y() + candidate.height() / 2.0; + + return switch (direction) { + case LEFT -> candidateCenterX < currentCenterX; + case DOWN -> candidateCenterY > currentCenterY; + case UP -> candidateCenterY < currentCenterY; + case RIGHT -> candidateCenterX > currentCenterX; + }; + } + + private static double distance(TerminalPane current, TerminalPane candidate) { + double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0); + double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0); + return Math.sqrt(dx * dx + dy * dy); + } + + @Override + public void close() { + for (TerminalPane pane : panes) { + pane.close(); + } + panes.clear(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java index 18130f3..200f563 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java +++ b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java @@ -1,312 +1,131 @@ package com.gregor.jprototerm; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; +/** + * Holds the tabs and renders only the current one. Pane operations delegate to the current + * tab; tab operations switch which tab is current. A single render version bumps on any + * change (intra-tab or tab switch) so the renderer recomposites when needed. + */ public final class TerminalWorkspace implements AutoCloseable { private final AppConfig config; - private final List panes = new ArrayList<>(); - private int activeIndex; - private int hiddenFloatingFocusIndex = -1; + private final List tabs = new ArrayList<>(); + private int currentTab; private long version; public TerminalWorkspace(AppConfig config) { this.config = config; - panes.add(openPane(false)); + tabs.add(new Tab(config)); } - public TerminalPane activePane() { - return panes.get(activeIndex); - } - - public List panes() { - List visible = panes.stream().filter(TerminalPane::visible).toList(); - TerminalPane active = activePane(); - if (!active.visible() || !active.floating()) { - return visible; - } - - List ordered = new ArrayList<>(visible.size()); - visible.stream() - .filter(pane -> pane != active) - .forEach(ordered::add); - ordered.add(active); - return List.copyOf(ordered); - } - - public boolean isActive(TerminalPane pane) { - return activePane() == pane; + private Tab current() { + return tabs.get(currentTab); } public long version() { return version; } - public void focus(TerminalPane pane) { - int index = panes.indexOf(pane); - if (index >= 0 && pane.visible() && activeIndex != index) { - activeIndex = index; - version++; - } + public boolean isEmpty() { + return tabs.isEmpty(); + } + + public TerminalPane activePane() { + return current().activePane(); + } + + public List panes() { + return tabs.isEmpty() ? List.of() : current().panes(); + } + + public boolean isActive(TerminalPane pane) { + return !tabs.isEmpty() && current().isActive(pane); } public void layout(double width, double height) { - List tiled = panes.stream() - .filter(TerminalPane::visible) - .filter(pane -> !pane.floating()) - .toList(); - int tileCount = Math.max(1, tiled.size()); - double tileWidth = width / tileCount; - for (int i = 0; i < tiled.size(); i++) { - tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height); + if (!tabs.isEmpty()) { + current().layout(width, height); } + } - List floating = panes.stream() - .filter(TerminalPane::visible) - .filter(TerminalPane::floating) - .toList(); - for (int i = 0; i < floating.size(); i++) { - TerminalPane pane = floating.get(i); - if (pane.visible() && pane.floating()) { - double floatingWidth = Math.max(420, width * 0.58); - double floatingHeight = Math.max(260, height * 0.58); - double offset = i * 28.0; - pane.bounds( - Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), - Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset), - floatingWidth, - floatingHeight - ); - } + public void focus(TerminalPane pane) { + if (!tabs.isEmpty() && current().focus(pane)) { + version++; } } public void navigate(Direction direction) { - TerminalPane current = activePane(); - if (current.floating() && navigateFloatingStack(direction)) { + if (!tabs.isEmpty() && current().navigate(direction)) { version++; - return; } - - panes.stream() - .filter(TerminalPane::visible) - .filter(pane -> pane != current) - .filter(pane -> directionFilter(direction, current, pane)) - .min(Comparator.comparingDouble(pane -> distance(current, pane))) - .ifPresent(pane -> { - activeIndex = panes.indexOf(pane); - version++; - }); } public void toggleFloating() { - List floating = panes.stream() - .filter(TerminalPane::floating) - .toList(); - if (floating.isEmpty()) { - createFloatingPane(); + if (tabs.isEmpty()) { return; } - - boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible); - if (anyVisible) { - TerminalPane active = activePane(); - hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex(); - floating.forEach(pane -> pane.setVisible(false)); - activeIndex = firstVisibleNonFloatingIndex(); - version++; - } else { - floating.forEach(pane -> pane.setVisible(true)); - activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))); - hiddenFloatingFocusIndex = -1; - version++; - } - } - - public void createFloatingPane() { - TerminalPane pane = openPane(true); - panes.add(pane); - activeIndex = panes.size() - 1; + current().toggleFloating(); version++; } - /** - * "New pane": adds a floating pane while floating panes are shown, otherwise adds a - * tiled pane (the tiled row is redistributed equally by the layout). - */ public void createPane() { - if (anyFloatingVisible()) { - createFloatingPane(); - } else { - TerminalPane pane = openPane(false); - panes.add(pane); - activeIndex = panes.size() - 1; - version++; + if (tabs.isEmpty()) { + return; } - } - - private boolean anyFloatingVisible() { - return panes.stream().anyMatch(pane -> pane.floating() && pane.visible()); + current().createPane(); + version++; } public void nextFloatingPane() { - TerminalPane next = nextFloatingAfter(activeIndex); - next.setVisible(true); - activeIndex = panes.indexOf(next); + if (tabs.isEmpty()) { + return; + } + current().nextFloatingPane(); version++; } public void closeActivePane() { - TerminalPane active = activePane(); - if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) { + if (tabs.isEmpty()) { return; } - - int removed = activeIndex; - int previous = previousVisibleIndex(removed); - panes.remove(removed); - active.close(); - activeIndex = adjustIndexAfterRemoval(previous, removed); - hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); + current().closeActivePane(); + if (current().isEmpty()) { + // Closing a tab's last pane closes the tab. When no tabs remain the workspace + // is empty and Main quits. + tabs.remove(currentTab); + if (currentTab >= tabs.size()) { + currentTab = Math.max(0, tabs.size() - 1); + } + } version++; } - private TerminalPane nextFloatingAfter(int index) { - for (int i = index + 1; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.floating()) { - return pane; - } - } - for (int i = 0; i <= index && i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.floating()) { - return pane; - } - } - return createAndReturnFloatingPane(); + public void newTab() { + tabs.add(new Tab(config)); + currentTab = tabs.size() - 1; + version++; } - private TerminalPane createAndReturnFloatingPane() { - TerminalPane pane = openPane(true); - panes.add(pane); - return pane; + public void nextTab() { + if (tabs.size() > 1) { + currentTab = (currentTab + 1) % tabs.size(); + version++; + } } - private boolean navigateFloatingStack(Direction direction) { - List floating = panes.stream() - .filter(TerminalPane::visible) - .filter(TerminalPane::floating) - .toList(); - if (floating.size() < 2) { - return false; + public void previousTab() { + if (tabs.size() > 1) { + currentTab = (currentTab - 1 + tabs.size()) % tabs.size(); + version++; } - - int current = floating.indexOf(activePane()); - if (current < 0) { - return false; - } - - int next = switch (direction) { - case LEFT, UP -> current - 1; - case DOWN, RIGHT -> current + 1; - }; - if (next < 0 || next >= floating.size()) { - return false; - } - - activeIndex = panes.indexOf(floating.get(next)); - return true; - } - - private int firstVisibleFloatingIndex() { - for (int i = 0; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.visible() && pane.floating()) { - return i; - } - } - return -1; - } - - private int firstVisibleNonFloatingIndex() { - for (int i = 0; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.visible() && !pane.floating()) { - return i; - } - } - return 0; - } - - private int previousVisibleIndex(int index) { - for (int i = index - 1; i >= 0; i--) { - if (panes.get(i).visible()) { - return i; - } - } - for (int i = index + 1; i < panes.size(); i++) { - if (panes.get(i).visible()) { - return i; - } - } - return firstVisibleNonFloatingIndex(); - } - - private int visibleIndexOrFallback(int index, int fallback) { - if (index >= 0 && index < panes.size() && panes.get(index).visible()) { - return index; - } - return fallback; - } - - private static int adjustIndexAfterRemoval(int index, int removedIndex) { - if (index < 0) { - return 0; - } - return index > removedIndex ? index - 1 : index; - } - - private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) { - if (index < 0 || index == removedIndex) { - return -1; - } - return index > removedIndex ? index - 1 : index; - } - - private TerminalPane openPane(boolean floating) { - TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback()); - pane.setFloating(floating); - pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, config.columns(), config.rows())); - return pane; - } - - private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { - double currentCenterX = current.x() + current.width() / 2.0; - double currentCenterY = current.y() + current.height() / 2.0; - double candidateCenterX = candidate.x() + candidate.width() / 2.0; - double candidateCenterY = candidate.y() + candidate.height() / 2.0; - - return switch (direction) { - case LEFT -> candidateCenterX < currentCenterX; - case DOWN -> candidateCenterY > currentCenterY; - case UP -> candidateCenterY < currentCenterY; - case RIGHT -> candidateCenterX > currentCenterX; - }; - } - - private static double distance(TerminalPane current, TerminalPane candidate) { - double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0); - double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0); - return Math.sqrt(dx * dx + dy * dy); } @Override public void close() { - for (TerminalPane pane : panes) { - pane.close(); + for (Tab tab : tabs) { + tab.close(); } - panes.clear(); + tabs.clear(); } }