diff options
| author | user <user@clank> | 2026-08-13 20:54:29 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-13 20:54:29 +0200 |
| commit | e220bb7cb784140ba5c9cc1fe59b71fdb9dbf4b9 (patch) | |
| tree | 2550d9edb930010b1766e697b839161188745910 | |
| parent | test: run the status bar's tests in `make test` (diff) | |
| download | sl0ppi-e220bb7cb784140ba5c9cc1fe59b71fdb9dbf4b9.tar.gz sl0ppi-e220bb7cb784140ba5c9cc1fe59b71fdb9dbf4b9.tar.xz sl0ppi-e220bb7cb784140ba5c9cc1fe59b71fdb9dbf4b9.zip | |
sl0p(bar): draw the bar as a framed box, and a single line when collapsed
Given three rows the bar now draws the same rounded box the panes do, so the
bottom of the screen reads as one more pane instead of a strip of text under
them. Given one row it stays the line it always was, which is what a terminal
narrow enough to collapse the layout should spend on chrome.
The height is the LAYOUT's decision, because a plugin cannot resize its own
pane:
- default.kdl defines the bar as a pane_template `sl0pbar` and places it at
size=3;
- default.swap.kdl defines tab_template "mobile" placing the same template at
size=1, and the `stacked` swap layout -- what responsive_layout collapses to
below responsive_min_cols -- uses it.
That reuse works because zellij parses a layout and its .swap.kdl with the same
parser, on purpose, "so that we can reuse assets (eg. pane and tab templates)".
Otherwise the collapsed template would have to duplicate the whole 100-line
zjstatus configuration, and the two would drift within a week.
ModuleConfig::viewport is the one definition of where the content line lives:
three rows or more and it is framed, fewer and it is not, plus the column the
content starts at. Rendering and click routing both read it, so the frame moves
the drawing and the hit-testing together -- a click on the new border hits
nothing rather than the widget it sits above. Clicks now check the LINE too,
which a single-line bar never had to.
status-bar-live.py grew the other half of the feature: the borders must span
exactly the columns the pane frames span with rounded corners at their ends, a
click on the border must not make a tab, and after shrinking the terminal below
the responsive threshold the bar must be one line with no box -- with its button
still working. The collapse is real: the bar lands on the last row, which only
happens if the swap layout actually switched.
One test bug worth naming: locating the button with str::find gave a BYTE
offset, and the box glyphs are three bytes each, so the click landed two columns
right of the thing it found. Same trap the pane frame tests document.
| -rw-r--r-- | FORKS.md | 5 | ||||
| -rw-r--r-- | config/layouts/default.kdl | 263 | ||||
| -rw-r--r-- | config/layouts/default.swap.kdl | 18 | ||||
| -rw-r--r-- | docs/roadmap.md | 34 | ||||
| -rwxr-xr-x | packaging/checks/status-bar-live.py | 81 | ||||
| -rw-r--r-- | sl0pstatus/src/bar_frame.rs | 163 | ||||
| -rw-r--r-- | sl0pstatus/src/bin/zjstatus.rs | 4 | ||||
| -rw-r--r-- | sl0pstatus/src/config.rs | 247 | ||||
| -rw-r--r-- | sl0pstatus/src/lib.rs | 1 |
9 files changed, 664 insertions, 152 deletions
@@ -56,7 +56,7 @@ monorepo is authoritative for what ships. | upstream version | `0.24.0` | | upstream base commit | `74f36b4` — *chore: bump version to v0.24.0*, 2026-07-15 | | lives at | `sl0pstatus/` | -| patches on top | 2 commits (`sl0p: double-click a tab to rename it`, `sl0p: a new-tab button and margins that match the pane gap`) | +| patches on top | 3 commits (double-click a tab to rename; a new-tab button and pane-gap margins; the bar as a framed box) | | builds to | `zjstatus.wasm` (cargo package name is still `zjstatus`) | | standalone repo | `ssh://git@git.sl0p.foo:39327/srv/git/sl0pstatus.git`, branch `sl0p` | @@ -75,6 +75,9 @@ Fork-only config keys, all inert when unset (upstream renders identically): |---|---| | `margin_left` / `margin_right` | blank columns at each end, so the bar lines up with panes inset by the pane gap | | `new_tab` | format for the `{new_tab}` widget, a clickable button that opens and focuses a tab | +| `bar_frame_enabled` | draw the bar as a box in the pane style, when its pane has 3+ rows | +| `bar_frame_rounded` | `╭╮╰╯` vs `┌┐└┘`; keep in step with `rounded_corners` in config.kdl | +| `bar_frame_format` | style for the box glyphs (we use the theme's `frame_unselected`) | **Its tests do not run under `make test`.** The crate builds for `wasm32-wasip1` and `.cargo/config.toml` points the test runner at `wasmtime`, diff --git a/config/layouts/default.kdl b/config/layouts/default.kdl index 4d65052..b898e7b 100644 --- a/config/layouts/default.kdl +++ b/config/layouts/default.kdl @@ -19,139 +19,164 @@ // Click the bar, press `y`. Must be repeated whenever zjstatus.wasm is updated. layout { - default_tab_template { - children - pane size=1 borderless=true { - plugin location="file:~/.config/sl0ppij/plugins/zjstatus.wasm" { + // A pane_template, not an inline pane: the SAME parser reads + // default.swap.kdl, so the collapsed layout in there can reuse this whole + // configuration at a different height instead of duplicating 100 lines of + // it. `size` is set where it is used, not here. + pane_template name="sl0pbar" borderless=true { + plugin location="file:~/.config/sl0ppij/plugins/zjstatus.wasm" { - // ── palette: lifted verbatim from gruber-darker-pop.kdl ────── - color_bg "#181818" // 24 24 24 theme background - color_bg_alt "#282828" // 40 40 40 ribbon_unselected bg - color_dim "#484848" // 72 72 72 frame_unselected base - color_fg "#e4e4ef" // 228 228 239 text base - color_sage "#95a99f" // 149 169 159 emphasis_0 - color_yellow "#ffdd33" // 255 221 51 frame_selected / gruber yellow - color_green "#73c936" // 115 201 54 frame_highlight / success - color_blue "#96a6c8" // 150 166 200 - color_purple "#9e95c7" // 158 149 199 - color_brown "#cc8c3c" // 204 140 60 - color_red "#ff4f58" // 255 79 88 exit_code_error + // ── palette: lifted verbatim from gruber-darker-pop.kdl ────── + color_bg "#181818" // 24 24 24 theme background + color_bg_alt "#282828" // 40 40 40 ribbon_unselected bg + color_dim "#484848" // 72 72 72 frame_unselected base + color_fg "#e4e4ef" // 228 228 239 text base + color_sage "#95a99f" // 149 169 159 emphasis_0 + color_yellow "#ffdd33" // 255 221 51 frame_selected / gruber yellow + color_green "#73c936" // 115 201 54 frame_highlight / success + color_blue "#96a6c8" // 150 166 200 + color_purple "#9e95c7" // 158 149 199 + color_brown "#cc8c3c" // 204 140 60 + color_red "#ff4f58" // 255 79 88 exit_code_error - format_left "{mode}{session}" - format_center "{tabs}{new_tab}" - format_right "{command_git} {datetime}" - format_space "#[bg=$bg]" + format_left "{mode}{session}" + format_center "{tabs}{new_tab}" + format_right "{command_git} {datetime}" + format_space "#[bg=$bg]" - // ── line up with the panes ────────────────────────────────── - // sl0p fork only. The bar is a full-width pane, but every pane - // above it is inset by the pane gap, so without this the bar's - // ends stick out past every frame on screen. - // - // Keep in step with config.kdl's `gap` x `gap_aspect` - // (1 x 2 = 2 columns). Deliberately not derived automatically: - // the plugin cannot see that config, and guessing it from pane - // geometry would be a magic trick that breaks on the first - // borderless pane. - margin_left "2" - margin_right "2" + // ── line up with the panes ────────────────────────────────── + // sl0p fork only. The bar is a full-width pane, but every pane + // above it is inset by the pane gap, so without this the bar's + // ends stick out past every frame on screen. + // + // Keep in step with config.kdl's `gap` x `gap_aspect` + // (1 x 2 = 2 columns). Deliberately not derived automatically: + // the plugin cannot see that config, and guessing it from pane + // geometry would be a magic trick that breaks on the first + // borderless pane. + margin_left "2" + margin_right "2" - // hide the least-important part instead of letting the three - // sections collide on a narrow terminal. Priority: center - // (tabs) > left (mode) > right (git/clock is the first to go). - format_hide_on_overlength "true" - format_precedence "clr" + // ── the bar as a pane ──────────────────────────────────── + // sl0p fork only. With three rows the bar draws the same box + // the panes do, so the bottom of the screen reads as one more + // pane rather than as a strip of text. With fewer -- the + // collapsed layout -- it stays a single line and this is + // ignored, so the box never appears half-drawn. + // + // $dim is `frame_unselected` from the theme: the bar is chrome, + // so it wears an unfocused pane's border colour and never the + // focused yellow. + bar_frame_enabled "true" + bar_frame_rounded "true" // keep in step with config.kdl's rounded_corners + bar_frame_format "#[fg=$dim]" - // ── mode ──────────────────────────────────────────────────── - // config.kdl is `default_mode "locked"` with Ctrl-g as leader, - // so LOCKED is the resting state and is styled to recede. - // Every *unlocked* mode is loud on purpose: with a leader-key - // setup the expensive mistake is typing into a pane while a - // mode is still live, so the bar should be impossible to miss. - // Powerline caps: U+E0B6 left-round, U+E0B4 right-round. - // These live in the Powerline Symbols block and exist in every - // Nerd Font AND every powerline-patched font, so they are the - // safe part. The lock/unlock glyphs below are Font Awesome - // range and are Nerd-Font-only: if ONLY those show as tofu, - // you have a powerline font rather than a Nerd Font. - // Pre-Nerd-Font version saved at default.kdl.pre-nerdfont. - mode_locked "#[bg=$dim,fg=$bg,bold] LOCKED #[bg=$bg,fg=$dim]#[bg=$bg,fg=$dim,italic] ^g " - mode_normal "#[bg=$yellow,fg=$bg,bold] NORMAL #[bg=$bg,fg=$yellow]#[bg=$bg,fg=$sage,italic] p t r m s o " - mode_pane "#[bg=$blue,fg=$bg,bold] PANE #[bg=$bg,fg=$blue]#[bg=$bg,fg=$sage,italic] hjkl n d r s x f w e c " - mode_tab "#[bg=$purple,fg=$bg,bold] TAB #[bg=$bg,fg=$purple]#[bg=$bg,fg=$sage,italic] hl n x r b 1-9 " - mode_resize "#[bg=$brown,fg=$bg,bold] RESIZE #[bg=$bg,fg=$brown]#[bg=$bg,fg=$sage,italic] hjkl HJKL +- " - mode_move "#[bg=$brown,fg=$bg,bold] MOVE #[bg=$bg,fg=$brown]#[bg=$bg,fg=$sage,italic] hjkl n p " - mode_scroll "#[bg=$green,fg=$bg,bold] SCROLL #[bg=$bg,fg=$green]#[bg=$bg,fg=$sage,italic] jk du f=find e=edit " - mode_enter_search "#[bg=$green,fg=$bg,bold] SEARCH: #[bg=$bg,fg=$green]#[bg=$bg,fg=$sage,italic] type, Enter " - mode_search "#[bg=$green,fg=$bg,bold] FOUND #[bg=$bg,fg=$green]#[bg=$bg,fg=$sage,italic] n p c w o " - mode_session "#[bg=$sage,fg=$bg,bold] SESSION #[bg=$bg,fg=$sage]#[bg=$bg,fg=$sage,italic] g=panes w d c p " - mode_rename_tab "#[bg=$red,fg=$bg,bold] RENAME TAB #[bg=$bg,fg=$red]" - mode_rename_pane "#[bg=$red,fg=$bg,bold] RENAME PANE #[bg=$bg,fg=$red]" - mode_prompt "#[bg=$red,fg=$bg,bold] PROMPT #[bg=$bg,fg=$red]" - mode_tmux "#[bg=$brown,fg=$bg,bold] TMUX #[bg=$bg,fg=$brown]" - mode_default_to_mode "locked" + // hide the least-important part instead of letting the three + // sections collide on a narrow terminal. Priority: center + // (tabs) > left (mode) > right (git/clock is the first to go). + format_hide_on_overlength "true" + format_precedence "clr" - // ── session ───────────────────────────────────────────────── - session "#[bg=$bg,fg=$blue,bold] {name} #[bg=$bg,fg=$dim] " + // ── mode ──────────────────────────────────────────────────── + // config.kdl is `default_mode "locked"` with Ctrl-g as leader, + // so LOCKED is the resting state and is styled to recede. + // Every *unlocked* mode is loud on purpose: with a leader-key + // setup the expensive mistake is typing into a pane while a + // mode is still live, so the bar should be impossible to miss. + // Powerline caps: U+E0B6 left-round, U+E0B4 right-round. + // These live in the Powerline Symbols block and exist in every + // Nerd Font AND every powerline-patched font, so they are the + // safe part. The lock/unlock glyphs below are Font Awesome + // range and are Nerd-Font-only: if ONLY those show as tofu, + // you have a powerline font rather than a Nerd Font. + // Pre-Nerd-Font version saved at default.kdl.pre-nerdfont. + mode_locked "#[bg=$dim,fg=$bg,bold] LOCKED #[bg=$bg,fg=$dim]#[bg=$bg,fg=$dim,italic] ^g " + mode_normal "#[bg=$yellow,fg=$bg,bold] NORMAL #[bg=$bg,fg=$yellow]#[bg=$bg,fg=$sage,italic] p t r m s o " + mode_pane "#[bg=$blue,fg=$bg,bold] PANE #[bg=$bg,fg=$blue]#[bg=$bg,fg=$sage,italic] hjkl n d r s x f w e c " + mode_tab "#[bg=$purple,fg=$bg,bold] TAB #[bg=$bg,fg=$purple]#[bg=$bg,fg=$sage,italic] hl n x r b 1-9 " + mode_resize "#[bg=$brown,fg=$bg,bold] RESIZE #[bg=$bg,fg=$brown]#[bg=$bg,fg=$sage,italic] hjkl HJKL +- " + mode_move "#[bg=$brown,fg=$bg,bold] MOVE #[bg=$bg,fg=$brown]#[bg=$bg,fg=$sage,italic] hjkl n p " + mode_scroll "#[bg=$green,fg=$bg,bold] SCROLL #[bg=$bg,fg=$green]#[bg=$bg,fg=$sage,italic] jk du f=find e=edit " + mode_enter_search "#[bg=$green,fg=$bg,bold] SEARCH: #[bg=$bg,fg=$green]#[bg=$bg,fg=$sage,italic] type, Enter " + mode_search "#[bg=$green,fg=$bg,bold] FOUND #[bg=$bg,fg=$green]#[bg=$bg,fg=$sage,italic] n p c w o " + mode_session "#[bg=$sage,fg=$bg,bold] SESSION #[bg=$bg,fg=$sage]#[bg=$bg,fg=$sage,italic] g=panes w d c p " + mode_rename_tab "#[bg=$red,fg=$bg,bold] RENAME TAB #[bg=$bg,fg=$red]" + mode_rename_pane "#[bg=$red,fg=$bg,bold] RENAME PANE #[bg=$bg,fg=$red]" + mode_prompt "#[bg=$red,fg=$bg,bold] PROMPT #[bg=$bg,fg=$red]" + mode_tmux "#[bg=$brown,fg=$bg,bold] TMUX #[bg=$bg,fg=$brown]" + mode_default_to_mode "locked" - // ── tabs ──────────────────────────────────────────────────── - // ▐ / ▌ are half-block glyphs: they fake a hard-edged powerline - // pill without needing a Nerd Font, and stay square-cornered to - // match `rounded_corners false`. - tab_normal "#[bg=$bg,fg=$dim] {index}#[bg=$bg,fg=$sage] {name} {floating_indicator}#[bg=$bg,fg=$dim] " - tab_normal_fullscreen "#[bg=$bg,fg=$dim] {index}#[bg=$bg,fg=$sage] {name} {fullscreen_indicator}#[bg=$bg,fg=$dim] " - tab_normal_sync "#[bg=$bg,fg=$dim] {index}#[bg=$bg,fg=$sage] {name} {sync_indicator}#[bg=$bg,fg=$dim] " + // ── session ───────────────────────────────────────────────── + session "#[bg=$bg,fg=$blue,bold] {name} #[bg=$bg,fg=$dim] " - tab_active "#[bg=$yellow,fg=$bg,bold] {index} {name} {floating_indicator}#[bg=$bg,fg=$yellow]" - tab_active_fullscreen "#[bg=$yellow,fg=$bg,bold] {index} {name} {fullscreen_indicator}#[bg=$bg,fg=$yellow]" - tab_active_sync "#[bg=$yellow,fg=$bg,bold] {index} {name} {sync_indicator}#[bg=$bg,fg=$yellow]" + // ── tabs ──────────────────────────────────────────────────── + // ▐ / ▌ are half-block glyphs: they fake a hard-edged powerline + // pill without needing a Nerd Font, and stay square-cornered to + // match `rounded_corners false`. + tab_normal "#[bg=$bg,fg=$dim] {index}#[bg=$bg,fg=$sage] {name} {floating_indicator}#[bg=$bg,fg=$dim] " + tab_normal_fullscreen "#[bg=$bg,fg=$dim] {index}#[bg=$bg,fg=$sage] {name} {fullscreen_indicator}#[bg=$bg,fg=$dim] " + tab_normal_sync "#[bg=$bg,fg=$dim] {index}#[bg=$bg,fg=$sage] {name} {sync_indicator}#[bg=$bg,fg=$dim] " - tab_separator "#[bg=$bg] " - tab_rename "#[bg=$red,fg=$bg,bold] {index} {name} #[bg=$bg,fg=$red]" + tab_active "#[bg=$yellow,fg=$bg,bold] {index} {name} {floating_indicator}#[bg=$bg,fg=$yellow]" + tab_active_fullscreen "#[bg=$yellow,fg=$bg,bold] {index} {name} {fullscreen_indicator}#[bg=$bg,fg=$yellow]" + tab_active_sync "#[bg=$yellow,fg=$bg,bold] {index} {name} {sync_indicator}#[bg=$bg,fg=$yellow]" - tab_sync_indicator "<> " - tab_fullscreen_indicator "[] " - tab_floating_indicator "^ " + tab_separator "#[bg=$bg] " + tab_rename "#[bg=$red,fg=$bg,bold] {index} {name} #[bg=$bg,fg=$red]" - // ── new tab button ───────────────────────────────────────── - // sl0p fork only ({new_tab}). Clicking it opens a tab with the - // default layout and focuses it -- the mouse spelling of the - // `n` binding in TAB mode. Styled like an inactive tab so it - // reads as the next thing in the strip rather than as chrome. - new_tab "#[bg=$bg,fg=$dim] + " + tab_sync_indicator "<> " + tab_fullscreen_indicator "[] " + tab_floating_indicator "^ " - // ── git branch of the FOCUSED pane's cwd ──────────────────── - // {focused_pane_cwd} (zjstatus 0.24.0+) makes this track - // whichever pane you are in rather than the session's cwd. - // - // The `bash -c` wrapper is REQUIRED, not cosmetic: zjstatus - // execs the command directly with no shell, so a bare - // `git ... 2>/dev/null` hands `2>/dev/null` to git as a literal - // revision argument and git dies with "ambiguous argument". - // The redirect also matters: outside a git repo git writes to - // stderr, and without it that noise can reach the bar. - // - // rendermode "static" = format with the config's format string - // and ignore any zjstatus directives in the command's own output. - command_git_command "bash -c \"git rev-parse --abbrev-ref HEAD 2>/dev/null\"" - command_git_format "#[bg=$bg,fg=$green] {stdout}" - command_git_interval "10" - command_git_rendermode "static" - command_git_cwd "{focused_pane_cwd}" + // ── new tab button ───────────────────────────────────────── + // sl0p fork only ({new_tab}). Clicking it opens a tab with the + // default layout and focuses it -- the mouse spelling of the + // `n` binding in TAB mode. Styled like an inactive tab so it + // reads as the next thing in the strip rather than as chrome. + new_tab "#[bg=$bg,fg=$dim] + " - // ── clock ─────────────────────────────────────────────────── - // Keep `#[..]` directives OUT of datetime_format: that string is - // strftime output substituted into {format} after the outer - // format is parsed, so a directive in here renders literally. - datetime "#[bg=$bg,fg=$sage]{format}" - datetime_format "%a %d %b %H:%M" - datetime_timezone "Europe/Amsterdam" + // ── git branch of the FOCUSED pane's cwd ──────────────────── + // {focused_pane_cwd} (zjstatus 0.24.0+) makes this track + // whichever pane you are in rather than the session's cwd. + // + // The `bash -c` wrapper is REQUIRED, not cosmetic: zjstatus + // execs the command directly with no shell, so a bare + // `git ... 2>/dev/null` hands `2>/dev/null` to git as a literal + // revision argument and git dies with "ambiguous argument". + // The redirect also matters: outside a git repo git writes to + // stderr, and without it that noise can reach the bar. + // + // rendermode "static" = format with the config's format string + // and ignore any zjstatus directives in the command's own output. + command_git_command "bash -c \"git rev-parse --abbrev-ref HEAD 2>/dev/null\"" + command_git_format "#[bg=$bg,fg=$green] {stdout}" + command_git_interval "10" + command_git_rendermode "static" + command_git_cwd "{focused_pane_cwd}" - // ── thin rule above the bar, so it reads as chrome ────────── - border_enabled "false" - border_char "─" - border_format "#[fg=$dim]{char}" - border_position "top" - } + // ── clock ─────────────────────────────────────────────────── + // Keep `#[..]` directives OUT of datetime_format: that string is + // strftime output substituted into {format} after the outer + // format is parsed, so a directive in here renders literally. + datetime "#[bg=$bg,fg=$sage]{format}" + datetime_format "%a %d %b %H:%M" + datetime_timezone "Europe/Amsterdam" + + // ── thin rule above the bar, so it reads as chrome ────────── + border_enabled "false" + border_char "─" + border_format "#[fg=$dim]{char}" + border_position "top" } } + + default_tab_template { + children + // size=3: the bar draws itself as a framed box in the pane style (see + // bar_frame_enabled), which needs a row for each border. The collapsed + // "mobile" template in default.swap.kdl uses size=1 instead and the + // plugin falls back to a single line -- a plugin cannot resize its own + // pane, so the height is the layout's call. + sl0pbar size=3 + } } diff --git a/config/layouts/default.swap.kdl b/config/layouts/default.swap.kdl index 1a5714b..ddefda8 100644 --- a/config/layouts/default.swap.kdl +++ b/config/layouts/default.swap.kdl @@ -58,7 +58,7 @@ swap_tiled_layout name="horizontal" { // this layout by name, but still has to satisfy this constraint, so at 4 a // narrow 2- or 3-pane tab would silently refuse to collapse. swap_tiled_layout name="stacked" { - tab min_panes=2 { + mobile min_panes=2 { pane stacked=true { children; } } } @@ -112,6 +112,22 @@ swap_floating_layout name="spread" { } } +// sl0p fork: the collapsed ("mobile") tab template. +// +// Same bar, one row instead of three: `sl0pbar` is the pane_template from +// default.kdl, and zellij parses both files with the SAME parser, so the whole +// zjstatus configuration is reused here rather than duplicated. At one row the +// plugin skips its frame and renders the single line it always used to -- on a +// terminal narrow enough to collapse the layout, two rows of border are two +// rows of content. +// +// Reached automatically by `responsive_layout "stacked"` in config.kdl, which +// switches to the swap layout of that name below `responsive_min_cols`. +tab_template name="mobile" { + children + sl0pbar size=1 +} + // sl0p fork: `arrangement` builds a tree for N panes, instead of the default // "every extra pane becomes a flat sibling". swap_tiled_layout name="grid" { diff --git a/docs/roadmap.md b/docs/roadmap.md index 3588e95..4f7cfb6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -328,12 +328,38 @@ That check tracks painted cells, not ink: a bar segment starts with a padding space inside its coloured pill, so stripping colour puts the left edge one column off — the exact size of the thing being measured. +### The bar as a framed box + +Second pass: given three rows the bar draws the same rounded box the panes do, +and given one it stays the single line it always was. Verified live — its +borders span exactly the columns the pane frames span, and the corners are the +same glyphs. + +The height is the **layout's** decision, because a plugin cannot resize its own +pane: + +- `config/layouts/default.kdl` defines the bar as a `pane_template` named + `sl0pbar` and places it at `size=3`; +- `config/layouts/default.swap.kdl` defines a `tab_template name="mobile"` that + places the same template at `size=1`, and the `stacked` swap layout — the one + `responsive_layout` collapses to below `responsive_min_cols` — uses it. + +That reuse is only possible because zellij parses a layout and its `.swap.kdl` +with the **same parser**, deliberately, "so that we can reuse assets (eg. pane +and tab templates)". Without it the collapsed template would have to duplicate +the entire 100-line zjstatus configuration. + +The plugin adapts to the rows it is handed (`ModuleConfig::viewport`): three or +more and it frames itself, fewer and it does not, so a `size=1` bar can never +render half a box. That one function also returns where the content line starts, +and click routing reads it — which is why a click on the new box's border hits +nothing instead of the widget underneath. + ### Still open on the bar -The request was "quite a bit of an overhaul", and this is the first pass. Not -done, and worth deciding before more work: whether the bar should be a framed -box in the pane style (rounded corners, its own gap) rather than a single line; -whether it should be multi-row; what else belongs on it. +Content and further click targets: a close-`x` per tab, session name → session +manager, mode indicator → unlock, drag-to-reorder tabs. Also: the pane-split `+` +and the new-tab `+` are currently the same glyph for different verbs. ## Standing risks diff --git a/packaging/checks/status-bar-live.py b/packaging/checks/status-bar-live.py index 5a63922..e5ad8af 100755 --- a/packaging/checks/status-bar-live.py +++ b/packaging/checks/status-bar-live.py @@ -150,14 +150,18 @@ def read_screen(fd, buffered="", seconds=8, until=None, quiet_for=0.7): def bar_row(rows): - """The bar is the last non-blank row: the tab strip and the clock are on it.""" + """The bar's CONTENT row: the one with the tab strip on it. + + Not "the last non-blank row" -- framed, that is the bottom border. + """ for y in range(len(rows) - 1, -1, -1): - if rows[y].strip(): + if "Tab #" in rows[y]: return y, rows[y] return None, "" def main(): + global COLS for path in (BIN, WASM): if not os.path.isfile(path): print(f"FAIL: missing {path}") @@ -229,7 +233,29 @@ def main(): if frame_span[0] is not None and bar_span != frame_span: failures.append(f"bar spans {bar_span} but the pane frames span {frame_span}") - # 2. the new-tab button makes a tab + # 2. framed: the bar is a box in the pane style, and its borders are + # part of it -- a box whose corners do not line up with the panes + # above is worse than no box + if y == 0 or y + 1 >= len(rows): + failures.append(f"the bar is not framed: content row {y} has no room for borders") + print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows))) + return failures + top, bottom = rows[y - 1], rows[y + 1] + print(f"above: {top.strip()[:12]!r} below: {bottom.strip()[:12]!r}") + for label, line, corners in ( + ("top", top, ("╭", "╮")), + ("bottom", bottom, ("╰", "╯")), + ): + span = painted_span(grid, y - 1 if label == "top" else y + 1) + if span != bar_span: + failures.append(f"the {label} border spans {span}, the bar spans {bar_span}") + if line[bar_span[0]] != corners[0] or line[bar_span[1]] != corners[1]: + failures.append( + f"the {label} border does not have rounded corners at its ends: " + f"{line[bar_span[0]]!r} {line[bar_span[1]]!r}" + ) + + # 3. the new-tab button makes a tab def tabs(): out = subprocess.run( [BIN, "-s", SESSION, "action", "list-tabs", "--json"], @@ -257,7 +283,7 @@ def main(): if after != before + 1: failures.append(f"clicking the new-tab button did not add a tab ({before} -> {after})") - # 3. and a click just left of the tab strip must not: the button is a + # 4. and a click just left of the tab strip must not: the button is a # button, not "anywhere on the right half of the bar" before = tabs() os.write(fd, f"\x1b[<0;{max(1, plus - 2)};{y + 1}M".encode()) @@ -265,6 +291,53 @@ def main(): time.sleep(1.5) if tabs() != before: failures.append("a click beside the button also made a tab") + + # 5. a click on the box itself must not either: the frame is chrome + before = tabs() + os.write(fd, f"\x1b[<0;{button_col + 1};{y}M".encode()) + os.write(fd, f"\x1b[<0;{button_col + 1};{y}m".encode()) + time.sleep(1.5) + if tabs() != before: + failures.append("a click on the bar's top border made a tab") + + # 6. collapsed ("mobile"): below responsive_min_cols the tab layout + # swaps to the stacked one, whose template gives the bar a single + # row -- and the plugin must then draw a line, not a broken box + narrow = 70 + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, narrow, 0, 0)) + time.sleep(2.5) + wide, COLS = COLS, narrow + try: + raw, rows = read_screen(fd, "", seconds=10) + grid = screen_cells(raw) + y, bar = bar_row(rows) + if y is None: + failures.append("no status bar after collapsing to the mobile layout") + return failures + above = rows[y - 1] if y > 0 else "" + below = rows[y + 1] if y + 1 < len(rows) else "" + print(f"collapsed: bar row {y}, above {above.strip()[:14]!r}") + if any(c in above or c in below for c in "╭╮╰╯"): + failures.append("the collapsed bar still draws a box") + span = painted_span(grid, y) + if span != (EXPECTED_MARGIN, narrow - 1 - EXPECTED_MARGIN): + failures.append(f"collapsed bar spans {span}, expected margins only") + plus = bar.rfind(" + ") + if plus < 0: + failures.append("no new-tab button on the collapsed bar") + else: + before = tabs() + os.write(fd, f"\x1b[<0;{plus + 2};{y + 1}M".encode()) + os.write(fd, f"\x1b[<0;{plus + 2};{y + 1}m".encode()) + time.sleep(2) + after = tabs() + print(f"collapsed tabs: {before} -> {after}") + if after != before + 1: + failures.append( + f"the collapsed bar's button did not make a tab ({before} -> {after})" + ) + finally: + COLS = wide return failures finally: subprocess.run([BIN, "-s", SESSION, "kill-session"], env=env, capture_output=True) diff --git a/sl0pstatus/src/bar_frame.rs b/sl0pstatus/src/bar_frame.rs new file mode 100644 index 0000000..4340242 --- /dev/null +++ b/sl0pstatus/src/bar_frame.rs @@ -0,0 +1,163 @@ +use std::collections::BTreeMap; + +use crate::render::FormattedPart; + +/// sl0p fork: draw the bar as a framed box in the pane style, when it has the +/// rows to do it. +/// +/// The bar is chrome sitting under a screenful of rounded, gapped panes, and a +/// bare line of text at the bottom edge does not read as part of that. Given +/// three rows it draws itself the same box the panes have; given one, it stays +/// a single line, which is what the responsive ("mobile") layout gives it on a +/// narrow terminal where two rows of decoration are two rows of content. +/// +/// The height is the LAYOUT's decision, not this plugin's: a plugin cannot +/// resize its own pane, and the tab template that places the bar is the thing +/// that knows whether we are in the collapsed layout. Here we only adapt to the +/// rows we are given, which also means a `size=1` bar degrades to the old +/// single line rather than drawing a broken box. +#[derive(Debug)] +pub struct BarFrameConfig { + pub enabled: bool, + pub rounded: bool, + pub format: FormattedPart, +} + +impl Default for BarFrameConfig { + fn default() -> Self { + Self { + // off unless asked for, so an unmodified zjstatus config renders + // exactly as it did + enabled: false, + rounded: true, + format: FormattedPart::default(), + } + } +} + +/// The number of rows a framed bar needs: two borders and a line of content. +pub const FRAMED_ROWS: usize = 3; + +pub struct FrameGlyphs { + pub top_left: &'static str, + pub top_right: &'static str, + pub bottom_left: &'static str, + pub bottom_right: &'static str, + pub horizontal: &'static str, + pub vertical: &'static str, +} + +impl BarFrameConfig { + pub fn glyphs(&self) -> FrameGlyphs { + // matches zellij's own boundary_type constants, so the box is the same + // one the panes draw and `rounded_corners` in config.kdl has a twin + // here rather than a lookalike + match self.rounded { + true => FrameGlyphs { + top_left: "╭", + top_right: "╮", + bottom_left: "╰", + bottom_right: "╯", + horizontal: "─", + vertical: "│", + }, + false => FrameGlyphs { + top_left: "┌", + top_right: "┐", + bottom_left: "└", + bottom_right: "┘", + horizontal: "─", + vertical: "│", + }, + } + } + + /// Wrap one rendered line of bar content in the box. + /// + /// `content` must already be exactly `inner_cols` wide; it is placed + /// verbatim between the vertical borders, so whatever laid it out decided + /// where every column of it lands. + pub fn wrap(&self, content: &str, inner_cols: usize) -> String { + let g = self.glyphs(); + let horizontal = g.horizontal.repeat(inner_cols); + let top = self + .format + .format_string(&format!("{}{}{}", g.top_left, horizontal, g.top_right)); + let bottom = self.format.format_string(&format!( + "{}{}{}", + g.bottom_left, horizontal, g.bottom_right + )); + let left = self.format.format_string(g.vertical); + let right = self.format.format_string(g.vertical); + format!("{top}\n{left}{content}{right}\n{bottom}") + } +} + +pub fn parse_bar_frame_config(config: &BTreeMap<String, String>) -> BarFrameConfig { + let default = BarFrameConfig::default(); + BarFrameConfig { + enabled: config + .get("bar_frame_enabled") + .map(|value| value == "true") + .unwrap_or(default.enabled), + rounded: config + .get("bar_frame_rounded") + .map(|value| value == "true") + .unwrap_or(default.rounded), + format: match config.get("bar_frame_format") { + Some(format) => FormattedPart::from_format_string(format, config), + None => default.format, + }, + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn framed(rounded: bool) -> BarFrameConfig { + BarFrameConfig { + enabled: true, + rounded, + format: FormattedPart::default(), + } + } + + #[test] + fn the_box_is_three_lines_of_equal_width() { + let wrapped = framed(true).wrap("content--", 9); + let lines: Vec<&str> = wrapped.split('\n').collect(); + assert_eq!(lines.len(), FRAMED_ROWS); + for line in &lines { + assert_eq!( + console::measure_text_width(line), + 11, + "line {line:?} is not inner_cols + 2 borders wide" + ); + } + } + + #[test] + fn rounded_matches_the_pane_frame_glyphs() { + let wrapped = framed(true).wrap(" ", 1); + assert!(wrapped.starts_with('╭'), "{wrapped:?}"); + assert!(wrapped.ends_with('╯'), "{wrapped:?}"); + + let square = framed(false).wrap(" ", 1); + assert!(square.starts_with('┌'), "{square:?}"); + assert!(square.ends_with('┘'), "{square:?}"); + } + + #[test] + fn the_content_is_placed_verbatim() { + // the caller owns every column of the content line: this must not trim, + // pad or re-align it, or the click math stops matching the drawing + let wrapped = framed(true).wrap(" spaced ", 10); + assert!(wrapped.contains("│ spaced │"), "{wrapped:?}"); + } + + #[test] + fn disabled_by_default_for_an_unmodified_config() { + assert!(!parse_bar_frame_config(&BTreeMap::new()).enabled); + } +} diff --git a/sl0pstatus/src/bin/zjstatus.rs b/sl0pstatus/src/bin/zjstatus.rs index d3b7beb..25cceed 100644 --- a/sl0pstatus/src/bin/zjstatus.rs +++ b/sl0pstatus/src/bin/zjstatus.rs @@ -100,6 +100,7 @@ impl ZellijPlugin for State { self.state = ZellijState { cols: 0, + rows: 0, command_results: BTreeMap::new(), pipe_results: BTreeMap::new(), mode: ModeInfo::default(), @@ -163,7 +164,7 @@ impl ZellijPlugin for State { } #[tracing::instrument(skip_all)] - fn render(&mut self, _rows: usize, cols: usize) { + fn render(&mut self, rows: usize, cols: usize) { if !self.got_permissions { return; } @@ -175,6 +176,7 @@ impl ZellijPlugin for State { } self.state.cols = cols; + self.state.rows = rows; tracing::debug!("{:?}", self.state.mode.session_name); diff --git a/sl0pstatus/src/config.rs b/sl0pstatus/src/config.rs index 9748abf..bf8cde3 100644 --- a/sl0pstatus/src/config.rs +++ b/sl0pstatus/src/config.rs @@ -5,6 +5,7 @@ use regex::Regex; use zellij_tile::prelude::*; use crate::{ + bar_frame::{parse_bar_frame_config, BarFrameConfig, FRAMED_ROWS}, border::{parse_border_config, BorderConfig, BorderPosition}, render::FormattedPart, widgets::{command::CommandResult, notification, widget::Widget}, @@ -14,6 +15,9 @@ use chrono::{DateTime, Local}; #[derive(Default, Debug, Clone)] pub struct ZellijState { pub cols: usize, + /// sl0p fork: the pane's height, which decides whether the bar is drawn as + /// a framed box or as a single line. See `ModuleConfig::viewport`. + pub rows: usize, pub command_results: BTreeMap<String, CommandResult>, pub pipe_results: BTreeMap<String, String>, pub mode: ModeInfo, @@ -109,6 +113,26 @@ pub struct ModuleConfig { /// margins are the same space. pub margin_left: usize, pub margin_right: usize, + /// sl0p fork: draw the bar as a box in the pane style when it has the rows. + pub bar_frame: BarFrameConfig, +} + +/// sl0p fork: where the bar's content sits inside its pane. +/// +/// Rendering and click routing both come from here, so the frame and the +/// margins can only ever move the drawing and the hit-testing together. Get +/// this wrong in one place only and you get a button you can see and not press, +/// which this project has now paid for once. +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct BarViewport { + /// row of the pane the content line is drawn on (1 inside a box, else 0) + pub line: usize, + /// first column of the content line + pub col: usize, + /// how many columns the content gets + pub cols: usize, + /// whether the box is being drawn at all + pub framed: bool, } @@ -221,6 +245,7 @@ impl ModuleConfig { Ok(Self { margin_left: margin("margin_left"), margin_right: margin("margin_right"), + bar_frame: parse_bar_frame_config(config), left_parts_config: left_parts_config.to_owned(), left_parts: parts_from_config(Some(&left_parts_config.to_owned()), config), center_parts_config: center_parts_config.to_owned(), @@ -239,15 +264,36 @@ impl ModuleConfig { }) } - /// The bar's own viewport: the pane it lives in, less the margins. + /// Where the content line goes, given the pane we were handed. /// - /// Everything downstream -- spacing, trimming, widgets, click positions -- - /// works in these coordinates, so the margins exist in exactly one place - /// and cannot be applied to the drawing but forgotten in the hit-testing. - fn inset_state(&self, mut state: ZellijState) -> ZellijState { - state.cols = state - .cols - .saturating_sub(self.margin_left + self.margin_right); + /// The box is drawn only when there are rows for it: the layout decides the + /// bar's height (three rows normally, one in the collapsed "mobile" + /// layout), and this adapts. A framed bar loses two more columns to its + /// vertical borders, which is why the click math cannot just subtract the + /// margins. + pub fn viewport(&self, rows: usize, cols: usize) -> BarViewport { + let framed = self.bar_frame.enabled && rows >= FRAMED_ROWS; + let margins = self.margin_left + self.margin_right; + match framed { + true => BarViewport { + line: 1, + col: self.margin_left + 1, + cols: cols.saturating_sub(margins + 2), + framed: true, + }, + false => BarViewport { + line: 0, + col: self.margin_left, + cols: cols.saturating_sub(margins), + framed: false, + }, + } + } + + /// The bar's own viewport as the widgets see it: the columns they get to + /// lay out in, whatever chrome is around them. + fn inset_state(&self, mut state: ZellijState, viewport: &BarViewport) -> ZellijState { + state.cols = viewport.cols; state } @@ -257,23 +303,31 @@ impl ModuleConfig { mouse: Mouse, widget_map: BTreeMap<String, Arc<dyn Widget>>, ) { - let click_pos = match mouse { + let (click_line, click_pos) = match mouse { Mouse::ScrollUp(_) => return, Mouse::ScrollDown(_) => return, - Mouse::LeftClick(_, y) => y, - Mouse::RightClick(_, y) => y, - Mouse::Hold(_, y) => y, - Mouse::Release(_, y) => y, + Mouse::LeftClick(x, y) => (x, y), + Mouse::RightClick(x, y) => (x, y), + Mouse::Hold(x, y) => (x, y), + Mouse::Release(x, y) => (x as isize, y), Mouse::Hover(_, _) => return, }; - // a click in the margin is a click on nothing, and must not be counted - // as one on the first widget - if click_pos < self.margin_left { + // Coordinates are relative to the plugin's own pane, so a framed bar + // puts the widgets on line 1 with borders above and below. A click on a + // border is a click on the frame, not on whatever widget happens to be + // below it. + let viewport = self.viewport(state.rows, state.cols); + if click_line != viewport.line as isize { + return; + } + // likewise the margins and the vertical borders: they are not part of + // any widget, and must not be counted as a click on the first one + if click_pos < viewport.col { return; } - let click_pos = click_pos - self.margin_left; - let state = self.inset_state(state); + let click_pos = click_pos - viewport.col; + let state = self.inset_state(state, &viewport); if click_pos >= state.cols { return; } @@ -438,13 +492,26 @@ impl ModuleConfig { state: ZellijState, widget_map: BTreeMap<String, Arc<dyn Widget>>, ) -> String { - let bar = self.render_bar_content(self.inset_state(state), widget_map); + let viewport = self.viewport(state.rows, state.cols); + let mut bar = self.render_bar_content(self.inset_state(state, &viewport), widget_map); + + if viewport.framed { + // pad the content to the full inner width first: the box's right + // border has to land in a fixed column whatever the widgets did, + // and a short line would otherwise pull it left + let width = console::measure_text_width(&bar); + if width < viewport.cols { + bar.push_str(&" ".repeat(viewport.cols - width)); + } + bar = self.bar_frame.wrap(&bar, viewport.cols); + } + if self.margin_left == 0 && self.margin_right == 0 { return bar; } - // Every line of the bar is inset, not just the first: with a border - // enabled the output is two lines, and a rule that ran the full width - // under a margined bar would look like a mistake. + // Every line is inset, not just the first: the box is three of them, + // and a border that ran the full width under a margined bar would look + // like a mistake. bar.split('\n') .map(|line| { format!( @@ -808,6 +875,142 @@ mod sl0p_double_click_tests { } } + /// Column (in CHARACTERS, not bytes) where `needle` is drawn. + /// + /// The box glyphs are three bytes each, so a byte offset here reads as a + /// click two columns to the right of the thing it found. + fn drawn_column(haystack: &str, needle: &str) -> usize { + let byte = haystack + .find(needle) + .unwrap_or_else(|| panic!("{needle:?} not drawn in {haystack:?}")); + haystack[..byte].chars().count() + } + + fn state_of(rows: usize, cols: usize) -> ZellijState { + ZellijState { + rows, + cols, + ..Default::default() + } + } + + fn framed_bar() -> (ModuleConfig, Arc<SpyWidget>) { + let mut config = BTreeMap::new(); + config.insert("format_left".to_owned(), "{spy}".to_owned()); + config.insert("margin_left".to_owned(), "2".to_owned()); + config.insert("margin_right".to_owned(), "2".to_owned()); + config.insert("bar_frame_enabled".to_owned(), "true".to_owned()); + let module_config = ModuleConfig::new(&config).unwrap(); + let spy = Arc::new(SpyWidget { + label: "[button]".to_owned(), + clicks: Default::default(), + }); + (module_config, spy) + } + + #[test] + fn three_rows_get_a_box_and_one_row_stays_a_line() { + let (config, _) = framed_bar(); + + let framed = config.viewport(3, 40); + assert!(framed.framed); + assert_eq!(framed.line, 1, "content sits between the borders"); + assert_eq!(framed.col, 3, "margin plus the left border"); + assert_eq!(framed.cols, 34, "40 less two margins and two borders"); + + // the collapsed layout gives the bar a single row: no box, and the + // content gets the columns the borders would have taken + let mobile = config.viewport(1, 40); + assert!(!mobile.framed); + assert_eq!(mobile.line, 0); + assert_eq!(mobile.col, 2); + assert_eq!(mobile.cols, 36); + } + + #[test] + fn the_box_fills_its_pane_exactly() { + let (mut config, spy) = framed_bar(); + let rendered = config.render_bar(state_of(3, 40), spy_map(spy)); + let lines: Vec<&str> = rendered.split('\n').collect(); + + assert_eq!(lines.len(), 3, "a framed bar is three lines: {rendered:?}"); + for line in &lines { + assert_eq!( + console::measure_text_width(line), + 40, + "line {line:?} does not fill the pane" + ); + } + assert!(lines[0].starts_with(" ╭"), "{:?}", lines[0]); + assert!(lines[0].ends_with("╮ "), "{:?}", lines[0]); + assert!(lines[2].starts_with(" ╰"), "{:?}", lines[2]); + assert!(lines[2].ends_with("╯ "), "{:?}", lines[2]); + } + + #[test] + fn a_framed_widget_is_clicked_where_it_is_drawn() { + let (mut config, spy) = framed_bar(); + let widget_map = spy_map(spy.clone()); + let rendered = config.render_bar(state_of(3, 40), widget_map.clone()); + let content_line = rendered.split('\n').nth(1).expect("no content line"); + let drawn_at = drawn_column(content_line, "[button]"); + + config.handle_mouse_action(state_of(3, 40), Mouse::LeftClick(1, drawn_at), widget_map); + + assert_eq!( + *spy.clicks.lock().unwrap(), + vec![0], + "the button's first drawn column should be its column 0" + ); + } + + #[test] + fn clicks_on_the_frame_hit_nothing() { + let (mut config, spy) = framed_bar(); + let widget_map = spy_map(spy.clone()); + let rendered = config.render_bar(state_of(3, 40), widget_map.clone()); + let content_line = rendered.split('\n').nth(1).expect("no content line"); + let drawn_at = drawn_column(content_line, "[button]"); + + // the border rows, at the column the button occupies on the row between + for line in [0, 2] { + config.handle_mouse_action( + state_of(3, 40), + Mouse::LeftClick(line, drawn_at), + widget_map.clone(), + ); + } + // and the vertical borders themselves + for column in [2, 3 + 34] { + config.handle_mouse_action( + state_of(3, 40), + Mouse::LeftClick(1, column), + widget_map.clone(), + ); + } + + assert!( + spy.clicks.lock().unwrap().is_empty(), + "the frame is not part of any widget: {:?}", + spy.clicks.lock().unwrap() + ); + } + + #[test] + fn a_one_row_bar_ignores_the_frame_setting() { + // the responsive layout hands the bar a single row: it must not try to + // draw a box into it + let (mut config, spy) = framed_bar(); + let widget_map = spy_map(spy.clone()); + let rendered = config.render_bar(state_of(1, 40), widget_map.clone()); + + assert!(!rendered.contains('\n'), "{rendered:?}"); + assert!(rendered.starts_with(" [button]"), "{rendered:?}"); + + config.handle_mouse_action(state_of(1, 40), Mouse::LeftClick(0, 2), widget_map); + assert_eq!(*spy.clicks.lock().unwrap(), vec![0]); + } + #[test] fn margins_inset_the_bar_at_both_ends() { let (mut config, spy) = bar_with_margins(2, 2); diff --git a/sl0pstatus/src/lib.rs b/sl0pstatus/src/lib.rs index 345d4af..3d2ad35 100644 --- a/sl0pstatus/src/lib.rs +++ b/sl0pstatus/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bar_frame; pub mod border; pub mod config; pub mod frames; |
