diff --git a/README.md b/README.md index c877120..60558a9 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,9 @@ next_tab = "ALT+SHIFT+L" open_font_selector = "ALT+T" open_scrollback = "ALT+S" create_worktree = "ALT+W" +pane_sync_toggle = "ALT+Y" +pane_sync_select = "SPACE" +paste = "CTRL+SHIFT+V" ``` ## Defaults @@ -187,6 +190,10 @@ create_worktree = "ALT+W" - `Alt+s`: open the active pane scrollback in `$EDITOR` - `Alt+w`: edit a worktree name, then run `git worktree add /` from the previously focused pane's working directory +- `Alt+y`: enter pane-sync selection mode, commit the selection, or stop an active pane sync +- `Space`: toggle the focused pane in the sync set while pane-sync selection mode is active +- Once committed, input typed or pasted into any synced pane is mirrored to the other synced panes +- `Ctrl+Shift+v`: paste - Font default: `JetBrainsMono Nerd Font` - Kitty graphics protocol parsing is enabled by default diff --git a/config.example.toml b/config.example.toml index e81eb8b..a3875f6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -41,4 +41,6 @@ next_tab = "ALT+SHIFT+L" open_font_selector = "ALT+T" open_scrollback = "ALT+S" create_worktree = "ALT+W" +pane_sync_toggle = "ALT+Y" +pane_sync_select = "SPACE" paste = "CTRL+SHIFT+V" diff --git a/src/main/java/com/gregor/jprototerm/AppConfig.java b/src/main/java/com/gregor/jprototerm/AppConfig.java index cbe7380..876c875 100644 --- a/src/main/java/com/gregor/jprototerm/AppConfig.java +++ b/src/main/java/com/gregor/jprototerm/AppConfig.java @@ -50,6 +50,8 @@ public record AppConfig( "open_font_selector", "open_scrollback", "create_worktree", + "pane_sync_toggle", + "pane_sync_select", "paste" ); @@ -116,6 +118,8 @@ public record AppConfig( Map.entry("open_font_selector", KeyBinding.parse("ALT+T")), Map.entry("open_scrollback", KeyBinding.parse("ALT+S")), Map.entry("create_worktree", KeyBinding.parse("ALT+W")), + Map.entry("pane_sync_toggle", KeyBinding.parse("ALT+Y")), + Map.entry("pane_sync_select", KeyBinding.parse("SPACE")), Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V")) ) ); diff --git a/src/main/java/com/gregor/jprototerm/Compositor.java b/src/main/java/com/gregor/jprototerm/Compositor.java index b8bae3f..f53ef55 100644 --- a/src/main/java/com/gregor/jprototerm/Compositor.java +++ b/src/main/java/com/gregor/jprototerm/Compositor.java @@ -20,6 +20,7 @@ import javafx.scene.text.TextAlignment; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,6 +38,8 @@ public final class Compositor { // Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite. private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18); private static final Color TAB_TEXT = Color.rgb(225, 229, 235); + private static final Color PANE_SYNC_SELECT_BORDER = Color.rgb(255, 183, 77); + private static final Color PANE_SYNC_COMMITTED_BORDER = Color.rgb(105, 214, 128); // Thin tab strip shown at the top when more than one tab is open. private static final double TAB_BAR_HEIGHT = 22.0; @@ -62,6 +65,9 @@ public final class Compositor { private final Map hiddenSince = new HashMap<>(); // Panes whose backbuffer is currently released, so we don't release again every frame. private final Set released = new HashSet<>(); + private final Set paneSyncSelection = new LinkedHashSet<>(); + private final Set paneSyncPanes = new LinkedHashSet<>(); + private boolean paneSyncSelectMode; // layoutVersion at the last sweep: lets an idle, all-released steady state skip the scan. private long lastSweepLayoutVersion = Long.MIN_VALUE; // Cheap per-frame dirty signal: skip the whole render when none of these changed. @@ -134,6 +140,54 @@ public final class Compositor { } } + public boolean isPaneSyncSelecting() { + return paneSyncSelectMode; + } + + public void togglePaneSync() { + if (paneSyncSelectMode) { + paneSyncPanes.clear(); + paneSyncPanes.addAll(paneSyncSelection); + paneSyncSelectMode = false; + paneSyncSelection.clear(); + prunePaneSyncState(); + layoutVersion++; + return; + } + if (!paneSyncPanes.isEmpty()) { + paneSyncPanes.clear(); + layoutVersion++; + return; + } + if (activePane() == null) { + return; + } + paneSyncSelectMode = true; + paneSyncSelection.clear(); + layoutVersion++; + } + + public void togglePaneSyncSelection() { + TerminalPane active = activePane(); + if (active == null || !paneSyncSelectMode) { + return; + } + if (!paneSyncSelection.add(active)) { + paneSyncSelection.remove(active); + } + layoutVersion++; + } + + public List paneSyncPeers(TerminalPane source) { + prunePaneSyncState(); + if (source == null || !paneSyncPanes.contains(source)) { + return List.of(); + } + return paneSyncPanes.stream() + .filter(pane -> pane != source) + .toList(); + } + public void toggleFloating() { mutateCurrentTab(() -> currentTab().toggleFloating()); } @@ -190,6 +244,7 @@ public final class Compositor { for (int i = 0; i < tabs.size(); i++) { Tab tab = tabs.get(i); if (tab.closePane(pane)) { + removePaneFromSyncState(pane); if (tab.isEmpty()) { // Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the // same tab (or clamp it when the current/last tab went away). @@ -238,6 +293,9 @@ public final class Compositor { tab.close(); } tabs.clear(); + paneSyncSelectMode = false; + paneSyncSelection.clear(); + paneSyncPanes.clear(); } /** @@ -377,6 +435,7 @@ public final class Compositor { for (TerminalPane pane : panes) { paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane))); } + drawPaneSyncOverlay(gc, panes); imageOverlay.sync(panes); } @@ -396,6 +455,7 @@ public final class Compositor { paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); imageOverlay.updatePane(pane); } + drawPaneSyncOverlay(gc, panes); } private GraphicsContext beginFrame() { @@ -431,6 +491,63 @@ public final class Compositor { gc.setFontSmoothingType(FontSmoothingType.LCD); } + private void drawPaneSyncOverlay(GraphicsContext gc, List panes) { + Set highlighted = paneSyncSelectMode ? paneSyncSelection : paneSyncPanes; + if (highlighted.isEmpty()) { + return; + } + + gc.save(); + try { + gc.setLineWidth(4.0); + gc.setStroke(paneSyncSelectMode ? PANE_SYNC_SELECT_BORDER : PANE_SYNC_COMMITTED_BORDER); + for (TerminalPane pane : panes) { + if (!highlighted.contains(pane)) { + continue; + } + gc.save(); + double x = Math.round(pane.x()) + 2.0; + double y = Math.round(pane.y()) + 2.0; + double width = Math.max(0.0, pane.width() - 4.0); + double height = Math.max(0.0, pane.height() - 4.0); + TerminalRenderer.clip(gc, Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height(), pane.clip()); + gc.strokeRect(x, y, width, height); + gc.restore(); + } + } finally { + gc.restore(); + } + } + + private void removePaneFromSyncState(TerminalPane pane) { + boolean changed = paneSyncSelection.remove(pane); + changed |= paneSyncPanes.remove(pane); + if (paneSyncPanes.size() < 2) { + changed |= !paneSyncPanes.isEmpty(); + paneSyncPanes.clear(); + } + if (changed) { + layoutVersion++; + } + } + + private void prunePaneSyncState() { + Set live = livePanes(); + paneSyncSelection.retainAll(live); + paneSyncPanes.retainAll(live); + if (paneSyncPanes.size() < 2) { + paneSyncPanes.clear(); + } + } + + private Set livePanes() { + Set live = new HashSet<>(); + for (Tab tab : tabs) { + live.addAll(tab.allPanes()); + } + return live; + } + // ---- Input ---------------------------------------------------------------------- private void handleMousePressed(MouseEvent event) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalWindow.java b/src/main/java/com/gregor/jprototerm/TerminalWindow.java index 1630375..0601b5a 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalWindow.java +++ b/src/main/java/com/gregor/jprototerm/TerminalWindow.java @@ -72,6 +72,8 @@ final class TerminalWindow { keyActions.put("open_font_selector", this::openFontSelector); keyActions.put("open_scrollback", this::openScrollbackInEditor); keyActions.put("create_worktree", this::createWorktreeInEditor); + keyActions.put("pane_sync_toggle", compositor::togglePaneSync); + keyActions.put("pane_sync_select", compositor::togglePaneSyncSelection); keyActions.put("paste", this::pasteFromClipboard); StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay()); @@ -131,19 +133,51 @@ final class TerminalWindow { private void handlePressed(KeyEvent event) { for (Map.Entry action : keyActions.entrySet()) { - if (config.keybindings().get(action.getKey()).matches(event)) { + String actionName = action.getKey(); + if (config.keybindings().get(actionName).matches(event)) { + if (actionName.equals("pane_sync_select") && !compositor.isPaneSyncSelecting()) { + continue; + } + if (compositor.isPaneSyncSelecting() && !allowedDuringPaneSyncSelection(actionName)) { + event.consume(); + return; + } action.getValue().run(); event.consume(); return; } } + if (compositor.isPaneSyncSelecting()) { + event.consume(); + return; + } String encoded = KeyEncoder.encode(event); if (encoded != null) { sendToActivePane(encoded, event); } } + private static boolean allowedDuringPaneSyncSelection(String action) { + return switch (action) { + case "navigate_left", + "navigate_down", + "navigate_up", + "navigate_right", + "toggle_floating", + "next_floating", + "previous_tab", + "next_tab", + "pane_sync_toggle", + "pane_sync_select" -> true; + default -> false; + }; + } + private void handleTyped(KeyEvent event) { + if (compositor.isPaneSyncSelecting()) { + event.consume(); + return; + } if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) { return; } @@ -160,6 +194,9 @@ final class TerminalWindow { TerminalPane active = compositor.activePane(); if (active != null) { active.send(text); + for (TerminalPane peer : compositor.paneSyncPeers(active)) { + peer.send(text); + } event.consume(); } } @@ -168,7 +205,11 @@ final class TerminalWindow { TerminalPane active = compositor.activePane(); Clipboard clipboard = Clipboard.getSystemClipboard(); if (active != null && clipboard.hasString()) { - active.paste(clipboard.getString()); + String text = clipboard.getString(); + active.paste(text); + for (TerminalPane peer : compositor.paneSyncPeers(active)) { + peer.paste(text); + } } }