diff options
| author | user <user@clank> | 2026-08-14 12:14:17 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 12:14:17 +0200 |
| commit | 1ceafa4d82bdc37449c174a75fcd38b4be880080 (patch) | |
| tree | 33c2904620ac1e5477eb1b8d387bff00ba3f2157 | |
| parent | sl0ppty: one-shot --headless printed a blank screen (diff) | |
| download | slosh-1ceafa4d82bdc37449c174a75fcd38b4be880080.tar.gz slosh-1ceafa4d82bdc37449c174a75fcd38b4be880080.tar.xz slosh-1ceafa4d82bdc37449c174a75fcd38b4be880080.zip | |
sl0ppty: the pane title is a name — double-click it to rename
The top row was both the frame's drag handle and its top edge: resting on it
armed the split guide, and a click that never moved split upward. That is
right for a border and wrong for the cells the title sits on, because it left
no gesture free for naming a pane — a double-click there split twice.
So the title text carves its own hit region out of the top row. It is
registered after the row's handle and after the title is painted, which is
what makes it correct: hit_test scans backwards, so the region that wins is
the one whose glyphs the user can actually see, and its width comes from
screen_text's return so a title of wide characters claims the cells it drew.
Resting there arms nothing (draw_split_guide matches "title:" exactly), and a
press takes no side, so the click-splits path — now guarded on a side being
set — does nothing. Dragging still moves the pane: that reads as grabbing it
by its name. Everything else on the top row still splits upward.
Renaming happens in the title cell itself. There is no dialog to place,
nothing to dismiss, and the pane it belongs to cannot be in doubt. It is the
finder's shape: state on app_t, a key handler that owns the keyboard while it
is open, drawn by the pass that draws the frame. Enter keeps the name, Escape
abandons it, clicking away keeps it the way leaving a field does.
A typed name shadows the program's title rather than overwriting it. The
program's OSC 2 keeps arriving underneath, so a shell that rewrites its title
cannot undo a rename, and clearing the name gives the pane back to what the
program calls itself *now* rather than to whatever it happened to say when the
rename began. That is the whole reason pane_t grew a second string instead of
a flag.
Backspace deletes a character rather than a byte — a title is whatever a
program could set, and half a UTF-8 sequence is not a name — and a name too
long for the frame scrolls its head off, because the caret is the one thing
that has to stay visible. double_click_ms (400) joins hover_delay_ms in the
config.
The tests keep a control beside each behaviour, since most of these could pass
by breaking something else: two slow clicks must open no editor, the top edge
must still split, keystrokes must not reach the shell, and a pane closing
mid-rename must give the keyboard back.
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | config/config.kdl | 6 | ||||
| -rw-r--r-- | src/app.c | 178 | ||||
| -rw-r--r-- | src/config.c | 3 | ||||
| -rw-r--r-- | src/config.h | 1 | ||||
| -rw-r--r-- | src/pane.c | 17 | ||||
| -rw-r--r-- | src/sl0ppty.h | 4 | ||||
| -rw-r--r-- | tests/test_pane_title.py | 339 |
9 files changed, 540 insertions, 17 deletions
@@ -58,7 +58,7 @@ test: $(BIN) $(TEST_BIN) $(KDL_TEST) ## unit + headless checks (fast) @echo cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py && python3 test_osc5577.py && \ python3 test_responsive.py && python3 test_finder.py && \ - python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py && python3 test_split_ux.py && python3 test_graphics.py + python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py && python3 test_split_ux.py && python3 test_graphics.py && python3 test_pane_title.py test-live: $(BIN) ## the checks that need a real tty (slow) python3 tests/live_m0.py @@ -61,6 +61,13 @@ onto another pane to swap them, or drag the gap between two panes to move the boundary.** Focus follows the pointer (`focus_follows_mouse`, on by default, and it knows when not to). +**Double-click a pane's name to rename it**, in the title itself rather than in +a dialog. Enter keeps the new name, Escape abandons it, clicking away keeps it. +A name you type outranks whatever the program calls itself, so a shell that +rewrites its title cannot undo it; clear the name and the pane goes back to the +program's title. The name is only the title *text*: the rest of the top border +is still the top edge, and still splits upward when clicked. + **Select to copy.** Drag over text and release: it is on your clipboard, sent to your terminal as OSC 52 so it works over ssh. Middle click pastes it back, the way a primary selection behaves everywhere else. A program in a pane can diff --git a/config/config.kdl b/config/config.kdl index b211475..248839c 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -46,6 +46,12 @@ scroll_lines 3 // somewhere else should not make the screen flash. 0 arms immediately. hover_delay_ms 250 +// How close together two clicks on a pane's title must be to count as a +// double-click, which renames that pane in place. Enter keeps the new name, +// Escape abandons it, and an empty name gives the pane back to whatever its +// program calls itself. +double_click_ms 400 + // How long an announcement ("copied 13 chars", a pane's OSC 9 notification, // "config reloaded") stays in the bottom-right corner. toast_ms 2500 @@ -159,6 +159,17 @@ struct app { bool finder; char query[64]; size_t sel; + + /* Renaming a pane in place: the title cell becomes the editor, so the name + * is typed where it will live rather than in a dialog somewhere else. */ + bool renaming; + uint32_t rename_id; + /* As wide as a pane title, so seeding the editor never truncates one (and so + * never truncates one mid-UTF-8). */ + char rename_buf[256]; + /* The first click of a candidate double-click on a title. */ + int64_t name_click_ms; + uint32_t name_click_id; const char *const *argv; /* the screen we last composed into: its hit list is what a click resolves * against, so routing can never consult geometry the user never saw */ @@ -1325,20 +1336,57 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { } const char *title = pane_title(leaf->pane); - if (title && *title && avail >= 3) { - char buf[256]; - int len = snprintf(buf, sizeof buf, " %s ", title); + bool editing = a->renaming && a->rename_id == leaf->id; + if ((editing || (title && *title)) && avail >= 3) { + char buf[320]; /* a full-length name, plus the caret and its spaces */ + int len = editing ? snprintf(buf, sizeof buf, " %s\u2588 ", a->rename_buf) + : snprintf(buf, sizeof buf, " %s ", title); + /* snprintf reports what it *would* have written; clamp to what it did, or + * the scroll below would move bytes that are not there. */ + if (len < 0) len = 0; + if ((size_t)len >= sizeof buf) len = (int)strlen(buf); if (len > (int)avail) { - len = (int)avail; - buf[len] = 0; + if (editing) { + /* Scroll the head off, not the tail: the cursor is the one thing that + * must stay on screen while typing. Whole characters only. */ + size_t off = (size_t)(len - (int)avail); + while (off < (size_t)len && ((unsigned char)buf[off] & 0xC0) == 0x80) + off++; + memmove(buf, buf + off, (size_t)len - off + 1); + len = (int)((size_t)len - off); + } else { + len = (int)avail; + buf[len] = 0; + } } uint16_t tx = (uint16_t)(r.x + 1); if (CFG.title_align == ALIGN_CENTER) tx = (uint16_t)(r.x + 1 + (avail - len) / 2); else if (CFG.title_align == ALIGN_RIGHT) tx = (uint16_t)(r.x + 1 + avail - len); - screen_text(s, tx, r.y, buf, focused ? TITLE_FOCUS : fg, NO_COLOR, - focused ? ATTR_BOLD : 0); + /* An editor announces itself: the name sits in the button colours while it + * is being typed, so a half-finished rename can never be mistaken for what + * the pane is actually called. */ + uint16_t drawn = + screen_text(s, tx, r.y, buf, editing ? BTN_FG : (focused ? TITLE_FOCUS : fg), + editing ? BTN_BG : NO_COLOR, + editing || focused ? ATTR_BOLD : 0); + + /* The title names the pane; it is not an edge. It carves its own cells out + * of the top row's handle so that resting there arms no split guide and + * clicking there splits nothing, which leaves the gesture free for a + * double-click rename — otherwise a rename would split twice on its way. + * Dragging still moves the pane: that reads as grabbing it by its name. + * + * Registered last, because hit_test() scans backwards and the title is + * painted last too. Hit-testing therefore agrees with what is on screen, + * and the width comes from screen_text() so a title of wide characters + * claims the cells it actually drew. */ + if (drawn) { + char action[48]; + snprintf(action, sizeof action, "panetitle:%u", leaf->id); + hit_add(&s->hits, tx, r.y, drawn, 1, action); + } } } @@ -1626,6 +1674,70 @@ static bool finder_key(app_t *a, const input_event_t *ev) { return true; } +/* ---- renaming a pane ---------------------------------------------------- */ + +/* The editor lives in the title cell itself (drawn by draw_frame), so the name + * is typed where it is going to live. There is no dialog to place, nothing to + * dismiss, and the pane it belongs to cannot be in doubt. */ +static void rename_begin(app_t *a, uint32_t id) { + node_t *n = pane_by_id(a, id); + if (!n) return; + a->renaming = true; + a->rename_id = id; + /* Seed with what is on screen: a rename is usually an edit, not a retype. */ + const char *shown = pane_title(n->pane); + snprintf(a->rename_buf, sizeof a->rename_buf, "%s", shown ? shown : ""); + a->name_click_ms = 0; + a->name_click_id = 0; + app_focus_pane(a, id); +} + +static void rename_end(app_t *a, bool keep) { + if (!a->renaming) return; + node_t *n = pane_by_id(a, a->rename_id); + if (keep && n) { + pane_set_name(n->pane, a->rename_buf); + app_toast(a, a->rename_buf[0] ? "renamed" : "name cleared"); + } + a->renaming = false; + a->rename_id = 0; + a->rename_buf[0] = 0; +} + +/* Returns true when the rename editor consumed the event. */ +static bool rename_key(app_t *a, const input_event_t *ev) { + if (!a->renaming) return false; + if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true; + + switch (ev->key) { + case GHOSTTY_KEY_ESCAPE: + rename_end(a, false); + return true; + case GHOSTTY_KEY_ENTER: + rename_end(a, true); + return true; + case GHOSTTY_KEY_BACKSPACE: { + /* A character, not a byte: a title is whatever the program could set, + * and half a UTF-8 sequence is not a name. */ + size_t l = strlen(a->rename_buf); + while (l && ((unsigned char)a->rename_buf[l - 1] & 0xC0) == 0x80) l--; + if (l) l--; + a->rename_buf[l] = 0; + return true; + } + default: + break; + } + if (ev->text_len && (unsigned char)ev->text[0] >= 0x20) { + size_t l = strlen(a->rename_buf); + if (l + ev->text_len < sizeof a->rename_buf) { + memcpy(a->rename_buf + l, ev->text, ev->text_len); + a->rename_buf[l + ev->text_len] = 0; + } + } + return true; +} + void app_compose(app_t *a, screen_t *s) { screen_clear(s); /* every frame starts blank: no ghosts in the gap ring */ hit_reset(&s->hits); @@ -1766,27 +1878,43 @@ static void drag_edge(app_t *a, node_t *sp, size_t i, int cells) { * pointer crossed its header — which is why only `pane:` and `title:` targets * count, and the frame rect's `focus:` does not. */ static bool hover_focus_allowed(const app_t *a) { - return CFG.focus_follows_mouse && !a->prefix && !a->finder && + return CFG.focus_follows_mouse && !a->prefix && !a->finder && !a->renaming && a->drag.kind == DRAG_NONE; } static void do_action(app_t *a, const char *action, const input_event_t *ev) { - if (strncmp(action, "title:", 6) == 0) { - uint32_t id = (uint32_t)strtoul(action + 6, NULL, 10); + bool on_name = strncmp(action, "panetitle:", 10) == 0; + if (on_name || strncmp(action, "title:", 6) == 0) { + uint32_t id = (uint32_t)strtoul(action + (on_name ? 10 : 6), NULL, 10); if (ev->maction == MOUSE_MOTION && hover_focus_allowed(a)) { app_focus_pane(a, id); return; } if (ev->maction == MOUSE_PRESS) { + /* Two clicks on the name, close together, rename the pane. This is why + * the name is not an edge: the first click must not have split, or the + * second would be renaming a pane that had already moved. */ + if (on_name) { + int64_t now = now_ms_(); + if (a->name_click_id == id && + now - a->name_click_ms <= (int64_t)CFG.double_click_ms) { + rename_begin(a, id); + return; /* a rename, not a grab: start no drag */ + } + a->name_click_ms = now; + a->name_click_id = id; + } /* The top border is both a drag handle and an edge: whether this is a - * move or a split-upward is decided by whether the pointer moves. */ + * move or a split-upward is decided by whether the pointer moves. The + * title text is only a handle, so it takes no side: a click that never + * moved does nothing there instead of splitting upward. */ a->drag.kind = DRAG_TITLE; a->drag.src = id; a->drag.target = id; a->drag.x = ev->mx; a->drag.y = ev->my; a->drag.moved = false; - a->drag.side = 't'; + a->drag.side = on_name ? 0 : 't'; app_focus_pane(a, id); } return; @@ -1965,8 +2093,16 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { void app_event(app_t *a, const input_event_t *ev) { if (!a->ntabs || !cur(a)->focus) return; - /* The finder owns the keyboard while it is open; the mouse still routes - * through the hit list, whose topmost entries are the finder's own rows. */ + /* A rename whose pane went away must not keep the keyboard. */ + if (a->renaming && !pane_by_id(a, a->rename_id)) rename_end(a, false); + + /* The rename editor and the finder each own the keyboard while open; the + * mouse still routes through the hit list, whose topmost entries are the + * finder's own rows. */ + if (a->renaming && ev->kind == EV_KEY) { + rename_key(a, ev); + return; + } if (a->finder && ev->kind == EV_KEY) { finder_key(a, ev); return; @@ -2021,6 +2157,15 @@ void app_event(app_t *a, const input_event_t *ev) { if (!a->painted) break; const char *action = hit_test(&a->painted->hits, ev->mx, ev->my); + /* Clicking away keeps the name, the way leaving a field commits it. + * Clicking the pane's own name again does not, so a stray second + * double-click lands in the editor instead of closing it. */ + if (a->renaming && ev->maction == MOUSE_PRESS) { + char own[48]; + snprintf(own, sizeof own, "panetitle:%u", a->rename_id); + if (!action || strcmp(action, own) != 0) rename_end(a, true); + } + if (!a->ptr_valid || ev->mx != a->ptr_x || ev->my != a->ptr_y) a->ptr_still_since = now_ms_(); a->ptr_x = ev->mx; @@ -2043,6 +2188,9 @@ void app_event(app_t *a, const input_event_t *ev) { drag_edge(a, sp, a->drag.edge, cells); } else if (action && strncmp(action, "title:", 6) == 0) { a->drag.target = (uint32_t)strtoul(action + 6, NULL, 10); + } else if (action && strncmp(action, "panetitle:", 10) == 0) { + /* Dropping onto a pane's name is dropping onto that pane. */ + a->drag.target = (uint32_t)strtoul(action + 10, NULL, 10); } else if (action && strncmp(action, "pane:", 5) == 0) { a->drag.target = (uint32_t)strtoul(action + 5, NULL, 10); } @@ -2051,7 +2199,7 @@ void app_event(app_t *a, const input_event_t *ev) { } else if (ev->maction == MOUSE_RELEASE) { /* A press that never moved is a click, and a click on an edge * splits toward it. */ - if (!a->drag.moved && + if (!a->drag.moved && a->drag.side && (a->drag.kind == DRAG_BORDER || a->drag.kind == DRAG_TITLE)) { node_t *n = pane_by_id(a, a->drag.src); if (n) { diff --git a/src/config.c b/src/config.c index 09d6e09..a55969a 100644 --- a/src/config.c +++ b/src/config.c @@ -128,6 +128,7 @@ void config_defaults(config_t *c) { c->scroll_lines = 3; c->toast_ms = 2500; c->hover_delay_ms = 250; + c->double_click_ms = 400; c->status_bar = true; c->focus_follows_mouse = true; @@ -248,6 +249,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->toast_ms = (uint16_t)kdl_arg_int(kdl_child(root, "toast_ms"), 0, c->toast_ms); c->hover_delay_ms = (uint16_t)kdl_arg_int(kdl_child(root, "hover_delay_ms"), 0, c->hover_delay_ms); + c->double_click_ms = (uint16_t)kdl_arg_int(kdl_child(root, "double_click_ms"), + 0, c->double_click_ms); const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL); if (sh) { diff --git a/src/config.h b/src/config.h index cee2203..8482116 100644 --- a/src/config.h +++ b/src/config.h @@ -60,6 +60,7 @@ typedef struct { uint16_t scroll_lines; uint16_t toast_ms; uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */ + uint16_t double_click_ms; /* how close two clicks must be to be a double */ bool status_bar; bool focus_follows_mouse; @@ -27,6 +27,11 @@ struct pane { bool alive; bool dirty; char title[256]; + /* A name the user typed. It shadows `title` rather than overwriting it, so + * the program's own title keeps arriving underneath and clearing the name + * (renaming to nothing) falls back to whatever the program calls itself + * now — not to whatever it happened to say when the rename began. */ + char name[256]; /* OSC 5577 state: what this pane asked us to draw in its frame */ /* A suspended pane is real, sized and laid out, but has not run anything: @@ -312,7 +317,17 @@ void pane_free(pane_t *p) { int pane_fd(const pane_t *p) { return p->pty.fd; } bool pane_alive(const pane_t *p) { return p->alive; } bool pane_dirty(pane_t *p) { return p->dirty; } -const char *pane_title(const pane_t *p) { return p->title; } +const char *pane_title(const pane_t *p) { + return p->name[0] ? p->name : p->title; +} + +const char *pane_name(const pane_t *p) { return p->name; } + +void pane_set_name(pane_t *p, const char *name) { + if (!name) name = ""; + snprintf(p->name, sizeof p->name, "%s", name); + p->dirty = true; +} ssize_t pane_pump(pane_t *p) { if (p->pty.fd < 0) return 0; diff --git a/src/sl0ppty.h b/src/sl0ppty.h index 955185c..b7a1de2 100644 --- a/src/sl0ppty.h +++ b/src/sl0ppty.h @@ -156,6 +156,10 @@ void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total); bool pane_alt_screen(const pane_t *p); bool pane_wants_mouse(const pane_t *p); const char *pane_title(const pane_t *p); +/* The user-assigned name, "" when the program's title is showing through. */ +const char *pane_name(const pane_t *p); +/* "" clears the name and gives the pane back to the program's title. */ +void pane_set_name(pane_t *p, const char *name); const char *pane_status(const pane_t *p); size_t pane_buttons(const pane_t *p, const pane_button_t **out); void pane_click_button(pane_t *p, const char *id); diff --git a/tests/test_pane_title.py b/tests/test_pane_title.py new file mode 100644 index 0000000..07ec1ce --- /dev/null +++ b/tests/test_pane_title.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""The pane title is a name, not an edge. + +The top row is the frame's drag handle *and* its top edge: resting on it arms +a split guide and clicking it splits upward. That is right for the border, and +wrong for the cells the title is painted on — a double-click there is meant to +rename the pane, and it would split twice on its way to the rename. + +So the title text carves its own region out of the top row. It still drags and +still focuses; it just no longer suggests or performs a split. Everything the +rest of the top row does must keep working, which is most of what is checked +here. +""" +import sys +import time + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", 'printf "\\033]2;shell\\007"; stty raw -echo; cat'] + +HEAVY = "\u2501" # the armed top edge of the split guide +CURSOR = "\u2588" # the rename editor's caret + + +def hover(s, x, y): + s.send(rf"\e[<35;{x + 1};{y + 1}M") + + +def rest(s, x, y): + """The guide arms on dwell (250ms), not on contact.""" + hover(s, x, y) + time.sleep(0.35) + + +def click(s, x, y): + s.send(rf"\e[<0;{x + 1};{y + 1}M") + s.send(rf"\e[<0;{x + 1};{y + 1}m") + + +def title_span(snap, pane): + """(first, last, middle) columns of the title text on the pane's top row.""" + row = snap.line(pane["y"]) + x = row.find(" shell ") + assert x >= 0, f"no title in top row: {row!r}" + return x, x + 6, x + 3 + + +def off_title(pane): + """A top-row cell the centered title cannot reach.""" + return pane["x"] + 2 + + +def col_of(snap, pane, text): + """Middle column of `text` on the pane's top row.""" + row = snap.line(pane["y"]) + x = row.find(text) + assert x >= 0, f"{text!r} not in {row!r}" + return x + len(text) // 2 + + +def dbl(s, x, y): + s.click(x, y) + s.click(x, y) + + +def open_editor(s, pane, text="shell"): + dbl(s, col_of(s.snapshot(), pane, text), pane["y"]) + s.settle(80) + + +def backspace(s, n): + for _ in range(n): + s.send(r"\x7f") + + +def titles(s): + return [p["title"] for p in s.panes()] + + +def test_the_title_text_is_its_own_region(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + snap = s.snapshot() + first, last, mid = title_span(snap, p) + + check("the title text hit-tests as the pane's name", + snap.hit_at(mid, p["y"]) == f"panetitle:{p['id']}", + str(snap.hit_at(mid, p["y"]))) + check("both its ends do too", + snap.hit_at(first, p["y"]) == f"panetitle:{p['id']}" + and snap.hit_at(last, p["y"]) == f"panetitle:{p['id']}", + f"{snap.hit_at(first, p['y'])} .. {snap.hit_at(last, p['y'])}") + check("the cell just outside it is still the top edge", + snap.hit_at(first - 1, p["y"]) == f"title:{p['id']}", + str(snap.hit_at(first - 1, p["y"]))) + check("and so is the rest of the top row", + snap.hit_at(off_title(p), p["y"]) == f"title:{p['id']}", + str(snap.hit_at(off_title(p), p["y"]))) + + +def test_resting_on_the_title_arms_no_guide(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + _, _, mid = title_span(s.snapshot(), p) + + rest(s, mid, p["y"]) + s.settle(60) + check("resting on the title suggests no split", + HEAVY not in s.snapshot().line(p["y"]), + repr(s.snapshot().line(p["y"]))) + + # The control: the same dwell two cells away must still arm it, or the + # check above would pass simply because the guide is broken. + rest(s, off_title(p), p["y"]) + s.settle(60) + check("resting on the top edge still does", + HEAVY in s.snapshot().line(p["y"]), + repr(s.snapshot().line(p["y"]))) + + +def test_clicking_the_title_does_not_split(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + _, _, mid = title_span(s.snapshot(), p) + + click(s, mid, p["y"]) + s.settle(80) + check("clicking the title splits nothing", len(s.panes()) == 1, + str(len(s.panes()))) + + click(s, off_title(p), p["y"]) + s.settle(80) + check("clicking the top edge still splits up", len(s.panes()) == 2, + str(len(s.panes()))) + + +def test_double_clicking_the_title_does_not_split(): + """The gesture the exclusion exists for: it must survive both halves.""" + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + _, _, mid = title_span(s.snapshot(), p) + + click(s, mid, p["y"]) + click(s, mid, p["y"]) + s.settle(80) + check("a double-click on the title leaves one pane", + len(s.panes()) == 1, str(len(s.panes()))) + + +def test_the_title_still_drags_the_pane(): + with Session(SH, cols=70, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + _, _, mid = title_span(s.snapshot(), left) + + # press on the left pane's *title text*, move onto the right pane + s.send(rf"\e[<0;{mid + 1};{left['y'] + 1}M") + s.send(rf"\e[<32;{right['x'] + 5};{right['y'] + 3}M") + s.settle(60) + s.send(rf"\e[<0;{right['x'] + 5};{right['y'] + 3}m") + s.settle() + + panes = {p["id"]: p for p in s.panes()} + check("dragging by the title still moves the pane", + len(panes) == 2 + and panes[left["id"]]["x"] > panes[right["id"]]["x"], + str([(p["id"], p["x"]) for p in s.panes()])) + + +def test_hovering_the_title_still_focuses(): + with Session(SH, cols=70, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + focused = s.focused()["id"] + other = left if focused == right["id"] else right + _, _, mid = title_span(s.snapshot(), other) + + hover(s, mid, other["y"]) + s.settle(60) + check("hovering the title still focuses its pane", + s.focused()["id"] == other["id"], + f"{s.focused()['id']} != {other['id']}") + + +def test_double_click_opens_an_editor_seeded_with_the_name(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + open_editor(s, p) + row = s.snapshot().line(p["y"]) + check("double-clicking the title opens an editor", CURSOR in row, + repr(row)) + check("seeded with the current name, so a rename is an edit", + "shell" + CURSOR in row, repr(row)) + + +def test_enter_keeps_the_new_name(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + open_editor(s, p) + backspace(s, 5) + s.send("api") + s.send(r"\r") + s.settle(80) + check("Enter commits the new name", titles(s) == ["api"], str(titles(s))) + check("and the editor is gone", + CURSOR not in s.snapshot().line(p["y"]), + repr(s.snapshot().line(p["y"]))) + + +def test_escape_abandons_it(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + open_editor(s, p) + backspace(s, 5) + s.send("discarded") + s.send(r"\e") + s.settle(80) + check("Escape leaves the old name alone", titles(s) == ["shell"], + str(titles(s))) + check("and closes the editor", + CURSOR not in s.snapshot().line(p["y"]), + repr(s.snapshot().line(p["y"]))) + + +def test_clicking_away_keeps_the_name(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + open_editor(s, p) + backspace(s, 5) + s.send("elsewhere") + s.click(p["content_x"] + 2, p["content_y"] + 1) + s.settle(80) + check("clicking away commits, the way leaving a field does", + titles(s) == ["elsewhere"], str(titles(s))) + + +def test_a_typed_name_outranks_the_program(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + open_editor(s, p) + backspace(s, 5) + s.send("mine") + s.send(r"\r") + s.settle(80) + + s.raw(r"\e]2;prog-later\x07") + s.settle(120) + check("the program retitling itself does not undo a rename", + titles(s) == ["mine"], str(titles(s))) + + # Clearing the name hands the pane back — to what the program calls + # itself *now*, not what it said when the rename began. + open_editor(s, p, "mine") + backspace(s, 8) + s.send(r"\r") + s.settle(80) + check("an empty name returns the pane to its program's title", + titles(s) == ["prog-later"], str(titles(s))) + + +def test_slow_clicks_are_not_a_double_click(): + """The control for the gesture: two clicks are not automatically a rename.""" + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + x = col_of(s.snapshot(), p, "shell") + s.click(x, p["y"]) + s.settle(80) + time.sleep(0.6) # default double_click_ms is 400 + s.click(x, p["y"]) + s.settle(80) + row = s.snapshot().line(p["y"]) + check("two slow clicks open no editor", CURSOR not in row, repr(row)) + check("and still split nothing", len(s.panes()) == 1, + str(len(s.panes()))) + + +def test_typing_a_name_does_not_reach_the_pane(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + open_editor(s, p) + backspace(s, 5) + s.send("zzz") + s.send(r"\r") + s.settle(120) + body = s.snapshot().pane_text(s.pane()) + check("the editor owns the keyboard: nothing leaked to the shell", + "zzz" not in body, repr(body)) + + +def test_closing_the_pane_does_not_wedge_the_keyboard(): + with Session(SH, cols=70, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + target = left if not left["focused"] else right + open_editor(s, target) + s.api("close", id=target["id"]) + s.settle(80) + check("the pane is gone", len(s.panes()) == 1, str(len(s.panes()))) + # If the rename still owned the keyboard, this would be swallowed. + s.send("after") + s.settle(120) + body = s.snapshot().pane_text(s.pane()) + check("and the keyboard went back to the surviving pane", + "after" in body, repr(body)) + + +if __name__ == "__main__": + test_the_title_text_is_its_own_region() + test_resting_on_the_title_arms_no_guide() + test_clicking_the_title_does_not_split() + test_double_clicking_the_title_does_not_split() + test_the_title_still_drags_the_pane() + test_hovering_the_title_still_focuses() + test_double_click_opens_an_editor_seeded_with_the_name() + test_enter_keeps_the_new_name() + test_escape_abandons_it() + test_clicking_away_keeps_the_name() + test_a_typed_name_outranks_the_program() + test_slow_clicks_are_not_a_double_click() + test_typing_a_name_does_not_reach_the_pane() + test_closing_the_pane_does_not_wedge_the_keyboard() + sys.exit(report()) |
