This commit is contained in:
Gregor Lohaus
2026-05-29 21:41:25 +02:00
parent ebba6cc44f
commit 250b182060
4 changed files with 420 additions and 259 deletions

View File

@@ -39,6 +39,9 @@ public record AppConfig(
"new_pane", "new_pane",
"next_floating", "next_floating",
"close_pane", "close_pane",
"new_tab",
"previous_tab",
"next_tab",
"open_font_selector", "open_font_selector",
"open_scrollback" "open_scrollback"
); );
@@ -86,17 +89,20 @@ public record AppConfig(
true, true,
defaultScrollbackEditorCommand(), defaultScrollbackEditorCommand(),
Map.of(), Map.of(),
Map.of( Map.ofEntries(
"navigate_left", KeyBinding.parse("ALT+H"), Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
"navigate_down", KeyBinding.parse("ALT+J"), Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
"navigate_up", KeyBinding.parse("ALT+K"), Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
"navigate_right", KeyBinding.parse("ALT+L"), Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
"toggle_floating", KeyBinding.parse("ALT+F"), Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
"new_pane", KeyBinding.parse("ALT+N"), Map.entry("new_pane", KeyBinding.parse("ALT+N")),
"next_floating", KeyBinding.parse("ALT+F12"), Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
"close_pane", KeyBinding.parse("ALT+X"), Map.entry("close_pane", KeyBinding.parse("ALT+X")),
"open_font_selector", KeyBinding.parse("ALT+T"), Map.entry("new_tab", KeyBinding.parse("ALT+A")),
"open_scrollback", KeyBinding.parse("ALT+S") 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"))
) )
); );
} }

View File

@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; import javafx.animation.AnimationTimer;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
@@ -80,6 +81,20 @@ public final class Main extends Application {
} else if (config.keybindings().get("close_pane").matches(event)) { } else if (config.keybindings().get("close_pane").matches(event)) {
workspace.closeActivePane(); workspace.closeActivePane();
event.consume(); 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)) { } else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector(); openFontSelector();
event.consume(); event.consume();

View File

@@ -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<TerminalPane> 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<TerminalPane> panes() {
if (panes.isEmpty()) {
return List.of();
}
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
TerminalPane active = activePane();
if (!active.visible() || !active.floating()) {
return visible;
}
List<TerminalPane> 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<TerminalPane> 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<TerminalPane> 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<TerminalPane> 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<TerminalPane> 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();
}
}

View File

@@ -1,312 +1,131 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; 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 { public final class TerminalWorkspace implements AutoCloseable {
private final AppConfig config; private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>(); private final List<Tab> tabs = new ArrayList<>();
private int activeIndex; private int currentTab;
private int hiddenFloatingFocusIndex = -1;
private long version; private long version;
public TerminalWorkspace(AppConfig config) { public TerminalWorkspace(AppConfig config) {
this.config = config; this.config = config;
panes.add(openPane(false)); tabs.add(new Tab(config));
} }
public TerminalPane activePane() { private Tab current() {
return panes.get(activeIndex); return tabs.get(currentTab);
}
public List<TerminalPane> panes() {
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
TerminalPane active = activePane();
if (!active.visible() || !active.floating()) {
return visible;
}
List<TerminalPane> 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;
} }
public long version() { public long version() {
return version; return version;
} }
public void focus(TerminalPane pane) { public boolean isEmpty() {
int index = panes.indexOf(pane); return tabs.isEmpty();
if (index >= 0 && pane.visible() && activeIndex != index) {
activeIndex = index;
version++;
} }
public TerminalPane activePane() {
return current().activePane();
}
public List<TerminalPane> 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) { public void layout(double width, double height) {
List<TerminalPane> tiled = panes.stream() if (!tabs.isEmpty()) {
.filter(TerminalPane::visible) current().layout(width, height);
.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<TerminalPane> floating = panes.stream() public void focus(TerminalPane pane) {
.filter(TerminalPane::visible) if (!tabs.isEmpty() && current().focus(pane)) {
.filter(TerminalPane::floating) version++;
.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 navigate(Direction direction) { public void navigate(Direction direction) {
TerminalPane current = activePane(); if (!tabs.isEmpty() && current().navigate(direction)) {
if (current.floating() && navigateFloatingStack(direction)) {
version++; 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() { public void toggleFloating() {
List<TerminalPane> floating = panes.stream() if (tabs.isEmpty()) {
.filter(TerminalPane::floating)
.toList();
if (floating.isEmpty()) {
createFloatingPane();
return; return;
} }
current().toggleFloating();
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;
version++; 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() { public void createPane() {
if (anyFloatingVisible()) { if (tabs.isEmpty()) {
createFloatingPane(); return;
} else { }
TerminalPane pane = openPane(false); current().createPane();
panes.add(pane);
activeIndex = panes.size() - 1;
version++; version++;
} }
}
private boolean anyFloatingVisible() {
return panes.stream().anyMatch(pane -> pane.floating() && pane.visible());
}
public void nextFloatingPane() { public void nextFloatingPane() {
TerminalPane next = nextFloatingAfter(activeIndex); if (tabs.isEmpty()) {
next.setVisible(true); return;
activeIndex = panes.indexOf(next); }
current().nextFloatingPane();
version++; version++;
} }
public void closeActivePane() { public void closeActivePane() {
TerminalPane active = activePane(); if (tabs.isEmpty()) {
if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) {
return; return;
} }
current().closeActivePane();
int removed = activeIndex; if (current().isEmpty()) {
int previous = previousVisibleIndex(removed); // Closing a tab's last pane closes the tab. When no tabs remain the workspace
panes.remove(removed); // is empty and Main quits.
active.close(); tabs.remove(currentTab);
activeIndex = adjustIndexAfterRemoval(previous, removed); if (currentTab >= tabs.size()) {
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); currentTab = Math.max(0, tabs.size() - 1);
}
}
version++; version++;
} }
private TerminalPane nextFloatingAfter(int index) { public void newTab() {
for (int i = index + 1; i < panes.size(); i++) { tabs.add(new Tab(config));
TerminalPane pane = panes.get(i); currentTab = tabs.size() - 1;
if (pane.floating()) { version++;
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() { public void nextTab() {
TerminalPane pane = openPane(true); if (tabs.size() > 1) {
panes.add(pane); currentTab = (currentTab + 1) % tabs.size();
return pane; version++;
}
} }
private boolean navigateFloatingStack(Direction direction) { public void previousTab() {
List<TerminalPane> floating = panes.stream() if (tabs.size() > 1) {
.filter(TerminalPane::visible) currentTab = (currentTab - 1 + tabs.size()) % tabs.size();
.filter(TerminalPane::floating) version++;
.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 @Override
public void close() { public void close() {
for (TerminalPane pane : panes) { for (Tab tab : tabs) {
pane.close(); tab.close();
} }
panes.clear(); tabs.clear();
} }
} }