diff options
| author | user <user@clank> | 2026-08-15 20:10:27 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 20:10:27 +0200 |
| commit | bd84065613df44f131476d4b66fd39b2c76da801 (patch) | |
| tree | 9ee09027b05cea7f5210bef184b578cd62579ff7 | |
| parent | sl0ppty: answer the terminal when it is asked how big it is (diff) | |
| download | slosh-bd84065613df44f131476d4b66fd39b2c76da801.tar.gz slosh-bd84065613df44f131476d4b66fd39b2c76da801.tar.xz slosh-bd84065613df44f131476d4b66fd39b2c76da801.zip | |
sl0ppty: make the finder a modal, and a picker worth using
It was a bare coloured rectangle painted over whatever pane it landed
on: no border, no title, no scrim, no way out you could see. It read as
output from the program underneath rather than as a window in front of
it, which is the one thing an overlay has to get right. It is now a
modal like the cheatsheet -- same frame, same scrim, same close button
in the place a pane keeps one -- differing in the way that matters: the
cheatsheet is read, so any key dismisses it, while this one is used, so
it keeps the keyboard until it is answered.
The list was the other half of the problem. It showed `1:` for a tab
with no name, which looks like something failed to load; a row for a
pane with no purpose was three columns of nothing; there was no mark for
the pane you are already in, no count, and nothing to say a list had
more below the tenth row -- so a list that scrolled looked like a list
that ended. An empty result was an empty box, which reads as a broken
finder rather than a wrong query.
Now: a query field that looks like one (so it shows where the typing
goes before you type), the current pane marked, tab/title/purpose told
apart by weight, the row under the pointer lit, "N of M" in the footer,
arrows for what is off each end, and "no pane matches that" when there
is nothing.
Keys, because a picker in this shape is reached for by hands that
already know one: C-n/C-p, tab/shift-tab, home/end, page up/down, C-u to
clear the query, and wrapping at both ends -- one press of Up for the
last entry is worth more in a short list than overshoot protection.
Backspace deleted a *byte*, so one press through a multi-byte character
left the rest of it in the query; it deletes a character now, the way
the rename editor already did.
The finder owns the pointer as well as the keyboard: a press that is not
on one of its rows dismisses it and stops there, rather than also
landing on the layout behind and focusing the pane you were clicking
away from.
Found on the way: CSI Z (shift+tab) was not parsed at all, so it reached
neither a binding nor the program in the pane -- every terminal sends
back-tab that way, and it is the one chord whose shift lives in the
final byte rather than in a modifier param.
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | config/config.kdl | 6 | ||||
| -rw-r--r-- | src/app.c | 258 | ||||
| -rw-r--r-- | src/input.c | 10 | ||||
| -rw-r--r-- | tests/input_test.c | 5 | ||||
| -rw-r--r-- | tests/test_finder.py | 156 |
6 files changed, 396 insertions, 45 deletions
@@ -115,6 +115,12 @@ mistyped command in a fresh session no longer closes the session. ### The rest of it +- **Find a pane instead of remembering where it is.** `C-a f` opens a picker + over the whole session — every tab, including panes a small window has + collapsed out of sight. Type to narrow it by pane title, tab name or + purpose; arrows or `C-n`/`C-p` or tab to move, enter to go, and a dot marks + where you already are. Tabs stop being navigation somewhere around six + projects, and this is what replaces them. - **Select text to copy it.** Release the mouse and it is on your clipboard, sent to your terminal over ssh as well as locally. Middle click pastes. - **Images work.** Kitty graphics pass through to your terminal, which is the diff --git a/config/config.kdl b/config/config.kdl index d0250d9..b016b97 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -471,10 +471,14 @@ theme { modal_button_hover "#ff5fd7" // ── the pane finder ───────────────────────────────────────────────────── + // The finder is a modal, so its box is the modal colours above; these are + // the parts only it has. `finder_bg` is the query field and the row under + // the pointer — a band that is not the box, so typing and hovering both + // have somewhere to show. finder_fg "#ffffff" finder_bg "#45454a" finder_sel_fg "#141418" - finder_sel_bg "#ff5fd7" + finder_sel_bg "#ff5fd7" // the row enter would take // ── the in-place editor a double-click opens on a pane title ──────────── rename_fg "#141418" @@ -3447,53 +3447,194 @@ static void draw_help(app_t *a, screen_t *s) { (uint16_t)(in.y + in.h), foot, HINT_C, MODAL_BG, 0); } +/* How many rows of results the box shows at once. Past this it scrolls, and + * the point of the finder is that you narrow rather than scroll. */ +#define FINDER_ROWS 10 + +/* A band of background, for a row that is selected or under the pointer. The + * whole width, not just the text: a highlight that stops where the words stop + * reads as a highlighted *word* rather than a chosen row. */ +static void fill_row(screen_t *s, uint16_t x, uint16_t y, uint16_t w, + color_t bg) { + for (uint16_t i = 0; i < w; i++) + screen_text(s, (uint16_t)(x + i), y, " ", NO_COLOR, bg, 0); +} + +/* The finder is a modal, like the cheatsheet: scrim behind it, a pane's frame + * around it, a title and a close button in the places a pane keeps them. It + * used to be a bare coloured rectangle painted over whatever pane it landed + * on, which read as output from the program underneath rather than as a + * window in front of it -- and gave it no title, no way out you could see, + * and no edge to tell you where it ended. + * + * It differs from the cheatsheet in the one way that matters: the cheatsheet + * is *read*, so any key dismisses it, while this one is *used*, so it keeps + * the keyboard until it is answered. */ static void draw_finder(app_t *a, screen_t *s) { find_entry_t entries[64]; size_t n = finder_entries(a, entries, 64); if (a->sel >= n) a->sel = n ? n - 1 : 0; - uint16_t w = (uint16_t)(s->cols > 60 ? 56 : (s->cols > 20 ? s->cols - 8 : 12)); - uint16_t rows = (uint16_t)(n > 10 ? 10 : (n ? n : 1)); - uint16_t h = (uint16_t)(rows + 4); + /* The window of results on screen. Kept around the selection rather than + * anchored at the top, so arrowing past the bottom scrolls instead of + * moving the selection somewhere you cannot see it. */ + size_t shown = n > FINDER_ROWS ? FINDER_ROWS : n; + size_t first = a->sel >= shown ? a->sel - shown + 1 : 0; + + uint16_t w = 64; + if (w > s->cols - 4) w = (uint16_t)(s->cols > 24 ? s->cols - 4 : s->cols); + /* border, query, blank, rows (at least one, for "no matches"), border */ + uint16_t list = (uint16_t)(shown ? shown : 1); + uint16_t h = (uint16_t)(list + 5); if (h > s->rows) h = s->rows; - uint16_t x = (uint16_t)((s->cols - w) / 2), y = (uint16_t)((s->rows - h) / 2); - for (uint16_t yy = y; yy < y + h; yy++) - for (uint16_t xx = x; xx < x + w; xx++) - screen_text(s, xx, yy, " ", NO_COLOR, FINDER_BG, 0); + rect_t in = modal_frame(a, s, w, h, "find", "closefind"); - char head[128]; - snprintf(head, sizeof head, " find: %s\u2588", a->query); - screen_text(s, (uint16_t)(x + 1), (uint16_t)(y + 1), head, FINDER_FG, - FINDER_BG, ATTR_BOLD); + /* The query, in a field that looks like one: a program you type into should + * show you where the typing goes even when you have not typed anything. */ + uint16_t fy = (uint16_t)(in.y + 1); + fill_row(s, in.x, fy, in.w, FINDER_BG); + uint16_t qx = (uint16_t)(in.x + 1); + qx += help_text(s, qx, fy, "\u203a ", in.w, HINT_C, FINDER_BG, 0); + qx += help_text(s, qx, fy, a->query, (uint16_t)(in.x + in.w - qx), FINDER_FG, + FINDER_BG, ATTR_BOLD); + if (qx < in.x + in.w) + screen_text(s, qx, fy, "\u2588", FINDER_FG, FINDER_BG, 0); - for (size_t i = 0; i < rows && i < n; i++) { - size_t idx = i + (a->sel >= rows ? a->sel - rows + 1 : 0); + uint16_t ly = (uint16_t)(in.y + 3); /* the first result row */ + + if (!n) { + /* An empty box says the finder is broken; this says the query is. */ + const char *none = a->query[0] ? "no pane matches that" : "no panes"; + help_text(s, (uint16_t)(in.x + 2), ly, none, (uint16_t)(in.w - 2), HINT_C, + MODAL_BG, 0); + return; + } + + for (size_t i = 0; i < shown; i++) { + size_t idx = first + i; if (idx >= n) break; find_entry_t *e = &entries[idx]; - char line[256]; - const char *title = pane_title(e->node->pane); - snprintf(line, sizeof line, " %zu:%-12.12s %-16.16s %s", - e->tab + 1, - e->tab == (size_t)-1 ? "" : a->tabs[e->tab].name, - title && *title ? title : "pane", e->node->purpose); - line[w - 2 < sizeof line ? w - 2 : sizeof line - 1] = 0; - bool on = idx == a->sel; - uint16_t yy = (uint16_t)(y + 3 + i); - uint16_t drawn = screen_text(s, (uint16_t)(x + 1), yy, line, - on ? FINDER_SEL_FG : FINDER_FG, - on ? FINDER_SEL_BG : FINDER_BG, 0); + uint16_t yy = (uint16_t)(ly + i); + if (yy >= in.y + in.h) break; + char action[48]; snprintf(action, sizeof action, "find:%u", e->node->id); - hit_add(&s->hits, (uint16_t)(x + 1), yy, drawn, 1, action); + hit_add(&s->hits, in.x, yy, in.w, 1, action); + + /* Selected wins over hovered: the pointer may be resting anywhere, and + * the row Enter would take is the one that has to be unambiguous. */ + bool on = idx == a->sel; + bool hot = !on && ptr_on(a, in.x, yy, in.w, 1); + color_t bg = on ? FINDER_SEL_BG : (hot ? FINDER_BG : MODAL_BG); + color_t fg = on ? FINDER_SEL_FG : FINDER_FG; + fill_row(s, in.x, yy, in.w, bg); + + /* Which pane you are in now, so the list says where you are as well as + * where you could go. A different mark from the query prompt: two rows + * that both begin with the same glyph invite you to read one as the + * other. */ + bool here = e->node == cur(a)->focus && e->tab == a->cur; + uint16_t cx = (uint16_t)(in.x + 1); + cx += help_text(s, cx, yy, here ? "\u2022" : " ", 1, fg, bg, 0); + cx++; + + /* Tab, then title, then purpose, in columns -- and drawn as three pieces + * rather than one printf so the parts that matter can be told apart by + * weight. A tab with no name is its number alone: "1:" with nothing after + * it looks like something failed to load. */ + char tab[24]; + const char *tname = e->tab == (size_t)-1 ? "" : a->tabs[e->tab].name; + if (tname && *tname) + snprintf(tab, sizeof tab, "%zu:%.10s", e->tab + 1, tname); + else + snprintf(tab, sizeof tab, "%zu", e->tab + 1); + uint16_t tw = 13; + if (in.w > 40) help_text(s, cx, yy, tab, tw, on ? fg : HINT_C, bg, 0); + if (in.w > 40) cx = (uint16_t)(cx + tw); + + const char *title = pane_title(e->node->pane); + if (!title || !*title) title = "pane"; + uint16_t room = (uint16_t)(in.x + in.w > cx ? in.x + in.w - cx - 1 : 0); + uint16_t tcol = (uint16_t)(room > 20 ? 18 : room); + uint16_t drew = help_text(s, cx, yy, title, tcol, fg, bg, ATTR_BOLD); + cx = (uint16_t)(cx + (drew > tcol ? drew : tcol) + 1); + + if (*e->node->purpose && cx < in.x + in.w) + help_text(s, cx, yy, e->node->purpose, + (uint16_t)(in.x + in.w - cx - 1), on ? fg : HINT_C, bg, 0); } + + /* What is off the top and bottom of the window. Without this a list that + * scrolls looks like a list that ends. */ + if (first) + screen_text(s, (uint16_t)(in.x + in.w - 1), ly, "\u2191", HINT_C, MODAL_BG, 0); + if (first + shown < n) + screen_text(s, (uint16_t)(in.x + in.w - 1), + (uint16_t)(ly + shown - 1), "\u2193", HINT_C, MODAL_BG, 0); + + /* The count belongs where the eye already goes for "how much is there", and + * it is the answer to "is my query too narrow, or is there nothing?". */ + char foot[64]; + snprintf(foot, sizeof foot, " %zu of %zu \u00b7 \u2191\u2193 enter ", a->sel + 1, n); + if (in.w > cells(foot)) + screen_text(s, (uint16_t)(in.x + (in.w - cells(foot)) / 2), + (uint16_t)(in.y + in.h), foot, HINT_C, MODAL_BG, 0); +} + +static size_t finder_count(app_t *a) { + find_entry_t entries[64]; + return finder_entries(a, entries, 64); } -/* Returns true when the finder consumed the event. */ +/* Move the selection by `d`, wrapping. + * + * Wrapping because the list is short and the selection starts at the top: one + * press of Up to reach the last entry is worth more here than the protection + * against overshooting that a long list wants. */ +static void finder_move(app_t *a, int d) { + size_t n = finder_count(a); + if (!n) { + a->sel = 0; + return; + } + int64_t sel = (int64_t)a->sel + d; + int64_t last = (int64_t)n - 1; + if (sel < 0) sel = d == -1 ? last : 0; /* a page up stops at the top */ + if (sel > last) sel = d == 1 ? 0 : last; + a->sel = (size_t)sel; +} + +/* Returns true when the finder consumed the event. + * + * It consumes *everything* while it is open, including keys it does nothing + * with: it is a text field, and a text field that lets an unrecognised key + * fall through to the pane behind it types into that pane. */ static bool finder_key(app_t *a, const input_event_t *ev) { if (!a->finder) return false; if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true; + bool ctrl = (ev->mods & MOD_CTRL) != 0; + bool shift = (ev->mods & MOD_SHIFT) != 0; + + /* The emacs pair every picker in this shape answers to, and which the hands + * of anyone who has used one reach for before the arrows. */ + if (ctrl && (ev->key == GHOSTTY_KEY_N || ev->unshifted == 'n')) { + finder_move(a, 1); + return true; + } + if (ctrl && (ev->key == GHOSTTY_KEY_P || ev->unshifted == 'p')) { + finder_move(a, -1); + return true; + } + /* Clear the query rather than the whole finder: the shell's own C-u, and + * the alternative is backspacing a long wrong guess out one key at a time. */ + if (ctrl && (ev->key == GHOSTTY_KEY_U || ev->unshifted == 'u')) { + a->query[0] = 0; + a->sel = 0; + return true; + } + switch (ev->key) { case GHOSTTY_KEY_ESCAPE: a->finder = false; @@ -3505,22 +3646,44 @@ static bool finder_key(app_t *a, const input_event_t *ev) { a->finder = false; return true; } + case GHOSTTY_KEY_TAB: + finder_move(a, shift ? -1 : 1); + return true; case GHOSTTY_KEY_ARROW_DOWN: - a->sel++; + finder_move(a, 1); return true; case GHOSTTY_KEY_ARROW_UP: - if (a->sel) a->sel--; + finder_move(a, -1); return true; + case GHOSTTY_KEY_PAGE_DOWN: + finder_move(a, FINDER_ROWS); + return true; + case GHOSTTY_KEY_PAGE_UP: + finder_move(a, -FINDER_ROWS); + return true; + case GHOSTTY_KEY_HOME: + a->sel = 0; + return true; + case GHOSTTY_KEY_END: { + size_t n = finder_count(a); + a->sel = n ? n - 1 : 0; + return true; + } case GHOSTTY_KEY_BACKSPACE: { + /* A character, not a byte: the query can hold whatever a pane's title + * did, and half a UTF-8 sequence is not a search. (The rename editor + * learned this first; this one was still eating bytes.) */ size_t l = strlen(a->query); - if (l) a->query[l - 1] = 0; + while (l && ((unsigned char)a->query[l - 1] & 0xC0) == 0x80) l--; + if (l) l--; + a->query[l] = 0; a->sel = 0; return true; } default: break; } - if (ev->text_len && (unsigned char)ev->text[0] >= 0x20) { + if (!ctrl && ev->text_len && (unsigned char)ev->text[0] >= 0x20) { size_t l = strlen(a->query); if (l + ev->text_len < sizeof a->query) { memcpy(a->query + l, ev->text, ev->text_len); @@ -3641,15 +3804,13 @@ void app_compose(app_t *a, screen_t *s) { draw_corners(a, s); draw_min_bar(a, s); draw_status_line(a, s); - if (a->finder) draw_finder(a, s); /* painted last, so its hits win */ - /* And the modal on top of even that: it is the only thing that can be + /* The modals, on top of everything: each is the only thing that can be * interacted with while it is up, so it owns the topmost hits. The scrim - * goes down first, over everything already painted — including the finder, - * if that happened to be open. */ - if (a->help) { - draw_scrim(a, s); - draw_help(a, s); - } + * goes down first, over everything already painted, and once for both — + * dimming twice would make the second one darker for no reason. */ + if (a->finder || a->help) draw_scrim(a, s); + if (a->finder) draw_finder(a, s); + if (a->help) draw_help(a, s); draw_toasts(a, s); /* and above even that: it is transient */ } @@ -3940,6 +4101,10 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { a->finder = false; return; } + if (strcmp(action, "closefind") == 0) { + if (ev->maction == MOUSE_PRESS) a->finder = false; + return; + } if (strncmp(action, "tab:", 4) == 0) { if (ev->maction != MOUSE_PRESS) return; uint32_t id = (uint32_t)strtoul(action + 4, NULL, 10); @@ -4332,6 +4497,21 @@ void app_event(app_t *a, const input_event_t *ev) { break; } + /* The finder owns the pointer as well as the keyboard. A press on one + * of its rows chooses; a press anywhere else dismisses it and does + * nothing further -- clicking past a modal must not also land on the + * layout behind it, which is how you end up focusing a pane you were + * trying to click *away* from. Motion still falls through, so rows + * light up under the pointer. */ + if (a->finder && ev->maction != MOUSE_MOTION) { + bool own = action && (strncmp(action, "find:", 5) == 0 || + strcmp(action, "closefind") == 0); + if (!own) { + if (ev->maction == MOUSE_PRESS) a->finder = false; + break; + } + } + if (action) do_action(a, action, ev); break; } diff --git a/src/input.c b/src/input.c index 75da2fd..04dc0b5 100644 --- a/src/input.c +++ b/src/input.c @@ -351,6 +351,16 @@ static size_t parse_one(input_parser_t *p, input_cb_t cb, void *ud) { return i + 1; } + /* Back-tab. Every terminal sends shift+tab as CSI Z rather than as tab + * with a modifier param, so it is the one chord whose shift lives in + * the final byte -- and a picker that walks its list with tab is + * useless without it. */ + if (final == 'Z') { + emit_key(cb, ud, GHOSTTY_KEY_TAB, (uint16_t)(mods | MOD_SHIFT), action, + NULL, 0, 0); + return i + 1; + } + int key = final_key(final); if (key != GHOSTTY_KEY_UNIDENTIFIED) { emit_key(cb, ud, key, mods, action, NULL, 0, 0); diff --git a/tests/input_test.c b/tests/input_test.c index 62c8f5c..7863c21 100644 --- a/tests/input_test.c +++ b/tests/input_test.c @@ -79,6 +79,11 @@ int main(void) { T("ctrl-up", "\x1b[1;5A", "key ARROW_UP mods=C press"); T("shift-alt-left", "\x1b[1;4D", "key ARROW_LEFT mods=SA press"); T("home", "\x1b[H", "key HOME mods=- press"); + /* Back-tab: the one chord whose shift lives in the final byte rather than + * in a modifier param. It was falling through as an unrecognised CSI, so + * shift+tab reached neither a binding nor the program in the pane. */ + T("shift-tab", "\x1b[Z", "key TAB mods=S press"); + T("ctrl-shift-tab", "\x1b[1;5Z", "key TAB mods=SC press"); T("delete", "\x1b[3~", "key DELETE mods=- press"); T("f5", "\x1b[15~", "key F5 mods=- press"); T("ss3 f1", "\x1bOP", "key F1 mods=- press"); diff --git a/tests/test_finder.py b/tests/test_finder.py index fa6e1d4..944597f 100644 --- a/tests/test_finder.py +++ b/tests/test_finder.py @@ -56,6 +56,36 @@ def setup_panes(s): return a, b +# The finder is a modal now, so "is it open" is asked the way it is asked of +# any modal: it owns rows in the hit list. Not by looking for a word on the +# screen, which is what the old tests did and which broke the moment the box +# got a title instead of a "find:" prompt. +def is_open(s): + snap = s.snapshot() + return any(h["action"].startswith("find:") or h["action"] == "closefind" + for h in snap.hits) + + +def counter(s): + """(selected, total) from the footer, or None when it is not shown.""" + import re + for line in s.snapshot().screen().split("\n"): + m = re.search(r"(\d+) of (\d+)", line) + if m: + return int(m.group(1)), int(m.group(2)) + return None + + +def rows(s): + """The finder's result rows, top to bottom, as (action, y, text).""" + snap = s.snapshot() + out = [] + for h in snap.hits: + if h["action"].startswith("find:"): + out.append((h["action"], h["y"], snap.text[h["y"]])) + return sorted(out, key=lambda r: r[1]) + + def test_finder_keyboard(): with Session(SH, cols=70, rows=16) as s: a, b = setup_panes(s) @@ -63,40 +93,106 @@ def test_finder_keyboard(): s.key("f") s.settle(80) snap = s.snapshot() - check("the finder opens", "find:" in snap.screen(), repr(snap.screen()[:200])) + check("the finder opens", is_open(s), repr(snap.screen()[:200])) check("it lists panes from every tab", "agent:main" in snap.screen() and "service:web" in snap.screen(), repr(snap.screen())) + check("and says how many it found", counter(s) == (1, 2), str(counter(s))) s.send("web") s.settle(80) snap = s.snapshot() check("typing filters the list", "service:web" in snap.screen() and "agent:main" not in snap.screen(), repr(snap.screen())) + check("the query is shown as you type", "web" in snap.screen(), + repr(snap.screen())) + check("and the count follows it", counter(s) == (1, 1), str(counter(s))) s.send(r"\r") s.settle(80) check("enter focuses the match", s.focused()["id"] == b, str(s.focused())) - check("and closes the finder", "find:" not in s.snapshot().screen()) + check("and closes the finder", not is_open(s)) with Session(SH, cols=70, rows=16) as s: setup_panes(s) s.key("f") s.send("zzzz") s.settle(80) - check("a query matching nothing is not fatal", - "find: zzzz" in s.snapshot().screen(), repr(s.snapshot().screen()[:160])) + check("a query matching nothing is not fatal", is_open(s)) + check("and says so rather than showing an empty box", + "no pane matches" in s.snapshot().screen(), + repr(s.snapshot().screen()[:400])) s.send(r"\x7f\x7f\x7f\x7f") # backspace back to empty s.settle(80) check("backspace restores the list", "agent:main" in s.snapshot().screen(), repr(s.snapshot().screen()[:200])) s.send(r"\e") s.settle(120) - check("escape closes it", "find:" not in s.snapshot().screen()) + check("escape closes it", not is_open(s)) check("and the session is unharmed", s.api("alive")["alive"]) +def test_finder_navigation(): + """Arrows, the emacs pair, tab, and the ends -- a picker in this shape is + reached for by hands that already know one.""" + with Session(SH, cols=70, rows=16) as s: + setup_panes(s) + s.key("f") + s.settle(80) + check("it starts on the first row", counter(s) == (1, 2), str(counter(s))) + + for keys, want, what in ((r"\e[B", 2, "down"), + (r"\e[A", 1, "up"), + (r"\x0e", 2, "C-n"), + (r"\x10", 1, "C-p"), + (r"\t", 2, "tab"), + (r"\e[Z", 1, "shift-tab"), + (r"\e[F", 2, "end"), + (r"\e[H", 1, "home")): + s.send(keys) + s.settle(60) + check(f"{what} moves the selection", + counter(s) == (want, 2), f"{what}: {counter(s)}") + + # Wrapping: one press to reach the last row, which is most of why a + # short list wraps at all. + s.send(r"\e[A") + s.settle(60) + check("up from the first row wraps to the last", + counter(s) == (2, 2), str(counter(s))) + s.send(r"\e[B") + s.settle(60) + check("and down from the last wraps to the first", + counter(s) == (1, 2), str(counter(s))) + + +def test_finder_query_editing(): + with Session(SH, cols=70, rows=16) as s: + setup_panes(s) + s.key("f") + s.send("web") + s.settle(80) + check("the query narrows to one", counter(s) == (1, 1), str(counter(s))) + s.send(r"\x15") # C-u + s.settle(80) + check("C-u clears the query rather than the finder", is_open(s)) + check("and the whole list is back", counter(s) == (1, 2), str(counter(s))) + + # A character, not a byte. The rename editor learned this first; the + # finder was still deleting one byte of a multi-byte character and + # leaving the rest. + s.send(r"\xc3\xa9") # e-acute, two bytes of UTF-8 + s.settle(60) + check("a multi-byte character is accepted", + "\u00e9" in s.snapshot().screen(), repr(s.snapshot().screen()[:300])) + s.send(r"\x7f") + s.settle(60) + check("and one backspace removes all of it", + "\u00e9" not in s.snapshot().screen() and counter(s) == (1, 2), + repr(s.snapshot().screen()[:300])) + + def test_finder_mouse(): with Session(SH, cols=70, rows=16) as s: a, b = setup_panes(s) @@ -119,6 +215,52 @@ def test_finder_mouse(): check("and switches to its tab", s.tabs()[1]["active"], str(s.tabs())) +def test_finder_dismissal_by_mouse(): + """A modal owns the pointer. Clicking past it must not also land on the + layout behind -- that is how you focus the pane you were clicking away + from.""" + with Session(SH, cols=70, rows=16) as s: + a, b = setup_panes(s) + s.api("focus", id=b) + s.settle() + + s.key("f") + s.settle(80) + close = [h for h in s.snapshot().hits if h["action"] == "closefind"] + check("it has a close button, like any modal", len(close) == 1, + str(s.snapshot().hits)) + if close: + s.click(close[0]["x"], close[0]["y"]) + s.settle(80) + check("which closes it", not is_open(s)) + check("without changing the focus", s.focused()["id"] == b, + str(s.focused())) + + s.key("f") + s.settle(80) + check("it is open again", is_open(s)) + s.click(1, 15) # the corner of the screen, well outside the box + s.settle(80) + check("a click outside dismisses it", not is_open(s)) + check("and does not reach the pane behind it", + s.focused()["id"] == b, str(s.focused())) + + +def test_finder_marks_where_you_are(): + with Session(SH, cols=70, rows=16) as s: + a, b = setup_panes(s) + s.api("focus", id=b) + s.settle() + s.key("f") + s.settle(80) + marked = [r for r in rows(s) if "\u2022" in r[2]] + check("exactly one row is marked as the current pane", + len(marked) == 1, str(rows(s))) + if marked: + check("and it is the pane we are in", marked[0][0] == f"find:{b}", + str(marked)) + + def test_finder_over_a_collapsed_layout(): """The finder is how you reach a pane a small screen has collapsed away.""" with Session(SH, cols=120, rows=32) as s: @@ -149,6 +291,10 @@ def test_finder_over_a_collapsed_layout(): if __name__ == "__main__": test_status_bar() test_finder_keyboard() + test_finder_navigation() + test_finder_query_editing() test_finder_mouse() + test_finder_dismissal_by_mouse() + test_finder_marks_where_you_are() test_finder_over_a_collapsed_layout() sys.exit(report()) |
