diff options
| author | user <user@clank> | 2026-09-10 15:40:09 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-10 15:40:09 +0200 |
| commit | 9c5b3319ac4d9a63404fe0cf0d55320754c982ba (patch) | |
| tree | 28e0020a92a55512120467d698bcc09a95e8e17a /src | |
| parent | build: a vendor bump rebuilds libghostty-vt, everywhere it is consumed (diff) | |
| download | slosh-9c5b3319ac4d9a63404fe0cf0d55320754c982ba.tar.gz slosh-9c5b3319ac4d9a63404fe0cf0d55320754c982ba.tar.xz slosh-9c5b3319ac4d9a63404fe0cf0d55320754c982ba.zip | |
tabs: the strip can stand on its side (tab_bar_side left/right)
A sidebar lists one tab per row down the chosen edge, tab_bar_width
columns wide; the panes trade the strip's row for the sidebar's columns
in tab_area(), the one place that arithmetic lives. The per-tab cell
drawing is extracted from draw_tab_strip (label, rename editor, bell,
hover, drop states, hit) and shared, so every mouse verb -- click,
double-click rename, drag to a tab, drop on + -- works in both shapes
because the hits move with the cells.
The strip's indicators (pane count, prefix badge) take the sidebar's
bottom rows, reserved first under the same budgeting rule the strip
applies from the right. The status line below keeps the full width. A
terminal too narrow to give up the columns falls back to top, re-derived
per frame, so a resize moves the bar without anyone storing state.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app_draw.c | 253 | ||||
| -rw-r--r-- | src/app_layout.c | 39 | ||||
| -rw-r--r-- | src/app_ui.c | 10 | ||||
| -rw-r--r-- | src/config.c | 27 |
4 files changed, 236 insertions, 93 deletions
diff --git a/src/app_draw.c b/src/app_draw.c index 9a2c89e..372b2f5 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -1782,6 +1782,128 @@ static bool tab_has_bell(tab_t *t) { return b.found; } +/* One tab's cell, wherever the strip lives. Paints the label at (x, y) under + * the shared rules -- active weight, hover colour, the rename editor, the + * bell, the drop target -- and registers the hit. `max_w` is 0 for the top + * strip, where the label chooses its own width; the sidebar passes its row + * width, which truncates a long name and widens the click target to the whole + * row. Returns the width taken, which is what the strip advances by. */ +static uint16_t draw_tab_cell(app_t *a, screen_t *s, size_t i, uint16_t x, + uint16_t y, uint16_t max_w, bool dragging_pane) { + tab_t *t = &a->tabs[i]; + char label[96]; + const char *nm = t->name[0] ? t->name : (t->purpose[0] ? t->purpose : ""); + /* A pane that rang in a tab you are not looking at is invisible without + * this, and that is the case the whole indicator exists for. */ + bool rang = CFG.bell_indicator && tab_has_bell(t); + if (nm[0]) + snprintf(label, sizeof label, " %zu:%s ", i + 1, nm); + else + snprintf(label, sizeof label, " %zu ", i + 1); + + /* Renaming: the tab's own cell becomes the editor, in the editor's + * colours, so a half-typed name can never be mistaken for the tab's real + * one. The caret is part of the label, so the width below — and therefore + * the hit — is the width of what is actually drawn. */ + bool editing = a->renaming == RENAME_TAB && a->rename_id == t->id; + if (editing) snprintf(label, sizeof label, " %s\u2588 ", a->rename_buf); + + /* A pinned width cuts the label to fit -- bell budgeted first, so the mark + * survives the longest name -- and then pads it out, so the active fill and + * the hover read as the whole row rather than as the word on it. */ + if (max_w) { + uint16_t budget = max_w; + if (rang) { + uint16_t bw = (uint16_t)(cells(CFG.bell_mark) + 1); + budget = budget > bw ? (uint16_t)(budget - bw) : 0; + } + while (label[0] && cells(label) > budget) { + size_t len = strlen(label); + do len--; + while (len && (label[len] & 0xc0) == 0x80); /* whole UTF-8 sequences */ + label[len] = 0; + } + size_t len = strlen(label); + while (cells(label) < budget && len + 1 < sizeof label) { + label[len++] = ' '; + label[len] = 0; + } + } + + bool active = i == a->cur; + uint16_t attrs = active ? ATTR_BOLD : 0; + /* Two independent signals: weight says which tab you are in, colour says + * where the pointer is. Drawn once to learn the width, then again in the + * hover colour if that width turns out to be under the pointer — which + * costs a repaint of a few cells and guarantees the lit cells are the + * registered ones. */ + uint16_t w = screen_text( + s, x, y, label, editing ? RENAME_FG : (active ? TAB_ACTIVE_FG : TAB_IDLE), + editing ? RENAME_BG : (active ? TAB_ACTIVE_BG : NO_COLOR), + editing ? ATTR_BOLD : attrs); + /* Hovering keeps the active tab's fill — it is still the tab you are in — + * so its feedback lands on the text instead. An inactive tab has no fill + * to keep, and brightens. */ + /* The bell keeps its own colour here too, rather than taking the tab's — + * an indicator drawn in the same dim grey as the label it sits next to is + * an indicator you have to already be looking for. */ + if (rang) { + char mark[24]; + snprintf(mark, sizeof mark, "%s ", CFG.bell_mark); + w = (uint16_t)(w + screen_text(s, (uint16_t)(x + w), y, mark, BELL_C, + active ? TAB_ACTIVE_BG : NO_COLOR, + ATTR_BOLD)); + } + uint16_t hit_w = max_w ? max_w : w; + if (!editing && ptr_on(a, x, y, hit_w, 1)) + screen_text(s, x, y, label, active ? TAB_ACTIVE_HOVER_FG : TAB_HOVER, + active ? TAB_ACTIVE_BG : NO_COLOR, attrs | ATTR_BOLD); + + /* While a pane is in your hand, every tab it does not already live in is + * somewhere it could go, and `ptr_on` says nothing during a drag by design -- + * so the strip has to draw the drop states itself. Same two states the panes + * use: all the candidates in the drop colour, and the one under the pointer + * filled, because these are all targets and that is the one you are on. */ + if (dragging_pane) { + node_t *held = pane_by_id(a, a->drag.src); + bool mine = held && tab_of(a, held) == i; + if (!mine) { + bool on = a->drag.tab_target == t->id; + screen_text(s, x, y, label, on ? TAB_ACTIVE_HOVER_FG : DROP_C, + on ? DROP_C : NO_COLOR, attrs | ATTR_BOLD); + } + } + char action[48]; + snprintf(action, sizeof action, "tab:%u", t->id); + hit_add(&s->hits, x, y, hit_w, 1, action); + return hit_w; +} + +/* A bare mark, spaced like the frame's own buttons rather than spelled out. + * It used to read "+tab", because a pane frame carried a "+" for splitting + * and two verbs that look identical is a UI bug the fork shipped. That "+" + * went when the border became the button, so the collision it was avoiding + * no longer exists and the word was left explaining itself to nobody. + * + * Padded to three cells for the same reason the frame's buttons are: a + * one-cell target is a thing you miss with a mouse. What it does is said by + * the hint under the pointer, which is where every other one-character + * affordance here says it. */ +static void draw_newtab_button(app_t *a, screen_t *s, uint16_t x, uint16_t y, + bool dragging_pane) { + char btn[24]; + snprintf(btn, sizeof btn, " %s ", CFG.newtab_mark); + uint16_t w = screen_text(s, x, y, btn, TAB_IDLE, NO_COLOR, 0); + if (ptr_on(a, x, y, w, 1)) + screen_text(s, x, y, btn, TAB_HOVER, NO_COLOR, ATTR_BOLD); + /* The button that makes a tab is also a place to drop a pane into one. */ + if (dragging_pane) + screen_text(s, x, y, btn, + a->drag.new_tab_target ? TAB_ACTIVE_HOVER_FG : DROP_C, + a->drag.new_tab_target ? DROP_C : NO_COLOR, ATTR_BOLD); + hit_add(&s->hits, x, y, w, 1, "newtab"); +} + void draw_tab_strip(app_t *a, screen_t *s) { /* A pane is being carried: the strip is a row of destinations for as long as * that is true. Worked out once rather than per tab. */ @@ -1816,100 +1938,55 @@ void draw_tab_strip(app_t *a, screen_t *s) { } } - for (size_t i = 0; i < a->ntabs && x < right; i++) { - tab_t *t = &a->tabs[i]; - char label[80]; - const char *nm = t->name[0] ? t->name : (t->purpose[0] ? t->purpose : ""); - /* A pane that rang in a tab you are not looking at is invisible without - * this, and that is the case the whole indicator exists for. */ - bool rang = CFG.bell_indicator && tab_has_bell(t); - if (nm[0]) - snprintf(label, sizeof label, " %zu:%s ", i + 1, nm); - else - snprintf(label, sizeof label, " %zu ", i + 1); + for (size_t i = 0; i < a->ntabs && x < right; i++) + x = (uint16_t)(x + draw_tab_cell(a, s, i, x, y, 0, dragging_pane)); - /* Renaming: the tab's own cell becomes the editor, in the editor's - * colours, so a half-typed name can never be mistaken for the tab's real - * one. The caret is part of the label, so the width below — and therefore - * the hit — is the width of what is actually drawn. */ - bool editing = a->renaming == RENAME_TAB && a->rename_id == t->id; - if (editing) snprintf(label, sizeof label, " %s\u2588 ", a->rename_buf); + { + uint16_t bw = (uint16_t)(cells(CFG.newtab_mark) + 2); + if (bw > 2 && x + bw <= right) + draw_newtab_button(a, s, x, y, dragging_pane); + } +} - bool active = i == a->cur; - uint16_t attrs = active ? ATTR_BOLD : 0; - /* Two independent signals: weight says which tab you are in, colour says - * where the pointer is. Drawn once to learn the width, then again in the - * hover colour if that width turns out to be under the pointer — which - * costs a repaint of a few cells and guarantees the lit cells are the - * registered ones. */ - uint16_t w = - screen_text(s, x, y, label, - editing ? RENAME_FG : (active ? TAB_ACTIVE_FG : TAB_IDLE), - editing ? RENAME_BG : (active ? TAB_ACTIVE_BG : NO_COLOR), - editing ? ATTR_BOLD : attrs); - /* Hovering keeps the active tab's fill — it is still the tab you are in — - * so its feedback lands on the text instead. An inactive tab has no fill - * to keep, and brightens. */ - /* The bell keeps its own colour here too, rather than taking the tab's — - * an indicator drawn in the same dim grey as the label it sits next to is - * an indicator you have to already be looking for. */ - if (rang) { - char mark[24]; - snprintf(mark, sizeof mark, "%s ", CFG.bell_mark); - w = (uint16_t)(w + screen_text(s, (uint16_t)(x + w), y, mark, BELL_C, - active ? TAB_ACTIVE_BG : NO_COLOR, - ATTR_BOLD)); - } - if (!editing && ptr_on(a, x, y, w, 1)) - screen_text(s, x, y, label, active ? TAB_ACTIVE_HOVER_FG : TAB_HOVER, - active ? TAB_ACTIVE_BG : NO_COLOR, attrs | ATTR_BOLD); +/* The same strip stood on its side: one tab per row down the edge the config + * chose, the `+` on the row after the last tab, and the strip's indicators + * (pane count, prefix badge) on the bottom rows -- reserved first, the same + * budgeting rule the horizontal strip applies from the right, so a long tab + * list can never eat them. The status line below keeps the full width; the + * sidebar stops above it. */ +void draw_tab_sidebar(app_t *a, screen_t *s) { + bool dragging_pane = a->drag.kind == DRAG_TITLE && a->drag.moved; + uint16_t sw = app_tab_bar_cols(); + uint16_t x = app_tab_bar_side(a) == TAB_BAR_LEFT + ? 0 + : (uint16_t)(s->cols > sw ? s->cols - sw : 0); + uint16_t y = CFG.compact ? 0 : CFG.gap; /* same rule as the strip's row */ + uint16_t limit = (uint16_t)(s->rows > (CFG.status_line ? 1 : 0) + ? s->rows - (CFG.status_line ? 1 : 0) + : 0); - /* While a pane is in your hand, every tab it does not already live in is - * somewhere it could go, and `ptr_on` says nothing during a drag by design -- - * so the strip has to draw the drop states itself. Same two states the panes - * use: all the candidates in the drop colour, and the one under the pointer - * filled, because these are all targets and that is the one you are on. */ - if (dragging_pane) { - node_t *held = pane_by_id(a, a->drag.src); - bool mine = held && tab_of(a, held) == i; - if (!mine) { - bool on = a->drag.tab_target == t->id; - screen_text(s, x, y, label, on ? TAB_ACTIVE_HOVER_FG : DROP_C, - on ? DROP_C : NO_COLOR, attrs | ATTR_BOLD); - } - } - char action[48]; - snprintf(action, sizeof action, "tab:%u", t->id); - hit_add(&s->hits, x, y, w, 1, action); - x = (uint16_t)(x + w); + /* Bottom rows first: what the horizontal strip keeps on its right. */ + char info[64]; + size_t np = app_pane_count(a); + snprintf(info, sizeof info, "%zu pane%s", np, np == 1 ? "" : "s"); + if (limit > y && (uint16_t)strlen(info) <= sw) { + limit--; + screen_text(s, x, limit, info, TAB_COUNT, NO_COLOR, 0); } - - /* A bare mark, spaced like the frame's own buttons rather than spelled out. - * It used to read "+tab", because a pane frame carried a "+" for splitting - * and two verbs that look identical is a UI bug the fork shipped. That "+" - * went when the border became the button, so the collision it was avoiding - * no longer exists and the word was left explaining itself to nobody. - * - * Padded to three cells for the same reason the frame's buttons are: a - * one-cell target is a thing you miss with a mouse. What it does is said by - * the hint under the pointer, which is where every other one-character - * affordance here says it. */ - { - char btn[24]; - snprintf(btn, sizeof btn, " %s ", CFG.newtab_mark); - uint16_t bw = (uint16_t)(cells(CFG.newtab_mark) + 2); - if (bw > 2 && x + bw <= right) { - uint16_t w = screen_text(s, x, y, btn, TAB_IDLE, NO_COLOR, 0); - if (ptr_on(a, x, y, w, 1)) - screen_text(s, x, y, btn, TAB_HOVER, NO_COLOR, ATTR_BOLD); - /* The button that makes a tab is also a place to drop a pane into one. */ - if (dragging_pane) - screen_text(s, x, y, btn, - a->drag.new_tab_target ? TAB_ACTIVE_HOVER_FG : DROP_C, - a->drag.new_tab_target ? DROP_C : NO_COLOR, ATTR_BOLD); - hit_add(&s->hits, x, y, w, 1, "newtab"); + if (a->prefix) { /* the prefix is a mode: say so, and say which key */ + char pfx[24]; + config_chord_name(CFG.prefix_key, CFG.prefix_mods, pfx, sizeof pfx); + if (limit > y && cells(pfx) <= sw) { + limit--; + screen_text(s, x, limit, pfx, PREFIX_FG, PREFIX_BG, ATTR_BOLD); } } + + for (size_t i = 0; i < a->ntabs && y < limit; i++, y++) + draw_tab_cell(a, s, i, x, y, sw, dragging_pane); + + if (cells(CFG.newtab_mark) && y < limit) + draw_newtab_button(a, s, x, y, dragging_pane); } /* The space between two of a split's children, or false if they are flush. diff --git a/src/app_layout.c b/src/app_layout.c index db43056..4de1bf3 100644 --- a/src/app_layout.c +++ b/src/app_layout.c @@ -322,6 +322,29 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { #define STRIP_ROWS (CFG.status_bar ? 1 : 0) #define LINE_ROWS (CFG.status_line ? 1 : 0) +/* The sidebar's width, held to a range where it is neither an unreadable + * sliver nor half the screen. Clamped at use rather than at parse so a config + * written for a big terminal degrades on a small one instead of erroring. */ +uint16_t app_tab_bar_cols(void) { + uint16_t w = CFG.tab_bar_width; + if (w < 8) w = 8; + if (w > 60) w = 60; + return w; +} + +/* The side the strip actually lands on this frame. The configured side, + * except that a screen too narrow to give up the sidebar's columns falls + * back to the top row: a sidebar that leaves no room for a pane is chrome + * displacing the thing it is chrome for. Re-derived per frame like the + * layout itself, so a resize moves the bar without anyone storing state. */ +int app_tab_bar_side(const app_t *a) { + int side = CFG.tab_bar_side; + if (side == TAB_BAR_TOP || !CFG.status_bar) return TAB_BAR_TOP; + uint16_t sw = app_tab_bar_cols(); + if (a->cols < sw + CFG.min_pane_cols + 4) return TAB_BAR_TOP; + return side; +} + node_t *pane_by_id(app_t *a, uint32_t id); /* defined below */ size_t tab_of(app_t *a, node_t *n); @@ -401,11 +424,19 @@ static rect_t tab_area(app_t *a) { gx = (uint16_t)(CFG.gap * CFG.gap_aspect); gy = CFG.gap; } - uint16_t top = (uint16_t)(gy + STRIP_ROWS); - return (rect_t){.x = gx, + /* A sidebar spends columns on one edge instead of the top row; the strip's + * row comes back to the panes. The status line below is untouched either + * way -- it keeps the full width (LINE_ROWS is subtracted from the height + * alone, never from the sidebar's rows: the sidebar stops above it). */ + int side = app_tab_bar_side(a); + uint16_t sw = side != TAB_BAR_TOP ? app_tab_bar_cols() : 0; + uint16_t strip = side == TAB_BAR_TOP ? STRIP_ROWS : 0; + uint16_t top = (uint16_t)(gy + strip); + uint16_t lx = (uint16_t)(gx + (side == TAB_BAR_LEFT ? sw : 0)); + uint16_t wsub = (uint16_t)(2 * gx + sw); + return (rect_t){.x = lx, .y = top, - .w = - (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols), + .w = (uint16_t)(a->cols > wsub ? a->cols - wsub : a->cols), .h = (uint16_t)(a->rows > top + gy + LINE_ROWS ? a->rows - top - gy - LINE_ROWS : 1)}; diff --git a/src/app_ui.c b/src/app_ui.c index 11e667a..4e67003 100644 --- a/src/app_ui.c +++ b/src/app_ui.c @@ -1141,7 +1141,15 @@ void app_compose(app_t *a, screen_t *s) { layout(a); find_corners(a); - if (CFG.status_bar) draw_tab_strip(a, s); + if (CFG.status_bar) { + /* The side is re-derived, not stored: a terminal that shrank below the + * sidebar's budget draws the strip on top this frame, and the layout + * above made the same call from the same numbers. */ + if (app_tab_bar_side(a) == TAB_BAR_TOP) + draw_tab_strip(a, s); + else + draw_tab_sidebar(a, s); + } /* Compact's shared lines go down first, so every pane's title and buttons * land on top of them; the hover hints come after the panes, because each * hint cell asks the finished hit list whether the boundary still owns it. */ diff --git a/src/config.c b/src/config.c index 1b7971a..31ca0e1 100644 --- a/src/config.c +++ b/src/config.c @@ -919,6 +919,10 @@ void config_defaults(config_t *c) { c->anim_ms = 50; c->modal_scrim = 120; c->status_bar = true; + c->tab_bar_side = TAB_BAR_TOP; + /* Wide enough for " 12:a-real-name " plus a bell; narrow enough that a + * 100-column terminal keeps a working layout beside it. */ + c->tab_bar_width = 18; c->status_line = true; /* Deliberately wider than the panes' own margin (gap * gap_aspect = 2), so * the strip and the line read as chrome sitting outside the layout rather @@ -1615,6 +1619,12 @@ char *config_render(const config_t *c) { cb_add(&b, "\n// ---- what is on screen ----\n"); cb_add(&b, "status_bar %s // the strip along the top\n", yesno(c->status_bar)); + cb_add(&b, "tab_bar_side \"%s\" // top, or a sidebar: left / right\n", + c->tab_bar_side == TAB_BAR_LEFT ? "left" + : c->tab_bar_side == TAB_BAR_RIGHT ? "right" + : "top"); + cb_add(&b, "tab_bar_width %u // columns a sidebar takes\n", + c->tab_bar_width); cb_add(&b, "status_line %s // the line along the bottom\n", yesno(c->status_line)); cb_add(&b, "status_pad %u\n", c->status_pad); @@ -1923,6 +1933,8 @@ static const char *const KNOWN_TOP[] = { "status_bar", "status_line", "status_pad", + "tab_bar_side", + "tab_bar_width", "theme", "title_align", "title_inset", @@ -2118,6 +2130,21 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, c->rounded = kdl_arg_bool(kdl_child(root, "rounded"), 0, c->rounded); c->compact = kdl_arg_bool(kdl_child(root, "compact"), 0, c->compact); c->status_bar = kdl_arg_bool(kdl_child(root, "status_bar"), 0, c->status_bar); + const char *tbside = kdl_arg(kdl_child(root, "tab_bar_side"), 0, NULL); + if (tbside) { + if (strcmp(tbside, "top") == 0) + c->tab_bar_side = TAB_BAR_TOP; + else if (strcmp(tbside, "left") == 0) + c->tab_bar_side = TAB_BAR_LEFT; + else if (strcmp(tbside, "right") == 0) + c->tab_bar_side = TAB_BAR_RIGHT; + else + complain(c, err, errcap, kdl_child(root, "tab_bar_side")->line, + "tab_bar_side is \"top\", \"left\" or \"right\", not \"%s\"", + tbside); + } + c->tab_bar_width = (uint16_t)kdl_arg_int(kdl_child(root, "tab_bar_width"), 0, + c->tab_bar_width); c->status_line = kdl_arg_bool(kdl_child(root, "status_line"), 0, c->status_line); c->status_pad = |
