Compare commits
19 Commits
ec769f0f92
...
thinner-sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 86dd79d5b1 | |||
| 2c4b89f8f7 | |||
| cff4fba81c | |||
| 5a50ce62f3 | |||
| 27b7ba6904 | |||
| 7b3a370371 | |||
| 4e2ed7f99f | |||
| b8a4f52609 | |||
| 7367b2f778 | |||
| a6a700b2c0 | |||
| 47b2daa782 | |||
| 8bd8170270 | |||
| c922968376 | |||
| 64263ee3fb | |||
| f621366b0c | |||
| 77fb5e7e11 | |||
| 08fcb93e01 | |||
| 8954482222 | |||
| 692511e445 |
27
README.md
27
README.md
@@ -78,6 +78,9 @@ After that, a bare `jprototerm` connects to the daemon and opens a window in the
|
|||||||
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
|
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
|
||||||
(today's behavior), so it always works.
|
(today's behavior), so it always works.
|
||||||
|
|
||||||
|
For development testing, use `jprototerm --standalone` to skip the daemon even when one is
|
||||||
|
running.
|
||||||
|
|
||||||
To start the daemon automatically with your graphical session, enable the bundled **user**
|
To start the daemon automatically with your graphical session, enable the bundled **user**
|
||||||
service (it's a user service, not a system one, because X11 needs a display — which only
|
service (it's a user service, not a system one, because X11 needs a display — which only
|
||||||
exists after you log in):
|
exists after you log in):
|
||||||
@@ -146,6 +149,14 @@ enabled = true
|
|||||||
[scrollback]
|
[scrollback]
|
||||||
editor_command = "vi {file}"
|
editor_command = "vi {file}"
|
||||||
|
|
||||||
|
[worktree]
|
||||||
|
relative_worktree_path = "./.worktrees"
|
||||||
|
split_regex = ","
|
||||||
|
# One of: "none", "cd", "create_panes", "create_panes_floating".
|
||||||
|
post_create_action = "none"
|
||||||
|
commands = []
|
||||||
|
sync_panes = false
|
||||||
|
|
||||||
[env.override]
|
[env.override]
|
||||||
ZELLIJ_SESSION_NAME = ""
|
ZELLIJ_SESSION_NAME = ""
|
||||||
|
|
||||||
@@ -163,6 +174,10 @@ previous_tab = "ALT+SHIFT+H"
|
|||||||
next_tab = "ALT+SHIFT+L"
|
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"
|
||||||
|
pane_sync_toggle = "ALT+Y"
|
||||||
|
pane_sync_select = "SPACE"
|
||||||
|
paste = "CTRL+SHIFT+V"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
@@ -178,6 +193,18 @@ open_scrollback = "ALT+S"
|
|||||||
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
|
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
|
||||||
- `Alt+t`: open the font selector
|
- `Alt+t`: open the font selector
|
||||||
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
||||||
|
- `Alt+w`: edit one or more worktree names, split by `worktree.split_regex`, then run
|
||||||
|
`git worktree add <relative_worktree_path>/<name>` for each name from the previously focused
|
||||||
|
pane's working directory. `worktree.post_create_action` can then do nothing with `none`, `cd` the
|
||||||
|
previously active pane to the last created worktree, create one tiled pane per worktree with
|
||||||
|
`create_panes`, or create one floating pane per worktree with `create_panes_floating`.
|
||||||
|
`worktree.commands`, for example `["npm install", "git status"]`, can run commands in created
|
||||||
|
panes; commands are assigned in order and repeat when fewer commands than panes are created.
|
||||||
|
`worktree.sync_panes = true` syncs those created panes after the configured commands are sent.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ enabled = true
|
|||||||
[scrollback]
|
[scrollback]
|
||||||
editor_command = "vi {file}"
|
editor_command = "vi {file}"
|
||||||
|
|
||||||
|
[worktree]
|
||||||
|
relative_worktree_path = "./.worktrees"
|
||||||
|
split_regex = ","
|
||||||
|
# One of: "none", "cd", "create_panes", "create_panes_floating".
|
||||||
|
post_create_action = "none"
|
||||||
|
commands = []
|
||||||
|
sync_panes = false
|
||||||
|
|
||||||
[env.override]
|
[env.override]
|
||||||
ZELLIJ_SESSION_NAME = ""
|
ZELLIJ_SESSION_NAME = ""
|
||||||
|
|
||||||
@@ -28,9 +36,16 @@ navigate_down = "ALT+J"
|
|||||||
navigate_up = "ALT+K"
|
navigate_up = "ALT+K"
|
||||||
navigate_right = "ALT+L"
|
navigate_right = "ALT+L"
|
||||||
toggle_floating = "ALT+F"
|
toggle_floating = "ALT+F"
|
||||||
new_floating = "ALT+SHIFT+F"
|
new_pane = "ALT+N"
|
||||||
next_floating = "ALT+F12"
|
next_floating = "ALT+F12"
|
||||||
|
promote_floating = "ALT+P"
|
||||||
close_pane = "ALT+X"
|
close_pane = "ALT+X"
|
||||||
|
new_tab = "ALT+A"
|
||||||
|
previous_tab = "ALT+SHIFT+H"
|
||||||
|
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"
|
||||||
|
pane_sync_toggle = "ALT+Y"
|
||||||
|
pane_sync_select = "SPACE"
|
||||||
paste = "CTRL+SHIFT+V"
|
paste = "CTRL+SHIFT+V"
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ public record AppConfig(
|
|||||||
double windowHeight,
|
double windowHeight,
|
||||||
boolean kittyGraphics,
|
boolean kittyGraphics,
|
||||||
String scrollbackEditorCommand,
|
String scrollbackEditorCommand,
|
||||||
|
String worktreeRelativePath,
|
||||||
|
String worktreeSplitRegex,
|
||||||
|
String worktreePostCreateAction,
|
||||||
|
List<String> worktreeCommands,
|
||||||
|
boolean worktreeSyncPanes,
|
||||||
String closeSignal,
|
String closeSignal,
|
||||||
Map<String, String> envOverride,
|
Map<String, String> envOverride,
|
||||||
Map<String, KeyBinding> keybindings
|
Map<String, KeyBinding> keybindings
|
||||||
@@ -48,6 +53,9 @@ public record AppConfig(
|
|||||||
"next_tab",
|
"next_tab",
|
||||||
"open_font_selector",
|
"open_font_selector",
|
||||||
"open_scrollback",
|
"open_scrollback",
|
||||||
|
"create_worktree",
|
||||||
|
"pane_sync_toggle",
|
||||||
|
"pane_sync_select",
|
||||||
"paste"
|
"paste"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,7 +63,7 @@ public record AppConfig(
|
|||||||
AppConfig defaults = defaults();
|
AppConfig defaults = defaults();
|
||||||
Path path = configPath();
|
Path path = configPath();
|
||||||
if (!Files.isRegularFile(path)) {
|
if (!Files.isRegularFile(path)) {
|
||||||
writeDefaultConfig(path, defaults);
|
save(path, defaults);
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +80,11 @@ public record AppConfig(
|
|||||||
doubleValue(document, "window.height", defaults.windowHeight),
|
doubleValue(document, "window.height", defaults.windowHeight),
|
||||||
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
||||||
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
||||||
|
stringValue(document, "worktree.relative_worktree_path", defaults.worktreeRelativePath),
|
||||||
|
stringValue(document, "worktree.split_regex", defaults.worktreeSplitRegex),
|
||||||
|
stringValue(document, "worktree.post_create_action", defaults.worktreePostCreateAction),
|
||||||
|
stringListValue(document, "worktree.commands", defaults.worktreeCommands),
|
||||||
|
booleanValue(document, "worktree.sync_panes", defaults.worktreeSyncPanes),
|
||||||
closeSignalValue(document, defaults.closeSignal),
|
closeSignalValue(document, defaults.closeSignal),
|
||||||
envOverride(document, defaults.envOverride),
|
envOverride(document, defaults.envOverride),
|
||||||
keybindings(document, defaults)
|
keybindings(document, defaults)
|
||||||
@@ -94,6 +107,11 @@ public record AppConfig(
|
|||||||
760.0,
|
760.0,
|
||||||
true,
|
true,
|
||||||
defaultScrollbackEditorCommand(),
|
defaultScrollbackEditorCommand(),
|
||||||
|
"./.worktrees",
|
||||||
|
",",
|
||||||
|
"none",
|
||||||
|
List.of(),
|
||||||
|
false,
|
||||||
"SIGTERM",
|
"SIGTERM",
|
||||||
Map.of(),
|
Map.of(),
|
||||||
Map.ofEntries(
|
Map.ofEntries(
|
||||||
@@ -111,6 +129,9 @@ public record AppConfig(
|
|||||||
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
||||||
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("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"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -128,6 +149,11 @@ public record AppConfig(
|
|||||||
windowHeight,
|
windowHeight,
|
||||||
kittyGraphics,
|
kittyGraphics,
|
||||||
scrollbackEditorCommand,
|
scrollbackEditorCommand,
|
||||||
|
worktreeRelativePath,
|
||||||
|
worktreeSplitRegex,
|
||||||
|
worktreePostCreateAction,
|
||||||
|
worktreeCommands,
|
||||||
|
worktreeSyncPanes,
|
||||||
closeSignal,
|
closeSignal,
|
||||||
envOverride,
|
envOverride,
|
||||||
keybindings
|
keybindings
|
||||||
@@ -195,10 +221,6 @@ public record AppConfig(
|
|||||||
return Map.copyOf(parsed);
|
return Map.copyOf(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeDefaultConfig(Path path, AppConfig defaults) {
|
|
||||||
save(path, defaults);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void save(Path path, AppConfig config) {
|
private static void save(Path path, AppConfig config) {
|
||||||
try {
|
try {
|
||||||
Path parent = path.getParent();
|
Path parent = path.getParent();
|
||||||
@@ -234,6 +256,12 @@ public record AppConfig(
|
|||||||
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
|
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
|
||||||
builder.append("[scrollback]\n");
|
builder.append("[scrollback]\n");
|
||||||
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
|
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
|
||||||
|
builder.append("[worktree]\n");
|
||||||
|
builder.append("relative_worktree_path = ").append(quoted(worktreeRelativePath)).append('\n');
|
||||||
|
builder.append("split_regex = ").append(quoted(worktreeSplitRegex)).append('\n');
|
||||||
|
builder.append("post_create_action = ").append(quoted(worktreePostCreateAction)).append('\n');
|
||||||
|
builder.append("commands = ").append(quotedList(worktreeCommands)).append('\n');
|
||||||
|
builder.append("sync_panes = ").append(worktreeSyncPanes).append("\n\n");
|
||||||
builder.append("[env.override]\n");
|
builder.append("[env.override]\n");
|
||||||
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
|
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
|
||||||
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
|
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
|
||||||
@@ -340,15 +368,7 @@ public record AppConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static int intValue(TomlTable table, String key, int fallback) {
|
private static int intValue(TomlTable table, String key, int fallback) {
|
||||||
TomlPrimitive primitive = primitive(table, key);
|
return (int) longValue(table, key, fallback);
|
||||||
if (primitive == null) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return primitive.asInteger();
|
|
||||||
} catch (RuntimeException ex) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long longValue(TomlTable table, String key, long fallback) {
|
private static long longValue(TomlTable table, String key, long fallback) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -114,7 +120,7 @@ public final class Compositor {
|
|||||||
public void setFont(String family, double size) {
|
public void setFont(String family, double size) {
|
||||||
metrics.setFont(family, size);
|
metrics.setFont(family, size);
|
||||||
paneContentVersion.clear();
|
paneContentVersion.clear();
|
||||||
lastWidth = -1.0; // force a redraw on the next frame
|
layoutVersion++; // recomposite with the new metrics on the next frame
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tabs and panes -------------------------------------------------------------
|
// ---- Tabs and panes -------------------------------------------------------------
|
||||||
@@ -123,8 +129,9 @@ public final class Compositor {
|
|||||||
return tabs.isEmpty();
|
return tabs.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The active pane of the current tab, or {@code null} when no tab is left. */
|
||||||
public TerminalPane activePane() {
|
public TerminalPane activePane() {
|
||||||
return currentTab().activePane();
|
return isEmpty() ? null : currentTab().activePane();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void navigate(Direction direction) {
|
public void navigate(Direction direction) {
|
||||||
@@ -133,28 +140,89 @@ public final class Compositor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void toggleFloating() {
|
public boolean isPaneSyncSelecting() {
|
||||||
if (isEmpty()) {
|
return paneSyncSelectMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void togglePaneSync() {
|
||||||
|
if (paneSyncSelectMode) {
|
||||||
|
paneSyncPanes.clear();
|
||||||
|
paneSyncPanes.addAll(paneSyncSelection);
|
||||||
|
paneSyncSelectMode = false;
|
||||||
|
paneSyncSelection.clear();
|
||||||
|
prunePaneSyncState();
|
||||||
|
layoutVersion++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentTab().toggleFloating();
|
if (!paneSyncPanes.isEmpty()) {
|
||||||
|
paneSyncPanes.clear();
|
||||||
layoutVersion++;
|
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 syncPanes(List<TerminalPane> panes) {
|
||||||
|
paneSyncSelectMode = false;
|
||||||
|
paneSyncSelection.clear();
|
||||||
|
paneSyncPanes.clear();
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
if (pane != null) {
|
||||||
|
paneSyncPanes.add(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prunePaneSyncState();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleFloating() {
|
||||||
|
mutateCurrentTab(() -> currentTab().toggleFloating());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void createPane() {
|
public void createPane() {
|
||||||
if (isEmpty()) {
|
mutateCurrentTab(() -> currentTab().createPane());
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentTab().createPane();
|
|
||||||
layoutVersion++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
|
public TerminalPane createTiledPane(String workingDirectory) {
|
||||||
public TerminalPane openFloatingPane() {
|
|
||||||
if (isEmpty()) {
|
if (isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
TerminalPane pane = currentTab().createFloatingPane();
|
TerminalPane pane = currentTab().createTiledPane(workingDirectory);
|
||||||
|
layoutVersion++;
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TerminalPane pane = currentTab().createFloatingPaneInDirectory(workingDirectory);
|
||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
@@ -173,26 +241,25 @@ public final class Compositor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void nextFloatingPane() {
|
public void nextFloatingPane() {
|
||||||
if (isEmpty()) {
|
mutateCurrentTab(() -> currentTab().nextFloatingPane());
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentTab().nextFloatingPane();
|
|
||||||
layoutVersion++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void toggleActiveFloating() {
|
public void toggleActiveFloating() {
|
||||||
|
mutateCurrentTab(() -> currentTab().toggleActiveFloating());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a structural change on the current tab and bump the layout version so the next frame
|
||||||
|
// recomposites. No-op when no tab is left.
|
||||||
|
private void mutateCurrentTab(Runnable change) {
|
||||||
if (isEmpty()) {
|
if (isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentTab().toggleActiveFloating();
|
change.run();
|
||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void closeActivePane() {
|
public void closeActivePane() {
|
||||||
if (isEmpty()) {
|
TerminalPane active = activePane();
|
||||||
return;
|
|
||||||
}
|
|
||||||
TerminalPane active = currentTab().activePane();
|
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
closePane(active);
|
closePane(active);
|
||||||
}
|
}
|
||||||
@@ -208,6 +275,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).
|
||||||
@@ -230,7 +298,8 @@ public final class Compositor {
|
|||||||
public void newTab() {
|
public void newTab() {
|
||||||
// Open the new tab in the currently active pane's working directory, so it lands where the
|
// Open the new tab in the currently active pane's working directory, so it lands where the
|
||||||
// user currently is rather than always in home.
|
// user currently is rather than always in home.
|
||||||
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
|
TerminalPane active = activePane();
|
||||||
|
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
|
||||||
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||||
currentTabIndex = tabs.size() - 1;
|
currentTabIndex = tabs.size() - 1;
|
||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
@@ -255,6 +324,9 @@ public final class Compositor {
|
|||||||
tab.close();
|
tab.close();
|
||||||
}
|
}
|
||||||
tabs.clear();
|
tabs.clear();
|
||||||
|
paneSyncSelectMode = false;
|
||||||
|
paneSyncSelection.clear();
|
||||||
|
paneSyncPanes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -394,6 +466,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +486,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() {
|
||||||
@@ -448,6 +522,68 @@ 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 {
|
||||||
|
// Draw a thin sync border inset just inside the pane's own edge border, so the
|
||||||
|
// renderer's active (blue) / inactive border stays visible and you can still tell
|
||||||
|
// which synced pane is focused.
|
||||||
|
double lineWidth = 2.0;
|
||||||
|
double inset = 2.0 + lineWidth / 2.0; // clear the renderer's 2px active border
|
||||||
|
gc.setLineWidth(lineWidth);
|
||||||
|
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()) + inset;
|
||||||
|
double y = Math.round(pane.y()) + inset;
|
||||||
|
double width = Math.max(0.0, pane.width() - 2.0 * inset);
|
||||||
|
double height = Math.max(0.0, pane.height() - 2.0 * inset);
|
||||||
|
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) {
|
||||||
@@ -470,11 +606,11 @@ public final class Compositor {
|
|||||||
private void handleMouseReleased(MouseEvent event) {
|
private void handleMouseReleased(MouseEvent event) {
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
if (pane == null) {
|
if (pane == null) {
|
||||||
pane = activePane();
|
pane = activePane(); // released outside every pane (e.g. mid-drag): route to the active one
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
MouseTarget target = mouseTarget(pane);
|
MouseTarget target = pane == null ? null : mouseTarget(pane);
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
||||||
}
|
}
|
||||||
@@ -485,7 +621,10 @@ public final class Compositor {
|
|||||||
private void handleMouseDragged(MouseEvent event) {
|
private void handleMouseDragged(MouseEvent event) {
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
if (pane == null) {
|
if (pane == null) {
|
||||||
pane = activePane();
|
pane = activePane(); // dragged outside every pane: route to the active one
|
||||||
|
}
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import java.nio.channels.ServerSocketChannel;
|
|||||||
import java.nio.channels.SocketChannel;
|
import java.nio.channels.SocketChannel;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.attribute.PosixFilePermissions;
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
|
||||||
@@ -23,6 +24,11 @@ import java.nio.file.attribute.PosixFilePermissions;
|
|||||||
* (mode 0700), so only the owning user can connect.
|
* (mode 0700), so only the owning user can connect.
|
||||||
*/
|
*/
|
||||||
public final class Daemon {
|
public final class Daemon {
|
||||||
|
// One request is a single line holding a filesystem path; anything bigger is bogus.
|
||||||
|
private static final int MAX_REQUEST_BYTES = 4096;
|
||||||
|
// The accept loop is single-threaded, so a client that stalls must not wedge the daemon.
|
||||||
|
private static final long READ_TIMEOUT_NANOS = 5_000_000_000L;
|
||||||
|
|
||||||
private Daemon() {
|
private Daemon() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +37,9 @@ public final class Daemon {
|
|||||||
Path socket = socketPath();
|
Path socket = socketPath();
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(socket.getParent());
|
Files.createDirectories(socket.getParent());
|
||||||
trySecureDir(socket.getParent());
|
secureDir(socket.getParent());
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage());
|
System.err.println("jprototerm: cannot secure socket dir " + socket.getParent() + ": " + ex.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,14 +113,22 @@ public final class Daemon {
|
|||||||
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
||||||
? null
|
? null
|
||||||
: workingDirectory.trim());
|
: workingDirectory.trim());
|
||||||
|
connection.configureBlocking(true);
|
||||||
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reads the request line non-blocking with a deadline and a size cap: the accept loop is
|
||||||
|
// single-threaded, so a client that stalls or never sends a newline must fail the connection
|
||||||
|
// (an IOException logged by run()) rather than wedge the daemon or grow the buffer unbounded.
|
||||||
private static String readLine(SocketChannel channel) throws IOException {
|
private static String readLine(SocketChannel channel) throws IOException {
|
||||||
|
channel.configureBlocking(false);
|
||||||
|
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
||||||
while (channel.read(buffer) != -1) {
|
while (true) {
|
||||||
|
int n = channel.read(buffer);
|
||||||
|
if (n > 0) {
|
||||||
buffer.flip();
|
buffer.flip();
|
||||||
while (buffer.hasRemaining()) {
|
while (buffer.hasRemaining()) {
|
||||||
byte b = buffer.get();
|
byte b = buffer.get();
|
||||||
@@ -122,10 +136,25 @@ public final class Daemon {
|
|||||||
return out.toString(StandardCharsets.UTF_8);
|
return out.toString(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
out.write(b);
|
out.write(b);
|
||||||
|
if (out.size() > MAX_REQUEST_BYTES) {
|
||||||
|
throw new IOException("request line too long");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
}
|
} else if (n == -1) {
|
||||||
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
||||||
|
} else {
|
||||||
|
if (System.nanoTime() >= deadline) {
|
||||||
|
throw new IOException("request timed out");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(5);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("interrupted while reading request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Path socketPath() {
|
private static Path socketPath() {
|
||||||
@@ -136,11 +165,19 @@ public final class Daemon {
|
|||||||
return dir.resolve("daemon.sock");
|
return dir.resolve("daemon.sock");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void trySecureDir(Path dir) {
|
// Make the socket dir private, and refuse to use it if it is not ours. The /tmp fallback
|
||||||
|
// path is predictable, so another user could have pre-created it (the classic /tmp race);
|
||||||
|
// binding a socket inside a directory someone else owns would hand them control of it.
|
||||||
|
private static void secureDir(Path dir) throws IOException {
|
||||||
try {
|
try {
|
||||||
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
||||||
} catch (IOException | UnsupportedOperationException ignored) {
|
} catch (UnsupportedOperationException ignored) {
|
||||||
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
|
return; // not a POSIX filesystem: nothing more we can check
|
||||||
|
}
|
||||||
|
String owner = Files.getOwner(dir, LinkOption.NOFOLLOW_LINKS).getName();
|
||||||
|
String user = System.getProperty("user.name");
|
||||||
|
if (!owner.equals(user)) {
|
||||||
|
throw new IOException(dir + " is owned by '" + owner + "', not '" + user + "'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import javafx.scene.image.Image;
|
|||||||
import javafx.scene.image.PixelFormat;
|
import javafx.scene.image.PixelFormat;
|
||||||
import javafx.scene.image.PixelBuffer;
|
import javafx.scene.image.PixelBuffer;
|
||||||
import javafx.scene.image.WritableImage;
|
import javafx.scene.image.WritableImage;
|
||||||
import javafx.scene.paint.Color;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.nio.IntBuffer;
|
import java.nio.IntBuffer;
|
||||||
@@ -40,19 +39,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
private static final int DIRTY_PARTIAL = 1;
|
private static final int DIRTY_PARTIAL = 1;
|
||||||
private static final int DIRTY_FULL = 2;
|
private static final int DIRTY_FULL = 2;
|
||||||
|
|
||||||
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
|
// All colors are packed ARGB ints (alpha always 0xff): the software backbuffer never needs
|
||||||
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
|
// a javafx Color, so cells go straight from RenderColor to the pixel format.
|
||||||
|
private static final int DEFAULT_FOREGROUND = 0xffe1e5eb; // rgb(225, 229, 235)
|
||||||
|
private static final int SELECTED_BACKGROUND = 0xff345c8c; // rgb(52, 92, 140)
|
||||||
// The default cell background (used for cells with no explicit bg, and as the foreground
|
// The default cell background (used for cells with no explicit bg, and as the foreground
|
||||||
// for reverse-video cells whose background is the terminal default).
|
// for reverse-video cells whose background is the terminal default).
|
||||||
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
|
private static final int PANE_BACKGROUND = 0xff090a0c; // rgb(9, 10, 12)
|
||||||
private static final Color ACTIVE_BORDER = Color.rgb(87, 166, 255);
|
private static final int ACTIVE_BORDER = 0xff57a6ff; // rgb(87, 166, 255)
|
||||||
private static final Color INACTIVE_BORDER = Color.rgb(52, 57, 65);
|
private static final int INACTIVE_BORDER = 0xff343941; // rgb(52, 57, 65)
|
||||||
private static final Color CURSOR_FILL = Color.rgb(225, 229, 235, 0.28);
|
// Block cursor: DEFAULT_FOREGROUND blended at 28% alpha by fillRectAlpha.
|
||||||
|
private static final int CURSOR_FILL_ALPHA = 71;
|
||||||
// A full-screen redraw asks for one Color per cell; most cells share a handful of colors,
|
|
||||||
// so cache them by packed RGB instead of allocating a Color each time. Bounded so a
|
|
||||||
// truecolor gradient can't grow it without limit.
|
|
||||||
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
|
|
||||||
|
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
|
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
|
||||||
@@ -131,16 +128,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
|
|
||||||
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
|
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
|
||||||
// unset colour falls back to the defaults).
|
// unset colour falls back to the defaults).
|
||||||
private static Color cellBackgroundColor(RenderCell cell) {
|
private static int cellBackgroundColor(RenderCell cell) {
|
||||||
if (cell.inverse()) {
|
int override = cellBackgroundOverride(cell);
|
||||||
var fg = cell.foreground();
|
return override != 0 ? override : PANE_BACKGROUND;
|
||||||
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
|
|
||||||
}
|
|
||||||
var bg = cell.background();
|
|
||||||
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
|
private static int rowEdgeBackground(RenderRow row, boolean firstCell) {
|
||||||
List<RenderCell> cells = row.cells();
|
List<RenderCell> cells = row.cells();
|
||||||
if (cells.isEmpty()) {
|
if (cells.isEmpty()) {
|
||||||
return PANE_BACKGROUND;
|
return PANE_BACKGROUND;
|
||||||
@@ -148,41 +141,28 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background override for a cell: null means the pane default background already covers it.
|
// Background override for a cell: 0 means the pane default background already covers it
|
||||||
private static Color cellBackgroundOverride(RenderCell cell) {
|
// (real colors always carry 0xff alpha, so 0 is never a valid color).
|
||||||
|
private static int cellBackgroundOverride(RenderCell cell) {
|
||||||
if (cell.inverse()) {
|
if (cell.inverse()) {
|
||||||
var fg = cell.foreground();
|
var fg = cell.foreground();
|
||||||
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
|
return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
|
||||||
}
|
}
|
||||||
var bgOpt = cell.background();
|
var bg = cell.background();
|
||||||
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
|
return bg.isPresent() ? packArgb(bg.get()) : 0;
|
||||||
return bg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color cellForegroundColor(RenderCell cell) {
|
private static int cellForegroundColor(RenderCell cell) {
|
||||||
var fgOpt = cell.foreground();
|
|
||||||
var bgOpt = cell.background();
|
|
||||||
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
|
|
||||||
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
|
|
||||||
|
|
||||||
if (cell.inverse()) {
|
if (cell.inverse()) {
|
||||||
return (bg != null) ? bg : PANE_BACKGROUND;
|
var bg = cell.background();
|
||||||
|
return bg.isPresent() ? packArgb(bg.get()) : PANE_BACKGROUND;
|
||||||
}
|
}
|
||||||
return fg;
|
var fg = cell.foreground();
|
||||||
|
return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color toFxColor(RenderColor color) {
|
private static int packArgb(RenderColor color) {
|
||||||
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
|
return 0xff000000 | (color.red() << 16) | (color.green() << 8) | color.blue();
|
||||||
Color cached = COLOR_CACHE.get(key);
|
|
||||||
if (cached != null) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
if (COLOR_CACHE.size() >= 4096) {
|
|
||||||
COLOR_CACHE.clear();
|
|
||||||
}
|
|
||||||
Color created = Color.rgb(color.red(), color.green(), color.blue());
|
|
||||||
COLOR_CACHE.put(key, created);
|
|
||||||
return created;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Kitty graphics --------------------------------------------------------------
|
// ---- Kitty graphics --------------------------------------------------------------
|
||||||
@@ -474,7 +454,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot,
|
private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot,
|
||||||
double px, double py, double paneWidth, double paneHeight, boolean active) {
|
double px, double py, double paneWidth, double paneHeight, boolean active) {
|
||||||
ensure(paneWidth, paneHeight);
|
ensure(paneWidth, paneHeight);
|
||||||
fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND));
|
fillRect(0, 0, width, height, PANE_BACKGROUND);
|
||||||
if (snapshot != null) {
|
if (snapshot != null) {
|
||||||
paintSnapshot(snapshot);
|
paintSnapshot(snapshot);
|
||||||
drawCursor(snapshot);
|
drawCursor(snapshot);
|
||||||
@@ -574,11 +554,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (repaintedRowHasCursor(newCursorRow, repainted)
|
|
||||||
&& !repaintCursorRow(snapshot, newCursorRow, repainted)) {
|
|
||||||
paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastCursor = cursor;
|
lastCursor = cursor;
|
||||||
if (needsCursorDraw) {
|
if (needsCursorDraw) {
|
||||||
drawCursor(snapshot);
|
drawCursor(snapshot);
|
||||||
@@ -601,10 +576,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean repaintedRowHasCursor(int rowIndex, boolean[] repainted) {
|
|
||||||
return rowIndex >= 0 && rowIndex < repainted.length && repainted[rowIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) {
|
private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) {
|
||||||
for (RenderRow row : snapshot.renderRows()) {
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
if (row.row() == rowIndex) {
|
if (row.row() == rowIndex) {
|
||||||
@@ -737,7 +708,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
// per-strip fillRect calls don't touch, so mark the full content band for upload.
|
// per-strip fillRect calls don't touch, so mark the full content band for upload.
|
||||||
markDirtyRows(top, top + contentHeight);
|
markDirtyRows(top, top + contentHeight);
|
||||||
if (dy == 0 || Math.abs(dy) >= contentHeight) {
|
if (dy == 0 || Math.abs(dy) >= contentHeight) {
|
||||||
fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND));
|
fillRect(0, top, width, contentHeight, PANE_BACKGROUND);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,7 +719,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
for (int y = 0; y < copyHeight; y++) {
|
for (int y = 0; y < copyHeight; y++) {
|
||||||
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
|
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
|
||||||
}
|
}
|
||||||
fillRect(0, top + copyHeight, width, -dy, argbPre(PANE_BACKGROUND));
|
fillRect(0, top + copyHeight, width, -dy, PANE_BACKGROUND);
|
||||||
} else {
|
} else {
|
||||||
int srcY = top;
|
int srcY = top;
|
||||||
int dstY = top + dy;
|
int dstY = top + dy;
|
||||||
@@ -756,7 +727,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
for (int y = copyHeight - 1; y >= 0; y--) {
|
for (int y = copyHeight - 1; y >= 0; y--) {
|
||||||
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
|
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
|
||||||
}
|
}
|
||||||
fillRect(0, top, width, dy, argbPre(PANE_BACKGROUND));
|
fillRect(0, top, width, dy, PANE_BACKGROUND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,8 +738,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
}
|
}
|
||||||
int top = contentTop();
|
int top = contentTop();
|
||||||
int contentBottom = top + snapshot.rows() * lineHeight();
|
int contentBottom = top + snapshot.rows() * lineHeight();
|
||||||
fillRect(0, 0, width, top, argbPre(rowEdgeBackground(rows.get(0), true)));
|
fillRect(0, 0, width, top, rowEdgeBackground(rows.get(0), true));
|
||||||
fillRect(0, contentBottom, width, height - contentBottom, argbPre(rowEdgeBackground(rows.get(rows.size() - 1), true)));
|
fillRect(0, contentBottom, width, height - contentBottom, rowEdgeBackground(rows.get(rows.size() - 1), true));
|
||||||
for (RenderRow row : rows) {
|
for (RenderRow row : rows) {
|
||||||
paintRow(row);
|
paintRow(row);
|
||||||
}
|
}
|
||||||
@@ -777,13 +748,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
private void paintRow(RenderRow row) {
|
private void paintRow(RenderRow row) {
|
||||||
int rowTop = contentTop() + (row.row() * lineHeight());
|
int rowTop = contentTop() + (row.row() * lineHeight());
|
||||||
int rowHeight = lineHeight();
|
int rowHeight = lineHeight();
|
||||||
fillRect(0, rowTop, width, rowHeight, argbPre(PANE_BACKGROUND));
|
fillRect(0, rowTop, width, rowHeight, PANE_BACKGROUND);
|
||||||
if (row.row() == 0) {
|
if (row.row() == 0) {
|
||||||
fillRect(0, 0, width, contentTop(), argbPre(rowEdgeBackground(row, true)));
|
fillRect(0, 0, width, contentTop(), rowEdgeBackground(row, true));
|
||||||
}
|
}
|
||||||
if (row.row() == rowHashes.length - 1) {
|
if (row.row() == rowHashes.length - 1) {
|
||||||
int bottom = contentTop() + (rowHashes.length * lineHeight());
|
int bottom = contentTop() + (rowHashes.length * lineHeight());
|
||||||
fillRect(0, bottom, width, height - bottom, argbPre(rowEdgeBackground(row, true)));
|
fillRect(0, bottom, width, height - bottom, rowEdgeBackground(row, true));
|
||||||
}
|
}
|
||||||
paintRowSidePadding(row, rowTop, rowHeight);
|
paintRowSidePadding(row, rowTop, rowHeight);
|
||||||
paintRowBackgrounds(row, rowTop, rowHeight);
|
paintRowBackgrounds(row, rowTop, rowHeight);
|
||||||
@@ -797,31 +768,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
}
|
}
|
||||||
int left = contentLeft();
|
int left = contentLeft();
|
||||||
int contentRight = left + (cells.size() * cellWidth());
|
int contentRight = left + (cells.size() * cellWidth());
|
||||||
fillRect(0, rowTop, left, rowHeight, argbPre(rowEdgeBackground(row, true)));
|
fillRect(0, rowTop, left, rowHeight, rowEdgeBackground(row, true));
|
||||||
fillRect(contentRight, rowTop, width - contentRight, rowHeight, argbPre(rowEdgeBackground(row, false)));
|
fillRect(contentRight, rowTop, width - contentRight, rowHeight, rowEdgeBackground(row, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) {
|
private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) {
|
||||||
int cellWidth = cellWidth();
|
int runBackground = 0;
|
||||||
Color runBackground = null;
|
|
||||||
int runStartColumn = 0;
|
int runStartColumn = 0;
|
||||||
int previousColumn = -1;
|
int previousColumn = -1;
|
||||||
for (RenderCell cell : row.cells()) {
|
for (RenderCell cell : row.cells()) {
|
||||||
if (cell.kittyPlaceholder().isPresent()) {
|
if (cell.kittyPlaceholder().isPresent()) {
|
||||||
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
||||||
runBackground = null;
|
runBackground = 0;
|
||||||
previousColumn = -1;
|
previousColumn = -1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
|
int bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
|
||||||
if (bg == null) {
|
if (bg == 0) {
|
||||||
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
||||||
runBackground = null;
|
runBackground = 0;
|
||||||
previousColumn = -1;
|
previousColumn = -1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) {
|
if (bg != runBackground || cell.column() != previousColumn + 1) {
|
||||||
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
||||||
runBackground = bg;
|
runBackground = bg;
|
||||||
runStartColumn = cell.column();
|
runStartColumn = cell.column();
|
||||||
@@ -831,12 +801,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void flushBackground(Color background, int startColumn, int endColumn, int rowTop, int rowHeight) {
|
private void flushBackground(int background, int startColumn, int endColumn, int rowTop, int rowHeight) {
|
||||||
if (background == null || endColumn < startColumn) {
|
if (background == 0 || endColumn < startColumn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fillRect(contentLeft() + (startColumn * cellWidth()), rowTop,
|
fillRect(contentLeft() + (startColumn * cellWidth()), rowTop,
|
||||||
(endColumn - startColumn + 1) * cellWidth(), rowHeight, argbPre(background));
|
(endColumn - startColumn + 1) * cellWidth(), rowHeight, background);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void paintRowText(RenderRow row, int rowTop) {
|
private void paintRowText(RenderRow row, int rowTop) {
|
||||||
@@ -847,8 +817,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
GlyphCache.Glyph glyph = metrics.glyphCache().glyph(cell.text());
|
GlyphCache.Glyph glyph = metrics.glyphCache().glyph(cell.text());
|
||||||
int color = rgb(cellForegroundColor(cell));
|
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, cellForegroundColor(cell));
|
||||||
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,13 +876,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
saveCursorUnder(x, y, cw, lh);
|
saveCursorUnder(x, y, cw, lh);
|
||||||
RenderCursorStyle style = snapshot.cursorStyle();
|
RenderCursorStyle style = snapshot.cursorStyle();
|
||||||
if (style == RenderCursorStyle.BAR) {
|
if (style == RenderCursorStyle.BAR) {
|
||||||
fillRect(x, y + 2, 1, Math.max(1, lh - 4), argbPre(DEFAULT_FOREGROUND));
|
fillRect(x, y + 2, 1, Math.max(1, lh - 4), DEFAULT_FOREGROUND);
|
||||||
} else if (style == RenderCursorStyle.UNDERLINE) {
|
} else if (style == RenderCursorStyle.UNDERLINE) {
|
||||||
fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, argbPre(DEFAULT_FOREGROUND));
|
fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, DEFAULT_FOREGROUND);
|
||||||
} else if (style == RenderCursorStyle.BLOCK) {
|
} else if (style == RenderCursorStyle.BLOCK) {
|
||||||
fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), CURSOR_FILL);
|
fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), DEFAULT_FOREGROUND, CURSOR_FILL_ALPHA);
|
||||||
} else {
|
} else {
|
||||||
strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), argbPre(DEFAULT_FOREGROUND), 1);
|
strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), DEFAULT_FOREGROUND, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,7 +926,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void drawBorder(boolean active) {
|
private void drawBorder(boolean active) {
|
||||||
strokeRect(0, 0, width, height, argbPre(active ? ACTIVE_BORDER : INACTIVE_BORDER), active ? 2 : 1);
|
strokeRect(0, 0, width, height, active ? ACTIVE_BORDER : INACTIVE_BORDER, active ? 2 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) {
|
private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) {
|
||||||
@@ -974,9 +943,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fillRectAlpha(int x, int y, int w, int h, Color color) {
|
private void fillRectAlpha(int x, int y, int w, int h, int rgb, int alpha) {
|
||||||
int alpha = (int) Math.round(color.getOpacity() * 255.0);
|
|
||||||
int rgb = rgb(color);
|
|
||||||
int red = (rgb >> 16) & 0xff;
|
int red = (rgb >> 16) & 0xff;
|
||||||
int green = (rgb >> 8) & 0xff;
|
int green = (rgb >> 8) & 0xff;
|
||||||
int blue = rgb & 0xff;
|
int blue = rgb & 0xff;
|
||||||
@@ -1082,21 +1049,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
return hash * 0x100000001b3L;
|
return hash * 0x100000001b3L;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int rgb(Color color) {
|
|
||||||
int red = (int) Math.round(color.getRed() * 255.0);
|
|
||||||
int green = (int) Math.round(color.getGreen() * 255.0);
|
|
||||||
int blue = (int) Math.round(color.getBlue() * 255.0);
|
|
||||||
return (red << 16) | (green << 8) | blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int argbPre(Color color) {
|
|
||||||
int alpha = (int) Math.round(color.getOpacity() * 255.0);
|
|
||||||
int red = (int) Math.round(color.getRed() * alpha);
|
|
||||||
int green = (int) Math.round(color.getGreen() * alpha);
|
|
||||||
int blue = (int) Math.round(color.getBlue() * alpha);
|
|
||||||
return (alpha << 24) | (red << 16) | (green << 8) | blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A kitty image is immutable for a given (id, number); re-transmitting under the same id
|
// A kitty image is immutable for a given (id, number); re-transmitting under the same id
|
||||||
// changes the number (and the snapshot below evicts stale entries by id anyway). So the
|
// changes the number (and the snapshot below evicts stale entries by id anyway). So the
|
||||||
// identity + dimensions + payload length are enough to key the decoded-image cache, and
|
// identity + dimensions + payload length are enough to key the decoded-image cache, and
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ final class GlyphCache {
|
|||||||
record Glyph(int width, int height, byte[] alpha) {
|
record Glyph(int width, int height, byte[] alpha) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounds the atlas so pathological glyph diversity (e.g. a dump of distinct CJK/emoji cells)
|
||||||
|
// can't grow it without limit; on overflow it clears and rebuilds on demand, like any
|
||||||
|
// metrics change.
|
||||||
|
private static final int MAX_GLYPHS = 4096;
|
||||||
|
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
private final Map<String, Glyph> glyphs = new HashMap<>();
|
private final Map<String, Glyph> glyphs = new HashMap<>();
|
||||||
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
|
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
|
||||||
@@ -43,7 +48,16 @@ final class GlyphCache {
|
|||||||
|
|
||||||
Glyph glyph(String text) {
|
Glyph glyph(String text) {
|
||||||
ensureCurrent();
|
ensureCurrent();
|
||||||
return glyphs.computeIfAbsent(text, this::renderGlyph);
|
Glyph cached = glyphs.get(text);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (glyphs.size() >= MAX_GLYPHS) {
|
||||||
|
glyphs.clear();
|
||||||
|
}
|
||||||
|
Glyph rendered = renderGlyph(text);
|
||||||
|
glyphs.put(text, rendered);
|
||||||
|
return rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to
|
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import javafx.scene.input.KeyEvent;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) {
|
public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
|
||||||
public static KeyBinding parse(String value) {
|
public static KeyBinding parse(String value) {
|
||||||
boolean alt = false;
|
boolean alt = false;
|
||||||
boolean control = false;
|
boolean control = false;
|
||||||
boolean shift = false;
|
boolean shift = false;
|
||||||
|
boolean meta = false;
|
||||||
KeyCode code = null;
|
KeyCode code = null;
|
||||||
|
|
||||||
for (String part : value.split("\\+")) {
|
for (String part : value.split("\\+")) {
|
||||||
String token = part.trim().toUpperCase(Locale.ROOT);
|
String token = part.trim().toUpperCase(Locale.ROOT);
|
||||||
switch (token) {
|
switch (token) {
|
||||||
case "ALT", "META" -> alt = true;
|
case "ALT" -> alt = true;
|
||||||
|
case "META", "SUPER" -> meta = true;
|
||||||
case "CTRL", "CONTROL" -> control = true;
|
case "CTRL", "CONTROL" -> control = true;
|
||||||
case "SHIFT" -> shift = true;
|
case "SHIFT" -> shift = true;
|
||||||
default -> code = keyCode(token);
|
default -> code = keyCode(token);
|
||||||
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
|||||||
if (code == null) {
|
if (code == null) {
|
||||||
throw new IllegalArgumentException("Key binding has no key code: " + value);
|
throw new IllegalArgumentException("Key binding has no key code: " + value);
|
||||||
}
|
}
|
||||||
return new KeyBinding(alt, control, shift, code);
|
return new KeyBinding(alt, control, shift, meta, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean matches(KeyEvent event) {
|
public boolean matches(KeyEvent event) {
|
||||||
return event.isAltDown() == alt
|
return event.isAltDown() == alt
|
||||||
&& event.isControlDown() == control
|
&& event.isControlDown() == control
|
||||||
&& event.isShiftDown() == shift
|
&& event.isShiftDown() == shift
|
||||||
|
&& event.isMetaDown() == meta
|
||||||
&& event.getCode() == code;
|
&& event.getCode() == code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
|||||||
if (alt) {
|
if (alt) {
|
||||||
builder.append("ALT+");
|
builder.append("ALT+");
|
||||||
}
|
}
|
||||||
|
if (meta) {
|
||||||
|
builder.append("META+");
|
||||||
|
}
|
||||||
if (shift) {
|
if (shift) {
|
||||||
builder.append("SHIFT+");
|
builder.append("SHIFT+");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void closeMaster() {
|
private void closeMaster() {
|
||||||
|
// Note: closing the master fd does NOT wake a reader thread blocked in read() on it —
|
||||||
|
// the reader unblocks via EOF when the child exits and the slave end closes. The signal
|
||||||
|
// here usually does that; if the child ignores it, the SIGKILL escalation in reap()
|
||||||
|
// guarantees it shortly after.
|
||||||
callKill(pid, closeSignal);
|
callKill(pid, closeSignal);
|
||||||
callInt(CLOSE, masterFd);
|
callInt(CLOSE, masterFd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ package com.gregor.jprototerm;
|
|||||||
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
||||||
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
||||||
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
||||||
* JVM, so client launches skip cold JVM/JavaFX/GL startup.
|
* JVM, so client launches skip cold JVM/JavaFX/GL startup. {@code --standalone} skips daemon client
|
||||||
|
* mode and always opens an in-process window, which is useful while testing development builds.
|
||||||
*/
|
*/
|
||||||
public final class Main {
|
public final class Main {
|
||||||
private Main() {
|
private Main() {
|
||||||
@@ -14,15 +15,19 @@ public final class Main {
|
|||||||
// Match the renderer order the app was tuned for; honor an explicit override if present.
|
// Match the renderer order the app was tuned for; honor an explicit override if present.
|
||||||
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
||||||
|
|
||||||
|
boolean standalone = false;
|
||||||
for (String arg : args) {
|
for (String arg : args) {
|
||||||
if (arg.equals("--daemon")) {
|
if (arg.equals("--daemon")) {
|
||||||
Daemon.run();
|
Daemon.run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (arg.equals("--standalone")) {
|
||||||
|
standalone = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String workingDirectory = System.getProperty("user.dir");
|
String workingDirectory = System.getProperty("user.dir");
|
||||||
if (Daemon.tryClient(workingDirectory)) {
|
if (!standalone && Daemon.tryClient(workingDirectory)) {
|
||||||
return; // a running daemon opened the window
|
return; // a running daemon opened the window
|
||||||
}
|
}
|
||||||
// No daemon reachable: fall back to a standalone window; the JVM exits when it closes.
|
// No daemon reachable: fall back to a standalone window; the JVM exits when it closes.
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ final class StartupTiming {
|
|||||||
private StartupTiming() {
|
private StartupTiming() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */
|
/**
|
||||||
static void mark(String phase) {
|
* Records a phase boundary, printing the delta since the previous mark and since JVM start.
|
||||||
|
* Synchronized because marks come from both the launcher thread and the FX thread.
|
||||||
|
*/
|
||||||
|
static synchronized void mark(String phase) {
|
||||||
if (!ENABLED) {
|
if (!ENABLED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,7 @@ final class StartupTiming {
|
|||||||
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
|
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
|
||||||
* from the render loop every frame (it only ever touches FX-thread state).
|
* from the render loop every frame (it only ever touches FX-thread state).
|
||||||
*/
|
*/
|
||||||
static void firstFrame() {
|
static synchronized void firstFrame() {
|
||||||
if (!ENABLED || firstFrameSeen) {
|
if (!ENABLED || firstFrameSeen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import java.util.stream.Stream;
|
|||||||
* return whether they actually changed anything so it can bump its layout version.
|
* return whether they actually changed anything so it can bump its layout version.
|
||||||
*/
|
*/
|
||||||
final class Tab implements AutoCloseable {
|
final class Tab implements AutoCloseable {
|
||||||
|
// Floating-pane sizing policy: a fraction of the tab's size with a floor so panes stay
|
||||||
|
// usable in small windows, cascaded diagonally per pane, kept off the window edge.
|
||||||
|
private static final double FLOATING_SIZE_FRACTION = 0.58;
|
||||||
|
private static final double FLOATING_MIN_WIDTH = 420.0;
|
||||||
|
private static final double FLOATING_MIN_HEIGHT = 260.0;
|
||||||
|
private static final double FLOATING_CASCADE_OFFSET = 28.0;
|
||||||
|
private static final double FLOATING_EDGE_MARGIN = 12.0;
|
||||||
|
|
||||||
private final AppConfig config;
|
private final AppConfig config;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
||||||
@@ -86,15 +94,24 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
||||||
ordered.addAll(tiled);
|
ordered.addAll(tiled);
|
||||||
|
ordered.addAll(floatingOrder());
|
||||||
|
return List.copyOf(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating panes bottom-to-top: insertion order, with the active pane moved to the top.
|
||||||
|
// Single source of the stacking order, so the clips assigned in assignClips() always match
|
||||||
|
// the compositing order in panes().
|
||||||
|
private List<TerminalPane> floatingOrder() {
|
||||||
|
List<TerminalPane> order = new ArrayList<>(floating.size());
|
||||||
for (TerminalPane pane : floating) {
|
for (TerminalPane pane : floating) {
|
||||||
if (pane != active) {
|
if (pane != active) {
|
||||||
ordered.add(pane);
|
order.add(pane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (floating.contains(active)) {
|
if (floating.contains(active)) {
|
||||||
ordered.add(active); // active floating pane on top
|
order.add(active);
|
||||||
}
|
}
|
||||||
return List.copyOf(ordered);
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isActive(TerminalPane pane) {
|
boolean isActive(TerminalPane pane) {
|
||||||
@@ -133,13 +150,15 @@ final class Tab implements AutoCloseable {
|
|||||||
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
double floatingWidth = Math.max(420, width * 0.58);
|
double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
|
||||||
double floatingHeight = Math.max(260, availHeight * 0.58);
|
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
|
||||||
for (int i = 0; i < floating.size(); i++) {
|
for (int i = 0; i < floating.size(); i++) {
|
||||||
double offset = i * 28.0;
|
double offset = i * FLOATING_CASCADE_OFFSET;
|
||||||
floating.get(i).bounds(
|
floating.get(i).bounds(
|
||||||
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
|
||||||
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
((width - floatingWidth) / 2.0) + offset),
|
||||||
|
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
|
||||||
|
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
||||||
floatingWidth,
|
floatingWidth,
|
||||||
floatingHeight);
|
floatingHeight);
|
||||||
}
|
}
|
||||||
@@ -154,27 +173,18 @@ final class Tab implements AutoCloseable {
|
|||||||
// every pane clips to its plain bounds.
|
// every pane clips to its plain bounds.
|
||||||
private void assignClips() {
|
private void assignClips() {
|
||||||
if (!floatingVisible || floating.isEmpty()) {
|
if (!floatingVisible || floating.isEmpty()) {
|
||||||
tiled.forEach(pane -> pane.setClip(null));
|
allPanes().forEach(pane -> pane.setClip(null));
|
||||||
floating.forEach(pane -> pane.setClip(null));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
|
// Walk the floating stack top-to-bottom, accumulating the union of the panes above
|
||||||
List<TerminalPane> order = new ArrayList<>(floating.size());
|
// each one. The topmost pane has nothing above it and keeps an unclipped bounds.
|
||||||
for (TerminalPane pane : floating) {
|
List<TerminalPane> order = floatingOrder();
|
||||||
if (pane != active) {
|
|
||||||
order.add(pane);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (floating.contains(active)) {
|
|
||||||
order.add(active);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk top-to-bottom, accumulating the union of the panes above each one.
|
|
||||||
Shape above = null;
|
Shape above = null;
|
||||||
for (int i = order.size() - 1; i >= 0; i--) {
|
for (int i = order.size() - 1; i >= 0; i--) {
|
||||||
Rectangle rect = rectOf(order.get(i));
|
TerminalPane pane = order.get(i);
|
||||||
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
|
Rectangle rect = rectOf(pane);
|
||||||
|
pane.setClip(above == null ? null : Shape.subtract(rect, above));
|
||||||
above = (above == null) ? rect : Shape.union(above, rect);
|
above = (above == null) ? rect : Shape.union(above, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +237,19 @@ final class Tab implements AutoCloseable {
|
|||||||
if (floatingVisible) {
|
if (floatingVisible) {
|
||||||
createFloatingPane();
|
createFloatingPane();
|
||||||
} else {
|
} else {
|
||||||
TerminalPane pane = openPane(false);
|
createTiledPane(paneWorkingDirectory());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalPane createTiledPane(String workingDirectory) {
|
||||||
|
TerminalPane pane = openPane(false, workingDirectory);
|
||||||
tiled.add(pane);
|
tiled.add(pane);
|
||||||
setActive(pane);
|
setActive(pane);
|
||||||
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
|
||||||
|
return addFloating(openPane(true, workingDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
void nextFloatingPane() {
|
void nextFloatingPane() {
|
||||||
@@ -259,12 +278,6 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void closeActivePane() {
|
|
||||||
if (active != null) {
|
|
||||||
closePane(active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
|
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
|
||||||
* exited) and re-selects the active pane only when the one that closed was active. Returns
|
* exited) and re-selects the active pane only when the one that closed was active. Returns
|
||||||
@@ -329,8 +342,8 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalPane createFloatingPane() {
|
private void createFloatingPane() {
|
||||||
return addFloating(openPane(true));
|
addFloating(openPane(true, paneWorkingDirectory()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -382,15 +395,21 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private TerminalPane openPane(boolean asFloating) {
|
private TerminalPane openPane(boolean asFloating) {
|
||||||
|
return openPane(asFloating, paneWorkingDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalPane openPane(boolean asFloating, String workingDirectory) {
|
||||||
double[] size = paneSize(asFloating);
|
double[] size = paneSize(asFloating);
|
||||||
return register(TerminalPane.create(
|
return register(TerminalPane.create(
|
||||||
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
|
config, metrics, this::markContentChanged, size[0], size[1], workingDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
private double[] paneSize(boolean asFloating) {
|
private double[] paneSize(boolean asFloating) {
|
||||||
double availHeight = lastHeight - lastTopInset;
|
double availHeight = lastHeight - lastTopInset;
|
||||||
if (asFloating) {
|
if (asFloating) {
|
||||||
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
|
return new double[] {
|
||||||
|
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
|
||||||
|
Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
|
||||||
}
|
}
|
||||||
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
||||||
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import javafx.application.Platform;
|
|||||||
import javafx.scene.canvas.GraphicsContext;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
import javafx.scene.shape.Shape;
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
||||||
@@ -41,8 +44,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
private RenderStateSnapshot cachedSnapshot;
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
private volatile ShellSession session;
|
private volatile ShellSession session;
|
||||||
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
|
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
|
||||||
// remove it. Set by the Tab that creates the pane; null until then.
|
// remove it and command panes can run follow-up actions.
|
||||||
private Runnable onExit;
|
private final List<Runnable> onExitHandlers = new ArrayList<>();
|
||||||
private boolean exited;
|
private boolean exited;
|
||||||
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
||||||
// null means clip to the plain bounds. See RenderTarget#clip().
|
// null means clip to the plain bounds. See RenderTarget#clip().
|
||||||
@@ -80,8 +83,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
double widthPx, double heightPx, String workingDirectory) {
|
double widthPx, double heightPx, String workingDirectory) {
|
||||||
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
|
attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
|
||||||
workingDirectory, config.closeSignalNumber()));
|
pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +96,23 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
double widthPx, double heightPx, String workingDirectory, String command) {
|
double widthPx, double heightPx, String workingDirectory, String command) {
|
||||||
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
|
attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
|
||||||
workingDirectory, command, config.closeSignalNumber()));
|
pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the pane's process, but never let a spawn failure (e.g. a bad `shell` in config)
|
||||||
|
// propagate and crash window/pane creation. ShellSession has already written the error into
|
||||||
|
// the pane, so the pane opens showing it; with no session attached it is inert (sends are
|
||||||
|
// dropped) and the user closes it with the close-pane key.
|
||||||
|
private static void attachOrShowError(TerminalPane pane, Supplier<ShellSession> start) {
|
||||||
|
try {
|
||||||
|
pane.attach(start.get());
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
System.err.println("jprototerm: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
double widthPx, double heightPx) {
|
double widthPx, double heightPx) {
|
||||||
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||||
@@ -112,12 +127,20 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
|
|
||||||
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
||||||
public void setOnExit(Runnable onExit) {
|
public void setOnExit(Runnable onExit) {
|
||||||
this.onExit = onExit;
|
onExitHandlers.clear();
|
||||||
|
addOnExit(onExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
||||||
|
public void addOnExit(Runnable onExit) {
|
||||||
|
if (onExit != null) {
|
||||||
|
onExitHandlers.add(onExit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called from the shell reader thread when the pty stream ends without us closing it (the
|
* Called from the shell reader thread when the pty stream ends without us closing it (the
|
||||||
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
|
* process exited). Hops to the FX thread and fires the exit handlers once, so tab/compositor
|
||||||
* mutation happens on the thread that owns the layout.
|
* mutation happens on the thread that owns the layout.
|
||||||
*/
|
*/
|
||||||
void handleSessionExit() {
|
void handleSessionExit() {
|
||||||
@@ -126,8 +149,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
exited = true;
|
exited = true;
|
||||||
if (onExit != null) {
|
for (Runnable handler : List.copyOf(onExitHandlers)) {
|
||||||
onExit.run();
|
handler.run();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,24 +41,17 @@ abstract class TerminalRenderer {
|
|||||||
void release() {
|
void release() {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
|
|
||||||
gc.beginPath();
|
|
||||||
gc.rect(x, y, width, height);
|
|
||||||
gc.clip();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
|
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
|
||||||
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
|
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
|
||||||
* path, so it replays onto the canvas as move/line/close segments.
|
* path, so it replays onto the canvas as move/line/close segments.
|
||||||
*/
|
*/
|
||||||
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
|
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
|
||||||
if (region == null) {
|
|
||||||
clipRect(gc, x, y, width, height);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var elements = ((Path) region).getElements();
|
|
||||||
gc.beginPath();
|
gc.beginPath();
|
||||||
|
if (region == null) {
|
||||||
|
gc.rect(x, y, width, height);
|
||||||
|
} else {
|
||||||
|
var elements = ((Path) region).getElements();
|
||||||
if (elements.isEmpty()) {
|
if (elements.isEmpty()) {
|
||||||
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
|
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
|
||||||
}
|
}
|
||||||
@@ -71,6 +64,7 @@ abstract class TerminalRenderer {
|
|||||||
gc.closePath();
|
gc.closePath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
gc.clip();
|
gc.clip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import javafx.stage.Stage;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
|
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
|
||||||
@@ -34,6 +38,9 @@ final class TerminalWindow {
|
|||||||
private final Compositor compositor;
|
private final Compositor compositor;
|
||||||
private final Stage stage;
|
private final Stage stage;
|
||||||
private final AnimationTimer renderLoop;
|
private final AnimationTimer renderLoop;
|
||||||
|
// Key-bound actions by their config keybinding name, checked in this order on each key press
|
||||||
|
// (the keys must match AppConfig's keybinding keys).
|
||||||
|
private final Map<String, Runnable> keyActions = new LinkedHashMap<>();
|
||||||
private AppConfig config;
|
private AppConfig config;
|
||||||
private boolean closed;
|
private boolean closed;
|
||||||
|
|
||||||
@@ -51,6 +58,26 @@ final class TerminalWindow {
|
|||||||
// The last pane closing closes this window (not the JVM); see teardown().
|
// The last pane closing closes this window (not the JVM); see teardown().
|
||||||
compositor.setOnEmpty(this::teardown);
|
compositor.setOnEmpty(this::teardown);
|
||||||
|
|
||||||
|
keyActions.put("navigate_left", () -> compositor.navigate(Direction.LEFT));
|
||||||
|
keyActions.put("navigate_down", () -> compositor.navigate(Direction.DOWN));
|
||||||
|
keyActions.put("navigate_up", () -> compositor.navigate(Direction.UP));
|
||||||
|
keyActions.put("navigate_right", () -> compositor.navigate(Direction.RIGHT));
|
||||||
|
keyActions.put("toggle_floating", compositor::toggleFloating);
|
||||||
|
keyActions.put("new_pane", compositor::createPane);
|
||||||
|
keyActions.put("next_floating", compositor::nextFloatingPane);
|
||||||
|
keyActions.put("promote_floating", compositor::toggleActiveFloating);
|
||||||
|
// Closing the last pane closes this window, via the compositor's onEmpty hook.
|
||||||
|
keyActions.put("close_pane", compositor::closeActivePane);
|
||||||
|
keyActions.put("new_tab", compositor::newTab);
|
||||||
|
keyActions.put("previous_tab", compositor::previousTab);
|
||||||
|
keyActions.put("next_tab", compositor::nextTab);
|
||||||
|
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());
|
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||||
compositor.canvas().widthProperty().bind(root.widthProperty());
|
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||||
compositor.canvas().heightProperty().bind(root.heightProperty());
|
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||||
@@ -107,77 +134,84 @@ final class TerminalWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handlePressed(KeyEvent event) {
|
private void handlePressed(KeyEvent event) {
|
||||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
for (Map.Entry<String, Runnable> action : keyActions.entrySet()) {
|
||||||
compositor.navigate(Direction.LEFT);
|
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();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
return;
|
||||||
compositor.navigate(Direction.DOWN);
|
}
|
||||||
|
action.getValue().run();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
return;
|
||||||
compositor.navigate(Direction.UP);
|
}
|
||||||
|
}
|
||||||
|
if (compositor.isPaneSyncSelecting()) {
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
return;
|
||||||
compositor.navigate(Direction.RIGHT);
|
}
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
|
||||||
compositor.toggleFloating();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
|
||||||
compositor.createPane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
|
||||||
compositor.nextFloatingPane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("promote_floating").matches(event)) {
|
|
||||||
compositor.toggleActiveFloating();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
|
||||||
// Closing the last pane closes this window, via the compositor's onEmpty hook.
|
|
||||||
compositor.closeActivePane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
|
||||||
compositor.newTab();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
|
||||||
compositor.previousTab();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
|
||||||
compositor.nextTab();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
|
||||||
openFontSelector();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("open_scrollback").matches(event)) {
|
|
||||||
openScrollbackInEditor();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("paste").matches(event)) {
|
|
||||||
pasteFromClipboard();
|
|
||||||
event.consume();
|
|
||||||
} else {
|
|
||||||
String encoded = KeyEncoder.encode(event);
|
String encoded = KeyEncoder.encode(event);
|
||||||
if (encoded != null) {
|
if (encoded != null) {
|
||||||
compositor.activePane().send(encoded);
|
sendToActivePane(encoded, event);
|
||||||
event.consume();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
String text = event.getCharacter();
|
String text = event.getCharacter();
|
||||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||||
compositor.activePane().send(text);
|
sendToActivePane(text, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key handlers run on every keystroke, including any that race the window's teardown, so
|
||||||
|
// tolerate the no-pane-left state instead of assuming one exists.
|
||||||
|
private void sendToActivePane(String text, KeyEvent event) {
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active != null) {
|
||||||
|
active.send(text);
|
||||||
|
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
|
||||||
|
peer.send(text);
|
||||||
|
}
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pasteFromClipboard() {
|
private void pasteFromClipboard() {
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||||
if (clipboard.hasString()) {
|
if (active != null && clipboard.hasString()) {
|
||||||
compositor.activePane().paste(clipboard.getString());
|
String text = clipboard.getString();
|
||||||
|
active.paste(text);
|
||||||
|
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
|
||||||
|
peer.paste(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,23 +259,51 @@ final class TerminalWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void openScrollbackInEditor() {
|
private void openScrollbackInEditor() {
|
||||||
try {
|
|
||||||
// Capture the active pane's scrollback before opening the floating pane, since that
|
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||||
// makes the new pane active.
|
// makes the new pane active.
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
Files.writeString(file, active.scrollbackText());
|
||||||
file.toFile().deleteOnExit();
|
|
||||||
|
|
||||||
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
|
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
|
||||||
// command into an interactive shell. The command runs deterministically from the start
|
// command into an interactive shell. The command runs deterministically from the start
|
||||||
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
|
// — no shell startup/rc race — and the pane auto-closes when the editor exits. The
|
||||||
compositor.openFloatingPane(scrollbackEditorCommand(file));
|
// trailing rm removes the file (which holds terminal contents) when the editor exits;
|
||||||
|
// deleteOnExit would leak files for the JVM's whole lifetime in daemon mode.
|
||||||
|
compositor.openFloatingPane(editorCommand(file) + "; rm -f " + shellQuote(file.toString()));
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String scrollbackEditorCommand(Path file) {
|
private void createWorktreeInEditor() {
|
||||||
|
// The floating pane command inherits the active pane's cwd at creation time, so the git
|
||||||
|
// worktree command runs from the pane that was focused before this shortcut opened.
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path file = Files.createTempFile("jprototerm-worktree-", ".txt");
|
||||||
|
Path createdFile = Files.createTempFile("jprototerm-worktree-created-", ".txt");
|
||||||
|
Files.writeString(file, "");
|
||||||
|
|
||||||
|
TerminalPane commandPane = compositor.openFloatingPane(worktreeEditorCommand(file, createdFile));
|
||||||
|
if (commandPane != null) {
|
||||||
|
commandPane.addOnExit(() -> runPostWorktreeAction(active, createdFile));
|
||||||
|
} else {
|
||||||
|
Files.deleteIfExists(createdFile);
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("Could not create worktree from editor input: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String editorCommand(Path file) {
|
||||||
String quotedFile = shellQuote(file.toString());
|
String quotedFile = shellQuote(file.toString());
|
||||||
String command = config.scrollbackEditorCommand();
|
String command = config.scrollbackEditorCommand();
|
||||||
if (command == null || command.isBlank()) {
|
if (command == null || command.isBlank()) {
|
||||||
@@ -253,6 +315,112 @@ final class TerminalWindow {
|
|||||||
return command + " " + quotedFile;
|
return command + " " + quotedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String worktreeEditorCommand(Path file, Path createdFile) {
|
||||||
|
String quotedFile = shellQuote(file.toString());
|
||||||
|
String quotedCreatedFile = shellQuote(createdFile.toString());
|
||||||
|
String relativePath = config.worktreeRelativePath();
|
||||||
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
|
relativePath = "./.worktrees";
|
||||||
|
}
|
||||||
|
String splitRegex = config.worktreeSplitRegex();
|
||||||
|
if (splitRegex == null || splitRegex.isBlank()) {
|
||||||
|
splitRegex = ",";
|
||||||
|
}
|
||||||
|
|
||||||
|
return editorCommand(file)
|
||||||
|
+ "; editor_status=$?"
|
||||||
|
+ "; git_status=$editor_status"
|
||||||
|
+ "; if [ \"$editor_status\" -eq 0 ]; then"
|
||||||
|
+ " if names_file=$(mktemp); then"
|
||||||
|
+ " if awk -v re=" + shellQuote(splitRegex)
|
||||||
|
+ " '{ text = text $0 \"\\n\" }"
|
||||||
|
+ " END { n = split(text, names, re); for (i = 1; i <= n; i++)"
|
||||||
|
+ " { name = names[i]; sub(/^[[:space:]]+/, \"\", name);"
|
||||||
|
+ " sub(/[[:space:]]+$/, \"\", name); if (name != \"\") print name; } }'"
|
||||||
|
+ " " + quotedFile + " > \"$names_file\"; then"
|
||||||
|
+ " git_status=0"
|
||||||
|
+ "; while IFS= read -r name; do"
|
||||||
|
+ " worktree_path=" + shellQuote(relativePath) + "/\"$name\""
|
||||||
|
+ "; git worktree add \"$worktree_path\""
|
||||||
|
+ " || { git_status=$?; break; }"
|
||||||
|
+ "; created_path=$(cd \"$worktree_path\" && pwd -P)"
|
||||||
|
+ " || { git_status=$?; break; }"
|
||||||
|
+ "; printf '%s\\n' \"$created_path\" >> " + quotedCreatedFile
|
||||||
|
+ " || { git_status=$?; break; }"
|
||||||
|
+ "; done < \"$names_file\""
|
||||||
|
+ "; else git_status=$?"
|
||||||
|
+ "; fi"
|
||||||
|
+ "; rm -f \"$names_file\""
|
||||||
|
+ "; else git_status=$?"
|
||||||
|
+ "; fi"
|
||||||
|
+ "; fi"
|
||||||
|
+ "; rm -f " + quotedFile
|
||||||
|
+ "; exit \"$git_status\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runPostWorktreeAction(TerminalPane lastActivePane, Path createdFile) {
|
||||||
|
List<String> worktreePaths = readCreatedWorktreePaths(createdFile);
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(createdFile);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("Could not remove worktree result file " + createdFile + ": " + ex.getMessage());
|
||||||
|
}
|
||||||
|
if (worktreePaths.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String action = config.worktreePostCreateAction();
|
||||||
|
if (action == null || action.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.trim().toLowerCase(Locale.ROOT)) {
|
||||||
|
case "none" -> { }
|
||||||
|
case "cd" -> lastActivePane.send("cd " + shellQuote(worktreePaths.get(worktreePaths.size() - 1)) + "\r");
|
||||||
|
case "create_panes" -> createWorktreePanes(worktreePaths, false);
|
||||||
|
case "create_panes_floating" -> createWorktreePanes(worktreePaths, true);
|
||||||
|
default -> System.err.println("Unknown worktree.post_create_action '" + action + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createWorktreePanes(List<String> worktreePaths, boolean floating) {
|
||||||
|
List<String> commands = config.worktreeCommands();
|
||||||
|
List<TerminalPane> createdPanes = new ArrayList<>();
|
||||||
|
for (int i = 0; i < worktreePaths.size(); i++) {
|
||||||
|
TerminalPane pane = floating
|
||||||
|
? compositor.createFloatingPaneInDirectory(worktreePaths.get(i))
|
||||||
|
: compositor.createTiledPane(worktreePaths.get(i));
|
||||||
|
if (pane != null) {
|
||||||
|
createdPanes.add(pane);
|
||||||
|
}
|
||||||
|
if (pane != null && !commands.isEmpty()) {
|
||||||
|
String command = commands.get(i % commands.size());
|
||||||
|
if (command != null && !command.isBlank()) {
|
||||||
|
pane.send(command + "\r");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.worktreeSyncPanes() && !createdPanes.isEmpty()) {
|
||||||
|
compositor.syncPanes(createdPanes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> readCreatedWorktreePaths(Path createdFile) {
|
||||||
|
try {
|
||||||
|
List<String> paths = new ArrayList<>();
|
||||||
|
for (String line : Files.readAllLines(createdFile)) {
|
||||||
|
String path = line.trim();
|
||||||
|
if (!path.isEmpty()) {
|
||||||
|
paths.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("Could not read created worktree paths from " + createdFile + ": " + ex.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String shellQuote(String value) {
|
private static String shellQuote(String value) {
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user