aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 19:48:30 +0200
committeruser <user@clank>2026-08-14 19:48:30 +0200
commite021e0d05926fbaae2b53edaf256fdd7b31aff1d (patch)
treee841ac08f4b5a241d4013fcfd0a6207eb78e4f43 /src
parentsl0ppty: zoom a pane, and give the frame buttons for it (diff)
downloadslosh-e021e0d05926fbaae2b53edaf256fdd7b31aff1d.tar.gz
slosh-e021e0d05926fbaae2b53edaf256fdd7b31aff1d.tar.xz
slosh-e021e0d05926fbaae2b53edaf256fdd7b31aff1d.zip
sl0ppty: minimise a pane into a strip along the bottom
A pane can be put away: it leaves the layout, becomes one row at the bottom of the tab, and keeps running. The panes it leaves behind take the space back. Clicking its row brings it back. Minimised is intent, so it sits beside the weight on the node rather than with the rect and the collapsed flag — and layout_node has to be careful not to clear it the way it clears those, or every frame would undo it. That is the only sharp edge in the whole thing and it has a comment. Everything else fell out of rules that already existed. A split divides among the children that still want space, so a split with one live child is that child, exactly as a split with one child already was. A minimised leaf returns from layout_node before its rect is touched, which also skips the floor check — a pane that is one row by request is not a pane that failed to fit. And the row itself is drawn by the same code as a collapsed header, because it is the same thing: a pane you can see the name of and click. Restoring is not a verb. A focused pane is never minimised, checked once a frame, so every route that ends with the pane focused restores it — clicking the row, the control API, and anything added later that moves focus without knowing minimising exists. In a tab that has flattened into a list, minimising has no effect at all, which is what was asked for and also what falls out: every pane down there is already a row, so there is no second thing for it to be. The strip is not reserved when it would leave too little above it to be worth looking at; the tab flattens instead, and arrives at the same place. Minimising the last visible pane is refused with a toast. A tab showing nothing has no way back that can be discovered from the tab, and it is not a state worth being able to reach by accident. The frame's third button costs another three columns, which is why they are drawn right to left and stop when they run out: a narrow pane keeps close and zoom, and loses minimise first.
Diffstat (limited to 'src')
-rw-r--r--src/app.c119
-rw-r--r--src/app.h5
-rw-r--r--src/config.c7
-rw-r--r--src/config.h3
4 files changed, 125 insertions, 9 deletions
diff --git a/src/app.c b/src/app.c
index 284245c..c2b556e 100644
--- a/src/app.c
+++ b/src/app.c
@@ -109,6 +109,12 @@ struct node {
* equal weights, so resizing is not a special case of anything. */
int weight;
+ /* Put away: out of the layout, into a strip along the bottom, still running.
+ * Intent, like the weight beside it and unlike the rect and the collapsed
+ * flag below — layout_node must not clear this, or it would un-minimise
+ * every pane on every frame. */
+ bool minimized;
+
/* leaf */
pane_t *pane;
uint32_t id;
@@ -669,6 +675,26 @@ static size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k) {
return k;
}
+/* Does anything under here still want space in the layout? */
+static bool subtree_live(node_t *n) {
+ if (!n) return false;
+ if (n->kind == NODE_LEAF) return !n->minimized;
+ for (size_t i = 0; i < n->nkids; i++)
+ if (subtree_live(n->kids[i])) return true;
+ return false;
+}
+
+static size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k) {
+ if (!n || k >= cap) return k;
+ if (n->kind == NODE_LEAF) {
+ if (n->minimized) out[k++] = n;
+ return k;
+ }
+ for (size_t i = 0; i < n->nkids; i++)
+ k = collect_minimized(n->kids[i], out, cap, k);
+ return k;
+}
+
static size_t count_leaves(node_t *n) {
if (!n) return 0;
if (n->kind == NODE_LEAF) return 1;
@@ -780,6 +806,12 @@ static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) {
}
static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
+ /* Minimised panes are placed by the strip, not by the tree. Returning before
+ * touching the rect leaves the strip's placement standing, and returning
+ * before the floor check is right too: a pane that is one row by request is
+ * not a pane that failed to fit. */
+ if (n->kind == NODE_LEAF && n->minimized) return;
+
n->rect = r;
n->collapsed = false;
if (n->kind == NODE_LEAF) n->hidden = false;
@@ -807,7 +839,18 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
return;
}
- size_t k = n->nkids;
+ /* Divide among the children that still want space. A split with one live
+ * child is that child, exactly as a split with one child would be. */
+ node_t *live[64];
+ size_t k = 0;
+ for (size_t i = 0; i < n->nkids && k < 64; i++)
+ if (subtree_live(n->kids[i])) live[k++] = n->kids[i];
+ if (k == 0) return;
+ if (k == 1) {
+ layout_node(live[0], r, ctx);
+ return;
+ }
+
uint16_t gap = n->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect)
: CFG.gap;
uint16_t total = n->dir == SPLIT_COLS ? r.w : r.h;
@@ -835,13 +878,13 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
/* Sizes are proportional to weights; the remainder goes to the widest
* children, largest first, so nothing drifts and nothing rounds to zero. */
long total_weight = 0;
- for (size_t i = 0; i < k; i++) total_weight += n->kids[i]->weight;
+ for (size_t i = 0; i < k; i++) total_weight += live[i]->weight;
if (total_weight <= 0) total_weight = 1;
uint16_t sizes[64];
uint16_t used = 0;
for (size_t i = 0; i < k && i < 64; i++) {
- long want = (long)avail * n->kids[i]->weight / total_weight;
+ long want = (long)avail * live[i]->weight / total_weight;
if (want < 1) want = 1;
sizes[i] = (uint16_t)want;
used = (uint16_t)(used + sizes[i]);
@@ -863,7 +906,7 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
rect_t cr = n->dir == SPLIT_COLS
? (rect_t){.x = pos, .y = r.y, .w = size, .h = r.h}
: (rect_t){.x = r.x, .y = pos, .w = r.w, .h = size};
- layout_node(n->kids[i], cr, ctx);
+ layout_node(live[i], cr, ctx);
pos = (uint16_t)(pos + size + gap);
}
}
@@ -887,6 +930,12 @@ static void layout(app_t *a) {
node_t *root = cur(a)->root;
if (!root) return;
+ /* Focusing a minimised pane is how you get it back, and it is checked here
+ * rather than in the several places focus can move: any route that ends with
+ * this pane focused restores it, including ones that do not exist yet. */
+ if (cur(a)->focus && cur(a)->focus->minimized)
+ cur(a)->focus->minimized = false;
+
/* A zoomed pane is the whole tab. Reuses the solo path rather than adding a
* third arrangement: "show this one and nothing else" is a thing the layout
* already knew how to do, it just used to be a last resort rather than a
@@ -912,13 +961,35 @@ static void layout(app_t *a) {
* layout with resizing switched off, rather than a second implementation of
* the same arithmetic — two copies of "does this fit" is exactly how the
* guide and the layout would start disagreeing. */
+ /* Minimised panes come out of the layout and sit in a strip along the
+ * bottom, one row each. The tree is then laid out in what is left, so a
+ * minimised pane costs a row rather than a share. */
+ node_t *mins[64];
+ size_t nmin = collect_minimized(root, mins, 64, 0);
+ rect_t tree_r = r;
+ if (nmin) {
+ /* If the strip would leave too little to be worth looking at, do not
+ * reserve it: the tab flattens instead, and down there a minimised pane is
+ * just another row, which is what it would have become anyway. */
+ if ((uint16_t)(r.h - nmin) >= (uint16_t)(MIN_PANE_ROWS + 2))
+ tree_r.h = (uint16_t)(r.h - nmin);
+ else
+ nmin = 0;
+ }
+
layout_ctx_t probe = {.focus = cur(a)->focus, .apply = false};
- layout_node(root, r, &probe);
+ layout_node(root, tree_r, &probe);
layout_ctx_t real = {.focus = cur(a)->focus, .apply = true};
if (!probe.overflow) {
- layout_node(root, r, &real);
- } else if (r.h >= (uint16_t)(count_leaves(root) + 2)) {
+ layout_node(root, tree_r, &real);
+ /* The strip, under the layout it was taken out of. */
+ for (size_t i = 0; i < nmin; i++)
+ collapse_leaf(mins[i], (rect_t){r.x, (uint16_t)(tree_r.y + tree_r.h + i),
+ r.w, 1});
+ return;
+ }
+ if (r.h >= (uint16_t)(count_leaves(root) + 2)) {
/* One row per pane, plus a body worth opening into: a frame is two rows. */
layout_stack(root, r, &real);
} else {
@@ -1203,6 +1274,34 @@ static node_t *pane_by_id(app_t *a, uint32_t id) {
return b.found;
}
+bool app_minimize(app_t *a, uint32_t id) {
+ node_t *n = id ? pane_by_id(a, id) : cur(a)->focus;
+ if (!n || n->minimized) return false;
+ size_t ti = tab_of(a, n);
+ if (ti == (size_t)-1) return false;
+
+ /* Refused if it would leave nothing on screen. A tab showing no panes at all
+ * has no way back that is discoverable from the tab, and "everything is put
+ * away" is not a state worth being able to reach by accident. */
+ node_t *next = NULL;
+ node_t *leaves[64];
+ size_t nl = collect_leaves(a->tabs[ti].root, leaves, 64, 0);
+ for (size_t i = 0; i < nl; i++)
+ if (leaves[i] != n && !leaves[i]->minimized) {
+ next = leaves[i];
+ break;
+ }
+ if (!next) return false;
+
+ n->minimized = true;
+ a->cur = ti;
+ /* Focus has to leave, or the rule that a focused pane is never minimised
+ * would undo this on the next frame. */
+ if (a->tabs[ti].focus == n) a->tabs[ti].focus = next;
+ layout(a);
+ return true;
+}
+
bool app_toggle_zoom(app_t *a, uint32_t id) {
node_t *n = id ? pane_by_id(a, id) : cur(a)->focus;
if (!n) return false;
@@ -1736,6 +1835,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
{CFG.close_mark, "close"},
{app_pane_zoomed(a, leaf->id) ? CFG.zoom_on_mark : CFG.zoom_mark,
"zoom"},
+ {CFG.min_mark, "minimize"},
};
uint16_t bx = (uint16_t)(x1 - 1);
for (size_t i = 0; i < sizeof btns / sizeof *btns; i++) {
@@ -2695,6 +2795,8 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
if (strncmp(action, "split:", 6) == 0) {
cur(a)->focus = n;
split_focus(a, SPLIT_COLS);
+ } else if (strncmp(action, "minimize:", 9) == 0) {
+ if (!app_minimize(a, n->id)) app_toast(a, "nothing else to show");
} else if (strncmp(action, "zoom:", 5) == 0) {
app_toggle_zoom(a, n->id);
} else if (strncmp(action, "close:", 6) == 0) {
@@ -2768,6 +2870,9 @@ static bool prefix_command(app_t *a, const input_event_t *ev) {
case ACT_SPLIT_COLS: split_focus_ui(a, SPLIT_COLS); return true;
case ACT_SPLIT_ROWS: split_focus_ui(a, SPLIT_ROWS); return true;
case ACT_ZOOM: app_toggle_zoom(a, 0); return true;
+ case ACT_MINIMIZE:
+ if (!app_minimize(a, 0)) app_toast(a, "nothing else to show");
+ return true;
case ACT_CLOSE_PANE:
if (cur(a)->focus) close_leaf(a, cur(a)->focus);
return true;
diff --git a/src/app.h b/src/app.h
index 58b2c41..7106305 100644
--- a/src/app.h
+++ b/src/app.h
@@ -87,6 +87,11 @@ bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err,
* because it opened the socket under that name. */
void app_set_session(app_t *a, const char *name);
+/* Put a pane away into the strip along the bottom. 0 means the focused one.
+ * False if it would leave the tab with nothing on screen. Restoring is done by
+ * focusing it, which is what clicking its row does. */
+bool app_minimize(app_t *a, uint32_t id);
+
/* Fill the tab with one pane, or put it back. 0 means the focused one. */
bool app_toggle_zoom(app_t *a, uint32_t id);
bool app_pane_zoomed(app_t *a, uint32_t id);
diff --git a/src/config.c b/src/config.c
index 1cc76af..bc82d7b 100644
--- a/src/config.c
+++ b/src/config.c
@@ -15,7 +15,8 @@ static const struct {
} ACTIONS[] = {
{"split-cols", ACT_SPLIT_COLS}, {"split-rows", ACT_SPLIT_ROWS},
{"close-pane", ACT_CLOSE_PANE},
- {"zoom", ACT_ZOOM}, {"focus-left", ACT_FOCUS_LEFT},
+ {"zoom", ACT_ZOOM},
+ {"minimize", ACT_MINIMIZE}, {"focus-left", ACT_FOCUS_LEFT},
{"focus-right", ACT_FOCUS_RIGHT}, {"focus-up", ACT_FOCUS_UP},
{"focus-down", ACT_FOCUS_DOWN}, {"focus-next", ACT_FOCUS_NEXT},
{"resize-left", ACT_RESIZE_LEFT}, {"resize-right", ACT_RESIZE_RIGHT},
@@ -174,6 +175,7 @@ void config_defaults(config_t *c) {
snprintf(c->zoom_mark, sizeof c->zoom_mark, "\u25a1");
snprintf(c->zoom_on_mark, sizeof c->zoom_on_mark, "\u25a3");
snprintf(c->close_mark, sizeof c->close_mark, "\u00d7");
+ snprintf(c->min_mark, sizeof c->min_mark, "\u2013");
c->bell_indicator = true;
snprintf(c->bell_mark, sizeof c->bell_mark, "\u2022");
c->min_pane_cols = 24;
@@ -270,6 +272,7 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_MINUS, 0, ACT_SPLIT_ROWS);
bind_add(c, GHOSTTY_KEY_X, 0, ACT_CLOSE_PANE);
bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM);
+ bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE);
bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT);
bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT);
bind_add(c, GHOSTTY_KEY_K, 0, ACT_FOCUS_UP);
@@ -378,6 +381,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
if (zo) snprintf(c->zoom_on_mark, sizeof c->zoom_on_mark, "%s", zo);
const char *cm = kdl_arg(kdl_child(root, "close_mark"), 0, NULL);
if (cm) snprintf(c->close_mark, sizeof c->close_mark, "%s", cm);
+ const char *mm = kdl_arg(kdl_child(root, "min_mark"), 0, NULL);
+ if (mm) snprintf(c->min_mark, sizeof c->min_mark, "%s", mm);
c->bell_indicator =
kdl_arg_bool(kdl_child(root, "bell_indicator"), 0, c->bell_indicator);
const char *bm = kdl_arg(kdl_child(root, "bell_mark"), 0, NULL);
diff --git a/src/config.h b/src/config.h
index 86a57a6..bbd9e0c 100644
--- a/src/config.h
+++ b/src/config.h
@@ -37,6 +37,7 @@ typedef enum {
ACT_SPLIT_ROWS,
ACT_CLOSE_PANE,
ACT_ZOOM,
+ ACT_MINIMIZE,
ACT_FOCUS_LEFT,
ACT_FOCUS_RIGHT,
ACT_FOCUS_UP,
@@ -84,7 +85,7 @@ typedef struct {
/* 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. */
bool pane_buttons;
- char zoom_mark[16], zoom_on_mark[16], close_mark[16];
+ char zoom_mark[16], zoom_on_mark[16], close_mark[16], min_mark[16];
bool bell_indicator;
/* What to draw. A short string rather than a fixed glyph: the obvious
* choice is an emoji bell, and emoji are drawn two columns wide by many