diff options
| author | user <user@clank> | 2026-08-14 13:59:05 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 13:59:05 +0200 |
| commit | 4bb2bb5f05dda628722ae363fa4580aa69f37e7c (patch) | |
| tree | 4c06be8b88295a266405a9ec28e67993e4936fae | |
| parent | sl0ppty: drag greys the other panes, and dim_unfocused (diff) | |
| download | slosh-4bb2bb5f05dda628722ae363fa4580aa69f37e7c.tar.gz slosh-4bb2bb5f05dda628722ae363fa4580aa69f37e7c.tar.xz slosh-4bb2bb5f05dda628722ae363fa4580aa69f37e7c.zip | |
sl0ppty: dashed borders and a deeper push-back while dragging
Two changes to the same moment, on opposite sides of the chrome boundary.
Every pane that is not being dragged now draws a dashed border, because every
one of them is somewhere the pane could be dropped and a dashed frame says so
without needing a legend. The pane in your hand keeps a solid one, so the two
states are distinguishable by shape and not only by colour — which matters on
a terminal whose palette makes the greying subtle, and to anyone who would not
see the greying at all. The pane actually under the pointer still takes the
existing drop-target highlight on top of the dashes: these are all targets,
that one is the one you are on.
Borders are chrome, so this is not a shader and could not have been. It lives
in draw_frame, keyed off the same drag.moved that gates the greying, so a press
that turns out to be a click neither greys nor dashes anything.
The greying itself gets a second pass: desaturated *and* darkened, rather than
only desaturated. Grey alone still competes for attention at the same
brightness, which is what made the dragged pane fail to pop. Two shaders
instead of one that does both, because "colourless" and "recessed" are
different things to want and want separate knobs — drag_grayscale 200 and
drag_dim 140. This is the first thing to actually use the chain, and it is
what the chain was for.
The knob that used to mean "off" no longer does, and the test that asserted it
caught that immediately: drag_grayscale 0 now leaves drag_dim still dimming.
Rather than couple them, the test became three — both off, each alone — which
is better coverage of a two-knob effect than the single case was of a one-knob
one.
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | config/config.kdl | 16 | ||||
| -rw-r--r-- | src/app.c | 57 | ||||
| -rw-r--r-- | src/config.c | 3 | ||||
| -rw-r--r-- | src/config.h | 1 | ||||
| -rw-r--r-- | tests/test_shaders.py | 108 |
6 files changed, 166 insertions, 31 deletions
@@ -58,9 +58,11 @@ it and shows a dashed line where the split would land. Click a pane to focus it, a tab to switch, `+tab` to open one, a collapsed header to expand it, and a pane's own buttons to talk to the program inside it. **Drag a pane's title bar onto another pane to swap them, or drag the gap between two panes to move the -boundary.** While you drag, every *other* pane greys out so the one you are -moving lifts off the page. Focus follows the pointer (`focus_follows_mouse`, -on by default, and it knows when not to). +boundary.** While you drag, every *other* pane drains to grey and drops in +brightness while its border turns dashed — they are all places the pane could +land — and the one in your hand keeps its colour and a solid border, so it +lifts off the page. 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. @@ -79,8 +81,8 @@ like "copied 13 chars". **Shaders** are colour passes over a pane's *contents* — never the frame, the title or the tab strip, so chrome stays legible over a pane that has been dimmed underneath it. `dim_unfocused` (off by default) fades every pane but -the focused one; `drag_grayscale` is the greying you get while dragging. Both -are strengths from 0 to 255. Because a cell's colour is often "whatever the +the focused one; `drag_grayscale` and `drag_dim` are the greying and the +recession you get while dragging. All are strengths from 0 to 255. Because a cell's colour is often "whatever the terminal calls default", shaders resolve those through `theme`'s `default_fg` 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 diff --git a/config/config.kdl b/config/config.kdl index f114719..4a71c38 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -64,11 +64,19 @@ double_click_ms 400 // fighting. 40 is a hint, 120 is obvious. dim_unfocused 0 -// While a pane is being dragged by its title, grey every *other* pane, so the -// one that is moving lifts off the page. Transient — it appears once the drag -// actually moves and is gone on release — so unlike the above it costs -// nothing when you are not doing it. +// While a pane is being dragged by its title, push every *other* pane back so +// the one that is moving lifts off the page: drag_grayscale drains its colour, +// drag_dim drops its brightness. Two knobs because "colourless" and "recessed" +// are different things to want, and grey alone still competes for attention at +// the same brightness. Transient — they appear once the drag actually moves and +// are gone on release — so unlike dim_unfocused they cost nothing when you are +// not dragging. Set both to 0 to turn the effect off. +// +// The dragged pane's border also stays solid while every other pane's turns +// dashed, marking them as places it could be dropped. That is chrome, not a +// shader, so it happens whatever these are set to. drag_grayscale 200 +drag_dim 140 // How long an announcement ("copied 13 chars", a pane's OSC 9 notification, // "config reloaded") stays in the bottom-right corner. @@ -1278,6 +1278,18 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { color_t fg = drop_target ? BTN_BG : (focused ? FRAME_FOCUS : FRAME_IDLE); uint16_t attrs = drop_target ? ATTR_BOLD : 0; + /* While a pane is being dragged, every other pane is somewhere it could be + * dropped, and a dashed border says so without needing a legend. The pane in + * your hand keeps a solid one, so the two states are told apart by the frame + * as well as by the colour — which matters on a terminal whose palette makes + * the greying subtle. The pane actually under the pointer still takes the + * drop_target highlight on top of the dashes: these are all targets, that is + * the one you are on. */ + bool drag_target = + a->drag.kind == DRAG_TITLE && a->drag.moved && a->drag.src != leaf->id; + const char *hbar = drag_target ? "\u2504" : "\u2500"; + const char *vbar = drag_target ? "\u2506" : "\u2502"; + const char *tl = CFG.rounded ? "╭" : "┌", *tr = CFG.rounded ? "╮" : "┐"; const char *bl = CFG.rounded ? "╰" : "└", *br = CFG.rounded ? "╯" : "┘"; @@ -1287,12 +1299,12 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { screen_text(s, r.x, y1, bl, fg, NO_COLOR, attrs); screen_text(s, x1, y1, br, fg, NO_COLOR, attrs); for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { - screen_text(s, x, r.y, "─", fg, NO_COLOR, attrs); - screen_text(s, x, y1, "─", fg, NO_COLOR, attrs); + screen_text(s, x, r.y, hbar, fg, NO_COLOR, attrs); + screen_text(s, x, y1, hbar, fg, NO_COLOR, attrs); } for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { - screen_text(s, r.x, y, "│", fg, NO_COLOR, attrs); - screen_text(s, x1, y, "│", fg, NO_COLOR, attrs); + screen_text(s, r.x, y, vbar, fg, NO_COLOR, attrs); + screen_text(s, x1, y, vbar, fg, NO_COLOR, attrs); } /* The frame's top row is the drag handle. Registered before the split @@ -1456,34 +1468,45 @@ size_t app_shade_count(app_t *a, uint32_t pane_id) { * that forgot to clear it is a bug that only shows up in front of someone. * The rect and the collapsed flag are recomputed every pass for exactly this * reason; this is the same rule applied to colour. */ -static size_t policy_shader(app_t *a, node_t *n, shader_t *out) { +static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) { + size_t k = 0; /* Only once the pointer has actually moved: a press that turns out to be a * click would otherwise flash the whole session grey on its way to nothing. */ if (a->drag.kind == DRAG_TITLE && a->drag.moved) { - /* The pane being dragged keeps its colour and everything else goes grey, - * so the one that is moving lifts off the page. This replaces the focus - * dimming rather than stacking with it: two reasons to be grey compound - * into one muddy grey that reads as neither. */ - if (n->id == a->drag.src || !CFG.drag_grayscale) return 0; - return shader_make(out, "grayscale", (color_t){0}, CFG.drag_grayscale) ? 1 - : 0; + /* The pane being dragged is left alone and everything else is pushed back + * — desaturated *and* darkened, because grey alone still competes for + * attention at the same brightness. Two passes rather than one shader + * that does both: they are independently tunable, and "colourless" and + * "recessed" are different things to want. + * + * This replaces the focus dimming rather than stacking with it: two + * reasons to be grey compound into a muddy grey that reads as neither. */ + if (n->id == a->drag.src) return 0; + if (CFG.drag_grayscale && k < cap && + shader_make(&out[k], "grayscale", (color_t){0}, CFG.drag_grayscale)) + k++; + if (CFG.drag_dim && k < cap && + shader_make(&out[k], "dim", (color_t){0}, CFG.drag_dim)) + k++; + return k; } - if (CFG.dim_unfocused && n != cur(a)->focus) - return shader_make(out, "dim", (color_t){0}, CFG.dim_unfocused) ? 1 : 0; - return 0; + if (CFG.dim_unfocused && n != cur(a)->focus && k < cap && + shader_make(&out[k], "dim", (color_t){0}, CFG.dim_unfocused)) + k++; + return k; } /* A pane with nothing to apply costs nothing: no context is built and no cell * is visited, so an unshaded session emits the same bytes it did before. */ static void shade_leaf(app_t *a, screen_t *s, node_t *n) { - shader_t chain[SHADE_MAX + 1]; + shader_t chain[SHADE_MAX + 2]; size_t nc = 0; /* Attached first, policy last: a pane's own colour is the thing the * session's opinion of the moment is then applied to. */ for (size_t i = 0; i < n->nshaders && nc < SHADE_MAX; i++) chain[nc++] = n->shaders[i]; - nc += policy_shader(a, n, &chain[nc]); + nc += policy_shaders(a, n, &chain[nc], (sizeof chain / sizeof *chain) - nc); if (!nc) return; shade_ctx_t base = { diff --git a/src/config.c b/src/config.c index 40a1cc9..6c1650c 100644 --- a/src/config.c +++ b/src/config.c @@ -131,6 +131,7 @@ void config_defaults(config_t *c) { c->double_click_ms = 400; c->dim_unfocused = 0; c->drag_grayscale = 200; + c->drag_dim = 140; c->status_bar = true; c->focus_follows_mouse = true; @@ -260,6 +261,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->dim_unfocused); c->drag_grayscale = (uint8_t)kdl_arg_int(kdl_child(root, "drag_grayscale"), 0, c->drag_grayscale); + c->drag_dim = + (uint8_t)kdl_arg_int(kdl_child(root, "drag_dim"), 0, c->drag_dim); const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL); if (sh) { diff --git a/src/config.h b/src/config.h index 17c3645..eed50a6 100644 --- a/src/config.h +++ b/src/config.h @@ -65,6 +65,7 @@ typedef struct { * off by default; the drag greying is transient and on. */ uint8_t dim_unfocused; uint8_t drag_grayscale; + uint8_t drag_dim; bool status_bar; bool focus_follows_mouse; diff --git a/tests/test_shaders.py b/tests/test_shaders.py index be86d35..adf029a 100644 --- a/tests/test_shaders.py +++ b/tests/test_shaders.py @@ -219,18 +219,112 @@ def test_the_drag_greying_outranks_the_focus_dimming(): s.settle(200) -def test_drag_greying_can_be_turned_off(): - with Session(SH, cols=90, rows=16, config=cfg("drag_grayscale 0\n")) as s: +def drag_and_sample(conf): + """Colour of a non-dragged pane's content mid-drag, under `conf`.""" + with Session(SH, cols=90, rows=16, config=cfg(conf)) as s: s.settle(200) left, right = split(s) s.settle(200) + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + got = content_fg(s.snapshot(), right) + release(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + return got + + +def test_the_two_drag_knobs_are_independent(): + both_off = drag_and_sample("drag_grayscale 0\ndrag_dim 0\n") + check("with both off a drag leaves every pane alone", both_off == GREEN, + str(both_off)) + + # Desaturate only: channels converge, brightness roughly survives. + gray_only = drag_and_sample("drag_grayscale 255\ndrag_dim 0\n") + ch = [int(gray_only[i:i + 2], 16) for i in (1, 3, 5)] + check("grayscale alone desaturates without darkening much", + ch[0] == ch[1] == ch[2] and ch[1] > 0x60, str(gray_only)) + + # Darken only: the hue survives, the brightness does not. + dim_only = drag_and_sample("drag_grayscale 0\ndrag_dim 200\n") + ch = [int(dim_only[i:i + 2], 16) for i in (1, 3, 5)] + check("dim alone darkens without desaturating", + ch[0] == 0 and ch[2] == 0 and 0 < ch[1] < 0x60, str(dim_only)) + + +def test_the_default_drag_look_is_grey_and_recessed(): + """Both together: the dragged pane has to win on contrast, not just hue.""" + got = drag_and_sample("") + ch = [int(got[i:i + 2], 16) for i in (1, 3, 5)] + check("a non-dragged pane is pushed well below full brightness", + max(ch) < 0x80, f"{got} max={max(ch):#x}") + check("and towards grey", max(ch) - min(ch) < 60, + f"{got} spread={max(ch) - min(ch)}") + + +# ---- the drop-target border (chrome, not a shader) ------------------------- + +DASH_H = "\u2504" +SOLID_H = "\u2500" + + +def test_non_dragged_panes_get_a_dashed_border(): + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + snap = s.snapshot() + check("borders are solid when nothing is being dragged", + DASH_H not in snap.line(right["y"]), repr(snap.line(right["y"]))) press(s, left["x"] + 4, left["y"]) motion(s, right["x"] + 5, right["y"] + 3) s.settle(120) snap = s.snapshot() - check("drag_grayscale 0 leaves every pane alone", - content_fg(snap, right) == GREEN, str(content_fg(snap, right))) + top = snap.line(right["y"])[right["x"]:right["x"] + right["w"]] + dragged_top = snap.line(left["y"])[left["x"]:left["x"] + left["w"]] + check("a pane you could drop onto is dashed", DASH_H in top, repr(top)) + check("the pane in your hand stays solid", + DASH_H not in dragged_top and SOLID_H in dragged_top, + repr(dragged_top)) + + release(s, right["x"] + 5, right["y"] + 3) + s.settle(200) + snap = s.snapshot() + for p in s.panes(): + row = snap.line(p["y"])[p["x"]:p["x"] + p["w"]] + check(f"pane {p['id']} is solid again after the drop", + DASH_H not in row, repr(row)) + + +def test_a_press_that_never_moves_dashes_nothing(): + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + press(s, left["x"] + 4, left["y"]) + s.settle(120) + snap = s.snapshot() + check("pressing without moving dashes nothing", + DASH_H not in snap.screen(), "") + release(s, left["x"] + 4, left["y"]) + s.settle(120) + + +def test_the_hovered_target_is_still_highlighted_over_the_dashes(): + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + snap = s.snapshot() + run = snap.style_at(right["x"], right["y"]) + check("the pane under the pointer keeps its drop highlight", + run and run["fg"] == "#ff5fd7" and "bold" in run["attrs"], + str(run)) release(s, right["x"] + 5, right["y"] + 3) s.settle(120) @@ -280,7 +374,11 @@ if __name__ == "__main__": test_full_strength_is_a_true_grey() test_a_press_that_never_moves_greys_nothing() test_the_drag_greying_outranks_the_focus_dimming() - test_drag_greying_can_be_turned_off() + test_the_two_drag_knobs_are_independent() + test_the_default_drag_look_is_grey_and_recessed() + test_non_dragged_panes_get_a_dashed_border() + test_a_press_that_never_moves_dashes_nothing() + test_the_hovered_target_is_still_highlighted_over_the_dashes() test_a_drag_that_ends_off_a_pane_still_clears() test_a_keystroke_mid_drag_clears_the_greying() sys.exit(report()) |
