diff options
| author | user <user@clank> | 2026-08-14 20:32:16 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 20:32:16 +0200 |
| commit | 686421cee2ebf7dac0d4da28cfa23fe201272352 (patch) | |
| tree | bb3c46b23a378323c33845fa532300f135d5d1de | |
| parent | sl0ppty: plain ASCII for the frame's buttons (diff) | |
| download | slosh-686421cee2ebf7dac0d4da28cfa23fe201272352.tar.gz slosh-686421cee2ebf7dac0d4da28cfa23fe201272352.tar.xz slosh-686421cee2ebf7dac0d4da28cfa23fe201272352.zip | |
sl0ppty: the status line says what the pointer is on
The frame's affordances are nearly all one character wide, and the widest one
is invisible: a border that splits when you click it looks exactly like a
border. Hovering anything now puts a word in the middle of the status line.
Read off the hit list rather than tracked. Anything that registers a hit gets a
hint by being named in one table, and nothing has to remember to raise or clear
one — which also means the hint cannot disagree about what is under the pointer
with the thing that would actually be clicked, since both are the same lookup.
The status line is drawn after everything else has registered, so the list is
complete by the time it asks.
It is centred in what is left between the two ends rather than in the row.
Centring it in the row would put it under the session name on a narrow screen,
and a hint that overwrites the thing it is explaining is worse than no hint; it
is simply not drawn when it does not fit, which has a test with a deliberately
long pane title.
Two of that test's checks were wrong before they were right, in the ordinary
way: one searched for a substring that also occurs in the text on the right,
and one assumed forty-four columns was too narrow to fit a hint when it was
not. Both now assert against measured positions rather than my guess at them.
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | config/config.kdl | 9 | ||||
| -rw-r--r-- | src/app.c | 58 | ||||
| -rw-r--r-- | src/config.c | 4 | ||||
| -rw-r--r-- | src/config.h | 7 | ||||
| -rw-r--r-- | tests/test_hints.py | 162 |
6 files changed, 242 insertions, 2 deletions
@@ -139,6 +139,10 @@ and `default_bg` — set them to your terminal's real colours, or a shaded pane will shift as it turns on. Panes with no shader defer to your terminal exactly as before. +Hovering anything says what it does, in a word in the middle of the bottom +line: which way a border would split, what a one-character button is for, that +a gap can be dragged. `hints false` turns it off. + A **strip along the top** carries the tabs, the pane count and the prefix indicator; a **line along the bottom** says what you are looking at — session, tab, the focused pane's purpose or title, which of the tab's panes it is diff --git a/config/config.kdl b/config/config.kdl index 689c7c9..db272a4 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -51,6 +51,15 @@ title_inset 2 // still done by clicking an edge. They do cost six columns of every frame, so // this turns them off -- the keybindings (C-a z, C-a x) are unaffected, because // the setting is about the affordance and not the verb. +// A word in the middle of the status line for whatever the pointer is on: +// which way a border would split, what a one-character button does, that the +// gap between two panes can be dragged. The frame's affordances are mostly one +// character wide and a border that splits when clicked looks exactly like a +// border, so this is where they say so. It gives way to the rest of the line +// when there is no room between the two ends -- a hint that overwrites what it +// is explaining is worse than no hint. +hints true + pane_buttons true // Plain single-width ASCII, after box-drawing and block characters were each @@ -97,6 +97,7 @@ bool app_reload_config(char *err, size_t errcap) { #define PANE_BTN_HOVER (CFG.pane_button_hover) #define MINBAR (CFG.minbar) #define MINBAR_HOVER (CFG.minbar_hover) +#define HINT_C (CFG.hint) static const color_t NO_COLOR = {0}; @@ -341,6 +342,43 @@ static size_t count_leaves(node_t *n); /* all defined with the layout */ static size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k); static size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k); +/* What the thing under the pointer would do, in a word. + * + * Read off the hit list rather than tracked, so anything that registers a hit + * gets a hint by being listed here and nothing has to remember to raise one. + * The status line is drawn after everything else has registered, so the list + * is complete by the time this is asked. */ +static const char *hint_for(app_t *a, const char *action) { + if (!action) return NULL; + uint32_t id = 0; + const char *colon = strchr(action, ':'); + if (colon) id = (uint32_t)strtoul(colon + 1, NULL, 10); + + if (strncmp(action, "minimize:", 9) == 0) return "minimise"; + if (strncmp(action, "zoom:", 5) == 0) + return app_pane_zoomed(a, id) ? "back to the layout" : "fill the tab"; + if (strncmp(action, "close:", 6) == 0) return "close this pane"; + if (strncmp(action, "scrollbottom:", 13) == 0) return "back to the bottom"; + if (strncmp(action, "panetitle:", 10) == 0) + return "double-click to rename \u00b7 drag to move"; + if (strncmp(action, "title:", 6) == 0) return "drag to move \u00b7 click to split up"; + if (strncmp(action, "border:", 7) == 0) { + const char *side = strrchr(action, ':'); + switch (side && side[1] ? side[1] : 0) { + case 'l': return "click to split left"; + case 'r': return "click to split right"; + case 't': return "click to split up"; + default: return "click to split down"; + } + } + if (strncmp(action, "edge:", 5) == 0) return "drag to resize"; + if (strncmp(action, "focus:", 6) == 0) return "open this pane"; + if (strncmp(action, "find:", 5) == 0) return "go to this pane"; + if (strncmp(action, "tab:", 4) == 0) return "switch to this tab"; + if (strcmp(action, "newtab") == 0) return "new tab"; + return NULL; /* a pane's own content, and anything not worth a word */ +} + /* The line along the bottom: what you are looking at, rather than what you * could switch to. The strip along the top already answers "which tab" and * "how many panes"; repeating that here would spend a row saying it twice. @@ -486,9 +524,25 @@ static void draw_status_line(app_t *a, screen_t *s) { else if (pt && *pt) n += (size_t)snprintf(line + n, sizeof line - n, "%s%s", n ? " \u00b7 " : "", pt); } - if (!n) return; if (n > (size_t)(right - x)) line[right - x] = 0; - screen_text(s, x, y, line, STATUS_C, NO_COLOR, 0); + if (n) screen_text(s, x, y, line, STATUS_C, NO_COLOR, 0); + + /* The hint sits in what is left between the two ends, centred there rather + * than in the row: centring it in the row would put it under the session + * name on a narrow screen, and a hint that overwrites what it is explaining + * is worse than no hint. It is simply not drawn when it does not fit. */ + if (!CFG.hints || !a->painted) return; + const char *hint = hint_for(a, hit_test(&s->hits, a->ptr_x, a->ptr_y)); + if (!hint || !a->ptr_valid) return; + + uint16_t used = (uint16_t)(x + cells(line)); + uint16_t from = (uint16_t)(used + 2); + if (right <= from) return; + uint16_t span = (uint16_t)(right - from); + uint16_t hw = cells(hint); + if (hw + 2 > span) return; + screen_text(s, (uint16_t)(from + (span - hw) / 2), y, hint, HINT_C, NO_COLOR, + 0); } static void draw_toasts(app_t *a, screen_t *s) { diff --git a/src/config.c b/src/config.c index 7159d47..6ce7a7f 100644 --- a/src/config.c +++ b/src/config.c @@ -171,6 +171,7 @@ void config_defaults(config_t *c) { c->rounded = true; c->title_align = ALIGN_CENTER; c->title_inset = 2; + c->hints = true; c->pane_buttons = true; snprintf(c->zoom_mark, sizeof c->zoom_mark, "#"); snprintf(c->zoom_on_mark, sizeof c->zoom_on_mark, "*"); @@ -256,6 +257,7 @@ void config_defaults(config_t *c) { c->finder_sel_bg = accent; c->bell = accent; + c->hint = bright; c->minbar = dim; c->minbar_hover = accent; c->pane_button = dim; @@ -375,6 +377,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->title_inset = (uint16_t)kdl_arg_int(kdl_child(root, "title_inset"), 0, c->title_inset); + c->hints = kdl_arg_bool(kdl_child(root, "hints"), 0, c->hints); c->pane_buttons = kdl_arg_bool(kdl_child(root, "pane_buttons"), 0, c->pane_buttons); const char *zm = kdl_arg(kdl_child(root, "zoom_mark"), 0, NULL); @@ -482,6 +485,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { {"finder_sel_fg", &c->finder_sel_fg}, {"finder_sel_bg", &c->finder_sel_bg}, {"bell", &c->bell}, + {"hint", &c->hint}, {"minbar", &c->minbar}, {"minbar_hover", &c->minbar_hover}, {"pane_button", &c->pane_button}, diff --git a/src/config.h b/src/config.h index 28350ce..76a9b85 100644 --- a/src/config.h +++ b/src/config.h @@ -84,6 +84,10 @@ typedef struct { * since. Off makes a bell silent *and* invisible, which is a real choice. */ /* Buttons in the top-right of a pane's frame: zoom, and close. Off leaves * the keyboard bindings alone; it is about the affordance, not the verb. */ + /* A word in the middle of the status line for whatever the pointer is on. + * Discoverability for a frame whose affordances are mostly one character + * wide; off if you already know them. */ + bool hints; bool pane_buttons; /* Marks of similar visual weight, so the even spacing between the buttons * reads as even: a glyph that floats in a mostly-empty cell donates its own @@ -196,6 +200,9 @@ typedef struct { /* the row of minimised panes along the bottom */ color_t minbar, minbar_hover; + /* the hint in the middle of the status line */ + color_t hint; + /* announcements */ color_t toast_fg, toast_bg; diff --git a/tests/test_hints.py b/tests/test_hints.py new file mode 100644 index 0000000..4f6a5d6 --- /dev/null +++ b/tests/test_hints.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""A word in the middle of the status line for whatever the pointer is on. + +The frame's affordances are mostly one character wide, and a border that splits +when clicked looks exactly like a border. The hint is read off the hit list +rather than tracked, so anything that registers a hit gets one by being named +in the table and nothing has to remember to raise it. +""" +import os +import sys +import tempfile + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat'] + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def hover(s, x, y): + s.send(rf"\e[<35;{x + 1};{y + 1}M") + + +def bar(s): + return s.snapshot().text[-2] + + +def hit(s, prefix): + return [h for h in s.snapshot().hits if h["action"].startswith(prefix)][0] + + +def test_each_button_says_what_it_does(): + with Session(SH, cols=96, rows=18) as s: + s.settle(20) + s.api("split", dir="cols") + s.settle(20) + check("nothing is said before the pointer is on anything", + "minimise" not in bar(s) and "close" not in bar(s), repr(bar(s))) + + for prefix, word in (("minimize:", "minimise"), + ("zoom:", "fill the tab"), + ("close:", "close this pane")): + h = hit(s, prefix) + hover(s, h["x"], h["y"]) + check(f"{prefix[:-1]} says its word", word in bar(s), repr(bar(s))) + + +def test_the_zoom_hint_says_which_way_it_goes(): + with Session(SH, cols=96, rows=18) as s: + s.settle(20) + s.api("split", dir="cols") + s.settle(20) + h = hit(s, "zoom:") + hover(s, h["x"], h["y"]) + check("it offers to fill the tab", "fill the tab" in bar(s), repr(bar(s))) + + s.send(r"\x01z") + s.settle(20) + h = hit(s, "zoom:") # the button moved with the pane + hover(s, h["x"], h["y"]) + check("and to undo it once it is zoomed", + "back to the layout" in bar(s), repr(bar(s))) + + +def test_a_border_names_the_direction_it_would_split(): + with Session(SH, cols=96, rows=22) as s: + s.settle(20) + p = s.pane() + for x, y, word in ( + (p["x"], p["y"] + 2, "split left"), + (p["x"] + p["w"] - 1, p["y"] + 2, "split right"), + (p["x"] + 6, p["y"] + p["h"] - 1, "split down")): + hover(s, x, y) + check(f"the border says {word}", word in bar(s), repr(bar(s))) + + +def test_the_gap_and_the_strip_and_the_bar(): + with Session(SH, cols=96, rows=20) as s: + s.settle(20) + s.api("split", dir="cols") + s.settle(20) + left = s.panes()[0] + hover(s, left["x"] + left["w"], left["y"] + 3) + check("the gap between panes offers a resize", + "drag to resize" in bar(s), repr(bar(s))) + + t = hit(s, "tab:") + hover(s, t["x"] + 1, t["y"]) + check("a tab offers to switch", "switch to this tab" in bar(s), + repr(bar(s))) + n = hit(s, "newtab") + hover(s, n["x"] + 1, n["y"]) + check("+tab offers a new one", "new tab" in bar(s), repr(bar(s))) + + s.send(r"\x01m") + s.settle(20) + gone = [p for p in s.panes() if p["w"] <= 1][0]["id"] + e = [h for h in s.snapshot().hits + if h["action"] == f"focus:{gone}" and h["h"] == 1][0] + hover(s, e["x"] + 1, e["y"]) + check("a pane put away offers to come back", + "open this pane" in bar(s), repr(bar(s))) + + +def test_the_hint_never_writes_over_the_line_it_shares(): + with Session(SH, cols=96, rows=18) as s: + s.settle(20) + s.api("split", dir="cols") + s.settle(20) + h = hit(s, "close:") + hover(s, h["x"], h["y"]) + row = bar(s) + left_end = len(row.rstrip()) and row.index("p") # the pane name + hint_at = row.index("close this pane") + right_at = row.rindex("pane 1/2" if "pane 1/2" in row else "pane 2/2") + check("the pane index on the right survives", right_at > 0, repr(row)) + check("and the hint sits between the two ends, touching neither", + left_end < hint_at + and hint_at + len("close this pane") < right_at, repr(row)) + + # A long name leaves no room between the ends, so the hint is dropped + # rather than drawn over the thing it would be explaining. + wordy = ["/bin/sh", "-c", + 'printf "\\033]2;a-very-long-pane-title-indeed\\007"; ' + 'stty raw -echo; cat'] + with Session(wordy, cols=60, rows=18) as s: + s.settle(20) + h = hit(s, "close:") + hover(s, h["x"], h["y"]) + row = bar(s) + check("with no room between them the hint gives way to the line", + "close this pane" not in row + and "a-very-long-pane-title-indeed" in row + and "pane 1/1" in row, repr(row)) + + +def test_hints_can_be_turned_off(): + conf = cfg("hints false\n") + with Session(SH, cols=96, rows=18, config=conf) as s: + s.settle(20) + s.api("split", dir="cols") + s.settle(20) + h = hit(s, "close:") + hover(s, h["x"], h["y"]) + check("hints false says nothing", "close this pane" not in bar(s), + repr(bar(s))) + os.unlink(conf) + + +if __name__ == "__main__": + test_each_button_says_what_it_does() + test_the_zoom_hint_says_which_way_it_goes() + test_a_border_names_the_direction_it_would_split() + test_the_gap_and_the_strip_and_the_bar() + test_the_hint_never_writes_over_the_line_it_shares() + test_hints_can_be_turned_off() + sys.exit(report()) |
