aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_search.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-08 16:58:24 +0200
committeruser <user@clank>2026-09-08 16:58:24 +0200
commit935fb7e93726dcb8ab0529c2dbcb6b5e75656794 (patch)
tree824b46e4cff68b7d4fa0c333717684ce273c9b10 /tests/test_search.py
parentrelease: v0.1.6 paperwork -- formula and PKGBUILD pinned (diff)
downloadslosh-935fb7e93726dcb8ab0529c2dbcb6b5e75656794.tar.gz
slosh-935fb7e93726dcb8ab0529c2dbcb6b5e75656794.tar.xz
slosh-935fb7e93726dcb8ab0529c2dbcb6b5e75656794.zip
search: a scrollback search bar on /, matches highlighted
C-a / opens an inline search bar on the focused pane's status row, on lib-vt's GhosttySearch (D4's pattern: the scrollback walk, reflow and alt-screen survival are the library's, the UI and the wiring are ours). Type to search live; the viewport follows the current match; up/down (or C-n/C-p) step through them with wraparound; Enter keeps your place, Escape snaps back. A k-of-n counter reads on the right, or "no matches". Every match on screen is tinted, and the one the bar is on is tinted apart -- warm gold by default, painted after the shader pass so the scrolled wash does not dilute a match you are hunting for. Four themeable colours (search_fg/bg, search_cur_fg/bg). Bare / took the key from help, which keeps ?; that needed the legacy decoder to read the shift out of a punctuation byte the way it already did for capitals -- without it ? and / decode identically (SLASH, no mods) and cannot be two keys on a client without the kitty protocol. tests/test_search.py drives it end to end (39 checks): the bar, the counter, stepping and wrap, all-match vs current highlighting, Enter/ Escape, new output updating the count, case-insensitivity, the keyboard belonging to the bar, click-away, and that / and ? no longer collide.
Diffstat (limited to 'tests/test_search.py')
-rw-r--r--tests/test_search.py351
1 files changed, 351 insertions, 0 deletions
diff --git a/tests/test_search.py b/tests/test_search.py
new file mode 100644
index 0000000..fdbbac6
--- /dev/null
+++ b/tests/test_search.py
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+"""Scrollback search: the bar, the matches, and the two ways out.
+
+The machinery is lib-vt's (a GhosttySearch: the scrollback walk, reflow
+survival, match tracking); what these tests pin down is ours -- the inline bar
+on the pane's status row, the keyboard while it is open, the current match
+shown as the selection with the viewport following it, and Enter/Escape
+meaning keep-my-place / put-me-back.
+"""
+
+import sys
+
+from harness import Session, check, report
+
+# a shell whose output we can generate on demand
+SH = ["/bin/sh", "-c", "stty raw -echo; sh"]
+
+# the default theme's match colours (config.c): every match, and the current one
+MATCH_BG = "#4d4124"
+CUR_BG = "#f2c97a"
+
+
+def match_cells(snap, bg):
+ """Total columns tinted with a given match background."""
+ return sum(st["w"] for st in snap.styles if st["bg"] == bg)
+
+
+def fill(s, n=100):
+ s.raw(f"seq 1 {n}\\n")
+ s.until_text(str(n))
+
+
+def bar(s):
+ """The search bar's row, or None."""
+ for row in s.snapshot().text:
+ if "/ " in row and "\u2588" in row:
+ return row
+ return None
+
+
+def test_bar_opens_and_finds():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s)
+ p = s.pane()
+ check("the viewport is at the bottom", "100" in s.snapshot().pane_text(p))
+
+ s.send(r"\x01/")
+ s.settle(50)
+ row = bar(s)
+ check(
+ "the bar opens on the status row",
+ row is not None,
+ repr(s.snapshot().screen()[:300]),
+ )
+
+ s.send("42")
+ s.settle(80)
+ row = bar(s)
+ check(
+ "the query is echoed in the field", row and "/ 42\u2588" in row, repr(row)
+ )
+ check("the counter answers k of n", row and "1 of 1" in row, repr(row))
+ text = s.snapshot().pane_text(p)
+ check(
+ "the viewport jumped to the match",
+ "42" in text and "100" not in text,
+ repr(text),
+ )
+ check(
+ "and says it is in scrollback",
+ "scrolled" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:300]),
+ )
+
+ # The current match is tinted in the current-match colour, bold.
+ snap = s.snapshot()
+ cur = [st for st in snap.styles if st["bg"] == CUR_BG]
+ check(
+ "the current match is highlighted apart",
+ len(cur) == 1 and "bold" in cur[0]["attrs"],
+ str(cur),
+ )
+ pos = snap.find("42")
+ check(
+ "and it is on the matched row",
+ bool(cur) and cur[0]["y"] == pos[1],
+ f"{cur} vs row {pos[1] if pos else None}",
+ )
+
+
+def test_all_matches_are_highlighted():
+ """Every occurrence on screen is tinted, not only the one you are on -- and
+ the current one is a different colour, so 'this one' and 'all of them' read
+ as two things."""
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ s.raw("seq 30 39\\n") # a screen full of 3s: many visible matches
+ s.until_text("39")
+ s.send(r"\x01/")
+ s.send("3")
+ s.settle(80)
+ snap = s.snapshot()
+ others = match_cells(snap, MATCH_BG)
+ cur = match_cells(snap, CUR_BG)
+ check("more than one match is tinted", others >= 2, f"{others} in {MATCH_BG}")
+ check("exactly one is the current match", cur == 1, f"{cur} in {CUR_BG}")
+
+ # Stepping moves which one is current, not how many are lit.
+ s.send(r"\e[A")
+ s.settle(50)
+ snap = s.snapshot()
+ check(
+ "stepping keeps exactly one current",
+ match_cells(snap, CUR_BG) == 1,
+ str(match_cells(snap, CUR_BG)),
+ )
+
+
+def test_stepping_and_wrap():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s)
+ s.send(r"\x01/")
+ s.send("9")
+ s.settle(80)
+ row = bar(s)
+ # 9, 19..89, 90..98 and two in 99: byte matches, newest first.
+ check("all matches are counted", row and "1 of 20" in row, repr(row))
+
+ s.send(r"\e[A") # up: older
+ s.send(r"\e[A")
+ s.settle(50)
+ check("up steps into history", "3 of 20" in bar(s), repr(bar(s)))
+
+ s.send(r"\e[B") # down: newer
+ s.settle(50)
+ check("down steps back", "2 of 20" in bar(s), repr(bar(s)))
+
+ s.send(r"\e[B")
+ s.send(r"\e[B") # past the newest: wraps to the oldest
+ s.settle(50)
+ check("stepping wraps", "20 of 20" in bar(s), repr(bar(s)))
+
+
+def test_enter_keeps_the_place():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s, 200)
+ p = s.pane()
+ s.send(r"\x01/")
+ s.send("42")
+ s.settle(80)
+ check("found it deep in history", "42" in s.snapshot().pane_text(p))
+
+ s.send(r"\r")
+ s.settle(50)
+ check("enter closes the bar", bar(s) is None, repr(s.snapshot().screen()[:300]))
+ text = s.snapshot().pane_text(p)
+ check(
+ "and the viewport stays on the match",
+ "42" in text and "200" not in text,
+ repr(text),
+ )
+ check("still marked as scrolled", "scrolled" in s.snapshot().screen())
+
+ # Typing snaps back to the live view, like any typing while scrolled.
+ s.send("x")
+ s.settle(80)
+ check(
+ "typing returns to the present",
+ "200" in s.snapshot().pane_text(p),
+ repr(s.snapshot().pane_text(p)),
+ )
+
+
+def test_escape_snaps_back():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s, 200)
+ p = s.pane()
+ s.send(r"\x01/")
+ s.send("42")
+ s.settle(80)
+ check("found it", "42" in s.snapshot().pane_text(p))
+
+ s.send(r"\e")
+ s.settle(120)
+ check("escape closes the bar", bar(s) is None)
+ text = s.snapshot().pane_text(p)
+ check("and snaps back to the present", "200" in text, repr(text))
+ check(
+ "the highlights went with it",
+ match_cells(s.snapshot(), MATCH_BG) == 0
+ and match_cells(s.snapshot(), CUR_BG) == 0,
+ str([st for st in s.snapshot().styles if st["bg"] in (MATCH_BG, CUR_BG)]),
+ )
+ check("the session is unharmed", s.api("alive")["alive"])
+
+
+def test_no_matches_says_so():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s)
+ s.send(r"\x01/")
+ s.send("zzz")
+ s.settle(80)
+ check("no matches is said in words", "no matches" in bar(s), repr(bar(s)))
+
+ # Backspacing to a needle that exists recovers.
+ s.send(r"\x7f\x7f\x7f")
+ s.send("42")
+ s.settle(80)
+ check("backspace re-searches", "1 of 1" in bar(s), repr(bar(s)))
+
+
+def test_matching_is_case_insensitive():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ s.raw("printf 'Hello World\\n'\\n")
+ s.until_text("Hello World")
+ s.send(r"\x01/")
+ s.send("hello")
+ s.settle(80)
+ check("ascii case does not matter", "1 of 1" in bar(s), repr(bar(s)))
+
+
+def test_new_output_updates_the_count():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s)
+ s.send(r"\x01/")
+ s.send("77")
+ s.settle(80)
+ check("one match to start", "1 of 1" in bar(s), repr(bar(s)))
+
+ # The program keeps printing while the bar is open: the search is fed
+ # per composed frame, so the count follows without being asked.
+ s.raw("echo 77\\n")
+ s.until(lambda snap: "of 2" in (bar_row(snap) or ""))
+ # The selected match is the one we were on -- now the *older* of the
+ # two, since matches count from the newest.
+ check("the count follows new output", "2 of 2" in bar(s), repr(bar(s)))
+
+
+def bar_row(snap):
+ for row in snap.text:
+ if "/ " in row and "\u2588" in row:
+ return row
+ return None
+
+
+def test_the_keyboard_belongs_to_the_bar():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s)
+ s.send(r"\x01/")
+ s.send("q") # would quit the session if it fell through as C-a q's kin
+ s.send("x") # would close the pane
+ s.settle(50)
+ check(
+ "letters land in the query, not the session",
+ "/ qx\u2588" in bar(s),
+ repr(bar(s)),
+ )
+ check(
+ "the pane did not see them either",
+ "qx" not in s.snapshot().pane_text(s.pane()),
+ )
+ s.send(r"\e")
+ s.settle(120)
+ check("the session is unharmed", s.api("alive")["alive"])
+
+
+def test_click_away_commits():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s, 200)
+ p = s.pane()
+ s.send(r"\x01/")
+ s.send("42")
+ s.settle(80)
+ check("found it", "42" in s.snapshot().pane_text(p))
+
+ # Clicking into the pane is leaving the field: it commits (keeps the
+ # viewport) rather than cancelling -- the click said done, not undo.
+ s.click(p["content_x"] + 2, p["content_y"] + 2)
+ s.settle(50)
+ check("a click away closes the bar", bar(s) is None)
+ check(
+ "and keeps the viewport",
+ "42" in s.snapshot().pane_text(p),
+ repr(s.snapshot().pane_text(p)),
+ )
+
+
+def test_the_wheel_is_not_a_click():
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ fill(s)
+ p = s.pane()
+ s.send(r"\x01/")
+ s.send("42")
+ s.settle(80)
+ # Scrolling the pane you are searching is part of searching it.
+ s.send(rf"\e[<64;{p['content_x'] + 3};{p['content_y'] + 3}M")
+ s.settle(50)
+ check(
+ "the wheel leaves the bar open",
+ bar(s) is not None,
+ repr(s.snapshot().screen()[:300]),
+ )
+
+
+def test_help_and_search_share_the_slash():
+ """`/` is search and `?` is help -- which needs the decoder to read the
+ shift out of a legacy `?` byte, since the byte carries no modifier."""
+ with Session(SH, cols=70, rows=20) as s:
+ s.settle()
+ s.send(r"\x01?")
+ s.settle(80)
+ check(
+ "? opens the cheatsheet",
+ "then:" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:200]),
+ )
+ s.send(r"\e")
+ s.settle(120)
+ s.send(r"\x01/")
+ s.settle(50)
+ check(
+ "/ opens the search bar",
+ bar(s) is not None,
+ repr(s.snapshot().screen()[:300]),
+ )
+
+
+if __name__ == "__main__":
+ test_bar_opens_and_finds()
+ test_all_matches_are_highlighted()
+ test_stepping_and_wrap()
+ test_enter_keeps_the_place()
+ test_escape_snaps_back()
+ test_no_matches_says_so()
+ test_matching_is_case_insensitive()
+ test_new_output_updates_the_count()
+ test_the_keyboard_belongs_to_the_bar()
+ test_click_away_commits()
+ test_the_wheel_is_not_a_click()
+ test_help_and_search_share_the_slash()
+ sys.exit(report())