diff --git a/README.md b/README.md index c877120..b4e4cd9 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,10 @@ next_tab = "ALT+SHIFT+L" open_font_selector = "ALT+T" open_scrollback = "ALT+S" create_worktree = "ALT+W" +pane_sync_select = "ALT+Y" +pane_sync_commit = "ALT+SHIFT+Y" +pane_sync_end = "ALT+U" +paste = "CTRL+SHIFT+V" ``` ## Defaults @@ -187,6 +191,11 @@ 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 and toggle the focused pane in the sync set +- `Alt+Shift+y`: commit the current pane-sync selection; input typed or pasted into any synced + pane is mirrored to the other synced panes +- `Alt+u`: end pane sync +- `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..dc90429 100644 --- a/config.example.toml +++ b/config.example.toml @@ -41,4 +41,7 @@ next_tab = "ALT+SHIFT+L" open_font_selector = "ALT+T" open_scrollback = "ALT+S" create_worktree = "ALT+W" +pane_sync_select = "ALT+Y" +pane_sync_commit = "ALT+SHIFT+Y" +pane_sync_end = "ALT+U" 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..b390c95 100644 --- a/src/main/java/com/gregor/jprototerm/AppConfig.java +++ b/src/main/java/com/gregor/jprototerm/AppConfig.java @@ -50,6 +50,9 @@ public record AppConfig( "open_font_selector", "open_scrollback", "create_worktree", + "pane_sync_select", + "pane_sync_commit", + "pane_sync_end", "paste" ); @@ -116,6 +119,9 @@ 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_select", KeyBinding.parse("ALT+Y")), + Map.entry("pane_sync_commit", KeyBinding.parse("ALT+SHIFT+Y")), + Map.entry("pane_sync_end", KeyBinding.parse("ALT+U")), 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..a14cf5a 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,57 @@ public final class Compositor { } } + public boolean isPaneSyncSelecting() { + return paneSyncSelectMode; + } + + public void togglePaneSyncSelection() { + TerminalPane active = activePane(); + if (active == null) { + return; + } + if (!paneSyncSelectMode) { + paneSyncSelectMode = true; + paneSyncSelection.clear(); + } + if (!paneSyncSelection.add(active)) { + paneSyncSelection.remove(active); + } + layoutVersion++; + } + + public void commitPaneSyncSelection() { + if (!paneSyncSelectMode) { + return; + } + paneSyncPanes.clear(); + paneSyncPanes.addAll(paneSyncSelection); + paneSyncSelection.clear(); + paneSyncSelectMode = false; + prunePaneSyncState(); + layoutVersion++; + } + + public void endPaneSync() { + if (!paneSyncSelectMode && paneSyncSelection.isEmpty() && paneSyncPanes.isEmpty()) { + return; + } + paneSyncSelectMode = false; + paneSyncSelection.clear(); + paneSyncPanes.clear(); + 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 +247,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 +296,9 @@ public final class Compositor { tab.close(); } tabs.clear(); + paneSyncSelectMode = false; + paneSyncSelection.clear(); + paneSyncPanes.clear(); } /** @@ -377,6 +438,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 +458,7 @@ public final class Compositor { paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); imageOverlay.updatePane(pane); } + drawPaneSyncOverlay(gc, panes); } private GraphicsContext beginFrame() { @@ -431,6 +494,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..9435451 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalWindow.java +++ b/src/main/java/com/gregor/jprototerm/TerminalWindow.java @@ -72,6 +72,9 @@ 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_select", compositor::togglePaneSyncSelection); + keyActions.put("pane_sync_commit", compositor::commitPaneSyncSelection); + keyActions.put("pane_sync_end", compositor::endPaneSync); keyActions.put("paste", this::pasteFromClipboard); StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay()); @@ -132,18 +135,47 @@ final class TerminalWindow { private void handlePressed(KeyEvent event) { for (Map.Entry action : keyActions.entrySet()) { if (config.keybindings().get(action.getKey()).matches(event)) { + if (compositor.isPaneSyncSelecting() && !allowedDuringPaneSyncSelection(action.getKey())) { + 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_select", + "pane_sync_commit", + "pane_sync_end" -> true; + default -> false; + }; + } + private void handleTyped(KeyEvent event) { + if (compositor.isPaneSyncSelecting()) { + event.consume(); + return; + } if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) { return; } @@ -160,6 +192,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 +203,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); + } } }