Merge branch 'pane-sync'
This commit is contained in:
@@ -170,6 +170,9 @@ next_tab = "ALT+SHIFT+L"
|
|||||||
open_font_selector = "ALT+T"
|
open_font_selector = "ALT+T"
|
||||||
open_scrollback = "ALT+S"
|
open_scrollback = "ALT+S"
|
||||||
create_worktree = "ALT+W"
|
create_worktree = "ALT+W"
|
||||||
|
pane_sync_toggle = "ALT+Y"
|
||||||
|
pane_sync_select = "SPACE"
|
||||||
|
paste = "CTRL+SHIFT+V"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
@@ -187,6 +190,10 @@ create_worktree = "ALT+W"
|
|||||||
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
||||||
- `Alt+w`: edit a worktree name, then run `git worktree add <relative_worktree_path>/<name>`
|
- `Alt+w`: edit a worktree name, then run `git worktree add <relative_worktree_path>/<name>`
|
||||||
from the previously focused pane's working directory
|
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`
|
- Font default: `JetBrainsMono Nerd Font`
|
||||||
- Kitty graphics protocol parsing is enabled by default
|
- Kitty graphics protocol parsing is enabled by default
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,6 @@ next_tab = "ALT+SHIFT+L"
|
|||||||
open_font_selector = "ALT+T"
|
open_font_selector = "ALT+T"
|
||||||
open_scrollback = "ALT+S"
|
open_scrollback = "ALT+S"
|
||||||
create_worktree = "ALT+W"
|
create_worktree = "ALT+W"
|
||||||
|
pane_sync_toggle = "ALT+Y"
|
||||||
|
pane_sync_select = "SPACE"
|
||||||
paste = "CTRL+SHIFT+V"
|
paste = "CTRL+SHIFT+V"
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ public record AppConfig(
|
|||||||
"open_font_selector",
|
"open_font_selector",
|
||||||
"open_scrollback",
|
"open_scrollback",
|
||||||
"create_worktree",
|
"create_worktree",
|
||||||
|
"pane_sync_toggle",
|
||||||
|
"pane_sync_select",
|
||||||
"paste"
|
"paste"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,6 +118,8 @@ public record AppConfig(
|
|||||||
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
||||||
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
|
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
|
||||||
Map.entry("create_worktree", KeyBinding.parse("ALT+W")),
|
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"))
|
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import javafx.scene.text.TextAlignment;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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.
|
// 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 GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
||||||
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
|
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.
|
// Thin tab strip shown at the top when more than one tab is open.
|
||||||
private static final double TAB_BAR_HEIGHT = 22.0;
|
private static final double TAB_BAR_HEIGHT = 22.0;
|
||||||
|
|
||||||
@@ -62,6 +65,9 @@ public final class Compositor {
|
|||||||
private final Map<TerminalPane, Long> hiddenSince = new HashMap<>();
|
private final Map<TerminalPane, Long> hiddenSince = new HashMap<>();
|
||||||
// Panes whose backbuffer is currently released, so we don't release again every frame.
|
// Panes whose backbuffer is currently released, so we don't release again every frame.
|
||||||
private final Set<TerminalPane> released = new HashSet<>();
|
private final Set<TerminalPane> released = new HashSet<>();
|
||||||
|
private final Set<TerminalPane> paneSyncSelection = new LinkedHashSet<>();
|
||||||
|
private final Set<TerminalPane> paneSyncPanes = new LinkedHashSet<>();
|
||||||
|
private boolean paneSyncSelectMode;
|
||||||
// layoutVersion at the last sweep: lets an idle, all-released steady state skip the scan.
|
// layoutVersion at the last sweep: lets an idle, all-released steady state skip the scan.
|
||||||
private long lastSweepLayoutVersion = Long.MIN_VALUE;
|
private long lastSweepLayoutVersion = Long.MIN_VALUE;
|
||||||
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
|
// 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<TerminalPane> paneSyncPeers(TerminalPane source) {
|
||||||
|
prunePaneSyncState();
|
||||||
|
if (source == null || !paneSyncPanes.contains(source)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return paneSyncPanes.stream()
|
||||||
|
.filter(pane -> pane != source)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public void toggleFloating() {
|
public void toggleFloating() {
|
||||||
mutateCurrentTab(() -> currentTab().toggleFloating());
|
mutateCurrentTab(() -> currentTab().toggleFloating());
|
||||||
}
|
}
|
||||||
@@ -190,6 +244,7 @@ public final class Compositor {
|
|||||||
for (int i = 0; i < tabs.size(); i++) {
|
for (int i = 0; i < tabs.size(); i++) {
|
||||||
Tab tab = tabs.get(i);
|
Tab tab = tabs.get(i);
|
||||||
if (tab.closePane(pane)) {
|
if (tab.closePane(pane)) {
|
||||||
|
removePaneFromSyncState(pane);
|
||||||
if (tab.isEmpty()) {
|
if (tab.isEmpty()) {
|
||||||
// Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the
|
// 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).
|
// same tab (or clamp it when the current/last tab went away).
|
||||||
@@ -238,6 +293,9 @@ public final class Compositor {
|
|||||||
tab.close();
|
tab.close();
|
||||||
}
|
}
|
||||||
tabs.clear();
|
tabs.clear();
|
||||||
|
paneSyncSelectMode = false;
|
||||||
|
paneSyncSelection.clear();
|
||||||
|
paneSyncPanes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -377,6 +435,7 @@ public final class Compositor {
|
|||||||
for (TerminalPane pane : panes) {
|
for (TerminalPane pane : panes) {
|
||||||
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
|
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
|
||||||
}
|
}
|
||||||
|
drawPaneSyncOverlay(gc, panes);
|
||||||
imageOverlay.sync(panes);
|
imageOverlay.sync(panes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +455,7 @@ public final class Compositor {
|
|||||||
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
||||||
imageOverlay.updatePane(pane);
|
imageOverlay.updatePane(pane);
|
||||||
}
|
}
|
||||||
|
drawPaneSyncOverlay(gc, panes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private GraphicsContext beginFrame() {
|
private GraphicsContext beginFrame() {
|
||||||
@@ -431,6 +491,63 @@ public final class Compositor {
|
|||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void drawPaneSyncOverlay(GraphicsContext gc, List<TerminalPane> panes) {
|
||||||
|
Set<TerminalPane> 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<TerminalPane> live = livePanes();
|
||||||
|
paneSyncSelection.retainAll(live);
|
||||||
|
paneSyncPanes.retainAll(live);
|
||||||
|
if (paneSyncPanes.size() < 2) {
|
||||||
|
paneSyncPanes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<TerminalPane> livePanes() {
|
||||||
|
Set<TerminalPane> live = new HashSet<>();
|
||||||
|
for (Tab tab : tabs) {
|
||||||
|
live.addAll(tab.allPanes());
|
||||||
|
}
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Input ----------------------------------------------------------------------
|
// ---- Input ----------------------------------------------------------------------
|
||||||
|
|
||||||
private void handleMousePressed(MouseEvent event) {
|
private void handleMousePressed(MouseEvent event) {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ final class TerminalWindow {
|
|||||||
keyActions.put("open_font_selector", this::openFontSelector);
|
keyActions.put("open_font_selector", this::openFontSelector);
|
||||||
keyActions.put("open_scrollback", this::openScrollbackInEditor);
|
keyActions.put("open_scrollback", this::openScrollbackInEditor);
|
||||||
keyActions.put("create_worktree", this::createWorktreeInEditor);
|
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);
|
keyActions.put("paste", this::pasteFromClipboard);
|
||||||
|
|
||||||
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||||
@@ -131,19 +133,51 @@ final class TerminalWindow {
|
|||||||
|
|
||||||
private void handlePressed(KeyEvent event) {
|
private void handlePressed(KeyEvent event) {
|
||||||
for (Map.Entry<String, Runnable> action : keyActions.entrySet()) {
|
for (Map.Entry<String, Runnable> 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();
|
action.getValue().run();
|
||||||
event.consume();
|
event.consume();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (compositor.isPaneSyncSelecting()) {
|
||||||
|
event.consume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
String encoded = KeyEncoder.encode(event);
|
String encoded = KeyEncoder.encode(event);
|
||||||
if (encoded != null) {
|
if (encoded != null) {
|
||||||
sendToActivePane(encoded, event);
|
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) {
|
private void handleTyped(KeyEvent event) {
|
||||||
|
if (compositor.isPaneSyncSelecting()) {
|
||||||
|
event.consume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -160,6 +194,9 @@ final class TerminalWindow {
|
|||||||
TerminalPane active = compositor.activePane();
|
TerminalPane active = compositor.activePane();
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
active.send(text);
|
active.send(text);
|
||||||
|
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
|
||||||
|
peer.send(text);
|
||||||
|
}
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +205,11 @@ final class TerminalWindow {
|
|||||||
TerminalPane active = compositor.activePane();
|
TerminalPane active = compositor.activePane();
|
||||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||||
if (active != null && clipboard.hasString()) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user