diff --git a/.github/workflows/regress.yml b/.github/workflows/regress.yml index b1e19e9e0..72a821344 100644 --- a/.github/workflows/regress.yml +++ b/.github/workflows/regress.yml @@ -27,10 +27,10 @@ jobs: runner: ubuntu-24.04 make: make configure: --enable-utf8proc --enable-asan - #- name: ubuntu-24.04-arm64 - # runner: ubuntu-24.04-arm - # make: make - # configure: --enable-utf8proc --enable-asan + - name: ubuntu-24.04-arm64 + runner: ubuntu-24.04-arm + make: make + configure: --enable-utf8proc --enable-asan - name: macos-26-arm64 runner: macos-26 make: gmake @@ -72,14 +72,14 @@ jobs: - name: build run: | - sh autogen.sh + sh autogen.sh ./configure ${{ matrix.configure }} ${{ matrix.make }} -j"$(getconf _NPROCESSORS_ONLN)" - name: test run: | cd regress - ${{ matrix.make }} + ${{ matrix.make }} -j"$(getconf _NPROCESSORS_ONLN)" - name: logs if: failure() diff --git a/CHANGES b/CHANGES index 093e29fc9..0a68f6323 100644 --- a/CHANGES +++ b/CHANGES @@ -9,7 +9,12 @@ CHANGES FROM 3.7c TO 3.8 - modal panes may be created with new-pane -O; a window can have one modal pane and it prevents interaction with the other panes while it is active. - A modal pane is now used for the editor in buffer mode; + -C closes the pane when the mouse is clicked outside it and -K sends all + keys, including the prefix key, to the pane. -D closes the modal pane with + Escape or C-c. A modal pane is now used for the editor in buffer mode; + + - new-pane -A creates a floating pane which remains above a zoomed pane; + modal panes do this by default; - move-pane and resize-pane can now move and resize floating panes, including mouse dragging; @@ -24,10 +29,18 @@ CHANGES FROM 3.7c TO 3.8 -k for choose commands kills the pane when the mode exits, which is useful for modes in floating panes; - - pane-border-status has top-floating and bottom-floating, and there are new - default bindings under C-b g for common move and resize operations; + - command-prompt -P opens a prompt inside a pane rather than on the status + line; - - floating panes are unzoomed before creation to avoid a crash. + - pane-border-status has top-floating and bottom-floating, and there are new + default bindings under C-b g for common move and resize operations. + +* Layout strings now use a JSON subset format which includes floating panes. + The old format is still accepted; control mode clients receive old layouts + unless they set the new-layouts flag. + +* Add remain-on-exit failed-key to keep a pane open after a failed command + until a key is pressed. * Add support for themes and improve default colours: @@ -74,7 +87,7 @@ CHANGES FROM 3.7c TO 3.8 - new hooks to cover areas including client create and destroy, pane activity, pane mode and prompt changes, pane movement and resizing, session - group changes and window creation, close and zoom; + group changes and window creation, close, swap and zoom; - OSC 133 escape sequences now trigger events: pane-command-started, pane-command-finished and pane-shell-prompt. @@ -87,10 +100,31 @@ CHANGES FROM 3.7c TO 3.8 while scrolling or while hovered and disappears after pane-scrollbars-timeout (Michael Grant). -* Extend the fill-character option so both inside and outside the window can be - changed. +* Add a clear-on-attach server option. When disabled, tmux does not enter the + alternate screen on attach and instead scrolls the existing terminal content + into the scrollback buffer so it remains accessible (issue 5508). -* Change set-option and set-hooks to use formats and add a -F flag to each. +* Copy mode can now automatically refresh as pane content changes with the + refresh-on, refresh-off and refresh-toggle commands. Automatic refresh is off + by default; refresh-now refreshes once and is bound to r. These replace + refresh-from-pane (issue 5165). + +* Extend the fill-character option so both inside and outside the window can be + changed, with a new default for unused areas inside the window. + +* Change show-options and show-hooks to use formats and add a -F flag to each. + +* Allow individual terminal features to be disabled by adding @ to their names + in terminal-features, and add a utf8 feature for terminals which support + UTF-8 output. + +* Add an appesc terminal feature for mintty's application escape key mode, + avoiding unsupported sequences on other terminals (Pete Dietl, issue 5626). + +* Preserve existing Ss and Se terminfo capabilities for cursor styles and + restoring the default cursor (issue 5476). + +* Enable left and right margins for WezTerm and Ghostty (Michael Grant). * Add a #{A/count:frames} modifier to show a series of frames as an animation in the status line (issue 5412 from Fernando Daciuk). @@ -105,6 +139,12 @@ CHANGES FROM 3.7c TO 3.8 * Add new-window -E, respawn-pane -E and respawn-window -E as more convenient methods to create an empty pane (rather than using '' for the command). +* Add capture-pane -I to include the time each line entered history. + +* Various memory leak fixes from Heon Jeong. + +* Add a default C-b T binding to change the current pane title. + * Menus now belong to the window, so appear on all clients. * Dragging over an existing copy mode selection now adjusts it (Michael Grant). @@ -118,6 +158,13 @@ CHANGES FROM 3.7c TO 3.8 * Copy mode no longer exits at the bottom while a selection is in progress (issue 5349). +* Add copy-mode-current-line-style to set the style of the line containing the + cursor in copy mode (issue 5391). + +* Copy mode word commands now recognise all Unicode whitespace characters; a + space in word-separators matches any Unicode whitespace character (issue + 5562). + * Add style attributes for dimming colours (dim=) and for hyperlinks (link= and nolink) (issues 4842 and 4280 from Moritz Angermann). @@ -128,7 +175,9 @@ CHANGES FROM 3.7c TO 3.8 * Add or improve format variables including client_colours, pane_start_command_list, window_manual_width, window_manual_height, - pane_last_output_time, pane_modal_flag and window_modal_pane. + pane_last_output_time, pane_modal_flag, window_modal_pane, pane_private_modes, + pane_output_generation, history_added, history_collected, history_generation, + hook_fire_count and hook_fire_time. * Add additional pane sort orders, and a z sort order for choose-tree so floating panes can be sorted by z-index. @@ -140,30 +189,59 @@ CHANGES FROM 3.7c TO 3.8 * The mouse option now defaults to on. +* The active-pane client flag has been removed. + * Fix send-keys -K so keys are inserted in the correct place in the input queue, like keys from key bindings (issue 3476). * Fix control mode clients hanging on exit if pty data was still queued (Ben Maurer, issue 5356), and avoid sending notifications to clients which are - already exiting (Ben Maurer, issue 5357). + already exiting (Ben Maurer, issue 5357). Queue notifications so they are not + sent inside %begin/%end (issue 5458), do not let a stuck client prevent the + server from exiting (issue 5444), and reset control mode offsets when a pane + is respawned (issue 5498). Bound buffered command replies so a control mode + client that stops reading cannot consume unlimited memory (issue 5565), and + fix a collision between the output discard and bracketed paste flags (issue + 5622). * Fix grouped sessions sometimes being left as unusable command targets while - they are being killed (Bryce Miller, issue 5180). + they are being killed (Bryce Miller, issue 5180), and prevent reuse of a + session with no windows while detaching (Heon Jeong, issue 5620). + +* Free wait-for clients when they disconnect and do not run their remaining + queued commands (issue 5614). + +* Expand formats in run-shell -c like other start-directory flags (reported by + Saul Nogueras). + +* Fix session alert flag formats to check all windows (issue 5599). + +* Do not clear the marked pane when clicking on a pane border. * Fix choose mode filtering when more than one pane exists (issue 5326), and fix an infinite loop in customize mode when a filter does not match. +* Preserve the scroll position in tree modes when rebuilding the list + (Kim A Brandt, issue 5631). + * Fix regsub when the pattern is anchored at the start (issue 5341). * Fix redraw issues around synchronized updates, copy mode, tabs, padding, status lines, menus, popups, wide characters and floating panes. This includes fixes for flicker with alternate screen applications and scrollbars (Michael Grant, issue 5351; Noam Stolero, issue 5350; Aung Myo Kyaw, issue - 5098). + 5098), and misplaced wrapped lines during partial redraws (Ben Maurer, issue + 5625). * Do not crash looking for the next or previous session (issue 5344), or when no client is available. +* Fix crashes when destroying a zoomed pane (Romain Francoise, issue 5591), + copying from a blank source pane (Kaixuan Li), or opening a mode prompt when + the client is offset into the window. + +* Avoid an infinite loop when assigning a width to WCHAR_MAX (issue 5602). + CHANGES FROM 3.7b TO 3.7c * Build with jemalloc on macOS to avoid what appears to be a bug in calloc diff --git a/Makefile.am b/Makefile.am index 1035b76e5..fd060413b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -15,6 +15,9 @@ AM_CPPFLAGS += @XOPEN_DEFINES@ \ -DTMUX_LOCK_CMD='"@DEFAULT_LOCK_CMD@"' \ -DTMUX_TERM='"@DEFAULT_TERM@"' \ -DTMUX_MOUSE=1 +if HAVE_TMUX_SOCK +AM_CPPFLAGS += -DTMUX_SOCK='"@TMUX_SOCK@"' +endif # Additional object files. LDADD = $(LIBOBJS) @@ -175,6 +178,7 @@ dist_tmux_SOURCES = \ input-keys.c \ input.c \ job.c \ + json.c \ key-bindings.c \ key-string.c \ layout-custom.c \ @@ -188,7 +192,6 @@ dist_tmux_SOURCES = \ options-table.c \ options.c \ paste.c \ - popup.c \ proc.c \ prompt-history.c \ prompt.c \ @@ -258,7 +261,9 @@ check_PROGRAMS = \ fuzz/input-fuzzer \ fuzz/cmd-parse-fuzzer \ fuzz/format-fuzzer \ - fuzz/style-fuzzer + fuzz/style-fuzzer \ + fuzz/layout-fuzzer \ + fuzz/json-fuzzer fuzz_input_fuzzer_LDFLAGS = $(FUZZING_LIBS) fuzz_input_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS) fuzz_cmd_parse_fuzzer_LDFLAGS = $(FUZZING_LIBS) @@ -267,6 +272,10 @@ fuzz_format_fuzzer_LDFLAGS = $(FUZZING_LIBS) fuzz_format_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS) fuzz_style_fuzzer_LDFLAGS = $(FUZZING_LIBS) fuzz_style_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS) +fuzz_layout_fuzzer_LDFLAGS = $(FUZZING_LIBS) +fuzz_layout_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS) +fuzz_json_fuzzer_LDFLAGS = $(FUZZING_LIBS) +fuzz_json_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS) endif # Install tmux.1 in the right format. diff --git a/SYNCING.md b/SYNCING.md index 9b710a463..a4673dac0 100644 --- a/SYNCING.md +++ b/SYNCING.md @@ -35,7 +35,7 @@ The usual local layout is: ```sh cd /some/where/useful git clone https://github.com/tmux/tmux.git tmux-portable -git clone https://github.com/ThomasAdam/tmux-obsd.git tmux-openbsd-cutover +git clone https://github.com/tmux/tmux-openbsd-cutover.git tmux-openbsd-cutover ``` The exact directory names do not matter, but the examples below use: @@ -56,18 +56,19 @@ The cutover repository has three important branches: # Adding the OpenBSD remote to portable -In the portable repository, add the cutover repository as a remote: +In the portable repository, add the published cutover repository as a remote. +This works regardless of which branch is checked out in a local cutover clone: ```sh cd /path/to/tmux-portable -git remote add tmux-openbsd /path/to/tmux-openbsd-cutover +git remote add tmux-openbsd https://github.com/tmux/tmux-openbsd-cutover.git git config remote.tmux-openbsd.tagOpt --no-tags ``` If the remote already exists, update it instead: ```sh -git remote set-url tmux-openbsd /path/to/tmux-openbsd-cutover +git remote set-url tmux-openbsd https://github.com/tmux/tmux-openbsd-cutover.git git config remote.tmux-openbsd.tagOpt --no-tags ``` @@ -77,6 +78,32 @@ Fetch the cutover master branch explicitly: git fetch --no-tags tmux-openbsd master:refs/remotes/tmux-openbsd/master ``` +To merge unpublished changes from a local cutover clone instead, first ensure +it has an up-to-date local `master` branch. A normal clone may check out +`automation` and have only `origin/master`; fetching `master` from that clone +will then fail with `couldn't find remote ref master`. + +With a clean cutover working tree: + +```sh +cd /path/to/tmux-openbsd-cutover +git fetch --no-tags origin +git switch master +git merge --ff-only origin/master +``` + +`git switch master` creates a tracking branch from `origin/master` if there is +no local `master` yet. If the fast-forward fails, reconcile the local cutover +changes before continuing; do not reset them away. + +Then, in portable, point the remote at that clone and fetch its local `master`: + +```sh +cd /path/to/tmux-portable +git remote set-url tmux-openbsd /path/to/tmux-openbsd-cutover +git fetch --no-tags tmux-openbsd master:refs/remotes/tmux-openbsd/master +``` + # Automated syncing The normal sync is performed by the GitHub Actions workflow in the @@ -99,7 +126,9 @@ OpenBSD changes. If the workflow fails while merging into portable, do the merge locally and push the result. -Start from an up-to-date portable master: +Start with a clean working tree and an up-to-date portable master. If a merge +is already in progress, skip to resolving conflicts, or abort it before +starting again: ```sh cd /path/to/tmux-portable @@ -108,10 +137,15 @@ git checkout master git pull --ff-only origin master ``` -Fetch the cutover branch: +Fetch the published cutover branch. Update an existing remote as well, since +it may point at a local clone without a `master` branch or with a stale one: ```sh -git remote add tmux-openbsd /path/to/tmux-openbsd-cutover 2>/dev/null || true +if git remote get-url tmux-openbsd >/dev/null 2>&1; then + git remote set-url tmux-openbsd https://github.com/tmux/tmux-openbsd-cutover.git +else + git remote add tmux-openbsd https://github.com/tmux/tmux-openbsd-cutover.git +fi git config remote.tmux-openbsd.tagOpt --no-tags git fetch --no-tags tmux-openbsd master:refs/remotes/tmux-openbsd/master ``` @@ -122,7 +156,22 @@ Merge it: git merge --no-ff --log refs/remotes/tmux-openbsd/master ``` -Resolve conflicts by deciding whether portable or OpenBSD owns the file. +If merging a local cutover branch instead, use the local-clone preparation +and fetch commands above in place of this fetch block. + +When the merge reports conflicts, it leaves the merge in progress. List the +unresolved files, edit the conflict markers to combine the required portable +and OpenBSD changes, then stage each resolved file: + +```sh +git diff --name-only --diff-filter=U +git diff -- path/to/file +git add path/to/file +``` + +For files that should come entirely from one side, decide whether portable or +OpenBSD owns the file before using the commands below. They replace the whole +file, including changes outside the conflicting hunks. Useful commands: @@ -140,11 +189,25 @@ git add path/to/file This takes the OpenBSD/cutover version of a conflicted file. +For a modify/delete conflict, the side that deleted the file has no version +to check out. For example, portable generates `Makefile` using autotools and +does not track OpenBSD's `Makefile`. If Git reports that `Makefile` was deleted +in HEAD and modified in cutover, keep the portable deletion with: + +```sh +git rm -- Makefile +``` + +This removes the OpenBSD file left by the merge; regenerate the portable +`Makefile` with your usual configure command before building. Use this only +for the unmerged OpenBSD file, not an existing generated build file. + Before committing, inspect the result: ```sh git status git diff --check +git diff --cached --check git diff --cached --stat ``` diff --git a/cfg.c b/cfg.c index 7a48cd9e1..7de458b8d 100644 --- a/cfg.c +++ b/cfg.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cfg.c,v 1.91 2026/08/03 13:38:42 nicm Exp $ */ +/* $OpenBSD: cfg.c,v 1.92 2026/10/02 12:28:07 nicm Exp $ */ /* * Copyright (c) 2008 Nicholas Marriott @@ -31,14 +31,17 @@ int cfg_finished; static char **cfg_causes; static u_int cfg_ncauses; static struct cmdq_item *cfg_item; +static int cfg_started; int cfg_quiet = 1; char **cfg_files; u_int cfg_nfiles; static enum cmd_retval -cfg_client_done(__unused struct cmdq_item *item, __unused void *data) +cfg_client_done(struct cmdq_item *item, __unused void *data) { + if (cmdq_get_client(item)->flags & CLIENT_DEAD) + return (CMD_RETURN_NORMAL); if (!cfg_finished) return (CMD_RETURN_WAIT); return (CMD_RETURN_NORMAL); @@ -53,14 +56,28 @@ cfg_done(__unused struct cmdq_item *item, __unused void *data) cfg_show_causes(NULL); - if (cfg_item != NULL) + if (cfg_item != NULL) { cmdq_continue(cfg_item); + cfg_item = NULL; + } prompt_load_history(); return (CMD_RETURN_NORMAL); } +void +cfg_client_lost(struct client *c) +{ + if (c != cfg_client) + return; + cfg_client = NULL; + if (cfg_item != NULL) { + cmdq_continue(cfg_item); + cfg_item = NULL; + } +} + void start_cfg(void) { @@ -77,12 +94,19 @@ start_cfg(void) * Because start_cfg() is called so early, we can be sure the client's * command queue is currently empty and our callback will be at the * front - we need to get in before MSG_COMMAND. + * + * If the initial client is lost before the configuration finishes, the + * next client to identify is first in the list and ends up here. Block + * it instead, but do not load the files again. */ cfg_client = c = TAILQ_FIRST(&clients); if (c != NULL) { cfg_item = cmdq_get_callback(cfg_client_done, NULL); cmdq_append(c, cfg_item); } + if (cfg_started) + return; + cfg_started = 1; if (cfg_quiet) flags = CMD_PARSE_QUIET; diff --git a/client.c b/client.c index c9a51440b..9f91ed4c9 100644 --- a/client.c +++ b/client.c @@ -286,7 +286,7 @@ client_main(struct event_base *base, int argc, char **argv, uint64_t flags, #ifdef HAVE_SYSTEMD if (systemd_activated()) { /* socket-based activation, do not even try to be a client. */ - fd = server_start(client_proc, flags, base, 0, NULL); + fd = server_start(client_proc, flags, base, -1, NULL); } else #endif fd = client_connect(base, socket_path, client_flags); diff --git a/cmd-attach-session.c b/cmd-attach-session.c index 050c7ca38..6557cef32 100644 --- a/cmd-attach-session.c +++ b/cmd-attach-session.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-attach-session.c,v 1.91 2026/07/10 13:38:45 nicm Exp $ */ +/* $OpenBSD: cmd-attach-session.c,v 1.92 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -91,8 +91,10 @@ cmd_attach_session(struct cmdq_item *item, const char *tflag, int dflag, wp = target.wp; if (wl != NULL) { - if (wp != NULL) + if (wp != NULL) { + window_redraw_active_switch(wp->window, wp); window_set_active_pane(wp->window, wp, 1); + } session_set_current(s, wl); if (wp != NULL) cmd_find_from_winlink_pane(current, wl, wp, 0); diff --git a/cmd-capture-pane.c b/cmd-capture-pane.c index a9ed4bc42..6c78e9cd8 100644 --- a/cmd-capture-pane.c +++ b/cmd-capture-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-capture-pane.c,v 1.68 2026/07/20 11:16:33 nicm Exp $ */ +/* $OpenBSD: cmd-capture-pane.c,v 1.69 2026/09/08 07:31:59 nicm Exp $ */ /* * Copyright (c) 2009 Jonathan Alvarado @@ -42,8 +42,8 @@ const struct cmd_entry cmd_capture_pane_entry = { .name = "capture-pane", .alias = "capturep", - .args = { "ab:CeE:FHJLMNpPqRS:Tt:", 0, 0, NULL }, - .usage = "[-aCeFHJLMNpPqRT] " CMD_BUFFER_USAGE " [-E end-line] " + .args = { "ab:CeE:FHIJLMNpPqRS:Tt:", 0, 0, NULL }, + .usage = "[-aCeFHIJLMNpPqRT] " CMD_BUFFER_USAGE " [-E end-line] " "[-S start-line] " CMD_TARGET_PANE_USAGE, .target = { 't', CMD_FIND_PANE, 0 }, @@ -200,7 +200,7 @@ cmd_capture_pane_pending(struct args *args, struct window_pane *wp, tmp[0] = line[i]; tmp[1] = '\0'; } else - xsnprintf(tmp, sizeof tmp, "\\%03hho", line[i]); + snprintf(tmp, sizeof tmp, "\\%03hho", line[i]); buf = cmd_capture_pane_append(buf, len, tmp, strlen(tmp)); } @@ -259,7 +259,7 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, struct grid_cell *gc = NULL; struct window_mode_entry *wme; int n, join_lines, number_lines, flags = 0; - int show_flags, hyperlinks; + int show_flags, show_time, hyperlinks; u_int *links = NULL, nlinks = 0; u_int i, sx, top, bottom, tmp; char *cause, *buf = NULL, *line, b[64], *cp; @@ -342,6 +342,7 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, flags |= GRID_STRING_TRIM_SPACES; number_lines = args_has(args, 'L'); show_flags = args_has(args, 'F'); + show_time = args_has(args, 'I'); hyperlinks = args_has(args, 'H'); if (hyperlinks) links = xreallocarray(NULL, gd->sx, sizeof *links); @@ -358,6 +359,7 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, free(line); continue; } + gl = grid_peek_line(gd, i); if (number_lines) { if (i >= gd->hsize) @@ -368,11 +370,16 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, if (n >= 0) buf = cmd_capture_pane_append(buf, len, b, n); } + if (show_time) { + n = snprintf(b, sizeof b, "%llu ", + (unsigned long long)grid_line_time(gl)); + if (n >= 0) + buf = cmd_capture_pane_append(buf, len, b, n); + } if (show_flags) { cp = b; *cp = '\0'; - gl = grid_peek_line(gd, i); if (gl->flags & GRID_LINE_DEAD) *cp++ = 'D'; if (gl->flags & GRID_LINE_HYPERLINK) @@ -393,7 +400,6 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, } buf = cmd_capture_pane_append(buf, len, line, linelen); - gl = grid_peek_line(gd, i); if (!join_lines || !(gl->flags & GRID_LINE_WRAPPED)) buf[(*len)++] = '\n'; diff --git a/cmd-copy-mode.c b/cmd-copy-mode.c index e6888ca7e..28a1a742e 100644 --- a/cmd-copy-mode.c +++ b/cmd-copy-mode.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-copy-mode.c,v 1.54 2026/07/14 17:17:17 nicm Exp $ */ +/* $OpenBSD: cmd-copy-mode.c,v 1.55 2026/10/02 12:12:51 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -103,6 +103,8 @@ cmd_copy_mode_exec(struct cmd *self, struct cmdq_item *item) if (args_has(args, 'd')) window_copy_pagedown(wp, 0, args_has(args, 'e')); if (args_has(args, 'S')) { + if (c == NULL) + return (CMD_RETURN_NORMAL); tty_window_offset(&c->tty, &tty_ox, &tty_oy, &tty_sx, &tty_sy); window_copy_scroll(wp, c->tty.mouse_slider_mpos, event->m.y, tty_oy, args_has(args, 'e')); diff --git a/cmd-display-menu.c b/cmd-display-menu.c index 0600c610c..f9627ca67 100644 --- a/cmd-display-menu.c +++ b/cmd-display-menu.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-display-menu.c,v 1.53 2026/08/31 07:46:55 nicm Exp $ */ +/* $OpenBSD: cmd-display-menu.c,v 1.55 2026/09/21 12:14:32 nicm Exp $ */ /* * Copyright (c) 2019 Nicholas Marriott @@ -54,8 +54,8 @@ const struct cmd_entry cmd_display_popup_entry = { .name = "display-popup", .alias = "popup", - .args = { "Bb:Cc:d:e:Eh:kNs:S:t:T:w:x:y:", 0, -1, NULL }, - .usage = "[-BCEkN] [-b border-lines] [-c target-client] " + .args = { "Bb:Cc:d:e:Eh:ks:S:t:T:w:x:y:", 0, -1, NULL }, + .usage = "[-BCEk] [-b border-lines] [-c target-client] " "[-d start-directory] [-e environment] [-h height] " "[-s style] [-S border-style] " CMD_TARGET_PANE_USAGE " [-T title] [-w width] [-x position] [-y position] " @@ -91,203 +91,6 @@ cmd_display_menu_args_parse(struct args *args, u_int idx, __unused char **cause) return (type); } -static int -cmd_display_menu_get_popup_pos(struct client *tc, struct cmdq_item *item, - struct args *args, u_int *px, u_int *py, u_int w, u_int h) -{ - struct tty *tty = &tc->tty; - struct cmd_find_state *target = cmdq_get_target(item); - struct key_event *event = cmdq_get_event(item); - struct session *s = tc->session; - struct winlink *wl = target->wl; - struct window_pane *wp = target->wp; - struct style_ranges *ranges = NULL; - struct style_range *sr = NULL; - const char *xp, *yp; - char *p; - int top; - u_int line, ox, oy, sx, sy, lines, position; - long n; - struct format_tree *ft; - - /* - * Work out the position from the -x and -y arguments. This is the - * bottom-left position. - */ - - /* If the popup is too big, stop now. */ - if (w > tty->sx || h > tty->sy) - return (0); - - /* Create format with mouse position if any. */ - ft = format_create_from_target(item); - if (event->m.valid) { - format_add(ft, "popup_mouse_x", "%u", event->m.x); - format_add(ft, "popup_mouse_y", "%u", event->m.y); - } - - /* Position of the previous menu, for -x/-y L. */ - format_add(ft, "popup_last_x", "%u", target->w->menu_last_px); - format_add(ft, "popup_last_y", "%u", target->w->menu_last_py + h); - - /* - * If there are any status lines, add this window position and the - * status line position. - */ - top = status_at_line(tc); - if (top != -1) { - lines = status_line_size(tc); - if (top == 0) - top = lines; - else - top = 0; - position = options_get_number(s->options, "status-position"); - - for (line = 0; line < lines; line++) { - ranges = &tc->status.entries[line].ranges; - TAILQ_FOREACH(sr, ranges, entry) { - if (sr->type != STYLE_RANGE_WINDOW) - continue; - if (sr->argument == (u_int)wl->idx) - break; - } - if (sr != NULL) - break; - } - - if (sr != NULL) { - format_add(ft, "popup_window_status_line_x", "%u", - sr->start); - if (position == 0) { - format_add(ft, "popup_window_status_line_y", - "%u", line + 1 + h); - } else { - format_add(ft, "popup_window_status_line_y", - "%u", tty->sy - lines + line); - } - } - - if (position == 0) - format_add(ft, "popup_status_line_y", "%u", lines + h); - else { - format_add(ft, "popup_status_line_y", "%u", - tty->sy - lines); - } - } else - top = 0; - - /* Popup width and height. */ - format_add(ft, "popup_width", "%u", w); - format_add(ft, "popup_height", "%u", h); - - /* Position so popup is in the centre. */ - n = (long)(tty->sx - 1) / 2 - w / 2; - if (n < 0) - format_add(ft, "popup_centre_x", "%u", 0); - else - format_add(ft, "popup_centre_x", "%ld", n); - n = (tty->sy - 1) / 2 + h / 2; - if (n >= tty->sy) - format_add(ft, "popup_centre_y", "%u", tty->sy - h); - else - format_add(ft, "popup_centre_y", "%ld", n); - - /* Position of popup relative to mouse. */ - if (event->m.valid) { - n = (long)event->m.x - w / 2; - if (n < 0) - format_add(ft, "popup_mouse_centre_x", "%u", 0); - else - format_add(ft, "popup_mouse_centre_x", "%ld", n); - n = event->m.y - h / 2; - if (n + h >= tty->sy) { - format_add(ft, "popup_mouse_centre_y", "%u", - tty->sy - h); - } else - format_add(ft, "popup_mouse_centre_y", "%ld", n); - n = (long)event->m.y + h; - if (n >= tty->sy) - format_add(ft, "popup_mouse_top", "%u", tty->sy - 1); - else - format_add(ft, "popup_mouse_top", "%ld", n); - n = event->m.y - h; - if (n < 0) - format_add(ft, "popup_mouse_bottom", "%u", 0); - else - format_add(ft, "popup_mouse_bottom", "%ld", n); - } - - /* Position in pane. */ - tty_window_offset(&tc->tty, &ox, &oy, &sx, &sy); - n = top + wp->yoff - oy + h; - if (n >= tty->sy) - format_add(ft, "popup_pane_top", "%u", tty->sy - h); - else - format_add(ft, "popup_pane_top", "%ld", n); - format_add(ft, "popup_pane_bottom", "%u", top + wp->yoff + wp->sy - oy); - format_add(ft, "popup_pane_left", "%u", wp->xoff - ox); - n = (long)wp->xoff + wp->sx - ox - w; - if (n < 0) - format_add(ft, "popup_pane_right", "%u", 0); - else - format_add(ft, "popup_pane_right", "%ld", n); - - /* Expand horizontal position. */ - xp = args_get(args, 'x'); - if (xp == NULL || strcmp(xp, "C") == 0) - xp = "#{popup_centre_x}"; - else if (strcmp(xp, "R") == 0) - xp = "#{popup_pane_right}"; - else if (strcmp(xp, "P") == 0) - xp = "#{popup_pane_left}"; - else if (strcmp(xp, "M") == 0) - xp = "#{popup_mouse_centre_x}"; - else if (strcmp(xp, "L") == 0) - xp = "#{popup_last_x}"; - else if (strcmp(xp, "W") == 0) - xp = "#{popup_window_status_line_x}"; - p = format_expand(ft, xp); - n = strtol(p, NULL, 10); - if (n + w >= tty->sx) - n = tty->sx - w; - else if (n < 0) - n = 0; - *px = n; - log_debug("%s: -x: %s = %s = %u (-w %u)", __func__, xp, p, *px, w); - free(p); - - /* Expand vertical position */ - yp = args_get(args, 'y'); - if (yp == NULL || strcmp(yp, "C") == 0) - yp = "#{popup_centre_y}"; - else if (strcmp(yp, "P") == 0) - yp = "#{popup_pane_bottom}"; - else if (strcmp(yp, "M") == 0) - yp = "#{popup_mouse_top}"; - else if (strcmp(yp, "L") == 0) - yp = "#{popup_last_y}"; - else if (strcmp(yp, "S") == 0) - yp = "#{popup_status_line_y}"; - else if (strcmp(yp, "W") == 0) - yp = "#{popup_window_status_line_y}"; - p = format_expand(ft, yp); - n = strtol(p, NULL, 10); - if (n < h) - n = 0; - else - n -= h; - if (n + h >= tty->sy) - n = tty->sy - h; - else if (n < 0) - n = 0; - *py = n; - log_debug("%s: -y: %s = %s = %u (-h %u)", __func__, yp, p, *py, h); - free(p); - - format_free(ft); - return (1); -} - static int cmd_display_menu_get_menu_pos(struct client *tc, struct cmdq_item *item, struct args *args, u_int *px, u_int *py, u_int w, u_int h) @@ -482,8 +285,8 @@ cmd_display_menu_exec(struct cmd *self, struct cmdq_item *item) enum box_lines lines = BOX_LINES_DEFAULT; char *title, *cause = NULL; int flags = 0, starting_choice = 0; - u_int px, py, i, count = args_count(args); - struct options *o = target->s->curw->window->options; + u_int px, py, sx, sy, i, count = args_count(args); + struct options *o = target->w->options; struct options_entry *oe; if (args_has(args, 'C')) { @@ -531,9 +334,6 @@ cmd_display_menu_exec(struct cmd *self, struct cmdq_item *item) } if (menu->count == 0) goto out; - if (!cmd_display_menu_get_menu_pos(tc, item, args, &px, &py, - menu->width + 4, menu->count + 2)) - goto out; value = args_get(args, 'b'); if (value != NULL) { @@ -544,7 +344,11 @@ cmd_display_menu_exec(struct cmd *self, struct cmdq_item *item) cmdq_error(item, "menu-border-lines %s", cause); goto fail; } - } + } else + lines = options_get_number(o, "menu-border-lines"); + menu_get_size(menu, lines, &sx, &sy); + if (!cmd_display_menu_get_menu_pos(tc, item, args, &px, &py, sx, sy)) + goto out; if (args_has(args, 'O')) flags |= MENU_STAYOPEN; @@ -565,6 +369,22 @@ fail: return (CMD_RETURN_ERROR); } +static enum pane_lines +cmd_display_popup_get_lines(const char *value, char **cause) +{ + const struct options_table_entry *oe; + + if (value == NULL) + value = "single"; + else if (strcmp(value, "rounded") == 0) + value = "single"; + else if (strcmp(value, "padded") == 0) + value = "spaces"; + + oe = options_search("pane-border-lines"); + return (options_find_choice(oe, value, cause)); +} + static enum cmd_retval cmd_display_popup_exec(struct cmd *self, struct cmdq_item *item) { @@ -572,148 +392,199 @@ cmd_display_popup_exec(struct cmd *self, struct cmdq_item *item) struct cmd_find_state *target = cmdq_get_target(item); struct session *s = target->s; struct client *tc = cmdq_get_target_client(item); - struct tty *tty = &tc->tty; - const char *value, *shell, *shellcmd = NULL; - const char *style = args_get(args, 's'); + struct winlink *wl = target->wl; + struct window *w = wl->window; + struct window_pane *wp = target->wp, *new_wp = NULL; + struct spawn_context sc = { 0 }; + struct layout_cell *lc = NULL; + struct layout_geometry lg; + struct event_payload *ep; + struct cmd_find_state fs; + const char *value, *style = args_get(args, 's'); const char *border_style = args_get(args, 'S'); - char *cwd = NULL, *cause = NULL, **argv = NULL; - char *title = NULL; - int modify = popup_present(tc); - int flags = -1, argc = 0; - enum box_lines lines = BOX_LINES_DEFAULT; - u_int px, py, w, h, count = args_count(args); + char *cause = NULL, *title = NULL; + enum pane_lines lines = PANE_LINES_SINGLE; + u_int px, py, sx, sy, count = args_count(args); struct args_value *av; - struct environ *env = NULL; - struct options *o = s->curw->window->options; - struct options_entry *oe; + long long ll; if (args_has(args, 'C')) { - server_client_clear_overlay(tc); + if (w->modal != NULL) + server_kill_pane(w->modal); return (CMD_RETURN_NORMAL); } if (tc->flags & CLIENT_CONTROL) return (CMD_RETURN_NORMAL); - if (!modify && tc->overlay_draw != NULL) + if (w->modal != NULL) return (CMD_RETURN_NORMAL); - if (!modify) { - h = tty->sy / 2; - if (args_has(args, 'h')) { - h = args_percentage(args, 'h', 1, tty->sy, tty->sy, - &cause); - if (cause != NULL) { - cmdq_error(item, "height %s", cause); - goto fail; - } - } - - w = tty->sx / 2; - if (args_has(args, 'w')) { - w = args_percentage(args, 'w', 1, tty->sx, tty->sx, - &cause); - if (cause != NULL) { - cmdq_error(item, "width %s", cause); - goto fail; - } - } - - if (w > tty->sx) - w = tty->sx; - if (h > tty->sy) - h = tty->sy; - if (!cmd_display_menu_get_popup_pos(tc, item, args, &px, &py, - w, h)) - goto out; - - value = args_get(args, 'd'); - if (value != NULL) - cwd = format_single_from_target(item, value); - else - cwd = xstrdup(server_client_get_cwd(tc, s)); - if (count == 0) { - shellcmd = options_get_string(s->options, - "default-command"); - } else if (count == 1) - shellcmd = args_string(args, 0); - if (count <= 1 && (shellcmd == NULL || *shellcmd == '\0')) { - shellcmd = NULL; - shell = options_get_string(s->options, "default-shell"); - if (!checkshell(shell)) - shell = _PATH_BSHELL; - cmd_append_argv(&argc, &argv, shell); - } else - args_to_vector(args, &argc, &argv); - - if (args_has(args, 'e') >= 1) { - env = environ_create(); - av = args_first_value(args, 'e'); - while (av != NULL) { - environ_put(env, av->string, 0); - av = args_next_value(av); - } - } - } - value = args_get(args, 'b'); if (args_has(args, 'B')) - lines = BOX_LINES_NONE; + lines = PANE_LINES_NONE; else if (value != NULL) { - oe = options_get(o, "popup-border-lines"); - lines = options_find_choice(options_table_entry(oe), value, - &cause); + lines = cmd_display_popup_get_lines(value, &cause); if (cause != NULL) { - cmdq_error(item, "popup-border-lines %s", cause); + cmdq_error(item, "pane-border-lines %s", cause); goto fail; } } + sy = w->sy / 2; + if (args_has(args, 'h')) { + ll = args_percentage(args, 'h', 1, w->sy, w->sy, &cause); + if (cause != NULL) { + cmdq_error(item, "height %s", cause); + goto fail; + } + sy = ll; + } + + sx = w->sx / 2; + if (args_has(args, 'w')) { + ll = args_percentage(args, 'w', 1, w->sx, w->sx, &cause); + if (cause != NULL) { + cmdq_error(item, "width %s", cause); + goto fail; + } + sx = ll; + } + + if (sx > w->sx) + sx = w->sx; + if (sy > w->sy) + sy = w->sy; + if ((lines == PANE_LINES_NONE && (sx < 1 || sy < 1)) || + (lines != PANE_LINES_NONE && (sx < 3 || sy < 3))) + goto out; + if (!cmd_display_menu_get_menu_pos(tc, item, args, &px, &py, sx, sy)) + goto out; + + lg.sx = sx; + lg.sy = sy; + lg.xoff = px; + lg.yoff = py; + if (lines != PANE_LINES_NONE) { + lg.sx -= 2; + lg.sy -= 2; + lg.xoff++; + lg.yoff++; + } + + window_push_zoom(w, 0, 1); + lc = layout_floating_pane(w, wp, &lg); + if (lc == NULL) { + window_pop_zoom(w); + goto out; + } + + sc.item = item; + sc.s = s; + sc.wl = wl; + sc.tc = tc; + sc.wp0 = wp; + sc.lc = lc; + sc.idx = -1; + sc.cwd = args_get(args, 'd'); + sc.flags = (SPAWN_FLOATING|SPAWN_MODAL|SPAWN_FLOATOVERZOOM); + + if (count != 1 || *args_string(args, 0) != '\0') + args_to_vector(args, &sc.argc, &sc.argv); + sc.environ = environ_create(); + av = args_first_value(args, 'e'); + while (av != NULL) { + environ_put(sc.environ, av->string, 0); + av = args_next_value(av); + } + + new_wp = spawn_pane(&sc, &cause); + if (new_wp == NULL) { + cmdq_error(item, "create pane failed: %s", cause); + free(cause); + cause = NULL; + window_pop_zoom(w); + goto fail; + } + window_pop_zoom(w); + new_wp->flags |= PANE_CAPTUREALLKEYS; + if (!args_has(args, 'E')) + new_wp->flags |= PANE_CLOSEONCANCEL; + + options_set_number(new_wp->options, "pane-border-lines", lines); + if (args_has(args, 'E') > 1) { + if (args_has(args, 'k')) + options_set_number(new_wp->options, "remain-on-exit", 4); + else + options_set_number(new_wp->options, "remain-on-exit", 2); + } else if (args_has(args, 'E')) + options_set_number(new_wp->options, "remain-on-exit", 0); + else if (args_has(args, 'k')) + options_set_number(new_wp->options, "remain-on-exit", 3); + else + options_set_number(new_wp->options, "remain-on-exit", 1); + options_set_string(new_wp->options, "remain-on-exit-format", 0, "%s", ""); + + if (style != NULL) { + if (options_set_string(new_wp->options, "window-style", 0, + "%s", style) == NULL) { + cmdq_error(item, "bad style: %s", style); + goto fail; + } + options_set_string(new_wp->options, "window-active-style", 0, + "%s", style); + new_wp->flags |= (PANE_REDRAW|PANE_STYLECHANGED| + PANE_THEMECHANGED); + } + if (border_style != NULL) { + if (options_set_string(new_wp->options, "pane-border-style", 0, + "%s", border_style) == NULL) { + cmdq_error(item, "bad border style: %s", border_style); + goto fail; + } + options_set_string(new_wp->options, "pane-active-border-style", + 0, "%s", border_style); + } if (args_has(args, 'T')) title = format_single_from_target(item, args_get(args, 'T')); - else - title = xstrdup(""); - - if (args_has(args, 'N') || !modify) - flags = 0; - if (args_has(args, 'E') > 1) { - if (flags == -1) - flags = 0; - flags |= POPUP_CLOSEEXITZERO; - } else if (args_has(args, 'E')) { - if (flags == -1) - flags = 0; - flags |= POPUP_CLOSEEXIT; - } - if (args_has(args, 'k')) { - if (flags == -1) - flags = 0; - flags |= POPUP_CLOSEANYKEY; + if (title != NULL) { + options_set_number(new_wp->options, "pane-border-status", + PANE_STATUS_TOP); + options_set_string(new_wp->options, "pane-border-format", 0, + "%s", "#{pane_title}"); + screen_set_title(&new_wp->base, title, 0); + ep = event_payload_create(); + cmd_find_from_pane(&fs, new_wp, 0); + event_payload_set_target(ep, &fs); + event_payload_set_pane(ep, "pane", new_wp); + event_payload_set_window(ep, "window", new_wp->window); + event_payload_set_string(ep, "new_title", "%s", title); + events_fire("pane-title-changed", ep); } - if (modify) { - popup_modify(tc, title, style, border_style, lines, flags); - goto out; - } - if (popup_display(flags, lines, item, px, py, w, h, env, shellcmd, argc, - argv, cwd, title, tc, s, style, border_style, NULL, NULL) != 0) - goto out; - environ_free(env); - free(cwd); + new_wp->wait_item = item; + server_redraw_session(s); + if (sc.argv != NULL) + cmd_free_argv(sc.argc, sc.argv); + environ_free(sc.environ); free(title); - cmd_free_argv(argc, argv); return (CMD_RETURN_WAIT); out: - cmd_free_argv(argc, argv); - environ_free(env); - free(cwd); + if (sc.argv != NULL) + cmd_free_argv(sc.argc, sc.argv); + environ_free(sc.environ); free(title); return (CMD_RETURN_NORMAL); fail: free(cause); - cmd_free_argv(argc, argv); - environ_free(env); - free(cwd); + if (new_wp != NULL) { + server_client_remove_pane(new_wp); + layout_close_pane(new_wp); + window_remove_pane(new_wp->window, new_wp); + } + if (sc.argv != NULL) + cmd_free_argv(sc.argc, sc.argv); + environ_free(sc.environ); free(title); return (CMD_RETURN_ERROR); } diff --git a/cmd-display-message.c b/cmd-display-message.c index f786006c3..ca56574f7 100644 --- a/cmd-display-message.c +++ b/cmd-display-message.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-display-message.c,v 1.65 2026/02/23 08:46:57 nicm Exp $ */ +/* $OpenBSD: cmd-display-message.c,v 1.67 2026/10/02 15:13:07 nicm Exp $ */ /* * Copyright (c) 2009 Tiago Cunha @@ -39,8 +39,8 @@ const struct cmd_entry cmd_display_message_entry = { .name = "display-message", .alias = "display", - .args = { "aCc:d:lINpt:F:v", 0, 1, NULL }, - .usage = "[-aCIlNpv] [-c target-client] [-d delay] [-F format] " + .args = { "aCc:d:jlINpt:F:v", 0, 1, NULL }, + .usage = "[-aCIjlNpv] [-c target-client] [-d delay] [-F format] " CMD_TARGET_PANE_USAGE " [message]", .target = { 't', CMD_FIND_PANE, CMD_FIND_CANFAIL }, @@ -67,14 +67,15 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) struct winlink *wl = target->wl; struct window_pane *wp = target->wp; const char *template; - char *msg, *cause; + char *msg, *cause = NULL; int delay = -1, flags, Nflag = args_has(args, 'N'); int Cflag = args_has(args, 'C'); struct format_tree *ft; u_int count = args_count(args); struct evbuffer *evb; + struct json_node *jn; - if (args_has(args, 'I')) { + if (args_has(args, 'I') && !args_has(args, 'j')) { if (wp == NULL) return (CMD_RETURN_NORMAL); switch (window_pane_start_input(wp, item, &cause)) { @@ -107,16 +108,16 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) template = args_string(args, 0); else template = args_get(args, 'F'); - if (template == NULL) + if (args_has(args, 'j') && template == NULL) + template = ""; + else if (template == NULL) template = DISPLAY_MESSAGE_TEMPLATE; /* - * -c is intended to be the client where the message should be - * displayed if -p is not given. But it makes sense to use it for the - * formats too, assuming it matches the session. If it doesn't, use the - * best client for the session. + * -c is also used for the client formats. If it was not given, tc is + * only the current client, so use it only if it matches the session. */ - if (tc != NULL && tc->session == s) + if (tc != NULL && (args_has(args, 'c') || tc->session == s)) c = tc; else if (s != NULL) c = cmd_find_best_client(s); @@ -129,7 +130,7 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, flags); format_defaults(ft, c, s, wl, wp); - if (args_has(args, 'a')) { + if (args_has(args, 'a') && !args_has(args, 'j')) { format_each(ft, cmd_display_message_each, item); format_free(ft); return (CMD_RETURN_NORMAL); @@ -139,6 +140,19 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) msg = xstrdup(template); else msg = format_expand_time(ft, template); + if (args_has(args, 'j')) { + jn = json_parse(msg, &cause); + if (jn == NULL) { + cmdq_error(item, "%s", cause); + free(cause); + free(msg); + format_free(ft); + return (CMD_RETURN_ERROR); + } + free(msg); + msg = json_to_string(jn); + json_destroy_node(jn); + } if (cmdq_get_client(item) == NULL) cmdq_error(item, "%s", msg); diff --git a/cmd-find.c b/cmd-find.c index f9621b53e..183e5525d 100644 --- a/cmd-find.c +++ b/cmd-find.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-find.c,v 1.87 2026/07/17 12:42:51 nicm Exp $ */ +/* $OpenBSD: cmd-find.c,v 1.88 2026/09/07 12:05:12 nicm Exp $ */ /* * Copyright (c) 2015 Nicholas Marriott @@ -388,9 +388,11 @@ cmd_find_get_window_with_session(struct cmd_find_state *fs, const char *window) /* Try as an offset. */ if (!exact && (window[0] == '+' || window[0] == '-')) { - if (window[1] != '\0') - n = strtonum(window + 1, 1, INT_MAX, NULL); - else + if (window[1] != '\0') { + n = strtonum(window + 1, 1, INT_MAX, &errstr); + if (errstr != NULL) + return (-1); + } else n = 1; s = fs->s; if (fs->flags & CMD_FIND_WINDOW_INDEX) { @@ -626,9 +628,11 @@ cmd_find_get_pane_with_window(struct cmd_find_state *fs, const char *pane) /* Try as an offset. */ if (pane[0] == '+' || pane[0] == '-') { - if (pane[1] != '\0') - n = strtonum(pane + 1, 1, INT_MAX, NULL); - else + if (pane[1] != '\0') { + n = strtonum(pane + 1, 1, INT_MAX, &errstr); + if (errstr != NULL) + return (-1); + } else n = 1; wp = fs->w->active; if (pane[0] == '+') diff --git a/cmd-join-pane.c b/cmd-join-pane.c index 34bc95c8e..89db90d75 100644 --- a/cmd-join-pane.c +++ b/cmd-join-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-join-pane.c,v 1.74 2026/08/03 20:29:52 nicm Exp $ */ +/* $OpenBSD: cmd-join-pane.c,v 1.76 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2011 George Nachman @@ -133,7 +133,7 @@ cmd_join_pane_place(struct cmdq_item *item, struct winlink *wl, } else if (strcmp(position, "back") == 0) { TAILQ_REMOVE(&w->z_index, wp, zentry); TAILQ_FOREACH(owp, &w->z_index, zentry) { - if (!window_pane_is_floating(owp)) + if (!window_pane_is_floating_with_hidden(owp)) break; } if (owp != NULL) @@ -142,24 +142,30 @@ cmd_join_pane_place(struct cmdq_item *item, struct winlink *wl, TAILQ_INSERT_TAIL(&w->z_index, wp, zentry); } else if (strcmp(position, "forward") == 0) { owp = TAILQ_PREV(wp, window_panes_zindex, zentry); + while (owp != NULL && owp->layout_cell == NULL) + owp = TAILQ_PREV(owp, window_panes_zindex, zentry); if (owp != NULL) { TAILQ_REMOVE(&w->z_index, wp, zentry); TAILQ_INSERT_BEFORE(owp, wp, zentry); } } else if (strcmp(position, "backward") == 0) { owp = TAILQ_NEXT(wp, zentry); + while (owp != NULL && owp->layout_cell == NULL) + owp = TAILQ_NEXT(owp, zentry); if (owp != NULL && window_pane_is_floating(owp)) { TAILQ_REMOVE(&w->z_index, wp, zentry); TAILQ_INSERT_AFTER(&w->z_index, owp, wp, zentry); } } else if (strcmp(position, "forward-loop") == 0) { owp = TAILQ_PREV(wp, window_panes_zindex, zentry); + while (owp != NULL && owp->layout_cell == NULL) + owp = TAILQ_PREV(owp, window_panes_zindex, zentry); TAILQ_REMOVE(&w->z_index, wp, zentry); if (owp != NULL) TAILQ_INSERT_BEFORE(owp, wp, zentry); else { TAILQ_FOREACH(owp, &w->z_index, zentry) { - if (!window_pane_is_floating(owp)) + if (!window_pane_is_floating_with_hidden(owp)) break; } if (owp != NULL) @@ -169,6 +175,8 @@ cmd_join_pane_place(struct cmdq_item *item, struct winlink *wl, } } else if (strcmp(position, "backward-loop") == 0) { owp = TAILQ_NEXT(wp, zentry); + while (owp != NULL && owp->layout_cell == NULL) + owp = TAILQ_NEXT(owp, zentry); if (owp != NULL && window_pane_is_floating(owp)) { TAILQ_REMOVE(&w->z_index, wp, zentry); TAILQ_INSERT_AFTER(&w->z_index, owp, wp, zentry); @@ -299,7 +307,7 @@ cmd_join_pane_mouse_move(struct client *c, struct mouse_event *m) struct window *w; struct window_pane *wp; struct layout_cell *lc; - int y, ly, x, lx; + int y, ly, x, lx, oxoff, oyoff, osx, osy; wp = cmd_mouse_pane(m, NULL, &wl); if (wp == NULL) { @@ -321,10 +329,16 @@ cmd_join_pane_mouse_move(struct client *c, struct mouse_event *m) ly = m->statusat - 1; if (x != lx || y != ly) { + oxoff = wp->xoff; + oyoff = wp->yoff; + osx = wp->sx; + osy = wp->sy; + lc->g.xoff += x - lx; lc->g.yoff += y - ly; layout_fix_panes(w, NULL); - server_redraw_window(w); + + window_redraw_floating_pane(wp, oxoff, oyoff, osx, osy); server_redraw_window_borders(w); } } @@ -347,8 +361,10 @@ cmd_join_pane_zindex(struct cmdq_item *item, struct winlink *wl, n = 0; TAILQ_FOREACH(owp, &w->z_index, zentry) { - if (!window_pane_is_floating(owp)) + if (!window_pane_is_floating_with_hidden(owp)) break; + if (owp->layout_cell == NULL) + continue; if (n >= z) break; n++; @@ -443,7 +459,6 @@ cmd_join_pane_exec(struct cmd *self, struct cmdq_item *item) cmdq_error(item, "pane is not floating"); return (CMD_RETURN_ERROR); } - server_unzoom_window(dst_w); if ((s = args_get(args, 'P')) != NULL) return (cmd_join_pane_place(item, dst_wl, dst_wp, s)); if ((s = args_get(args, 'z')) != NULL) diff --git a/cmd-list-panes.c b/cmd-list-panes.c index 54c8db7d3..5bc996c2f 100644 --- a/cmd-list-panes.c +++ b/cmd-list-panes.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-list-panes.c,v 1.40 2026/06/01 14:01:09 nicm Exp $ */ +/* $OpenBSD: cmd-list-panes.c,v 1.41 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -97,6 +97,7 @@ cmd_list_panes_window(struct cmd *self, struct session *s, struct winlink *wl, struct cmdq_item *item, int type) { struct args *args = cmd_get_args(self); + struct client *c = cmdq_get_client(item); struct window_pane *wp, **l; u_int i, n; struct format_tree *ft; @@ -147,7 +148,7 @@ cmd_list_panes_window(struct cmd *self, struct session *s, struct winlink *wl, wp = l[i]; ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, 0); format_add(ft, "line", "%u", n); - format_defaults(ft, NULL, s, wl, wp); + format_defaults(ft, c, s, wl, wp); if (filter != NULL) { expanded = format_expand(ft, filter); diff --git a/cmd-list-sessions.c b/cmd-list-sessions.c index 07a622979..30f2178ad 100644 --- a/cmd-list-sessions.c +++ b/cmd-list-sessions.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-list-sessions.c,v 1.36 2026/02/27 08:25:12 nicm Exp $ */ +/* $OpenBSD: cmd-list-sessions.c,v 1.37 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -53,6 +53,7 @@ static enum cmd_retval cmd_list_sessions_exec(struct cmd *self, struct cmdq_item *item) { struct args *args = cmd_get_args(self); + struct client *c = cmdq_get_client(item); struct session **l; u_int n, i; struct format_tree *ft; @@ -76,7 +77,7 @@ cmd_list_sessions_exec(struct cmd *self, struct cmdq_item *item) for (i = 0; i < n; i++) { ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, 0); format_add(ft, "line", "%u", i); - format_defaults(ft, NULL, l[i], NULL, NULL); + format_defaults(ft, c, l[i], NULL, NULL); if (filter != NULL) { expanded = format_expand(ft, filter); diff --git a/cmd-list-windows.c b/cmd-list-windows.c index 40f8c2db5..2cbccc1b8 100644 --- a/cmd-list-windows.c +++ b/cmd-list-windows.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-list-windows.c,v 1.50 2026/02/27 08:25:12 nicm Exp $ */ +/* $OpenBSD: cmd-list-windows.c,v 1.51 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -60,6 +60,7 @@ cmd_list_windows_exec(struct cmd *self, struct cmdq_item *item) { struct args *args = cmd_get_args(self); struct cmd_find_state *target = cmdq_get_target(item); + struct client *c = cmdq_get_client(item); struct winlink *wl, **l; struct session *s; u_int i, n; @@ -94,7 +95,7 @@ cmd_list_windows_exec(struct cmd *self, struct cmdq_item *item) s = wl->session; ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, 0); format_add(ft, "line", "%u", n); - format_defaults(ft, NULL, s, wl, NULL); + format_defaults(ft, c, s, wl, NULL); if (filter != NULL) { expanded = format_expand(ft, filter); diff --git a/cmd-new-window.c b/cmd-new-window.c index d1e2ab4e5..6ec55fe64 100644 --- a/cmd-new-window.c +++ b/cmd-new-window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-new-window.c,v 1.103 2026/07/08 08:07:42 nicm Exp $ */ +/* $OpenBSD: cmd-new-window.c,v 1.104 2026/09/03 21:04:11 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -60,7 +60,8 @@ cmd_new_window_exec(struct cmd *self, struct cmdq_item *item) struct client *tc = cmdq_get_target_client(item); struct session *s = target->s; struct winlink *wl = target->wl, *new_wl = NULL; - int idx = target->idx, before, count = args_count(args); + int idx = target->idx, before; + int count = args_count(args); char *cause = NULL, *cp, *expanded, *wname = NULL; const char *template, *name; struct cmd_find_state fs; @@ -74,8 +75,10 @@ cmd_new_window_exec(struct cmd *self, struct cmdq_item *item) } /* - * If -S and -n are given and -t is not and a single window with this - * name already exists, select it. + * If -S is given, select an existing window instead of creating a + * new one: preferring -t if it already points at a window, or + * otherwise -n if one exists with that name. If neither matches, + * fall through and create a window as normal. */ name = args_get(args, 'n'); if (name != NULL) { @@ -88,32 +91,39 @@ cmd_new_window_exec(struct cmd *self, struct cmdq_item *item) wname = clean_name(expanded, 0); free(expanded); } - if (args_has(args, 'S') && wname != NULL && target->idx == -1) { - expanded = format_single(item, wname, c, s, NULL, NULL); - RB_FOREACH(wl, winlinks, &s->windows) { - if (strcmp(wl->window->name, expanded) != 0) - continue; - if (new_wl == NULL) { - new_wl = wl; - continue; + if (args_has(args, 'S')) { + if (idx != -1) + new_wl = winlink_find_by_index(&s->windows, idx); + else if (wname != NULL) { + expanded = format_single(item, wname, c, s, NULL, NULL); + RB_FOREACH(wl, winlinks, &s->windows) { + if (strcmp(wl->window->name, expanded) != 0) + continue; + if (new_wl == NULL) { + new_wl = wl; + continue; + } + cmdq_error(item, "multiple windows named %s", + wname); + free(wname); + free(expanded); + return (CMD_RETURN_ERROR); } - cmdq_error(item, "multiple windows named %s", wname); - free(wname); free(expanded); - return (CMD_RETURN_ERROR); } - free(expanded); - if (new_wl != NULL) { - free(wname); - if (args_has(args, 'd')) - return (CMD_RETURN_NORMAL); - if (session_set_current(s, new_wl) == 0) - server_redraw_session(s); - if (c != NULL && c->session != NULL) - s->curw->window->latest = c; - recalculate_sizes(); + } + + /* Found an existing window to select instead of creating a new one. */ + if (new_wl != NULL) { + free(wname); + if (args_has(args, 'd')) return (CMD_RETURN_NORMAL); - } + if (session_set_current(s, new_wl) == 0) + server_redraw_session(s); + if (c != NULL && c->session != NULL) + s->curw->window->latest = c; + recalculate_sizes(); + return (CMD_RETURN_NORMAL); } before = args_has(args, 'b'); diff --git a/cmd-parse.y b/cmd-parse.y index 31c743124..4c3a83014 100644 --- a/cmd-parse.y +++ b/cmd-parse.y @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-parse.y,v 1.59 2026/08/31 07:51:56 nicm Exp $ */ +/* $OpenBSD: cmd-parse.y,v 1.61 2026/10/05 13:45:49 nicm Exp $ */ /* * Copyright (c) 2019 Nicholas Marriott diff --git a/cmd-queue.c b/cmd-queue.c index 0b4c2315a..1ea02c998 100644 --- a/cmd-queue.c +++ b/cmd-queue.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-queue.c,v 1.123 2026/08/24 20:34:26 nicm Exp $ */ +/* $OpenBSD: cmd-queue.c,v 1.124 2026/09/22 06:46:50 nicm Exp $ */ /* * Copyright (c) 2013 Nicholas Marriott @@ -553,6 +553,9 @@ cmdq_fire_command(struct cmdq_item *item) int flags, quiet = 0; char *tmp; + if (item->client != NULL && (item->client->flags & CLIENT_DEAD)) + return (CMD_RETURN_ERROR); + if (cfg_finished) cmdq_add_message(item); if (log_get_level() > 1) { diff --git a/cmd-resize-pane.c b/cmd-resize-pane.c index fc5062ab4..90ebff4c1 100644 --- a/cmd-resize-pane.c +++ b/cmd-resize-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-resize-pane.c,v 1.68 2026/08/31 07:44:39 nicm Exp $ */ +/* $OpenBSD: cmd-resize-pane.c,v 1.70 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -91,7 +91,8 @@ cmd_resize_pane_exec(struct cmd *self, struct cmdq_item *item) server_redraw_window(w); return (CMD_RETURN_NORMAL); } - server_unzoom_window(w); + if (!window_pane_is_floating(wp)) + server_unzoom_window(w); lc = wp->layout_cell; /* may have been replaced by unzoom */ if (args_has(args, 'x')) { @@ -236,8 +237,8 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c, struct window_pane *wp; struct layout_cell *lc; int y, ly, x, lx, sx, sy, new_sx, new_sy; - int left, right; - int new_xoff, new_yoff, resizes = 0; + int left, right, resizes = 0; + int new_xoff, new_yoff, old_xoff, old_yoff; wp = cmd_mouse_pane(m, NULL, &wl); if (wp == NULL) { @@ -248,6 +249,8 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c, lc = wp->layout_cell; sx = wp->sx; sy = wp->sy; + old_xoff = wp->xoff; + old_yoff = wp->yoff; left = wp->xoff - 1; right = wp->xoff + sx; if (window_pane_scrollbar_reserve(wp) && @@ -347,7 +350,7 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c, } if (resizes != 0) { layout_fix_panes(w, NULL); - server_redraw_window(w); + window_redraw_floating_pane(wp, old_xoff, old_yoff, sx, sy); server_redraw_window_borders(w); } } diff --git a/cmd-run-shell.c b/cmd-run-shell.c index 788bd6759..d05868083 100644 --- a/cmd-run-shell.c +++ b/cmd-run-shell.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-run-shell.c,v 1.94 2026/08/25 06:04:33 nicm Exp $ */ +/* $OpenBSD: cmd-run-shell.c,v 1.95 2026/09/20 07:59:55 nicm Exp $ */ /* * Copyright (c) 2009 Tiago Cunha @@ -172,7 +172,7 @@ cmd_run_shell_exec(struct cmd *self, struct cmdq_item *item) cdata->client->references++; if (args_has(args, 'c')) - cdata->cwd = xstrdup(args_get(args, 'c')); + cdata->cwd = format_single_from_target(item, args_get(args, 'c')); else cdata->cwd = xstrdup(server_client_get_cwd(c, s)); diff --git a/cmd-select-layout.c b/cmd-select-layout.c index 741dc63f4..95f60e6ca 100644 --- a/cmd-select-layout.c +++ b/cmd-select-layout.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-select-layout.c,v 1.43 2026/07/10 13:38:45 nicm Exp $ */ +/* $OpenBSD: cmd-select-layout.c,v 1.45 2026/09/09 07:03:39 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -73,12 +73,13 @@ cmd_select_layout_exec(struct cmd *self, struct cmdq_item *item) { struct args *args = cmd_get_args(self); struct cmd_find_state *target = cmdq_get_target(item); + struct client *c = cmdq_get_target_client(item); struct winlink *wl = target->wl; struct window *w = wl->window; struct window_pane *wp = target->wp; const char *layoutname; - char *oldlayout, *cause; - int next, previous, layout; + char *oldlayout, *cause = NULL; + int next, previous, layout, flags = 0; server_unzoom_window(w); @@ -89,8 +90,12 @@ cmd_select_layout_exec(struct cmd *self, struct cmdq_item *item) if (args_has(args, 'p')) previous = 1; + if (c != NULL && + (c->flags & CLIENT_CONTROL) && + (~c->flags & CLIENT_CONTROL_NEWLAYOUTS)) + flags |= LAYOUT_CUSTOM_OLD_FORMAT; oldlayout = w->old_layout; - w->old_layout = layout_dump(w, w->layout_root); + w->old_layout = layout_dump(w, w->layout_root, flags); if (next || previous) { if (next) diff --git a/cmd-split-window.c b/cmd-split-window.c index c907dd152..4949b1298 100644 --- a/cmd-split-window.c +++ b/cmd-split-window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-split-window.c,v 1.150 2026/08/20 09:19:24 nicm Exp $ */ +/* $OpenBSD: cmd-split-window.c,v 1.152 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -40,8 +40,8 @@ const struct cmd_entry cmd_new_pane_entry = { .name = "new-pane", .alias = "newp", - .args = { "AbB:Cc:de:EfF:hIkl:KLMm:Op:PR:s:S:t:T:vWx:X:y:Y:Z", 0, -1, NULL }, - .usage = "[-AbCdefhIkKLMOPvWZ] [-B border-lines] " + .args = { "AbB:Cc:Dde:EfF:hIkl:KLMm:Op:PR:s:S:t:T:vWx:X:y:Y:Z", 0, -1, NULL }, + .usage = "[-AbCDefhIkKLMOPvWZ] [-B border-lines] " "[-c start-directory] [-e environment] " "[-F format] [-l size] [-m message] [-p percentage] " "[-s style] [-S active-border-style] " @@ -219,6 +219,8 @@ cmd_split_window_exec(struct cmd *self, struct cmdq_item *item) new_wp->flags |= PANE_CAPTUREALLKEYS; if (args_has(args, 'C') && args_has(args, 'O')) new_wp->flags |= PANE_CLOSEONCLICK; + if (args_has(args, 'D') && args_has(args, 'O')) + new_wp->flags |= PANE_CLOSEONCANCEL; style = args_get(args, 's'); if (style != NULL) { @@ -360,6 +362,7 @@ cmd_split_window_mouse_resize(struct client *c, struct mouse_event *m) enum pane_lines lines; u_int sx, sy; int x, y, xoff, yoff, border; + int oxoff, oyoff, osx, osy; if (c->tty.mouse_last_pane == -1) return; @@ -415,8 +418,14 @@ cmd_split_window_mouse_resize(struct client *c, struct mouse_event *m) if (sy < PANE_MINIMUM) sy = PANE_MINIMUM; + oxoff = wp->xoff; + oyoff = wp->yoff; + osx = wp->sx; + osy = wp->sy; + layout_set_size(lc, sx, sy, xoff, yoff); layout_fix_panes(w, NULL); - server_redraw_window(w); + + window_redraw_floating_pane(wp, oxoff, oyoff, osx, osy); server_redraw_window_borders(w); } diff --git a/cmd-swap-pane.c b/cmd-swap-pane.c index 04e1cc7a9..1d0664cd6 100644 --- a/cmd-swap-pane.c +++ b/cmd-swap-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-swap-pane.c,v 1.55 2026/07/15 13:02:33 nicm Exp $ */ +/* $OpenBSD: cmd-swap-pane.c,v 1.56 2026/10/02 12:23:44 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -88,7 +88,7 @@ cmd_swap_pane_exec(struct cmd *self, struct cmdq_item *item) if (args_has(args, 'D')) { if (window_pane_is_floating(dst_wp)) { cmdq_error(item, "cannot swap down on floating pane"); - return (CMD_RETURN_ERROR); + goto error; } src_w = dst_w; src_wp = TAILQ_NEXT(dst_wp, entry); @@ -100,7 +100,7 @@ cmd_swap_pane_exec(struct cmd *self, struct cmdq_item *item) } else if (args_has(args, 'U')) { if (window_pane_is_floating(dst_wp)) { cmdq_error(item, "cannot swap up on floating pane"); - return (CMD_RETURN_ERROR); + goto error; } src_w = dst_w; src_wp = TAILQ_PREV(dst_wp, window_panes, entry); @@ -202,4 +202,9 @@ out: if (src_w != dst_w && window_pop_zoom(dst_w)) server_redraw_window(dst_w); return (CMD_RETURN_NORMAL); + +error: + if (window_pop_zoom(dst_w)) + server_redraw_window(dst_w); + return (CMD_RETURN_ERROR); } diff --git a/cmd-wait-for.c b/cmd-wait-for.c index 0e4130fcd..7326c7519 100644 --- a/cmd-wait-for.c +++ b/cmd-wait-for.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-wait-for.c,v 1.23 2026/07/10 13:38:45 nicm Exp $ */ +/* $OpenBSD: cmd-wait-for.c,v 1.24 2026/09/22 06:46:50 nicm Exp $ */ /* * Copyright (c) 2013 Nicholas Marriott @@ -480,6 +480,40 @@ cmd_wait_for_unlock(struct cmdq_item *item, const char *name, return (CMD_RETURN_NORMAL); } +void +cmd_wait_for_client_lost(struct client *c) +{ + struct wait_channel *wc, *wc1; + struct wait_item *wi, *wi1; + struct wait_event_item *wei, *wei1; + + TAILQ_FOREACH_SAFE(wei, &wait_event_items, entry, wei1) { + if (cmdq_get_client(wei->item) == c) { + TAILQ_REMOVE(&wait_event_items, wei, entry); + cmdq_continue(wei->item); + cmd_wait_for_event_free(wei); + } + } + + RB_FOREACH_SAFE(wc, wait_channels, &wait_channels, wc1) { + TAILQ_FOREACH_SAFE(wi, &wc->waiters, entry, wi1) { + if (cmdq_get_client(wi->item) == c) { + cmdq_continue(wi->item); + TAILQ_REMOVE(&wc->waiters, wi, entry); + free(wi); + } + } + TAILQ_FOREACH_SAFE(wi, &wc->lockers, entry, wi1) { + if (cmdq_get_client(wi->item) == c) { + cmdq_continue(wi->item); + TAILQ_REMOVE(&wc->lockers, wi, entry); + free(wi); + } + } + cmd_wait_for_remove_empty(wc); + } +} + void cmd_wait_for_flush(void) { diff --git a/compat/systemd.c b/compat/systemd.c index b1f470639..4d62f57c1 100644 --- a/compat/systemd.c +++ b/compat/systemd.c @@ -103,6 +103,172 @@ job_removed_handler(sd_bus_message *m, void *userdata, return (0); } +static int +systemd_new_scope_message(sd_bus *bus, int oom_policy, sd_bus_message **mp, + char **cause) +{ + char *name, *desc, *slice, *unit; + sd_id128_t uuid; + int r; + pid_t pid, parent_pid; + + /* Start building the method call. */ + r = sd_bus_message_new_method_call(bus, mp, + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "StartTransientUnit"); + if (r < 0) { + xasprintf(cause, "failed to create bus message: %s", + strerror(-r)); + return (r); + } + + /* Generate a unique name for the new scope, to avoid collisions. */ + r = sd_id128_randomize(&uuid); + if (r < 0) { + xasprintf(cause, "failed to generate uuid: %s", strerror(-r)); + return (r); + } + xasprintf(&name, "tmux-spawn-" SD_ID128_UUID_FORMAT_STR ".scope", + SD_ID128_FORMAT_VAL(uuid)); + r = sd_bus_message_append(*mp, "s", name); + free(name); + if (r < 0) { + xasprintf(cause, "failed to append to bus message: %s", + strerror(-r)); + return (r); + } + + /* Mode: fail if there's a queued unit with the same name. */ + r = sd_bus_message_append(*mp, "s", "fail"); + if (r < 0) { + xasprintf(cause, "failed to append to bus message: %s", + strerror(-r)); + return (r); + } + + /* Start properties array. */ + r = sd_bus_message_open_container(*mp, 'a', "(sv)"); + if (r < 0) { + xasprintf(cause, "failed to start properties array: %s", + strerror(-r)); + return (r); + } + + pid = getpid(); + parent_pid = getppid(); + xasprintf(&desc, "tmux child pane %ld launched by process %ld", + (long)pid, (long)parent_pid); + r = sd_bus_message_append(*mp, "(sv)", "Description", "s", desc); + free(desc); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + + /* + * Make sure that the session shells are terminated with SIGHUP since + * bash and friends tend to ignore SIGTERM. + */ + r = sd_bus_message_append(*mp, "(sv)", "SendSIGHUP", "b", 1); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + + /* + * Inherit the slice from the parent process, or default to + * "app-tmux.slice" if that fails. + */ + r = sd_pid_get_user_slice(parent_pid, &slice); + if (r < 0) { + slice = xstrdup("app-tmux.slice"); + } + r = sd_bus_message_append(*mp, "(sv)", "Slice", "s", slice); + free(slice); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + + /* PIDs to add to the scope: length - 1 array of uint32_t. */ + r = sd_bus_message_append(*mp, "(sv)", "PIDs", "au", 1, pid); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + + /* + * A pane is an interactive session like a login, so one process in + * it being killed by the OOM killer must not take the shell and every + * other process in the pane with it. systemd defaults scopes to + * OOMPolicy=stop, which does exactly that; logind uses continue for + * session scopes. + */ + if (oom_policy) { + r = sd_bus_message_append(*mp, "(sv)", "OOMPolicy", "s", + "continue"); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + } + + /* Clean up the scope even if it fails. */ + r = sd_bus_message_append(*mp, "(sv)", "CollectMode", "s", + "inactive-or-failed"); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + + /* + * Try locating systemd unit that started the server, and mark pane units + * as dependent on it. Use "Before" to make sure systemd will not try to + * kill them first. + */ + if (sd_pid_get_user_unit(parent_pid, &unit) == 0 || + sd_pid_get_unit(parent_pid, &unit) == 0) { + r = sd_bus_message_append(*mp, "(sv)", "Before", "as", 1, + unit); + if (r >= 0) { + r = sd_bus_message_append(*mp, "(sv)", "PartOf", "as", + 1, unit); + } + free(unit); + if (r < 0) { + xasprintf(cause, "failed to append to properties: %s", + strerror(-r)); + return (r); + } + } + + /* End properties array. */ + r = sd_bus_message_close_container(*mp); + if (r < 0) { + xasprintf(cause, "failed to end properties array: %s", + strerror(-r)); + return (r); + } + + /* aux is currently unused and should be passed an empty array. */ + r = sd_bus_message_append(*mp, "a(sa(sv))", 0); + if (r < 0) { + xasprintf(cause, "failed to append to bus message: %s", + strerror(-r)); + return (r); + } + + return (0); +} + int systemd_move_to_new_cgroup(char **cause) { @@ -110,11 +276,8 @@ systemd_move_to_new_cgroup(char **cause) sd_bus_message *m = NULL, *reply = NULL; sd_bus *bus = NULL; sd_bus_slot *slot = NULL; - char *name, *desc, *slice, *unit; - sd_id128_t uuid; int r; uint64_t elapsed_usec; - pid_t pid, parent_pid; struct timeval start, now; struct systemd_job_watch watch = {}; @@ -128,7 +291,7 @@ systemd_move_to_new_cgroup(char **cause) goto finish; } - /* Start watching for JobRemoved events */ + /* Start watching for JobRemoved events. */ r = sd_bus_match_signal(bus, &slot, "org.freedesktop.systemd1", "/org/freedesktop/systemd1", @@ -142,144 +305,23 @@ systemd_move_to_new_cgroup(char **cause) goto finish; } - /* Start building the method call. */ - r = sd_bus_message_new_method_call(bus, &m, - "org.freedesktop.systemd1", - "/org/freedesktop/systemd1", - "org.freedesktop.systemd1.Manager", - "StartTransientUnit"); - if (r < 0) { - xasprintf(cause, "failed to create bus message: %s", - strerror(-r)); - goto finish; - } - - /* Generate a unique name for the new scope, to avoid collisions. */ - r = sd_id128_randomize(&uuid); - if (r < 0) { - xasprintf(cause, "failed to generate uuid: %s", strerror(-r)); - goto finish; - } - xasprintf(&name, "tmux-spawn-" SD_ID128_UUID_FORMAT_STR ".scope", - SD_ID128_FORMAT_VAL(uuid)); - r = sd_bus_message_append(m, "s", name); - free(name); - if (r < 0) { - xasprintf(cause, "failed to append to bus message: %s", - strerror(-r)); - goto finish; - } - - /* Mode: fail if there's a queued unit with the same name. */ - r = sd_bus_message_append(m, "s", "fail"); - if (r < 0) { - xasprintf(cause, "failed to append to bus message: %s", - strerror(-r)); - goto finish; - } - - /* Start properties array. */ - r = sd_bus_message_open_container(m, 'a', "(sv)"); - if (r < 0) { - xasprintf(cause, "failed to start properties array: %s", - strerror(-r)); - goto finish; - } - - pid = getpid(); - parent_pid = getppid(); - xasprintf(&desc, "tmux child pane %ld launched by process %ld", - (long)pid, (long)parent_pid); - r = sd_bus_message_append(m, "(sv)", "Description", "s", desc); - free(desc); - if (r < 0) { - xasprintf(cause, "failed to append to properties: %s", - strerror(-r)); - goto finish; - } - /* - * Make sure that the session shells are terminated with SIGHUP since - * bash and friends tend to ignore SIGTERM. + * Build and send the request. systemd before 253 rejects OOMPolicy on a + * scope as an unknown property, so try once more without it. */ - r = sd_bus_message_append(m, "(sv)", "SendSIGHUP", "b", 1); - if (r < 0) { - xasprintf(cause, "failed to append to properties: %s", - strerror(-r)); + r = systemd_new_scope_message(bus, 1, &m, cause); + if (r < 0) goto finish; - } - - /* - * Inherit the slice from the parent process, or default to - * "app-tmux.slice" if that fails. - */ - r = sd_pid_get_user_slice(parent_pid, &slice); - if (r < 0) { - slice = xstrdup("app-tmux.slice"); - } - r = sd_bus_message_append(m, "(sv)", "Slice", "s", slice); - free(slice); - if (r < 0) { - xasprintf(cause, "failed to append to properties: %s", - strerror(-r)); - goto finish; - } - - /* PIDs to add to the scope: length - 1 array of uint32_t. */ - r = sd_bus_message_append(m, "(sv)", "PIDs", "au", 1, pid); - if (r < 0) { - xasprintf(cause, "failed to append to properties: %s", - strerror(-r)); - goto finish; - } - - /* Clean up the scope even if it fails. */ - r = sd_bus_message_append(m, "(sv)", "CollectMode", "s", - "inactive-or-failed"); - if (r < 0) { - xasprintf(cause, "failed to append to properties: %s", - strerror(-r)); - goto finish; - } - - /* - * Try locating systemd unit that started the server, and mark pane units - * as dependent on it. Use "Before" to make sure systemd will not try to - * kill them first. - */ - if (sd_pid_get_user_unit(parent_pid, &unit) == 0 || - sd_pid_get_unit(parent_pid, &unit) == 0) { - r = sd_bus_message_append(m, "(sv)", "Before", "as", 1, unit); - if (r >= 0) { - r = sd_bus_message_append(m, "(sv)", "PartOf", "as", 1, - unit); - } - free(unit); - if (r < 0) { - xasprintf(cause, "failed to append to properties: %s", - strerror(-r)); - goto finish; - } - } - - /* End properties array. */ - r = sd_bus_message_close_container(m); - if (r < 0) { - xasprintf(cause, "failed to end properties array: %s", - strerror(-r)); - goto finish; - } - - /* aux is currently unused and should be passed an empty array. */ - r = sd_bus_message_append(m, "a(sa(sv))", 0); - if (r < 0) { - xasprintf(cause, "failed to append to bus message: %s", - strerror(-r)); - goto finish; - } - - /* Call the method with a timeout of 1 second = 1e6 us. */ r = sd_bus_call(bus, m, 1000000, &error, &reply); + if (r < 0 && sd_bus_error_has_name(&error, + SD_BUS_ERROR_PROPERTY_READ_ONLY)) { + sd_bus_error_free(&error); + m = sd_bus_message_unref(m); + r = systemd_new_scope_message(bus, 0, &m, cause); + if (r < 0) + goto finish; + r = sd_bus_call(bus, m, 1000000, &error, &reply); + } if (r < 0) { if (error.message != NULL) { /* We have a specific error message from sd-bus. */ diff --git a/configure.ac b/configure.ac index b09bc5d4e..5da5f30af 100644 --- a/configure.ac +++ b/configure.ac @@ -1,6 +1,6 @@ # configure.ac -AC_INIT([tmux], next-3.8) +AC_INIT([tmux], next-3.9) AC_PREREQ([2.60]) AC_CONFIG_AUX_DIR(etc) @@ -111,6 +111,17 @@ case "x$DEFAULT_TERM" in ;; esac +# Allow the socket directory list to be set. It is a colon-separated list of +# directories, tried in order; a leading $ is an environment variable. +AC_ARG_WITH( + socket-path, + AS_HELP_STRING(--with-socket-path=PATHS, set socket directory list), + [TMUX_SOCK=`echo "$withval" | sed 's/[[$]]/$$/g'`], + [TMUX_SOCK=] +) +AM_CONDITIONAL(HAVE_TMUX_SOCK, [test "x$TMUX_SOCK" != x]) +AC_SUBST(TMUX_SOCK) + # Do we need fuzzers? AM_CONDITIONAL(NEED_FUZZING, test "x$enable_fuzzing" = xyes) diff --git a/control-notify.c b/control-notify.c index adfc9dcb0..9aa90662b 100644 --- a/control-notify.c +++ b/control-notify.c @@ -1,4 +1,4 @@ -/* $OpenBSD: control-notify.c,v 1.38 2026/08/03 13:38:42 nicm Exp $ */ +/* $OpenBSD: control-notify.c,v 1.39 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2012 Nicholas Marriott @@ -70,6 +70,7 @@ control_window_layout_changed_cb(__unused const char *name, struct session *s; struct winlink *wl; struct window *w = event_payload_get_window(ep, "window"); + struct format_tree *ft; const char *template; char *cp; @@ -84,19 +85,25 @@ control_window_layout_changed_cb(__unused const char *name, * and we don't need to inform the client about the layout change * because the whole window will go away soon. */ - wl = TAILQ_FIRST(&w->winlinks); - if (wl == NULL || w->layout_root == NULL) + if (TAILQ_FIRST(&w->winlinks) == NULL || w->layout_root == NULL) return; - cp = format_single(NULL, template, NULL, NULL, wl, NULL); TAILQ_FOREACH(c, &clients, entry) { if (!CONTROL_SHOULD_NOTIFY_CLIENT(c) || c->session == NULL) continue; s = c->session; - if (winlink_find_by_window_id(&s->windows, w->id) != NULL) - control_notify_write(c, "%s", cp); + wl = winlink_find_by_window_id(&s->windows, w->id); + if (wl == NULL) + continue; + + ft = format_create(c, NULL, FORMAT_NONE, 0); + format_defaults(ft, c, s, wl, NULL); + cp = format_expand(ft, template); + format_free(ft); + + control_notify_write(c, "%s", cp); + free(cp); } - free(cp); } /* Notify control clients that window pane changed. */ diff --git a/control.c b/control.c index 8a5d0999e..9fd2c6d3a 100644 --- a/control.c +++ b/control.c @@ -1,4 +1,4 @@ -/* $OpenBSD: control.c,v 1.66 2026/08/18 07:43:44 nicm Exp $ */ +/* $OpenBSD: control.c,v 1.68 2026/09/09 08:30:05 nicm Exp $ */ /* * Copyright (c) 2012 Nicholas Marriott @@ -108,6 +108,7 @@ struct control_state { u_int pending_count; TAILQ_HEAD(, control_block) all_blocks; + size_t queued_reply_bytes; struct bufferevent *read_event; struct bufferevent *write_event; @@ -132,6 +133,9 @@ struct control_state { /* Maximum age for clients that are not using pause mode. */ #define CONTROL_MAXIMUM_AGE 300000 +/* Maximum buffered command replies for a client that is not reading. */ +#define CONTROL_MAXIMUM_REPLY_BUFFER (64 * 1024 * 1024) + /* Flags to ignore client. */ #define CONTROL_IGNORE_FLAGS \ (CLIENT_CONTROL_NOOUTPUT| \ @@ -165,6 +169,15 @@ RB_GENERATE_STATIC(control_windows, control_window, entry, control_window_cmp); static void control_free_block(struct control_state *cs, struct control_block *cb) { + size_t size; + + if (cb->size == 0 && cb->line != NULL) { + size = strlen(cb->line) + 1; + if (cs->queued_reply_bytes > size) + cs->queued_reply_bytes -= size; + else + cs->queued_reply_bytes = 0; + } free(cb->line); TAILQ_REMOVE(&cs->all_blocks, cb, all_entry); free(cb); @@ -407,16 +420,52 @@ control_reset_pane(struct client *c, struct window_pane *wp) memcpy(&cp->queued, &wp->offset, sizeof cp->queued); } +/* + * Check if the replies buffered for a client, including one about to be + * added, have grown too large and kill it if so. Returns 1 if further output + * for the client should be dropped. + */ +static int +control_check_reply_buffer(struct client *c, size_t added) +{ + struct control_state *cs = c->control_state; + size_t size; + + if (c->flags & CLIENT_CONTROL_DISCARD) + return (1); + size = EVBUFFER_LENGTH(cs->write_event->output); + size += cs->queued_reply_bytes; + size += added; + if (size < CONTROL_MAXIMUM_REPLY_BUFFER) + return (0); + + log_debug("%s: %s: %zu bytes of replies buffered", __func__, c->name, + size); + if (~c->flags & CLIENT_EXIT) { + c->exit_message = xstrdup("too far behind"); + c->flags |= CLIENT_EXIT; + control_discard(c); + } + c->flags |= CLIENT_CONTROL_DISCARD; + return (1); +} + /* Write an already-formatted line, queueing it behind %output if needed. */ static void control_write_line(struct client *c, char *line) { struct control_state *cs = c->control_state; struct control_block *cb; + size_t size = strlen(line) + 1; + + if (control_check_reply_buffer(c, size)) { + free(line); + return; + } if (TAILQ_EMPTY(&cs->all_blocks)) { log_debug("%s: %s: writing line: %s", __func__, c->name, line); - bufferevent_write(cs->write_event, line, strlen(line)); + bufferevent_write(cs->write_event, line, size - 1); bufferevent_write(cs->write_event, "\n", 1); bufferevent_enable(cs->write_event, EV_WRITE); free(line); @@ -426,6 +475,7 @@ control_write_line(struct client *c, char *line) cb = xcalloc(1, sizeof *cb); cb->line = line; TAILQ_INSERT_TAIL(&cs->all_blocks, cb, all_entry); + cs->queued_reply_bytes += size; cb->t = get_timer(); log_debug("%s: %s: storing line: %s", __func__, c->name, cb->line); @@ -455,8 +505,12 @@ control_flush_deferred(struct client *c) void control_write(struct client *c, const char *fmt, ...) { - va_list ap; - char *line; + struct control_state *cs = c->control_state; + char *line; + va_list ap; + + if (cs == NULL) + return; va_start(ap, fmt); xvasprintf(&line, fmt, ap); @@ -479,6 +533,9 @@ control_write_guard(struct client *c, const char *guard, long t, u_int number, struct control_state *cs = c->control_state; char *line; + if (cs == NULL) + return; + if (strcmp(guard, "begin") == 0) cs->guard_depth++; @@ -503,6 +560,9 @@ control_notify_write(struct client *c, const char *fmt, ...) va_list ap; char *line; + if (cs == NULL) + return; + va_start(ap, fmt); xvasprintf(&line, fmt, ap); va_end(ap); @@ -1003,6 +1063,7 @@ control_discard_all(struct client *c) control_discard(c); TAILQ_FOREACH_SAFE(cb, &cs->all_blocks, all_entry, cb1) control_free_block(cs, cb); + cs->queued_reply_bytes = 0; bufferevent_disable(cs->write_event, EV_WRITE); } diff --git a/format.c b/format.c index 78e3f0fb5..26a64f964 100644 --- a/format.c +++ b/format.c @@ -1,4 +1,4 @@ -/* $OpenBSD: format.c,v 1.415 2026/08/31 19:34:09 nicm Exp $ */ +/* $OpenBSD: format.c,v 1.425 2026/10/05 10:59:37 nicm Exp $ */ /* * Copyright (c) 2011 Nicholas Marriott @@ -88,9 +88,12 @@ format_job_cmp(struct format_job *fj1, struct format_job *fj2) /* Maximum pad and trim width. */ #define FORMAT_MAX_WIDTH 10000 -/* Maximum repeat size. */ +/* Maximum repeat count. */ #define FORMAT_MAX_REPEAT 10000 +/* Maximum repeat result size in bytes. */ +#define FORMAT_MAX_REPEAT_SIZE 65536 + /* Maximum precision. */ #define FORMAT_MAX_PRECISION 100 @@ -855,26 +858,42 @@ format_cb_window_active_clients_list(struct format_tree *ft) static void * format_cb_window_layout(struct format_tree *ft) { - struct window *w = ft->w; + struct client *c = ft->client; + struct window *w = ft->w; + struct layout_cell *lcroot; + int flags = 0; if (w == NULL) return (NULL); if (w->saved_layout_root != NULL) - return (layout_dump(w, w->saved_layout_root)); - return (layout_dump(w, w->layout_root)); + lcroot = w->saved_layout_root; + else + lcroot = w->layout_root; + + if (c != NULL && + (c->flags & CLIENT_CONTROL) && + (~c->flags & CLIENT_CONTROL_NEWLAYOUTS)) + flags |= LAYOUT_CUSTOM_OLD_FORMAT; + return (layout_dump(w, lcroot, flags)); } /* Callback for window_visible_layout. */ static void * format_cb_window_visible_layout(struct format_tree *ft) { + struct client *c = ft->client; struct window *w = ft->w; + int flags = 0; if (w == NULL) return (NULL); - return (layout_dump(w, w->layout_root)); + if (c != NULL && + (c->flags & CLIENT_CONTROL) && + (~c->flags & CLIENT_CONTROL_NEWLAYOUTS)) + flags |= LAYOUT_CUSTOM_OLD_FORMAT; + return (layout_dump(w, w->layout_root, flags)); } /* Callback for pane_start_command. */ @@ -1625,7 +1644,7 @@ format_cb_client_last_session(struct format_tree *ft) static void * format_cb_client_name(struct format_tree *ft) { - if (ft->c != NULL) + if (ft->c != NULL && ft->c->name != NULL) return (xstrdup(ft->c->name)); return (NULL); } @@ -1688,7 +1707,7 @@ format_cb_client_termfeatures(struct format_tree *ft) static void * format_cb_client_termname(struct format_tree *ft) { - if (ft->c != NULL) + if (ft->c != NULL && ft->c->term_name != NULL) return (xstrdup(ft->c->term_name)); return (NULL); } @@ -1709,7 +1728,7 @@ format_cb_client_termtype(struct format_tree *ft) static void * format_cb_client_tty(struct format_tree *ft) { - if (ft->c != NULL) + if (ft->c != NULL && ft->c->ttyname != NULL) return (xstrdup(ft->c->ttyname)); return (NULL); } @@ -2860,10 +2879,10 @@ format_cb_session_activity_flag(struct format_tree *ft) if (ft->s != NULL) { RB_FOREACH(wl, winlinks, &ft->s->windows) { - if (ft->wl->flags & WINLINK_ACTIVITY) + if (wl->flags & WINLINK_ACTIVITY) return (xstrdup("1")); - return (xstrdup("0")); } + return (xstrdup("0")); } return (NULL); } @@ -2878,8 +2897,8 @@ format_cb_session_bell_flag(struct format_tree *ft) RB_FOREACH(wl, winlinks, &ft->s->windows) { if (wl->flags & WINLINK_BELL) return (xstrdup("1")); - return (xstrdup("0")); } + return (xstrdup("0")); } return (NULL); } @@ -2892,10 +2911,10 @@ format_cb_session_silence_flag(struct format_tree *ft) if (ft->s != NULL) { RB_FOREACH(wl, winlinks, &ft->s->windows) { - if (ft->wl->flags & WINLINK_SILENCE) + if (wl->flags & WINLINK_SILENCE) return (xstrdup("1")); - return (xstrdup("0")); } + return (xstrdup("0")); } return (NULL); } @@ -4504,8 +4523,8 @@ format_pretty_time(time_t t, int seconds) now = t; age = now - t; - localtime_r(&now, &now_tm); - localtime_r(&t, &tm); + if (localtime_r(&now, &now_tm) == NULL || localtime_r(&t, &tm) == NULL) + return (xstrdup("")); /* Last 24 hours. */ if (age < 24 * 3600) { @@ -4676,10 +4695,12 @@ found: found = format_pretty_time(t, 0); else { if (time_format != NULL) { - localtime_r(&t, &tm); + if (localtime_r(&t, &tm) == NULL) + return (NULL); format_strftime(s, sizeof s, time_format, &tm); } else { - ctime_r(&t, s); + if (ctime_r(&t, s) == NULL) + return (NULL); s[strcspn(s, "\n")] = '\0'; } found = xstrdup(s); @@ -4810,6 +4831,9 @@ format_skip1(struct format_expand_state *es, const char *s, const char *end) int brackets = 0; u_int check = 0; + if (es != NULL && !format_check_time(es, NULL)) + return (NULL); + for (; *s != '\0'; s++) { if (es != NULL && !format_check_time(es, &check)) return (NULL); @@ -5234,6 +5258,7 @@ format_loop_sessions(struct format_expand_state *es, const char *fmt) format_log(es, "session loop: $%u", s->id); if (active != NULL && ft->c != NULL && + ft->c->session != NULL && s->id == ft->c->session->id) use = active; else @@ -6002,7 +6027,7 @@ format_replace(struct format_expand_state *es, const char *key, size_t keylen, char *time_format = NULL; char *copy0, *condition, *found, *new; char *value, *left, *right; - size_t valuelen; + size_t n; uint64_t modifiers = 0; int limit = 0, width = 0; int j, c; @@ -6010,7 +6035,7 @@ format_replace(struct format_expand_state *es, const char *key, size_t keylen, struct format_modifier **sub = NULL, *mexp = NULL, *fm; struct format_modifier *bool_op_n = NULL; u_int cycle_count = 1; - u_int i, count, nsub = 0, nrep, check = 0; + u_int i, count, nsub = 0, nrep; const char *loop_flags = ""; struct format_expand_state next; struct environ_entry *envent; @@ -6409,17 +6434,15 @@ format_replace(struct format_expand_state *es, const char *key, size_t keylen, if (errstr != NULL) value = xstrdup(""); else { - value = xstrdup(""); - for (i = 0; i < nrep; i++) { - if (!format_check_time(es, &check)) { - free(right); - free(left); - free(value); - goto fail; - } - xasprintf(&new, "%s%s", value, left); - free(value); - value = new; + n = strlen(left); + if (n != 0 && nrep > FORMAT_MAX_REPEAT_SIZE / n) { + format_log(es, "repeat is too long: %s", copy); + value = xstrdup(""); + } else { + value = xmalloc((nrep * n) + 1); + for (i = 0; i < nrep; i++) + memcpy(value + (i * n), left, n); + value[nrep * n] = '\0'; } } free(right); @@ -6653,13 +6676,13 @@ done: } /* Expand the buffer and copy in the value. */ - valuelen = strlen(value); - while (*len - *off < valuelen + 1) { + n = strlen(value); + while (*len - *off < n + 1) { *buf = xreallocarray(*buf, 2, *len); *len *= 2; } - memcpy(*buf + *off, value, valuelen); - *off += valuelen; + memcpy(*buf + *off, value, n); + *off += n; format_log(es, "replaced '%s' with '%s'", copy0, value); free(value); @@ -6807,6 +6830,19 @@ format_expand1(struct format_expand_state *es, const char *fmt) fmt = ptr + 1; continue; } + if (ch == '#') { + while (len - off < (n / 2) + 1) { + buf = xreallocarray(buf, 2, len); + len *= 2; + } + memset(buf + off, '#', n / 2); + off += (n / 2); + if (n % 2 != 0) + fmt = ptr - 1; + else + fmt = ptr; + continue; + } /* FALLTHROUGH */ case '}': case ',': diff --git a/fuzz/input-fuzzer.c b/fuzz/input-fuzzer.c index 0e4415473..c7c5c99b5 100644 --- a/fuzz/input-fuzzer.c +++ b/fuzz/input-fuzzer.c @@ -44,7 +44,7 @@ LLVMFuzzerTestOneInput(const u_char *data, size_t size) w = window_create(PANE_WIDTH, PANE_HEIGHT, 0, 0); wp = window_add_pane(w, NULL, 0, 0); bufferevent_pair_new(libevent, BEV_OPT_CLOSE_ON_FREE, vpty); - wp->ictx = input_init(wp, vpty[0], NULL, NULL); + wp->ictx = input_init(wp, vpty[0], NULL); window_add_ref(w, __func__); wp->fd = open("/dev/null", O_WRONLY); diff --git a/fuzz/json-fuzzer.c b/fuzz/json-fuzzer.c new file mode 100644 index 000000000..3c33269f2 --- /dev/null +++ b/fuzz/json-fuzzer.c @@ -0,0 +1,88 @@ +/* + * Fuzz the tmux JSON parser and serializer. + */ + +#include +#include +#include +#include + +#include "tmux.h" + +struct event_base *libevent; + +static void +walk_json(struct json_node *node) +{ + struct json_node *child, *object; + const char *string; + int64_t number; + int boolean; + + if (node == NULL) + return; + + (void)json_get_string(node, &string); + (void)json_get_number(node, &number); + (void)json_get_boolean(node, &boolean); + + if (json_get_object(node, &object) == 0) { + (void)json_find(object, "V"); + (void)json_find(object, "L"); + } + + for (child = json_array_first(node); child != NULL; + child = json_array_next(child)) + walk_json(child); +} + +int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + struct json_node *node; + char *input, *serialized, *cause = NULL; + + if (size == 0 || size > 1U << 20) + return 0; + + input = malloc(size + 1); + if (input == NULL) + return 0; + memcpy(input, data, size); + input[size] = '\0'; + + node = json_parse(input, &cause); + free(cause); + if (node != NULL) { + walk_json(node); + serialized = json_to_string(node); + free(serialized); + json_destroy_node(node); + } + + free(input); + return 0; +} + +int +LLVMFuzzerInitialize(__unused int *argc, __unused char ***argv) +{ + const struct options_table_entry *oe; + + global_environ = environ_create(); + global_options = options_create(NULL); + global_s_options = options_create(NULL); + global_w_options = options_create(NULL); + for (oe = options_table; oe->name != NULL; oe++) { + if (oe->scope & OPTIONS_TABLE_SERVER) + options_default(global_options, oe); + if (oe->scope & OPTIONS_TABLE_SESSION) + options_default(global_s_options, oe); + if (oe->scope & OPTIONS_TABLE_WINDOW) + options_default(global_w_options, oe); + } + libevent = osdep_event_init(); + socket_path = xstrdup("dummy"); + + return 0; +} \ No newline at end of file diff --git a/fuzz/json-fuzzer.dict b/fuzz/json-fuzzer.dict new file mode 100644 index 000000000..a183f732e --- /dev/null +++ b/fuzz/json-fuzzer.dict @@ -0,0 +1,37 @@ +# JSON structural tokens +"{" +"}" +"[" +"]" +":" +"," +"\"" + +# JSON values +"true" +"false" +"null" +"0" +"-1" +"123456789" +"\\\"" +"\\\\" +"\\n" +"\\u0000" + +# tmux layout/control-mode keys +"V" +"L" +"W" +"H" +"X" +"Y" +"T" +"S" +"id" +"result" +"error" +"routes" +"uri" +"name" +"value" diff --git a/fuzz/json-fuzzer.options b/fuzz/json-fuzzer.options new file mode 100644 index 000000000..9fda93fcb --- /dev/null +++ b/fuzz/json-fuzzer.options @@ -0,0 +1,2 @@ +[libfuzzer] +max_len = 4096 diff --git a/fuzz/layout-fuzzer.c b/fuzz/layout-fuzzer.c new file mode 100644 index 000000000..b4d577615 --- /dev/null +++ b/fuzz/layout-fuzzer.c @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Arthur Chan + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER + * IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * Fuzz the custom layout parser. + * + * A layout string such as "bb62,80x24,0,0{40x24,0,0,1,39x24,41,0,2}" is + * accepted by select-layout and is what tmux stores and restores for a + * window, so it is parsed from configuration and from commands. It drives + * layout-custom.c (the string parser and the checksum), then layout.c, which + * resizes and assigns the cells. + * + * layout_parse() refuses a window with no panes, and refuses a layout whose + * cell count is smaller than the pane count, so the window is given a fixed + * number of panes. The count is fixed rather than derived from the input, so + * a mutation always means a different layout string. + */ + +#include + +#include +#include +#include + +#include "tmux.h" + +#define FUZZER_MAXLEN 1024 +#define FUZZER_PANES 4 + +struct event_base *libevent; + +int +LLVMFuzzerTestOneInput(const u_char *data, size_t size) +{ + struct window *w; + struct window_pane *wp; + char *buf, *cause = NULL, *dump; + u_int i; + + if (size == 0 || size > FUZZER_MAXLEN) + return 0; + + /* layout_parse() takes a C string. */ + buf = malloc(size + 1); + if (buf == NULL) + return 0; + memcpy(buf, data, size); + buf[size] = '\0'; + + w = window_create(80, 24, 0, 0); + if (w == NULL) { + free(buf); + return 0; + } + window_add_ref(w, __func__); + + for (i = 0; i < FUZZER_PANES; i++) { + wp = window_add_pane(w, NULL, 0, 0); + if (w->active == NULL) + w->active = wp; + } + + if (layout_parse(w, buf, &cause) == 0) { + dump = layout_dump(w, w->layout_root, 1); + free(dump); + } + free(cause); + + window_remove_ref(w, __func__); + + free(buf); + return 0; +} + +int +LLVMFuzzerInitialize(__unused int *argc, __unused char ***argv) +{ + const struct options_table_entry *oe; + + global_environ = environ_create(); + global_options = options_create(NULL); + global_s_options = options_create(NULL); + global_w_options = options_create(NULL); + for (oe = options_table; oe->name != NULL; oe++) { + if (oe->scope & OPTIONS_TABLE_SERVER) + options_default(global_options, oe); + if (oe->scope & OPTIONS_TABLE_SESSION) + options_default(global_s_options, oe); + if (oe->scope & OPTIONS_TABLE_WINDOW) + options_default(global_w_options, oe); + } + + libevent = osdep_event_init(); + + socket_path = xstrdup("dummy"); + + return 0; +} diff --git a/fuzz/layout-fuzzer.dict b/fuzz/layout-fuzzer.dict new file mode 100644 index 000000000..34953764a --- /dev/null +++ b/fuzz/layout-fuzzer.dict @@ -0,0 +1,17 @@ +# tmux custom layout strings: ",x,,{...}" / "[...]" +"," +"x" +"{" +"}" +"[" +"]" +"0" +"1" +"80x24,0,0" +"40x24,0,0" +"bb62," +"cafe," +",0,0,0" +",0,0{" +",0,0[" +"@" diff --git a/fuzz/layout-fuzzer.options b/fuzz/layout-fuzzer.options new file mode 100644 index 000000000..794888809 --- /dev/null +++ b/fuzz/layout-fuzzer.options @@ -0,0 +1,2 @@ +[libfuzzer] +max_len = 1024 diff --git a/fuzzy.c b/fuzzy.c index 83d34715c..f28e47bbf 100644 --- a/fuzzy.c +++ b/fuzzy.c @@ -1,4 +1,4 @@ -/* $OpenBSD: fuzzy.c,v 1.1 2026/06/26 14:40:30 nicm Exp $ */ +/* $OpenBSD: fuzzy.c,v 1.2 2026/09/28 16:55:08 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -245,16 +245,20 @@ fuzzy_column(const struct fuzzy_char *fc, const u_int *start, const u_int *src, return (0); } -/* Decode a UTF-8 term into an array of characters. */ -static u_int -fuzzy_decode(const char *tok, size_t len, struct utf8_data *out) +/* Decode a UTF-8 term, stopping if it cannot fit in the given limit. */ +static int +fuzzy_decode(const char *tok, size_t len, struct utf8_data *out, u_int limit, + u_int *n) { const char *cp = tok, *end = tok + len; - u_int n = 0; - while (cp != end) - cp = fuzzy_decode_one(cp, end, &out[n++]); - return (n); + *n = 0; + while (cp != end) { + if (*n == limit) + return (-1); + cp = fuzzy_decode_one(cp, end, &out[(*n)++]); + } + return (0); } /* Add the score for a fuzzy token matched at the given positions. */ @@ -474,7 +478,8 @@ fuzzy_match_term(const struct fuzzy_term *term, struct utf8_data *tok, u_int toklen; int value = 0, matched_term; - toklen = fuzzy_decode(term->text, term->len, tok); + if (fuzzy_decode(term->text, term->len, tok, ncs, &toklen) != 0) + return (term->inverse); if (term->exact) { matched_term = fuzzy_match_exact(tok, toklen, cs, ncs, fold, term->prefix, term->suffix, &value, @@ -539,6 +544,7 @@ fuzzy_match(const char *pattern, const char *text, u_int width, u_int *score) u_int src[STYLE_ALIGN_ABSOLUTE_CENTRE + 1]; u_int vis[STYLE_ALIGN_ABSOLUTE_CENTRE + 1]; u_int wl, wc, wr, wa; + size_t toksize; const char *cp, *sp; int bestscore = 0, groupscore, found = 0, fold; @@ -567,7 +573,12 @@ fuzzy_match(const char *pattern, const char *text, u_int width, u_int *score) cs = fuzzy_scan(text, &ncs, widths); matched = xcalloc(ncs == 0 ? 1 : ncs, sizeof *matched); best = xcalloc(ncs == 0 ? 1 : ncs, sizeof *best); - tok = xreallocarray(NULL, strlen(pattern) + 1, sizeof *tok); + + /* A term with more characters than the text cannot match. */ + toksize = strlen(pattern); + if (toksize > ncs) + toksize = ncs; + tok = xreallocarray(NULL, toksize == 0 ? 1 : toksize, sizeof *tok); /* Match each |-separated group and keep the best-scoring one. */ cp = pattern; diff --git a/hyperlinks.c b/hyperlinks.c index 27af5c4f0..1f2f3eaae 100644 --- a/hyperlinks.c +++ b/hyperlinks.c @@ -1,4 +1,4 @@ -/* $OpenBSD: hyperlinks.c,v 1.5 2026/06/29 16:44:06 nicm Exp $ */ +/* $OpenBSD: hyperlinks.c,v 1.6 2026/09/28 10:42:01 nicm Exp $ */ /* * Copyright (c) 2021 Will @@ -87,7 +87,11 @@ hyperlinks_by_uri_cmp(struct hyperlinks_uri *left, struct hyperlinks_uri *right) return (-1); if (*right->internal_id != '\0') return (1); - return (left->inner - right->inner); + if (left->inner < right->inner) + return (-1); + if (left->inner > right->inner) + return (1); + return (0); } r = strcmp(left->internal_id, right->internal_id); @@ -104,7 +108,11 @@ static int hyperlinks_by_inner_cmp(struct hyperlinks_uri *left, struct hyperlinks_uri *right) { - return (left->inner - right->inner); + if (left->inner < right->inner) + return (-1); + if (left->inner > right->inner) + return (1); + return (0); } RB_PROTOTYPE_STATIC(hyperlinks_by_inner_tree, hyperlinks_uri, by_inner_entry, hyperlinks_by_inner_cmp); diff --git a/input.c b/input.c index 2fef558cb..2732b616e 100644 --- a/input.c +++ b/input.c @@ -1,4 +1,4 @@ -/* $OpenBSD: input.c,v 1.271 2026/08/31 19:34:09 nicm Exp $ */ +/* $OpenBSD: input.c,v 1.272 2026/09/21 10:22:31 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -101,7 +101,6 @@ struct input_ctx { struct bufferevent *event; struct screen_write_ctx ctx; struct colour_palette *palette; - struct client *c; struct input_cell cell; struct input_cell old_cell; @@ -874,7 +873,7 @@ input_restore_state(struct input_ctx *ictx) /* Initialise input parser. */ struct input_ctx * input_init(struct window_pane *wp, struct bufferevent *bev, - struct colour_palette *palette, struct client *c) + struct colour_palette *palette) { struct input_ctx *ictx; @@ -882,7 +881,6 @@ input_init(struct window_pane *wp, struct bufferevent *bev, ictx->wp = wp; ictx->event = bev; ictx->palette = palette; - ictx->c = c; ictx->input_space = INPUT_BUF_START; ictx->input_buf = xmalloc(INPUT_BUF_START); @@ -3425,15 +3423,9 @@ input_osc_52(struct input_ctx *ictx, const char *p) return; if (wp == NULL) { - /* Popup window. */ - if (ictx->c == NULL) { - free(out); - return; - } - tty_set_selection(&ictx->c->tty, clip, out, outlen); - paste_add(NULL, out, outlen); + free(out); + return; } else { - /* Normal window. */ screen_write_start_pane(&ctx, wp, NULL); screen_write_setselection(&ctx, clip, out, outlen); screen_write_stop(&ctx); diff --git a/json.c b/json.c new file mode 100644 index 000000000..1e07ca95b --- /dev/null +++ b/json.c @@ -0,0 +1,1001 @@ +/* $OpenBSD: json.c,v 1.1 2026/09/08 08:33:10 nicm Exp $ */ + +/* + * Copyright (c) 2026 Dane Jensen + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER + * IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#include +#include +#include +#include +#include + +#include "tmux.h" + +/* + * Parse a subset of JSON. The subset accepted is: + * + * - Arrays may only hold objects. + * - The top-level value must be an object. + * - Numbers are 64-bit signed integers in base 10; there are no fractions and + * no exponents. + * - There is no null, and a string may not be empty. + * - Escapes are validated but not decoded. + * - A key may not appear twice in the same object. Note that because escapes + * are not decoded, duplicate keys may go undetected. + * - Objects may only be parsed to a fixed maximum depth. + */ + +#define ERROR_CTX_LEN 8 +#define PARSE_DEPTH_MAX 200 + +/* JSON token types. */ +enum json_token_type { + TOK_OPENOBJECT, + TOK_CLOSEOBJECT, + TOK_OPENARRAY, + TOK_CLOSEARRAY, + TOK_COMMA, + TOK_COLON, + TOK_QUOTE, + TOK_VALUE, + TOK_EOF +}; + +/* JSON token. */ +struct json_token { + enum json_token_type type; + int offset; + int len; +}; + +/* JSON tokens. */ +struct json_tokens { + int size; + int capacity; + struct json_token *toks; +}; + +/* JSON node type. */ +enum json_node_type { + NODE_STRING, + NODE_NUMBER, + NODE_BOOLEAN, + NODE_OBJECT, + NODE_ARRAY +}; + +/* JSON field tree. */ +RB_HEAD(json_fields, json_node); + +/* JSON array queue. */ +TAILQ_HEAD(json_members, json_node); + +/* JSON parse context. */ +struct json_parse_ctx { + const char *input; + char **cause; + int depth; +}; + +/* JSON node. */ +struct json_node { + enum json_node_type type; + char *key; + struct json_node *parent; + union { + char *str; + int64_t num; + int boolean; + struct json_fields fields; + struct json_members members; + }; + RB_ENTRY(json_node) oentry; + TAILQ_ENTRY(json_node) aentry; +}; + +static int +json_node_cmp(struct json_node *a, struct json_node *b) +{ + return (strcmp(a->key, b->key)); +} +RB_GENERATE_STATIC(json_fields, json_node, oentry, json_node_cmp); + +static struct json_tokens *json_tokenize_input(const char *, char **); +static struct json_tokens *json_create_tokens(void); +static void json_destroy_tokens(struct json_tokens *); +static void json_add_token(struct json_tokens *, + enum json_token_type, const char *, const char *, + int); +static int json_tokenize_value(struct json_tokens *, + const char *); +static void json_error(char **, const char *, const char *); +static struct json_node *json_create_node(struct json_node *, + enum json_node_type, const char *, void *); +static void json_assign_value(struct json_node *, void *); +static struct json_node *json_parse_tokens(struct json_tokens **, + struct json_parse_ctx *); +static char *json_parse_key(struct json_token **, + struct json_parse_ctx *); +static struct json_node *json_parse_object(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_array(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_string(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_number(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_boolean(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); + +/* Parse an input string into JSON. */ +struct json_node * +json_parse(const char *input, char **cause) +{ + struct json_tokens *tokens; + struct json_parse_ctx pctx; + + if (*input == '\0') { + json_error(cause, "empty input", NULL); + return (NULL); + } + + if ((tokens = json_tokenize_input(input, cause)) == NULL) + return (NULL); + + pctx.input = input; + pctx.cause = cause; + pctx.depth = 0; + + return (json_parse_tokens(&tokens, &pctx)); +} + +/* Returns a field node from an object node. */ +struct json_node * +json_find(struct json_node *jn, const char *key) +{ + struct json_node *node = (struct json_node *)jn, tmp = { 0 }; + + if (jn->type != NODE_OBJECT) + return (NULL); + + tmp.key = (char *)key; + return (RB_FIND(json_fields, &node->fields, &tmp)); +} + +/* Returns the first member of an array node. */ +struct json_node * +json_array_first(struct json_node *jn) +{ + if (jn->type != NODE_ARRAY) + return (NULL); + + return (TAILQ_FIRST(&jn->members)); +} + +/* Returns the next member of an array's member node. */ +struct json_node * +json_array_next(struct json_node *member) +{ + if (member == NULL || + member->parent == NULL || + member->parent->type != NODE_ARRAY) + return (NULL); + return (TAILQ_NEXT(member, aentry)); +} + +/* Returns the string value from a node. */ +int +json_get_string(struct json_node *jn, const char **s) +{ + if (jn->type != NODE_STRING) + return (-1); + + *s = jn->str; + return (0); +} + +/* Returns the number value from a node. */ +int +json_get_number(struct json_node *jn, int64_t *i) +{ + if (jn->type != NODE_NUMBER) + return (-1); + + *i = jn->num; + return (0); +} + +/* Returns the boolean value from a node. */ +int +json_get_boolean(struct json_node *jn, int *b) +{ + if (jn->type != NODE_BOOLEAN) + return (-1); + + *b = jn->boolean; + return (0); +} + +/* Returns the object value from a node. */ +int +json_get_object(struct json_node *jn, struct json_node **o) +{ + if (jn->type != NODE_OBJECT) + return (-1); + + *o = jn; + return (0); +} + +/* Returns the array value from a node. */ +int +json_get_array(struct json_node *jn, struct json_node **a) +{ + if (jn->type != NODE_ARRAY) + return (-1); + + *a = jn; + return (0); +} + +/* Returns the string value from a given key in an object node. */ +int +json_find_string(struct json_node *jn, const char *key, const char **out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_STRING) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected a string", key); + return (-1); + } + *out = field->str; + + return (0); +} + +/* Returns the number value from a given key in an object node. */ +int +json_find_number(struct json_node *jn, const char *key, int64_t *out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_NUMBER) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected a number", key); + return (-1); + } + *out = field->num; + + return (0); +} + +/* Returns the boolean value from a given key in an object node. */ +int +json_find_boolean(struct json_node *jn, const char *key, int *out, char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_BOOLEAN) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected a boolean", key); + return (-1); + } + *out = field->boolean; + + return (0); +} + +/* Returns the object value from a given key in an object node. */ +int +json_find_object(struct json_node *jn, const char *key, struct json_node **out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_OBJECT) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected an object", key); + return (-1); + } + *out = field; + + return (0); +} + +/* Returns the array value from a given key in an object node. */ +int +json_find_array(struct json_node *jn, const char *key, struct json_node **out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_ARRAY) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected an array", key); + return (-1); + } + *out = field; + + return (0); +} + +/* Fill an error cause. */ +static void +json_error(char **cause, const char *reason, const char *loc) +{ + const char *ellipsis = "..."; + int i; + + if (cause == NULL) + return; + if (loc == NULL || *loc == '\0') { + xasprintf(cause, "%s", reason); + return; + } + + for (i = 0; i < ERROR_CTX_LEN + 1; i++) { + if (loc[i] == '\0') { + ellipsis = ""; + break; + } + } + + xasprintf(cause, "%s: %.*s%s", reason, ERROR_CTX_LEN, loc, ellipsis); +} + +/* Tokenize the json string. */ +static struct json_tokens * +json_tokenize_input(const char *input, char **cause) +{ + struct json_tokens *tokens; + enum json_token_type type; + const char *loc, *start = input; + int in_string = 0, scan; + + tokens = json_create_tokens(); + while (*input != '\0') { + loc = input; + scan = 1; + + if (in_string && *input != '"') { + type = TOK_VALUE; + } else { + switch (*input) { + case ' ': + case '\t': + case '\n': + case '\r': + input++; + continue; + case '{': + type = TOK_OPENOBJECT; + break; + case '}': + type = TOK_CLOSEOBJECT; + break; + case '[': + type = TOK_OPENARRAY; + break; + case ']': + type = TOK_CLOSEARRAY; + break; + case '"': + type = TOK_QUOTE; + break; + case ':': + type = TOK_COLON; + break; + case ',': + type = TOK_COMMA; + break; + default: + type = TOK_VALUE; + break; + } + } + if (type == TOK_VALUE) { + scan = json_tokenize_value(tokens, loc); + if (scan == -1) + goto fail; + input += scan - 1; + } + json_add_token(tokens, type, start, loc, scan); + if (type == TOK_QUOTE) + in_string = !in_string; + + input++; + } + json_add_token(tokens, TOK_EOF, start, loc, 0); + + return (tokens); + +fail: + json_error(cause, "tokenization error", loc); + json_destroy_tokens(tokens); + return (NULL); +} + +/* + * Tokenize a value from the input string. Strings are terminated by a '"', and + * numbers/booleans are terminated by a ',', ']', '}', or whitespace. + */ +static int +json_tokenize_value(struct json_tokens *tokens, const char *loc) +{ + struct json_token *prev; + int i, scan = 0; + + if (tokens->size == 0) + return (-1); + prev = &tokens->toks[tokens->size - 1]; + + if (prev->type == TOK_QUOTE) { + while (loc[scan] != '"') { + if (loc[scan] == '\0' || (u_char)loc[scan] < 0x20) + return (-1); + if (loc[scan] != '\\') { + scan++; + continue; + } + scan++; + switch (loc[scan]) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + scan++; + break; + case 'u': + for (i = 1; i <= 4; i++) { + if (!isxdigit((u_char)loc[scan + i])) + return (-1); + } + scan += 5; + break; + default: + return (-1); + } + } + } else if (prev->type == TOK_COLON) { + do { + if (loc[scan] == '\0') + return (-1); + scan++; + } while (loc[scan] != ']' && loc[scan] != '}' && + loc[scan] != ',' && !isspace((u_char) loc[scan])); + } else + return (-1); + + return (scan); +} + +/* Create a new token container. */ +static struct json_tokens * +json_create_tokens(void) +{ + struct json_tokens *tokens; + + tokens = xmalloc(sizeof *tokens); + tokens->size = 0; + tokens->capacity = 1024; + tokens->toks = xmalloc(tokens->capacity * sizeof *tokens->toks); + + return (tokens); +} + +/* Free a token container. */ +static void +json_destroy_tokens(struct json_tokens *tokens) +{ + free(tokens->toks); + tokens->toks = NULL; + free(tokens); +} + +/* Add a token to tokens. */ +static void +json_add_token(struct json_tokens *tokens, enum json_token_type type, + const char *input, const char *loc, int len) +{ + struct json_token *tok; + + while (tokens->size >= tokens->capacity) { + tokens->capacity *= 2; + tokens->toks = xrealloc(tokens->toks, + sizeof *tokens->toks * tokens->capacity); + } + + tok = &tokens->toks[tokens->size++]; + tok->type = type; + tok->offset = loc - input; + tok->len = len; +} + +/* Create a node and assign given values. */ +static struct json_node * +json_create_node(struct json_node *parent, enum json_node_type type, + const char *key, void *val) +{ + struct json_node *node; + + node = xcalloc(1, sizeof *node); + node->parent = parent; + if (key != NULL) + node->key = xstrdup(key); + node->type = type; + if (type == NODE_OBJECT) + RB_INIT(&node->fields); + else if (type == NODE_ARRAY) + TAILQ_INIT(&node->members); + if (val != NULL) + json_assign_value(node, val); + + return (node); +} + +/* Destroy a node and all of the node's fields. */ +void +json_destroy_node(struct json_node *node) +{ + struct json_node *field, *field1, *member; + + if (node == NULL) + return; + + switch (node->type) { + case NODE_STRING: + free(node->str); + break; + case NODE_NUMBER: + case NODE_BOOLEAN: + break; + case NODE_OBJECT: + RB_FOREACH_SAFE(field, json_fields, &node->fields, field1) { + RB_REMOVE(json_fields, &node->fields, field); + json_destroy_node(field); + } + break; + case NODE_ARRAY: + while (!TAILQ_EMPTY(&node->members)) { + member = TAILQ_FIRST(&node->members); + TAILQ_REMOVE(&node->members, member, aentry); + json_destroy_node(member); + } + break; + } + + if (node->key != NULL) + free(node->key); + free(node); +} + +/* Assign a value to a node. */ +static void +json_assign_value(struct json_node *node, void *val) +{ + struct json_node *child = val; + + switch (node->type) { + case NODE_STRING: + node->str = val; + break; + case NODE_NUMBER: + node->num = *(int64_t *)val; + break; + case NODE_BOOLEAN: + node->boolean = *(int *)val; + break; + case NODE_OBJECT: + RB_INSERT(json_fields, &node->fields, child); + break; + case NODE_ARRAY: + TAILQ_INSERT_TAIL(&node->members, child, aentry); + break; + default: + fatalx("unknown node type"); + } +} + +/* Parse a stream of tokens into nodes. Consumes the tokens. */ +static struct json_node * +json_parse_tokens(struct json_tokens **tokens, struct json_parse_ctx *pctx) +{ + struct json_token *tok = (*tokens)->toks; + struct json_node *jn = NULL; + + if (tok->type == TOK_OPENOBJECT) + jn = json_parse_object(&tok, pctx, NULL, NULL); + else { + json_error(pctx->cause, "expected object", + pctx->input + tok->offset); + goto fail; + } + if (jn == NULL) + goto fail; + + if (tok->type != TOK_EOF) { + json_error(pctx->cause, "unexpected trailing data", + pctx->input + tok->offset); + goto fail; + } + json_destroy_tokens(*tokens); + *tokens = NULL; + + return (jn); + +fail: + if (jn != NULL) + json_destroy_node(jn); + json_destroy_tokens(*tokens); + *tokens = NULL; + return (NULL); +} + +/* Parse and return a key string, and advance the token pointer. */ +static char * +json_parse_key(struct json_token **tok, struct json_parse_ctx *pctx) +{ + int len; + const char *loc, *start = pctx->input + (*tok)->offset; + char *key; + + if ((*tok)->type != TOK_QUOTE) + goto fail; + (*tok)++; + + loc = pctx->input + (*tok)->offset; + len = (*tok)->len; + + if ((*tok)->type != TOK_VALUE) + goto fail; + (*tok)++; + if ((*tok)->type != TOK_QUOTE) + goto fail; + + key = xstrndup(loc, len); + (*tok)++; + + return (key); + +fail: + json_error(pctx->cause, "invalid key", start); + return (NULL); +} + +/* Parse an object value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_object(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + struct json_node *object, *field; + char *fkey = NULL; + u_char *valstr; + + if ((*tok)->type != TOK_OPENOBJECT) + return (NULL); + + pctx->depth++; + if (pctx->depth > PARSE_DEPTH_MAX) { + json_error(pctx->cause, "parse depth exceeded", + pctx->input + (*tok)->offset); + return (NULL); + } + + (*tok)++; + + object = json_create_node(parent, NODE_OBJECT, key, NULL); + while ((*tok)->type != TOK_CLOSEOBJECT) { + if ((fkey = json_parse_key(tok, pctx)) == NULL) + goto fail; + if (json_find(object, fkey) != NULL) { + json_error(pctx->cause, "duplicate key", + pctx->input + (*tok)->offset); + goto fail; + } + if ((*tok)->type != TOK_COLON) { + json_error(pctx->cause, "missing colon", + pctx->input + (*tok)->offset); + goto fail; + } + (*tok)++; + + switch ((*tok)->type) { + case TOK_QUOTE: + field = json_parse_string(tok, pctx, fkey, object); + break; + case TOK_VALUE: + valstr = (u_char *)(pctx->input + (*tok)->offset); + if ((*valstr == '-' && isdigit(valstr[1])) || + isdigit(*valstr)) { + field = json_parse_number(tok, pctx, fkey, + object); + } else { + field = json_parse_boolean(tok, pctx, fkey, + object); + } + break; + case TOK_OPENOBJECT: + field = json_parse_object(tok, pctx, fkey, object); + break; + case TOK_OPENARRAY: + field = json_parse_array(tok, pctx, fkey, object); + break; + default: + json_error(pctx->cause, + "unexpected value when parsing object", + pctx->input + (*tok)->offset); + goto fail; + } + if (field == NULL) + goto fail; + + json_assign_value(object, field); + if ((*tok)->type == TOK_COMMA) { + if ((*tok)[1].type == TOK_CLOSEOBJECT) { + json_error(pctx->cause, "invalid object", + pctx->input + (*tok)->offset); + goto fail; + } + (*tok)++; + } else if ((*tok)->type != TOK_CLOSEOBJECT) { + json_error(pctx->cause, "invalid object", + pctx->input + (*tok)->offset); + goto fail; + } + free(fkey); + } + (*tok)++; + pctx->depth--; + return (object); + +fail: + if (fkey != NULL) + free(fkey); + json_destroy_node(object); + return (NULL); +} + +/* Parse an array value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_array(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + struct json_node *array, *member; + + if ((*tok)->type != TOK_OPENARRAY) + return (NULL); + (*tok)++; + + array = json_create_node(parent, NODE_ARRAY, key, NULL); + while ((*tok)->type != TOK_CLOSEARRAY) { + switch ((*tok)->type) { + case TOK_OPENOBJECT: + member = json_parse_object(tok, pctx, NULL, array); + break; + default: + json_error(pctx->cause, "invalid array member", + pctx->input + (*tok)->offset); + goto fail; + } + if (member == NULL) + goto fail; + + json_assign_value(array, member); + + if ((*tok)->type == TOK_COMMA) { + if ((*tok)[1].type == TOK_CLOSEARRAY) { + json_error(pctx->cause, "invalid array", + pctx->input + (*tok)->offset); + goto fail; + } + (*tok)++; + } else if ((*tok)->type != TOK_CLOSEARRAY) { + json_error(pctx->cause, "invalid array", + pctx->input + (*tok)->offset); + goto fail; + } + } + (*tok)++; + return (array); + +fail: + json_destroy_node(array); + return (NULL); +} + +/* Parse a string value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_string(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + const char *loc, *start = pctx->input + (*tok)->offset; + char *str; + int len; + + if ((*tok)->type != TOK_QUOTE) + goto fail; + (*tok)++; + if ((*tok)->type != TOK_VALUE) + goto fail; + + loc = pctx->input + (*tok)->offset; + len = (*tok)->len; + (*tok)++; + + if ((*tok)->type != TOK_QUOTE) + goto fail; + (*tok)++; + + str = xstrndup(loc, len); + return (json_create_node(parent, NODE_STRING, key, str)); + +fail: + json_error(pctx->cause, "invalid string", start); + return (NULL); +} + +/* Parse a number value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_number(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + const char *start = pctx->input + (*tok)->offset; + char *endptr; + int64_t num; + int len = (*tok)->len; + + if ((start[0] == '0' && len != 1) || + (start[0] == '-' && start[1] == '0' && len != 2)) + goto fail; + + errno = 0; + num = strtoll(start, &endptr, 10); + if (errno != 0 || endptr != start + len) + goto fail; + (*tok)++; + + return (json_create_node(parent, NODE_NUMBER, key, &num)); + +fail: + json_error(pctx->cause, "invalid number", start); + return (NULL); +} + +/* Parse a boolean value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_boolean(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + int len = (*tok)->len, boolean; + const char *start = pctx->input + (*tok)->offset; + + if (strncmp(start, "true", len) == 0 && len == 4) + boolean = 1; + else if (strncmp(start, "false", len) == 0 && len == 5) + boolean = 0; + else + goto fail; + (*tok)++; + + return (json_create_node(parent, NODE_BOOLEAN, key, &boolean)); + +fail: + json_error(pctx->cause, "invalid boolean", start); + return (NULL); +} + +/* Append a node as JSON. */ +static void +json_string_append(struct evbuffer *buffer, struct json_node *node) +{ + struct json_node *field, *member; + const char *s; + int comma = 0; + + switch (node->type) { + case NODE_STRING: + evbuffer_add_printf(buffer, "\"%s\"", node->str); + break; + case NODE_NUMBER: + evbuffer_add_printf(buffer, "%lld", (long long)node->num); + break; + case NODE_BOOLEAN: + if (node->boolean) + s = "true"; + else + s = "false"; + evbuffer_add(buffer, s, strlen(s)); + break; + case NODE_OBJECT: + evbuffer_add(buffer, "{", 1); + RB_FOREACH(field, json_fields, &node->fields) { + if (comma) + evbuffer_add(buffer, ",", 1); + evbuffer_add_printf(buffer, "\"%s\":", field->key); + json_string_append(buffer, field); + comma = 1; + } + evbuffer_add(buffer, "}", 1); + break; + case NODE_ARRAY: + evbuffer_add(buffer, "[", 1); + TAILQ_FOREACH(member, &node->members, aentry) { + if (comma) + evbuffer_add(buffer, ",", 1); + json_string_append(buffer, member); + comma = 1; + } + evbuffer_add(buffer, "]", 1); + break; + } +} + +/* Convert a node back to JSON. */ +char * +json_to_string(struct json_node *node) +{ + struct evbuffer *buffer; + char *out; + + if (node == NULL) + return (NULL); + buffer = evbuffer_new(); + if (buffer == NULL) + fatalx("out of memory"); + json_string_append(buffer, node); + out = xmemdup(EVBUFFER_DATA(buffer), EVBUFFER_LENGTH(buffer)); + evbuffer_free(buffer); + return (out); +} diff --git a/key-bindings.c b/key-bindings.c index 4fd3dfac5..57d286f7a 100644 --- a/key-bindings.c +++ b/key-bindings.c @@ -1,4 +1,4 @@ -/* $OpenBSD: key-bindings.c,v 1.192 2026/08/06 09:05:04 nicm Exp $ */ +/* $OpenBSD: key-bindings.c,v 1.193 2026/09/21 20:45:48 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -521,7 +521,7 @@ key_bindings_init(void) "bind -n TripleClick1Pane { select-pane -t=; if -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' { send -M } { copy-mode -H; send -X select-line; run -d0.3; send -X copy-pipe-and-cancel } }", /* Mouse button 1 on border. */ - "bind -n MouseDown1Border { select-pane -M }", + "bind -n MouseDown1Border { select-pane -t= }", /* Mouse button 1 drag on border. */ "bind -n MouseDrag1Border { resize-pane -M }", diff --git a/layout-custom.c b/layout-custom.c index c58e92028..d7f8f7e9f 100644 --- a/layout-custom.c +++ b/layout-custom.c @@ -1,4 +1,4 @@ -/* $OpenBSD: layout-custom.c,v 1.38 2026/07/16 12:36:58 nicm Exp $ */ +/* $OpenBSD: layout-custom.c,v 1.44 2026/09/28 09:32:06 nicm Exp $ */ /* * Copyright (c) 2010 Nicholas Marriott @@ -19,18 +19,241 @@ #include #include +#include +#include +#include +#include #include #include "tmux.h" +/* + * Layouts can be represented as strings in a JSON format (v2). The version 1 + * format will be removed in the future and should no longer be used. + * + * The current (v2) format is JSON. The top level has two keys: + * "V": version number, currently 2 + * "L": root layout cell + * + * Each cell is an object with: + * "t": cell type: + * "h": horizontal + * "v": vertical + * "p": pane + * "w": cell width + * "h": cell height + * "x": horizontal position + * "y": vertical position + * + * If the cell is a node cell (with child cells), it additionally has: + * "c": array of child cells + * + * If the cell is a leaf cell (that is, containing a pane and no child cells), + * it additionally has: + * "I": pane ID as %n (currently ignored on parse) + * "l": index into last panes list if visited and not the active pane + * "a": true if the active pane + * "i": pane index + * "z": z-index, if a floating pane + */ + +/* Maximum nesting depth for version 1 layouts. */ +#define LAYOUT_V1_MAX_DEPTH 1000 + +/* Layout string. */ +struct layout_string { + char *dat; + size_t size; /* length written, not including terminator */ + size_t capacity; /* bytes allocated */ +}; + +/* Layout parse cell context. */ +struct layout_parse_cell_ctx { + struct layout_cell *lc; + int active; + int last; + int index; + int zindex; +}; + +/* Layout parse context. */ +struct layout_parse_ctx { + int64_t version; + int num_active; + struct layout_cell *root; + char **cause; + + int size; /* number used */ + int capacity; /* number allocated */ + struct layout_parse_cell_ctx *cctxs; +}; + static struct layout_cell *layout_find_bottomright(struct layout_cell *); static u_short layout_checksum(const char *); -static int layout_append(struct layout_cell *, char *, - size_t); -static int layout_construct(struct layout_cell *, - const char **, struct layout_cell **); -static void layout_assign(struct window_pane **, - struct layout_cell *, int); +static int layout_append(struct layout_cell *, + struct layout_string *, int); +static int layout_construct(const char *, + struct layout_parse_ctx *); +static void layout_assign(struct window *, + struct layout_parse_ctx *); +static void layout_parse_apply_ctx(struct window *, + struct layout_parse_ctx *); +static struct layout_cell *layout_parse_json_layout(struct json_node *, + struct layout_cell *, + struct layout_parse_ctx *); +static int layout_parse_ctx_check_indexes( + struct layout_parse_ctx *); + +/* Compare cell contexts in ascending order of index. */ +static int +layout_parse_index_cmp(const void *a, const void *b) +{ + const struct layout_parse_cell_ctx *cca = a; + const struct layout_parse_cell_ctx *ccb = b; + int retval = 0; + + if (cca->index < ccb->index) + retval = -1; + if (cca->index > ccb->index) + retval = 1; + return (retval); +} + +/* Compare cell contexts in descending order of z-index. */ +static int +layout_parse_zindex_cmp(const void *a, const void *b) +{ + const struct layout_parse_cell_ctx *cca = a; + const struct layout_parse_cell_ctx *ccb = b; + int retval = 0; + + if (cca->zindex > ccb->zindex) + retval = -1; + if (cca->zindex < ccb->zindex) + retval = 1; + return (retval); +} + +/* Compare cell contexts in descending order of last. */ +static int +layout_parse_last_cmp(const void *a, const void *b) +{ + const struct layout_parse_cell_ctx *cca = a; + const struct layout_parse_cell_ctx *ccb = b; + int retval = 0; + + if (cca->last > ccb->last) + retval = -1; + if (cca->last < ccb->last) + retval = 1; + return (retval); +} + +/* Initialize a layout string. */ +static void +layout_string_init(struct layout_string *ls) +{ + ls->capacity = 1024; + ls->dat = xmalloc(ls->capacity); + ls->dat[0] = '\0'; + ls->size = 0; +} + +/* Free a layout string. */ +static void +layout_string_free(struct layout_string *ls) +{ + free(ls->dat); + ls->dat = NULL; + ls->size = 0; + ls->capacity = 0; +} + +/* Write an optionally formatted string to the end of the layout string. */ +static void printflike(2, 3) +layout_string_write(struct layout_string *ls, const char *fmt, ...) +{ + va_list ap; + char *s; + int slen; + + va_start(ap, fmt); + slen = xvasprintf(&s, fmt, ap); + va_end(ap); + + while (ls->size + slen + 1 > ls->capacity) { + ls->dat = xreallocarray(ls->dat, 2, ls->capacity); + ls->capacity *= 2; + } + memcpy(ls->dat + ls->size, s, slen); + ls->size += slen; + ls->dat[ls->size] = '\0'; + + free(s); +} + +/* Initialize a parse context. */ +static void +layout_parse_init_ctx(struct layout_parse_ctx *pctx, char **cause) +{ + pctx->version = -1; + pctx->num_active = 0; + pctx->root = NULL; + pctx->cause = cause; + pctx->size = 0; + pctx->capacity = 64; + pctx->cctxs = xcalloc(pctx->capacity, sizeof *pctx->cctxs); +} + +/* Free a parse context. */ +static void +layout_parse_free_ctx(struct layout_parse_ctx *pctx) +{ + layout_free_cell(pctx->root, 0); + pctx->root = NULL; + free(pctx->cctxs); + pctx->cctxs = NULL; + pctx->size = 0; + pctx->capacity = 0; +} + +/* Add a cell context to the parse context. */ +static void +layout_parse_add_cctx(struct layout_parse_ctx *pctx, struct layout_cell *lc, + int active, int last, int index, int zindex) +{ + struct layout_parse_cell_ctx *cctx; + + if (pctx->size >= pctx->capacity) { + pctx->capacity *= 2; + pctx->cctxs = xreallocarray(pctx->cctxs, pctx->capacity, + sizeof *pctx->cctxs); + } + cctx = &pctx->cctxs[pctx->size++]; + + cctx->lc = lc; + cctx->active = active; + cctx->last = last; + cctx->index = index; + cctx->zindex = zindex; +} + +/* Remove a cell context from the parse context. Does not preserve ordering. */ +static int +layout_parse_remove_cctx(struct layout_parse_ctx *pctx, struct layout_cell *lc) +{ + struct layout_parse_cell_ctx *cctx; + int i; + + for (i = 0; i < pctx->size; i++) { + if (lc == pctx->cctxs[i].lc) { + cctx = &pctx->cctxs[--pctx->size]; + memmove(&pctx->cctxs[i], cctx, sizeof *cctx); + return (0); + } + } + return (-1); +} /* Find the bottom-right cell. */ static struct layout_cell * @@ -58,73 +281,155 @@ layout_checksum(const char *layout) /* Dump layout as a string. */ char * -layout_dump(struct window *w, struct layout_cell *root) +layout_dump(__unused struct window *w, struct layout_cell *lcroot, int flags) { - char layout[8192], *out; - int bracket = 0; - struct window_pane *wp; + struct layout_string layout_string = { 0 }; + char *out; - *layout = '\0'; - if (layout_append(root, layout, sizeof layout) != 0) - return (NULL); - - TAILQ_FOREACH(wp, &w->z_index, zentry) { - if (!window_pane_is_floating(wp)) - break; - if (!bracket) { - strlcat(layout, "<", sizeof layout); - bracket = 1; - } - if (layout_append(wp->layout_cell, layout, sizeof layout) != 0) - return (NULL); - strlcat(layout, ",", sizeof layout); + if (lcroot == NULL) + goto bad; + layout_string_init(&layout_string); + if (layout_append(lcroot, &layout_string, flags) != 0) + goto bad; + if (~flags & LAYOUT_CUSTOM_OLD_FORMAT) + xasprintf(&out, "{\"V\":2,\"L\":%s}", layout_string.dat); + else { + xasprintf(&out, "%04hx,%s", layout_checksum(layout_string.dat), + layout_string.dat); } - if (bracket) - layout[strlen(layout) - 1] = '>'; - - xasprintf(&out, "%04hx,%s", layout_checksum(layout), layout); + layout_string_free(&layout_string); return (out); + +bad: + layout_string_free(&layout_string); + if (~flags & LAYOUT_CUSTOM_OLD_FORMAT) + return (NULL); + return (xstrdup("0000,")); } -/* Append information for a single cell. */ -static int -layout_append(struct layout_cell *lc, char *buf, size_t len) +/* Get a floating pane cell's z-index in the layout being dumped. */ +static u_int +layout_cell_zindex(struct layout_cell *lc) { - struct layout_cell *lcchild; - char tmp[64]; - size_t tmplen; - const char *brackets = "]["; + struct window_pane *wp = lc->wp, *wq; + struct window *w = wp->window; + struct layout_cell *other; + int saved = (lc == wp->saved_layout_cell); + u_int i = 0; - if (len == 0) - return (-1); - if (lc == NULL) - return (0); - if (lc->wp != NULL) { - tmplen = xsnprintf(tmp, sizeof tmp, "%ux%u,%d,%d,%u", - lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff, lc->wp->id); - } else { - tmplen = xsnprintf(tmp, sizeof tmp, "%ux%u,%d,%d", - lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff); + if (saved && + w->active != NULL && + (w->active->flags & PANE_ZOOMED) && + (w->active->saved_layout_cell->flags & LAYOUT_CELL_FLOATING)) { + if (wp == w->active) + return (0); + i++; } - if (tmplen > (sizeof tmp) - 1) - return (-1); - if (strlcat(buf, tmp, len) >= len) + TAILQ_FOREACH(wq, &w->z_index, zentry) { + if (wq == wp) + break; + if (saved) + other = wq->saved_layout_cell; + else + other = wq->layout_cell; + if (other != NULL && (other->flags & LAYOUT_CELL_FLOATING)) + i++; + } + return (i); +} + +/* Append information for a single cell in a JSON (v2) format. */ +static int +layout_append_v2(struct layout_cell *lc, struct layout_string *ls) +{ + struct layout_cell *lcchild; + struct window_pane *wp; + enum layout_type type; + char c; + u_int i, n, z; + + if (lc == NULL) return (-1); + type = lc->type; + if (type == LAYOUT_TOPBOTTOM) + c = 'v'; + else if (type == LAYOUT_LEFTRIGHT) + c = 'h'; + else if (type == LAYOUT_WINDOWPANE) + c = 'p'; + else + return (-1); + + layout_string_write(ls, "{\"t\":\"%c\",\"w\":%u,\"h\":%u,\"x\":%d" + ",\"y\":%d", c, lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff); + if (type != LAYOUT_WINDOWPANE) { + layout_string_write(ls, ",\"c\":["); + n = 0; + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (layout_append_v2(lcchild, ls) != 0) + return (-1); + layout_string_write(ls, ","); + n++; + } + if (n == 0) + return (-1); + ls->dat[--ls->size] = '\0'; /* removing trailing comma */ + layout_string_write(ls, "]"); + } else { + wp = lc->wp; + if (wp == NULL) + return (-1); + if (wp == wp->window->active) + layout_string_write(ls, ",\"a\":true"); + else if (window_pane_last_index(wp, &i) == 0) + layout_string_write(ls, ",\"l\":%u", i); + if (window_pane_index(wp, &i) != 0) + return (-1); + layout_string_write(ls, ",\"i\":%u", i); + if (lc->flags & LAYOUT_CELL_FLOATING) { + z = layout_cell_zindex(lc); + layout_string_write(ls, ",\"z\":%u", z); + } + layout_string_write(ls, ",\"I\":\"%%%u\"", wp->id); + } + + layout_string_write(ls, "}"); + + return (0); +} + +/* Append information for a single cell in the version 1 format. */ +static int +layout_append_v1(struct layout_cell *lc, struct layout_string *ls) +{ + struct layout_cell *lcchild; + const char *brackets = "[]"; + + if (lc == NULL) + return (-1); + + if (lc->wp != NULL) { + layout_string_write(ls, "%ux%u,%d,%d,%u", lc->g.sx, lc->g.sy, + lc->g.xoff, lc->g.yoff, lc->wp->id); + } else { + layout_string_write(ls, "%ux%u,%d,%d", lc->g.sx, lc->g.sy, + lc->g.xoff, lc->g.yoff); + } switch (lc->type) { case LAYOUT_LEFTRIGHT: - brackets = "}{"; + brackets = "{}"; /* FALLTHROUGH */ case LAYOUT_TOPBOTTOM: - if (strlcat(buf, &brackets[1], len) >= len) - return (-1); + layout_string_write(ls, "%c", brackets[0]); TAILQ_FOREACH(lcchild, &lc->cells, entry) { - if (layout_append(lcchild, buf, len) != 0) - return (-1); - if (strlcat(buf, ",", len) >= len) + if (layout_append_v1(lcchild, ls) != 0) return (-1); + layout_string_write(ls, ","); } - buf[strlen(buf) - 1] = brackets[0]; + + ls->dat[--ls->size] = '\0'; /* removing trailing comma */ + layout_string_write(ls, "%c", brackets[1]); break; case LAYOUT_WINDOWPANE: break; @@ -133,6 +438,121 @@ layout_append(struct layout_cell *lc, char *buf, size_t len) return (0); } +/* + * Copies the tiled part of a layout. Only populates what is necessary to dump a + * V1 layout string. + */ +static struct layout_cell * +layout_custom_copy_layout(struct layout_cell *lc) +{ + struct layout_cell *lcchild, *lcnewchild, *lconly; + struct layout_cell *lcnew; + + if (lc->type == LAYOUT_WINDOWPANE && + (lc->flags & LAYOUT_CELL_FLOATING)) + return (NULL); + + lcnew = layout_create_cell(NULL); + + lcnew->type = lc->type; + lcnew->flags = lc->flags; + if (lc->type == LAYOUT_WINDOWPANE) + lcnew->wp = lc->wp; + layout_set_size(lcnew, lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff); + + switch (lc->type) { + case LAYOUT_WINDOWPANE: + break; + case LAYOUT_TOPBOTTOM: + case LAYOUT_LEFTRIGHT: + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + lcnewchild = layout_custom_copy_layout(lcchild); + if (lcnewchild == NULL) + continue; + TAILQ_INSERT_TAIL(&lcnew->cells, lcnewchild, entry); + lcnewchild->parent = lcnew; + } + + lconly = TAILQ_FIRST(&lcnew->cells); + if (lconly == NULL) { + layout_free_cell(lcnew, 0); + return (NULL); + } + if (TAILQ_NEXT(lconly, entry) == NULL) { + TAILQ_REMOVE(&lcnew->cells, lconly, entry); + lconly->parent = NULL; + layout_free_cell(lcnew, 0); + return (lconly); + } + break; + } + + return (lcnew); +} + +/* Create a compatibility layout for dumping a V1 layout string. */ +static struct layout_cell * +layout_custom_create_compat(struct layout_cell *lcroot) +{ + struct layout_cell *lccompat; + + lccompat = layout_custom_copy_layout(lcroot); + if (lccompat != NULL && layout_cell_is_tiled(lccompat)) { + lccompat->g.xoff = 0; + lccompat->g.yoff = 0; + } + + return (lccompat); +} + +/* Unlinks all panes from the given layout. */ +static void +layout_custom_unlink_panes(struct layout_cell *lc) +{ + struct layout_cell *lcchild; + + switch (lc->type) { + case LAYOUT_WINDOWPANE: + lc->wp = NULL; + break; + case LAYOUT_LEFTRIGHT: + case LAYOUT_TOPBOTTOM: + TAILQ_FOREACH(lcchild, &lc->cells, entry) + layout_custom_unlink_panes(lcchild); + break; + } +} + +/* Frees the compatibility layout. */ +static void +layout_custom_free_compat(struct layout_cell *lcroot) +{ + if (lcroot == NULL) + return; + layout_custom_unlink_panes(lcroot); + layout_free_cell(lcroot, 0); +} + +/* Dispatch to append the appropriate version. */ +static int +layout_append(struct layout_cell *lcroot, struct layout_string *ls, int flags) +{ + struct layout_cell *lccompat; + int result; + + if (flags & LAYOUT_CUSTOM_OLD_FORMAT) { + if (!layout_cell_is_tiled(lcroot) && + !layout_cell_has_tiled_child(lcroot)) + return (-1); + lccompat = layout_custom_create_compat(lcroot); + result = layout_append_v1(lccompat, ls); + layout_custom_free_compat(lccompat); + } else + result = layout_append_v2(lcroot, ls); + + return (result); +} + /* Check layout sizes fit. */ static int layout_check(struct layout_cell *lc) @@ -145,24 +565,30 @@ layout_check(struct layout_cell *lc) break; case LAYOUT_LEFTRIGHT: TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (!layout_cell_is_tiled(lcchild) && + !layout_cell_has_tiled_child(lcchild)) + continue; if (lcchild->g.sy != lc->g.sy) return (0); if (!layout_check(lcchild)) return (0); n += lcchild->g.sx + 1; } - if (n - 1 != lc->g.sx) + if (n != 0 && n - 1 != lc->g.sx) return (0); break; case LAYOUT_TOPBOTTOM: TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (!layout_cell_is_tiled(lcchild) && + !layout_cell_has_tiled_child(lcchild)) + continue; if (lcchild->g.sx != lc->g.sx) return (0); if (!layout_check(lcchild)) return (0); n += lcchild->g.sy + 1; } - if (n - 1 != lc->g.sy) + if (n != 0 && n - 1 != lc->g.sy) return (0); break; } @@ -171,45 +597,30 @@ layout_check(struct layout_cell *lc) /* Parse a layout string and arrange window as layout. */ int -layout_parse(struct window *w, const char *layout, char **cause) +layout_parse(struct window *w, const char *input, char **cause) { - struct layout_cell *lcchild, *tiled_lc = NULL; struct window_pane *wp; + struct layout_cell *lcchild, *lc = NULL; + struct layout_parse_ctx pctx; u_int npanes, ncells, sx = 0, sy = 0; - u_short csum; - int n = 0; - - /* Check validity. */ - if (sscanf(layout, "%hx,%n", &csum, &n) != 1 || n != 5) { - *cause = xstrdup("invalid layout"); - return (-1); - } - layout += n; - if (csum != layout_checksum(layout)) { - *cause = xstrdup("invalid layout"); - return (-1); - } + int with_floating; /* Build the layout. */ - if (layout_construct(NULL, &layout, &tiled_lc) != 0) { - *cause = xstrdup("invalid layout"); + layout_parse_init_ctx(&pctx, cause); + if (layout_construct(input, &pctx) != 0) { + layout_parse_free_ctx(&pctx); return (-1); } - if (tiled_lc == NULL) { - /* A stub layout cell for an empty window. */ - tiled_lc = layout_create_cell(NULL); - tiled_lc->type = LAYOUT_LEFTRIGHT; - layout_set_size(tiled_lc, w->sx, w->sy, 0, 0); - } - if (*layout != '\0') { - *cause = xstrdup("invalid layout"); - goto fail; - } + with_floating = pctx.version > 1; /* Check this window will fit into the layout. */ - npanes = window_count_panes(w, 1); + npanes = window_count_panes(w, with_floating); + if (npanes == 0) { + xasprintf(cause, "window @%u has no panes", w->id); + goto fail; + } for (;;) { - ncells = layout_count_cells(tiled_lc); + ncells = layout_count_cells(pctx.root, with_floating); if (npanes > ncells) { xasprintf(cause, "have %u panes but need %u", npanes, ncells); @@ -222,82 +633,127 @@ layout_parse(struct window *w, const char *layout, char **cause) * Fewer panes than cells, close the bottom right until none * remain. */ - lcchild = layout_find_bottomright(tiled_lc); - layout_destroy_cell(w, lcchild, &tiled_lc); + lcchild = layout_find_bottomright(pctx.root); + if (pctx.version > 1 && layout_parse_remove_cctx(&pctx, + lcchild) != 0) { + *cause = xstrdup("empty/missing layout parse context"); + goto fail; + } + layout_destroy_cell(NULL, lcchild, &pctx.root); } + /* The root is now owned by lc. */ + lc = pctx.root; + pctx.root = NULL; + /* * It appears older versions of tmux were able to generate layouts with * an incorrect top cell size - if it is larger than the top child then * correct that (if this is still wrong the check code will catch it). */ - - switch (tiled_lc->type) { + switch (lc->type) { case LAYOUT_WINDOWPANE: break; case LAYOUT_LEFTRIGHT: - TAILQ_FOREACH(lcchild, &tiled_lc->cells, entry) { - sy = lcchild->g.sy + 1; - sx += lcchild->g.sx + 1; + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (layout_cell_is_tiled(lcchild) || + layout_cell_has_tiled_child(lcchild)) { + sy = lcchild->g.sy + 1; + sx += lcchild->g.sx + 1; + } } break; case LAYOUT_TOPBOTTOM: - TAILQ_FOREACH(lcchild, &tiled_lc->cells, entry) { - sx = lcchild->g.sx + 1; - sy += lcchild->g.sy + 1; + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (layout_cell_is_tiled(lcchild) || + layout_cell_has_tiled_child(lcchild)) { + sx = lcchild->g.sx + 1; + sy += lcchild->g.sy + 1; + } } break; } - if (tiled_lc->type != LAYOUT_WINDOWPANE && - (tiled_lc->g.sx != sx || tiled_lc->g.sy != sy)) { - layout_print_cell(tiled_lc, __func__, 0); - tiled_lc->g.sx = sx - 1; tiled_lc->g.sy = sy - 1; + if (lc->type != LAYOUT_WINDOWPANE && sx != 0 && sy != 0 && + (lc->g.sx != sx || lc->g.sy != sy)) { + layout_print_cell(lc, __func__, 0); + lc->g.sx = sx - 1; lc->g.sy = sy - 1; } /* Check the new layout. */ - if (!layout_check(tiled_lc)) { + if (!layout_check(lc)) { *cause = xstrdup("size mismatch after applying layout"); goto fail; } /* Resize window to the layout size. */ - if (sx != 0 && sy != 0) - window_resize(w, tiled_lc->g.sx, tiled_lc->g.sy, -1, -1); + if (layout_cell_is_tiled(lc) || + layout_cell_has_tiled_child(lc)) + window_resize(w, lc->g.sx, lc->g.sy, -1, -1); + + /* Preserve floating panes for version 1. */ + if (pctx.version == 1) { + TAILQ_FOREACH(wp, &w->panes, entry) { + if (!window_pane_is_floating(wp)) + continue; + lcchild = wp->layout_cell; + TAILQ_REMOVE(&lcchild->parent->cells, lcchild, entry); + lcchild->parent = NULL; + } + } /* Destroy the old layout and swap to the new. */ layout_free_cell(w->layout_root, 0); - w->layout_root = tiled_lc; + w->layout_root = lc; /* Assign the panes into the cells. */ - wp = TAILQ_FIRST(&w->panes); - if (tiled_lc != NULL) - layout_assign(&wp, tiled_lc, 0); + layout_assign(w, &pctx); - /* Fix pane z-indexes. */ - while (!TAILQ_EMPTY(&w->z_index)) { - wp = TAILQ_FIRST(&w->z_index); - TAILQ_REMOVE(&w->z_index, wp, zentry); - } - layout_fix_zindexes(w, tiled_lc); - - /* Update pane offsets and sizes. */ + /* Update pane attributes. */ layout_fix_offsets(w); layout_fix_panes(w, NULL); + if (pctx.version > 1) + layout_parse_apply_ctx(w, &pctx); recalculate_sizes(); - layout_print_cell(tiled_lc, __func__, 0); + layout_print_cell(lc, __func__, 0); - events_fire_window("window-layout-changed", w); + /* Backwards compatibility. */ + if (pctx.version == 1) + events_fire_window("window-layout-changed", w); + layout_parse_free_ctx(&pctx); return (0); fail: - layout_free_cell(tiled_lc, 0); + layout_free_cell(lc, 0); + layout_parse_free_ctx(&pctx); return (-1); } -/* Assign panes into cells. */ +/* Assign panes into cells from the cell contexts. */ static void -layout_assign(struct window_pane **wp, struct layout_cell *lc, int flags) +layout_assign_from_ctx(struct window *w, struct layout_parse_ctx *pctx) +{ + struct layout_cell *lc; + struct window_pane *wp; + int i; + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_index_cmp); + + wp = TAILQ_FIRST(&w->panes); + for (i = 0; i < pctx->size; i++) { + lc = pctx->cctxs[i].lc; + layout_make_leaf(lc, wp); + wp = TAILQ_NEXT(wp, entry); + } +} + +/* + * Assign tiled cells to available panes. Panes that already have a cell are + * floating and are skipped over. + */ +static void +layout_assign_fallback_tiled(struct window_pane **wp, struct layout_cell *lc) { struct layout_cell *lcchild; @@ -306,18 +762,60 @@ layout_assign(struct window_pane **wp, struct layout_cell *lc, int flags) switch (lc->type) { case LAYOUT_WINDOWPANE: + while (*wp != NULL && (*wp)->layout_cell != NULL) + *wp = TAILQ_NEXT(*wp, entry); + if (*wp == NULL) + return; layout_make_leaf(lc, *wp); - lc->flags |= flags; *wp = TAILQ_NEXT(*wp, entry); return; case LAYOUT_LEFTRIGHT: case LAYOUT_TOPBOTTOM: - TAILQ_FOREACH(lcchild, &lc->cells, entry) - layout_assign(wp, lcchild, flags); + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + layout_assign_fallback_tiled(wp, lcchild); + } return; } } +/* + * Assign panes into cells when there are no cell contexts. This will be removed + * when the non-JSON format is deprecated. + */ +static void +layout_assign_fallback(struct window *w, struct layout_cell *lcroot) +{ + struct window_pane *wp = TAILQ_FIRST(&w->panes); + struct layout_cell *lc; + + layout_assign_fallback_tiled(&wp, lcroot); + + if (window_count_panes(w, 1) > 1 && + lcroot->type == LAYOUT_WINDOWPANE) + lcroot = layout_replace_with_node(w, lcroot, LAYOUT_TOPBOTTOM); + + wp = TAILQ_FIRST(&w->panes); + while (wp != NULL) { + if (window_pane_is_floating(wp)) { + lc = wp->layout_cell; + lc->parent = lcroot; + TAILQ_INSERT_TAIL(&lcroot->cells, lc, entry); + } + wp = TAILQ_NEXT(wp, entry); + } +} + +/* Assign panes into cells. Number of cells must match the number of panes. */ +static void +layout_assign(struct window *w, struct layout_parse_ctx *pctx) +{ + if (pctx->size > 0) + layout_assign_from_ctx(w, pctx); + else + layout_assign_fallback(w, w->layout_root); +} + +/* Construct a cell from the version 1 format. */ static struct layout_cell * layout_construct_cell(struct layout_cell *lcparent, const char **layout) { @@ -328,7 +826,12 @@ layout_construct_cell(struct layout_cell *lcparent, const char **layout) if (!isdigit((u_char) **layout)) return (NULL); - if (sscanf(*layout, "%ux%u,%d,%d", &sx, &sy, &xoff, &yoff) != 4) + if (sscanf(*layout, "%5ux%5u,%5d,%5d", &sx, &sy, &xoff, &yoff) != 4) + return (NULL); + if (sx < PANE_MINIMUM || sx > PANE_MAXIMUM || + sy < PANE_MINIMUM || sy > PANE_MAXIMUM || + xoff < 0 || xoff > WINDOW_MAXIMUM || + yoff < 0 || yoff > WINDOW_MAXIMUM) return (NULL); while (isdigit((u_char) **layout)) @@ -366,34 +869,30 @@ layout_construct_cell(struct layout_cell *lcparent, const char **layout) return (lc); } -/* - * Given a character string layout, recursively construct cells. - * Possible return values: - * lc LAYOUT_WINDOWPANE, no children - * lc LAYOUT_LEFTRIGHT or LAYOUT_TOPBOTTOM, with children - */ -static int -layout_construct(struct layout_cell *lcparent, const char **layout, - struct layout_cell **lc) +/* Construct a layout from the version 1 format. */ +static struct layout_cell * +layout_construct_v1(struct layout_cell *lcparent, const char **layout, u_int depth) { - struct layout_cell *lcchild; + struct layout_cell *lc, *lcchild; - *lc = layout_construct_cell(lcparent, layout); - if (*lc == NULL) - return (-1); + if (depth > LAYOUT_V1_MAX_DEPTH) + return (NULL); + + lc = layout_construct_cell(lcparent, layout); + if (lc == NULL) + return (NULL); switch (**layout) { case ',': case '}': case ']': - case '>': case '\0': - return (0); + return (lc); case '{': - (*lc)->type = LAYOUT_LEFTRIGHT; + lc->type = LAYOUT_LEFTRIGHT; break; case '[': - (*lc)->type = LAYOUT_TOPBOTTOM; + lc->type = LAYOUT_TOPBOTTOM; break; default: goto fail; @@ -401,12 +900,13 @@ layout_construct(struct layout_cell *lcparent, const char **layout, do { (*layout)++; - if (layout_construct(*lc, layout, &lcchild) != 0) + lcchild = layout_construct_v1(lc, layout, depth + 1); + if (lcchild == NULL) goto fail; - TAILQ_INSERT_TAIL(&(*lc)->cells, lcchild, entry); + TAILQ_INSERT_TAIL(&lc->cells, lcchild, entry); } while (**layout == ','); - switch ((*lc)->type) { + switch (lc->type) { case LAYOUT_LEFTRIGHT: if (**layout != '}') goto fail; @@ -420,9 +920,338 @@ layout_construct(struct layout_cell *lcparent, const char **layout, } (*layout)++; + return (lc); + +fail: + layout_free_cell(lc, 0); + return (NULL); +} + +/* + * Evaluate parsed JSON. Check metadata at the top level and return the new + * layout root. Consumes json input. + */ +static int +layout_parse_json(struct json_node *jnroot, struct layout_parse_ctx *pctx) +{ + struct json_node *jn, *object; + int64_t num; + char **cause = pctx->cause; + + if (json_get_object(jnroot, &jn) != 0) { + *cause = xstrdup("invalid layout json"); + goto fail; + } + + if (json_find_number(jn, "V", &num, cause) != 0) + goto fail; + pctx->version = num; + + if (json_find_object(jn, "L", &object, cause) != 0) + goto fail; + pctx->root = layout_parse_json_layout(object, NULL, pctx); + if (pctx->root == NULL) + goto fail; + + json_destroy_node(jnroot); + return (0); fail: - layout_free_cell(*lc, 0); + json_destroy_node(jnroot); + if (pctx->root != NULL) + layout_free_cell(pctx->root, 0); + pctx->root = NULL; return (-1); } + +/* Parse nodes into layout cells. */ +static struct layout_cell * +layout_parse_json_layout(struct json_node *node, struct layout_cell *lcparent, + struct layout_parse_ctx *pctx) +{ + struct json_node *member, *array; + struct layout_cell *lc = layout_create_cell(lcparent), *lcchild; + const char *str; + int64_t num; + char **cause = pctx->cause; + int boolean, index, zindex, active = -1; + int last = -1; + + if (json_find_string(node, "t", &str, cause) != 0) + goto fail; + if (strcmp(str, "p") == 0) + lc->type = LAYOUT_WINDOWPANE; + else if (strcmp(str, "v") == 0) + lc->type = LAYOUT_TOPBOTTOM; + else if (strcmp(str, "h") == 0) + lc->type = LAYOUT_LEFTRIGHT; + else { + xasprintf(cause, "unknown cell type \"%s\"", str); + goto fail; + } + + if (json_find_number(node, "w", &num, cause) != 0) + goto fail; + if (num < PANE_MINIMUM || num > PANE_MAXIMUM) { + xasprintf(cause, "invalid width %lld", (long long)num); + goto fail; + } + lc->g.sx = num; + + if (json_find_number(node, "h", &num, cause) != 0) + goto fail; + if (num < PANE_MINIMUM || num > PANE_MAXIMUM) { + xasprintf(cause, "invalid height %lld", (long long)num); + goto fail; + } + lc->g.sy = num; + + if (json_find_number(node, "x", &num, cause) != 0) + goto fail; + if (num < -WINDOW_MAXIMUM || num > WINDOW_MAXIMUM) { + xasprintf(cause, "invalid x-offset %lld", (long long)num); + goto fail; + } + lc->g.xoff = num; + + if (json_find_number(node, "y", &num, cause) != 0) + goto fail; + if (num < -WINDOW_MAXIMUM || num > WINDOW_MAXIMUM) { + xasprintf(cause, "invalid y-offset %lld", (long long)num); + goto fail; + } + lc->g.yoff = num; + + if (lc->type == LAYOUT_WINDOWPANE) { /* "I" is currently ignored */ + if (json_find(node, "c") != NULL) { + *cause = xstrdup("panes cannot have children"); + goto fail; + } + if (json_find_number(node, "i", &num, cause) != 0) + goto fail; + if (num < 0 || num > INT_MAX) { + xasprintf(cause, "invalid index %lld", (long long)num); + goto fail; + } + index = num; + + if (json_find(node, "a") != NULL) { + if (json_find_boolean(node, "a", &boolean, cause) != 0) + goto fail; + active = boolean; + if (active) + pctx->num_active++; + } else if (json_find(node, "l") != NULL) { + if (json_find_number(node, "l", &num, cause) != 0) + goto fail; + if (num < 0 || num > INT_MAX) { + xasprintf(cause, "invalid last %lld", + (long long)num); + goto fail; + } + last = num; + } + + if (json_find(node, "z") != NULL) { + if (json_find_number(node, "z", &num, cause) != 0) + goto fail; + if (num < 0 || num > INT_MAX - 1) { + xasprintf(cause, "invalid floating zindex %lld", + (long long)num); + goto fail; + } + zindex = num; + lc->flags |= LAYOUT_CELL_FLOATING; + } else + zindex = INT_MAX; + + layout_parse_add_cctx(pctx, lc, active, last, index, zindex); + } else { + if (json_find_array(node, "c", &array, cause) != 0) + goto fail; + if ((member = json_array_first(array)) == NULL || + json_array_next(member) == NULL) { + *cause = xstrdup("nodes must have more than one child"); + goto fail; + } + while (member != NULL) { + lcchild = layout_parse_json_layout(member, lc, + pctx); + if (lcchild == NULL) + goto fail; + TAILQ_INSERT_TAIL(&lc->cells, lcchild, entry); + member = json_array_next(member); + } + } + + return (lc); + +fail: + layout_free_cell(lc, 0); + return (NULL); +} + +/* Construct a layout root from a formatted string. */ +static int +layout_construct(const char *input, struct layout_parse_ctx *pctx) +{ + struct json_node *json; + u_short csum; + int n = 0; + + while (isspace((u_char) *input)) + input++; + + if (*input != '{') { /* sniffing version */ + if (sscanf(input, "%hx,%n", &csum, &n) != 1 || n != 5) { + *pctx->cause = xstrdup("malformed layout header"); + return (-1); + } + input += n; + if (csum != layout_checksum(input)) { + *pctx->cause = xstrdup("invalid layout checksum"); + return (-1); + } + pctx->root = layout_construct_v1(NULL, &input, 0); + if (pctx->root == NULL) { + *pctx->cause = xstrdup("invalid layout"); + return (-1); + } + if (*input != '\0') { + *pctx->cause = xstrdup("trailing data"); + return (-1); + } + pctx->version = 1; + } else { + if ((json = json_parse(input, pctx->cause)) == NULL) + return (-1); + + if (layout_parse_json(json, pctx) != 0) + return (-1); + + if (pctx->version != 2) { + *pctx->cause = xstrdup("version mismatch"); + return (-1); + } + if (pctx->num_active > 1) { + *pctx->cause = xstrdup("more than one active pane"); + return (-1); + } + if (pctx->size == 0) { + *pctx->cause = xstrdup("no panes"); + return (-1); + } + if (!layout_parse_ctx_check_indexes(pctx)) + return (-1); + } + + return (0); +} + +/* Apply the remaining context to the layout. */ +static void +layout_parse_apply_ctx(struct window *w, struct layout_parse_ctx *pctx) +{ + struct layout_parse_cell_ctx *cctx; + struct window_pane *wp, *wpnext; + int i; + + /* Apply z-indexes. */ + wp = TAILQ_FIRST(&w->z_index); + while (wp != NULL) { + wpnext = TAILQ_NEXT(wp, zentry); + if (window_pane_is_floating(wp)) + TAILQ_REMOVE(&w->z_index, wp, zentry); + wp = wpnext; + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_zindex_cmp); + + for (i = 0; i < pctx->size; i++) { + cctx = &pctx->cctxs[i]; + wp = cctx->lc->wp; + if (window_pane_is_floating(wp)) + TAILQ_INSERT_HEAD(&w->z_index, wp, zentry); + } + + /* Set the active pane. */ + for (i = 0; i < pctx->size; i++) { + cctx = &pctx->cctxs[i]; + if (cctx->active == 1) { + window_set_active_pane(w, cctx->lc->wp, 1); + break; + } + } + + /* Apply last panes. */ + while (!TAILQ_EMPTY(&w->last_panes)) { + wp = TAILQ_FIRST(&w->last_panes); + window_pane_stack_remove(&w->last_panes, wp); + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_last_cmp); + + for (i = 0; i < pctx->size; i++) { + cctx = &pctx->cctxs[i]; + wp = cctx->lc->wp; + if (cctx->last < 0 || cctx->active == 1) + continue; + window_pane_stack_push(&w->last_panes, wp); + } +} + +/* Checks for duplicate pane indexes, z-indexes, and last indexes. */ +static int +layout_parse_ctx_check_indexes(struct layout_parse_ctx *pctx) +{ + int i, n; + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_index_cmp); + + for (i = 1; i < pctx->size; i++) { + if (pctx->cctxs[i].index == pctx->cctxs[i - 1].index) { + *pctx->cause = xstrdup("duplicate pane index"); + return (0); + } + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_zindex_cmp); + + /* + * Sorted in descending order, so the panes without a z-index come first + * and the floating panes run to the end. + */ + n = 0; + while (n < pctx->size && pctx->cctxs[n].zindex == INT_MAX) + n++; + for (i = n + 1; i < pctx->size; i++) { + if (pctx->cctxs[i].zindex == pctx->cctxs[i - 1].zindex) { + *pctx->cause = xstrdup("duplicate pane z-index"); + return (0); + } + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_last_cmp); + + /* + * Sorted in descending order, so the panes without a last index come + * last. + */ + n = 0; + while (n < pctx->size && pctx->cctxs[n].last >= 0) + n++; + for (i = 1; i < n; i++) { + if (pctx->cctxs[i].last == pctx->cctxs[i - 1].last) { + *pctx->cause = xstrdup("duplicate last pane index"); + return (0); + } + } + + return (1); +} diff --git a/layout.c b/layout.c index ac87f30e5..92dad4946 100644 --- a/layout.c +++ b/layout.c @@ -1,4 +1,4 @@ -/* $OpenBSD: layout.c,v 1.98 2026/08/25 18:38:05 nicm Exp $ */ +/* $OpenBSD: layout.c,v 1.102 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -106,7 +106,7 @@ layout_free_cell(struct layout_cell *lc, int only_nodes) } break; case LAYOUT_WINDOWPANE: - if (lc->wp != NULL) { + if (lc->wp != NULL && lc->wp->layout_cell != NULL) { lc->wp->layout_cell->parent = NULL; lc->wp->layout_cell = NULL; } @@ -233,29 +233,6 @@ layout_make_node(struct layout_cell *lc, enum layout_type type) lc->wp = NULL; } -/* Fix z-indexes. */ -void -layout_fix_zindexes(struct window *w, struct layout_cell *lc) -{ - struct layout_cell *lcchild; - - if (lc == NULL) - return; - - switch (lc->type) { - case LAYOUT_WINDOWPANE: - TAILQ_INSERT_TAIL(&w->z_index, lc->wp, zentry); - break; - case LAYOUT_LEFTRIGHT: - case LAYOUT_TOPBOTTOM: - TAILQ_FOREACH(lcchild, &lc->cells, entry) - layout_fix_zindexes(w, lcchild); - return; - default: - fatalx("bad layout type"); - } -} - int layout_cell_is_tiled(struct layout_cell *lc) { @@ -265,7 +242,7 @@ layout_cell_is_tiled(struct layout_cell *lc) return is_leaf && !is_floating; } -static int +int layout_cell_has_tiled_child(struct layout_cell *lc) { struct layout_cell *lcchild; @@ -492,7 +469,6 @@ layout_fix_panes(struct window *w, struct window_pane *skip) sx = PANE_MINIMUM; else sx = sx - sb_w - sb_pad; - wp->flags |= PANE_REDRAWSCROLLBAR; } window_pane_resize(wp, sx, sy); @@ -500,8 +476,11 @@ layout_fix_panes(struct window *w, struct window_pane *skip) if (wp->xoff != old_xoff || wp->yoff != old_yoff || wp->sx != old_sx || - wp->sy != old_sy) + wp->sy != old_sy) { + if (window_pane_scrollbar_reserve(wp)) + wp->flags |= PANE_REDRAWSCROLLBAR; changed = 1; + } } if (changed) redraw_invalidate_scene(w); @@ -509,18 +488,20 @@ layout_fix_panes(struct window *w, struct window_pane *skip) /* Count the number of available cells in a layout. */ u_int -layout_count_cells(struct layout_cell *lc) +layout_count_cells(struct layout_cell *lc, int with_floating) { struct layout_cell *lcchild; u_int count = 0; switch (lc->type) { case LAYOUT_WINDOWPANE: + if (lc->flags & LAYOUT_CELL_FLOATING && !with_floating) + return 0; return (1); case LAYOUT_LEFTRIGHT: case LAYOUT_TOPBOTTOM: TAILQ_FOREACH(lcchild, &lc->cells, entry) - count += layout_count_cells(lcchild); + count += layout_count_cells(lcchild, with_floating); return (count); default: fatalx("bad layout type"); @@ -721,7 +702,7 @@ layout_destroy_cell(struct window *w, struct layout_cell *lc, /* If no parent, this is the last pane in a window. */ lcparent = lc->parent; if (lcparent == NULL) { - if (lc->wp != NULL) + if (*lcroot == lc) *lcroot = NULL; layout_free_cell(lc, 0); return; @@ -789,6 +770,49 @@ layout_free(struct window *w, int only_nodes) layout_free_cell(w->layout_root, only_nodes); } +/* Move and resize floating panes so they stay inside the window. */ +static void +layout_clamp_floating_panes(struct window *w, u_int sx, u_int sy) +{ + struct window_pane *wp; + struct layout_cell *lc; + u_int pad, avail, csx, csy; + + TAILQ_FOREACH(wp, &w->z_index, zentry) { + lc = wp->layout_cell; + if (lc == NULL || (~lc->flags & LAYOUT_CELL_FLOATING)) + continue; + if (window_pane_get_pane_lines(wp) == PANE_LINES_NONE) + pad = 0; + else + pad = 1; + + csx = lc->g.sx; + avail = (sx > 2 * pad) ? sx - 2 * pad : 0; + if (csx > avail) + csx = (avail > PANE_MINIMUM) ? avail : PANE_MINIMUM; + csy = lc->g.sy; + avail = (sy > 2 * pad) ? sy - 2 * pad : 0; + if (csy > avail) + csy = (avail > PANE_MINIMUM) ? avail : PANE_MINIMUM; + if (csx != lc->g.sx || csy != lc->g.sy) + layout_set_size(lc, csx, csy, lc->g.xoff, lc->g.yoff); + + if (lc->g.xoff + lc->g.sx + pad > sx) { + if (lc->g.sx + 2 * pad >= sx) + lc->g.xoff = pad; + else + lc->g.xoff = sx - lc->g.sx - pad; + } + if (lc->g.yoff + lc->g.sy + pad > sy) { + if (lc->g.sy + 2 * pad >= sy) + lc->g.yoff = pad; + else + lc->g.yoff = sy - lc->g.sy - pad; + } + } +} + /* Resize the entire layout after window resize. */ void layout_resize(struct window *w, u_int sx, u_int sy) @@ -809,8 +833,11 @@ layout_resize(struct window *w, u_int sx, u_int sy) * out proportionately - this should leave the layout fitting the new * window size. */ - if (lc->type == LAYOUT_WINDOWPANE && (lc->flags & LAYOUT_CELL_FLOATING)) + if (lc->type == LAYOUT_WINDOWPANE && (lc->flags & LAYOUT_CELL_FLOATING)) { + layout_clamp_floating_panes(w, sx, sy); + layout_fix_panes(w, NULL); return; + } xchange = sx - lc->g.sx; xlimit = layout_resize_check(w, lc, LAYOUT_LEFTRIGHT); if (xchange < 0 && xchange < -xlimit) @@ -840,6 +867,7 @@ layout_resize(struct window *w, u_int sx, u_int sy) /* Fix cell offsets. */ layout_fix_offsets(w); + layout_clamp_floating_panes(w, sx, sy); layout_fix_panes(w, NULL); } @@ -1702,7 +1730,7 @@ layout_floating_args_parse(struct cmdq_item *item, struct args *args, enum pane_lines lines, struct window *w, struct layout_geometry *lg, char **cause) { - int sx, sy, ox, oy; + int sx, sy, ox, oy, pad; char *error = NULL; sx = lg->sx == UINT_MAX ? w->sx / 2 : lg->sx; @@ -1751,12 +1779,20 @@ layout_floating_args_parse(struct cmdq_item *item, struct args *args, } } + if (!window_has_floating_panes(w)) { + w->last_new_pane_x = 0; + w->last_new_pane_y = 0; + } if (ox == INT_MAX) { if (w->last_new_pane_x == 0) ox = 4; else { + if (lines != PANE_LINES_NONE) + pad = 1; + else + pad = 0; ox = w->last_new_pane_x + 4; - if (w->last_new_pane_x > w->sx) + if (ox + sx + pad > (int)w->sx) ox = 4; } w->last_new_pane_x = ox; @@ -1767,8 +1803,12 @@ layout_floating_args_parse(struct cmdq_item *item, struct args *args, if (w->last_new_pane_y == 0) oy = 2; else { + if (lines != PANE_LINES_NONE) + pad = 1; + else + pad = 0; oy = w->last_new_pane_y + 2; - if (w->last_new_pane_y > w->sy) + if (oy + sy + pad > (int)w->sy) oy = 2; } w->last_new_pane_y = oy; diff --git a/menu.c b/menu.c index 1d3b0175b..bdbdcdd55 100644 --- a/menu.c +++ b/menu.c @@ -1,4 +1,4 @@ -/* $OpenBSD: menu.c,v 1.70 2026/08/17 07:33:55 nicm Exp $ */ +/* $OpenBSD: menu.c,v 1.71 2026/09/21 12:14:32 nicm Exp $ */ /* * Copyright (c) 2019 Nicholas Marriott @@ -51,6 +51,18 @@ struct menu_data { void *data; }; +void +menu_get_size(struct menu *menu, enum box_lines lines, u_int *sx, u_int *sy) +{ + if (lines == BOX_LINES_NONE) { + *sx = menu->item_width + 2; + *sy = menu->count; + } else { + *sx = menu->width + 4; + *sy = menu->count + 2; + } +} + void menu_add_items(struct menu *menu, const struct menu_item *items, struct cmdq_item *qitem, struct client *c, struct cmd_find_state *fs) @@ -142,6 +154,8 @@ menu_add_item(struct menu *menu, const struct menu_item *item, width = format_width(new_item->name); if (*new_item->name == '-') width--; + if (width > menu->item_width) + menu->item_width = width; if (width > menu->width) menu->width = width; } @@ -238,7 +252,7 @@ menu_update(struct menu_data *md) screen_write_clearscreen(&ctx, 8); if (md->border_lines != BOX_LINES_NONE) { - screen_write_box(&ctx, menu->width + 4, menu->count + 2, + screen_write_box(&ctx, menu_width(md), menu_height(md), md->border_lines, &md->border_style_gc, menu->title); } @@ -286,11 +300,13 @@ menu_destroy(struct window *w) void menu_get_cursor(struct menu_data *md, u_int *cx, u_int *cy) { - *cx = md->px + 2; + u_int border = (md->border_lines != BOX_LINES_NONE); + + *cx = md->px + 1 + border; if (md->choice == -1) *cy = md->py; else - *cy = md->py + 1 + md->choice; + *cy = md->py + border + md->choice; } struct screen * @@ -302,13 +318,19 @@ menu_screen(struct menu_data *md) u_int menu_width(struct menu_data *md) { - return (md->menu->width + 4); + u_int sx, sy; + + menu_get_size(md->menu, md->border_lines, &sx, &sy); + return (sx); } u_int menu_height(struct menu_data *md) { - return (md->menu->count + 2); + u_int sx, sy; + + menu_get_size(md->menu, md->border_lines, &sx, &sy); + return (sy); } u_int @@ -339,6 +361,7 @@ menu_key(struct client *c, struct menu_data *md, struct key_event *event) struct cmdq_state *state; char *error; key_code key; + u_int border; if (KEYC_IS_MOUSE(event->key)) { /* @@ -354,10 +377,10 @@ menu_key(struct client *c, struct menu_data *md, struct key_event *event) return (1); return (0); } - if (m->x < md->px || - m->x > md->px + 4 + menu->width || - m->y < md->py + 1 || - m->y > md->py + 1 + n - 1) { + border = (md->border_lines != BOX_LINES_NONE); + if (m->x < md->px || m->x >= md->px + menu_width(md) || + m->y < md->py + border || + m->y >= md->py + border + n) { if (~md->flags & MENU_STAYOPEN) { if (!move && MOUSE_RELEASE(m->b)) return (1); @@ -380,7 +403,7 @@ menu_key(struct client *c, struct menu_data *md, struct key_event *event) if (!MOUSE_WHEEL(m->b) && !MOUSE_DRAG(m->b)) goto chosen; } - md->choice = m->y - (md->py + 1); + md->choice = m->y - (md->py + border); if (md->choice != old) server_redraw_window_menu(md->w); return (0); @@ -550,20 +573,16 @@ menu_resize(struct menu_data *md, struct window *w) if (md == NULL) return; - nx = md->px; ny = md->py; - sx = md->menu->width + 4; - sy = md->menu->count + 2; - + menu_get_size(md->menu, md->border_lines, &sx, &sy); if (nx + sx > w->sx) { if (w->sx <= sx) nx = 0; else nx = w->sx - sx; } - if (ny + sy > w->sy) { if (w->sy <= sy) ny = 0; @@ -595,8 +614,9 @@ menu_display(struct menu *menu, int flags, int starting_choice, w = fs->w; o = w->options; - sx = menu->width + 4; - sy = menu->count + 2; + if (lines == BOX_LINES_DEFAULT) + lines = options_get_number(o, "menu-border-lines"); + menu_get_size(menu, lines, &sx, &sy); if (sx >= w->sx) px = 0; else if (px + sx > w->sx) @@ -608,9 +628,6 @@ menu_display(struct menu *menu, int flags, int starting_choice, w->menu_last_px = px; w->menu_last_py = py; - if (lines == BOX_LINES_DEFAULT) - lines = options_get_number(o, "menu-border-lines"); - md = xcalloc(1, sizeof *md); md->w = w; md->flags = flags; diff --git a/mode-tree.c b/mode-tree.c index 28c8f58eb..334624c8e 100644 --- a/mode-tree.c +++ b/mode-tree.c @@ -1,4 +1,4 @@ -/* $OpenBSD: mode-tree.c,v 1.101 2026/08/05 07:50:21 nicm Exp $ */ +/* $OpenBSD: mode-tree.c,v 1.103 2026/09/24 18:59:00 nicm Exp $ */ /* * Copyright (c) 2017 Nicholas Marriott @@ -277,11 +277,22 @@ mode_tree_free_items(struct mode_tree_list *mtl) static void mode_tree_check_selected(struct mode_tree_data *mtd) { + if (mtd->height == 0) + return; + + /* If the list has shrunk, do not leave empty lines at the bottom. */ + if (mtd->line_size <= mtd->height) + mtd->offset = 0; + else if (mtd->offset > mtd->line_size - mtd->height) + mtd->offset = mtd->line_size - mtd->height; + /* - * If the current line would now be off screen reset the offset to the - * last visible line. + * If the current line would now be off screen move the offset so it is + * visible. */ - if (mtd->current > mtd->height - 1) + if (mtd->current < mtd->offset) + mtd->offset = mtd->current; + else if (mtd->current > mtd->offset + mtd->height - 1) mtd->offset = mtd->current - mtd->height + 1; } @@ -501,20 +512,14 @@ mode_tree_set_current(struct mode_tree_data *mtd, uint64_t tag) if (mode_tree_get_tag(mtd, tag, &found)) { mtd->current = found; - if (mtd->current > mtd->height - 1) - mtd->offset = mtd->current - mtd->height + 1; - else - mtd->offset = 0; + mode_tree_check_selected(mtd); return (1); } if (mtd->current >= mtd->line_size) { if (mtd->line_size == 0) return (0); mtd->current = mtd->line_size - 1; - if (mtd->current > mtd->height - 1) - mtd->offset = mtd->current - mtd->height + 1; - else - mtd->offset = 0; + mode_tree_check_selected(mtd); } return (0); } @@ -683,13 +688,13 @@ mode_tree_build(struct mode_tree_data *mtd) if (mtd->line_list != NULL && tag == UINT64_MAX) tag = mtd->line_list[mtd->current].item->tag; - mode_tree_set_current(mtd, tag); mtd->width = screen_size_x(s); if (mtd->preview != MODE_TREE_PREVIEW_OFF) mode_tree_set_height(mtd); else mtd->height = screen_size_y(s); + mode_tree_set_current(mtd, tag); mode_tree_check_selected(mtd); } @@ -1375,12 +1380,14 @@ static void mode_tree_display_menu(struct mode_tree_data *mtd, struct client *c, u_int x, u_int y, int outside) { + struct window *w = mtd->wp->window; struct mode_tree_item *mti; struct menu *menu; const struct menu_item *items; struct mode_tree_menu *mtm; char *title; - u_int line; + enum box_lines lines; + u_int line, sx, sy; if (mtd->offset + y > mtd->line_size - 1) line = mtd->current; @@ -1405,14 +1412,17 @@ mode_tree_display_menu(struct mode_tree_data *mtd, struct client *c, u_int x, mtm->line = line; mtd->references++; - if (x >= (menu->width + 4) / 2) - x -= (menu->width + 4) / 2; + lines = options_get_number(w->options, "menu-border-lines"); + menu_get_size(menu, lines, &sx, &sy); + if (x >= sx / 2) + x -= sx / 2; else x = 0; x += mtd->wp->xoff; y += mtd->wp->yoff; - if (menu_display(menu, 0, 0, NULL, x, y, c, BOX_LINES_DEFAULT, NULL, - NULL, NULL, NULL, mode_tree_menu_callback, mtm) != 0) { + + if (menu_display(menu, 0, 0, NULL, x, y, c, lines, NULL, NULL, NULL, + NULL, mode_tree_menu_callback, mtm) != 0) { mode_tree_remove_ref(mtd); free(mtm); menu_free(menu); diff --git a/monitor.c b/monitor.c index ff0c6e5ed..596e137a7 100644 --- a/monitor.c +++ b/monitor.c @@ -1,4 +1,4 @@ -/* $OpenBSD: monitor.c,v 1.7 2026/07/27 19:15:58 nicm Exp $ */ +/* $OpenBSD: monitor.c,v 1.8 2026/09/07 10:15:25 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -614,8 +614,10 @@ monitor_parse(const char *value, char **name, enum monitor_type *type, int *id, *type = MONITOR_ALL_WINDOWS; else if (sscanf(what, "@%d", id) == 1 && *id >= 0) *type = MONITOR_WINDOW; - else + else if (*what == '\0') *type = MONITOR_SESSION; + else + goto fail; *name = xstrdup(copy); *format = xstrdup(split); diff --git a/options-table.c b/options-table.c index 5d171b16f..7cd0135ca 100644 --- a/options-table.c +++ b/options-table.c @@ -1,4 +1,4 @@ -/* $OpenBSD: options-table.c,v 1.244 2026/09/01 12:49:49 nicm Exp $ */ +/* $OpenBSD: options-table.c,v 1.249 2026/10/05 09:03:11 nicm Exp $ */ /* * Copyright (c) 2011 Nicholas Marriott @@ -76,9 +76,10 @@ static const char *options_table_pane_border_indicators_list[] = { "off", "colour", "arrows", "both", NULL }; static const char *options_table_pane_border_lines_list[] = { - "single", "double", "heavy", "simple", "number", "spaces", "none", NULL + "single", "double", "heavy", "simple", "number", "spaces", "none", + "rounded", NULL }; -static const char *options_table_popup_border_lines_list[] = { +static const char *options_table_menu_border_lines_list[] = { "single", "double", "heavy", "simple", "rounded", "padded", "none", NULL }; static const char *options_table_set_clipboard_list[] = { @@ -91,7 +92,7 @@ static const char *options_table_window_size_list[] = { "largest", "smallest", "manual", "latest", NULL }; static const char *options_table_remain_on_exit_list[] = { - "off", "on", "failed", "key", NULL + "off", "on", "failed", "key", "failed-key", NULL }; static const char *options_table_destroy_unattached_list[] = { "off", "on", "keep-last", "keep-group", NULL @@ -182,7 +183,8 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "#[pop-default]" \ "#[norange default]" #define OPTIONS_TABLE_STATUS_FORMAT2 \ - "#[align=left]#{R: ,#{n:#{session_name}}}P: " \ + "#[align=left]" \ + "#{R: ,#{e|-:#{w;T;=/#{status-left-length}:status-left},3}}P: " \ "#[norange default]" \ "#[list=on align=#{status-justify}]" \ "#[list=left-marker]<#[list=right-marker]>#[list=on]" \ @@ -193,7 +195,8 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "#[push-default]" \ "#{T:window-pane-status-format}" \ "#[pop-default]" \ - "#[norange list=on default] " \ + "#[norange list=on default]" \ + "#{?loop_last_flag,,#{E:window-status-separator}}" \ "," \ "#[range=pane|#{pane_id} list=focus " \ "#{?#{!=:#{E:pane-status-current-style},default}," \ @@ -204,10 +207,12 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "#[push-default]" \ "#{T:window-pane-current-status-format}" \ "#[pop-default]" \ - "#[norange list=on default] " \ + "#[norange list=on default]" \ + "#{?loop_last_flag,,#{E:window-status-separator}}" \ "}" #define OPTIONS_TABLE_STATUS_FORMAT3 \ - "#[align=left]#{R: ,#{n:#{session_name}}}S: " \ + "#[align=left]" \ + "#{R: ,#{e|-:#{w;T;=/#{status-left-length}:status-left},3}}S: " \ "#[norange default]" \ "#[list=on align=#{status-justify}]" \ "#[list=left-marker]<#[list=right-marker]>#[list=on]" \ @@ -216,7 +221,7 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "#{E:session-status-style}" \ "]" \ "#[push-default]" \ - "#S#{session_alert}" \ + "#S#{q/h:session_alert}" \ "#[pop-default]" \ "#[norange list=on default] " \ "," \ @@ -227,7 +232,7 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "}" \ "]" \ "#[push-default]" \ - "#S*#{session_alert}" \ + "#S*#{q/h:session_alert}" \ "#[pop-default]" \ "#[norange list=on default] " \ "}" @@ -492,7 +497,7 @@ const struct options_table_entry options_table[] = { { .name = "menu-border-lines", .type = OPTIONS_TABLE_CHOICE, .scope = OPTIONS_TABLE_WINDOW, - .choices = options_table_popup_border_lines_list, + .choices = options_table_menu_border_lines_list, .default_num = BOX_LINES_SINGLE, .text = "Type of characters used to draw menu border lines. Some of " "these are only supported on terminals with UTF-8 support." @@ -1665,40 +1670,14 @@ const struct options_table_entry options_table[] = { .text = "Pane scrollbar position." }, - { .name = "popup-style", - .type = OPTIONS_TABLE_STRING, - .scope = OPTIONS_TABLE_WINDOW, - .default_str = "bg=themedarkgrey,fg=themewhite", - .flags = OPTIONS_TABLE_IS_STYLE, - .separator = ",", - .text = "Default style of popups." - }, - - { .name = "popup-border-style", - .type = OPTIONS_TABLE_STRING, - .scope = OPTIONS_TABLE_WINDOW, - .default_str = "bg=themedarkgrey,fg=themelightgrey", - .flags = OPTIONS_TABLE_IS_STYLE, - .separator = ",", - .text = "Default style of popup borders." - }, - - { .name = "popup-border-lines", - .type = OPTIONS_TABLE_CHOICE, - .scope = OPTIONS_TABLE_WINDOW, - .choices = options_table_popup_border_lines_list, - .default_num = BOX_LINES_SINGLE, - .text = "Type of characters used to draw popup border lines. Some of " - "these are only supported on terminals with UTF-8 support." - }, - { .name = "remain-on-exit", .type = OPTIONS_TABLE_CHOICE, .scope = OPTIONS_TABLE_WINDOW|OPTIONS_TABLE_PANE, .choices = options_table_remain_on_exit_list, .default_num = 0, .text = "Whether panes should remain ('on'), remain until a key is " - "pressed ('key') or be automatically killed ('off' or " + "pressed after any exit ('key') or after a failure " + "('failed-key'), or be automatically killed ('off' or " "'failed') when the program inside exits." }, diff --git a/popup.c b/popup.c deleted file mode 100644 index 0345fa367..000000000 --- a/popup.c +++ /dev/null @@ -1,661 +0,0 @@ -/* $OpenBSD: popup.c,v 1.77 2026/08/28 08:02:16 nicm Exp $ */ - -/* - * Copyright (c) 2020 Nicholas Marriott - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER - * IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING - * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -#include -#include - -#include -#include -#include -#include - -#include "tmux.h" - -struct popup_data { - struct client *c; - struct cmdq_item *item; - int flags; - char *title; - - char *style; - char *border_style; - struct grid_cell border_cell; - enum box_lines border_lines; - - struct screen s; - struct grid_cell defaults; - struct colour_palette palette; - - struct visible_ranges r; - - struct job *job; - struct input_ctx *ictx; - int status; - popup_close_cb cb; - void *arg; - - int close; - - /* Current position and size. */ - u_int px; - u_int py; - u_int sx; - u_int sy; - - /* Preferred position and size. */ - u_int ppx; - u_int ppy; - u_int psx; - u_int psy; - - enum { OFF, MOVE, SIZE } dragging; - u_int dx; - u_int dy; - - u_int lx; - u_int ly; - u_int lb; -}; - -static void -popup_free(struct popup_data *pd) -{ - server_client_unref(pd->c); - - if (pd->job != NULL) - job_free(pd->job); - if (pd->ictx != NULL) - input_free(pd->ictx); - - free(pd->r.ranges); - screen_free(&pd->s); - colour_palette_free(&pd->palette); - - free(pd->title); - free(pd->style); - free(pd->border_style); - free(pd); -} - -static void -popup_reapply_styles(struct popup_data *pd) -{ - struct client *c = pd->c; - struct session *s = c->session; - struct options *o; - struct format_tree *ft; - struct style sytmp; - - if (s == NULL) - return; - o = s->curw->window->options; - - ft = format_create_defaults(NULL, c, s, s->curw, NULL); - - /* Reapply popup style from options. */ - memcpy(&pd->defaults, &grid_default_cell, sizeof pd->defaults); - style_apply(&pd->defaults, o, "popup-style", ft); - if (pd->style != NULL) { - style_set(&sytmp, &grid_default_cell); - if (style_parse(&sytmp, &pd->defaults, pd->style) == 0) { - pd->defaults.fg = sytmp.gc.fg; - pd->defaults.bg = sytmp.gc.bg; - } - } - pd->defaults.attr = 0; - - /* Reapply border style from options. */ - memcpy(&pd->border_cell, &grid_default_cell, sizeof pd->border_cell); - style_apply(&pd->border_cell, o, "popup-border-style", ft); - if (pd->border_style != NULL) { - style_set(&sytmp, &grid_default_cell); - if (style_parse(&sytmp, &pd->border_cell, - pd->border_style) == 0) { - pd->border_cell.fg = sytmp.gc.fg; - pd->border_cell.bg = sytmp.gc.bg; - } - } - pd->border_cell.attr = 0; - - format_free(ft); -} - -static void -popup_redraw_cb(const struct tty_ctx *ttyctx) -{ - struct popup_data *pd = ttyctx->arg; - - pd->c->flags |= CLIENT_REDRAWOVERLAY; -} - -static int -popup_set_client_cb(struct tty_ctx *ttyctx, struct client *c) -{ - struct popup_data *pd = ttyctx->arg; - - if (c != pd->c) - return (0); - if (pd->c->flags & CLIENT_REDRAWOVERLAY) - return (0); - - ttyctx->wox = 0; - ttyctx->woy = 0; - ttyctx->wsx = c->tty.sx; - ttyctx->wsy = c->tty.sy; - - if (pd->border_lines == BOX_LINES_NONE) { - ttyctx->xoff = ttyctx->rxoff = pd->px; - ttyctx->yoff = ttyctx->ryoff = pd->py; - } else { - ttyctx->xoff = ttyctx->rxoff = pd->px + 1; - ttyctx->yoff = ttyctx->ryoff = pd->py + 1; - } - - return (1); -} - -static void -popup_init_ctx_cb(struct screen_write_ctx *ctx, struct tty_ctx *ttyctx) -{ - struct popup_data *pd = ctx->arg; - - memcpy(&ttyctx->defaults, &pd->defaults, sizeof ttyctx->defaults); - ttyctx->flags &= ~TTY_CTX_WINDOW_BIGGER; - ttyctx->style_ctx.defaults = &ttyctx->defaults; - ttyctx->style_ctx.palette = &pd->palette; - ttyctx->redraw_cb = popup_redraw_cb; - ttyctx->set_client_cb = popup_set_client_cb; - ttyctx->arg = pd; -} - -static struct screen * -popup_mode_cb(__unused struct client *c, void *data, u_int *cx, u_int *cy) -{ - struct popup_data *pd = data; - - if (pd->border_lines == BOX_LINES_NONE) { - *cx = pd->px + pd->s.cx; - *cy = pd->py + pd->s.cy; - } else { - *cx = pd->px + 1 + pd->s.cx; - *cy = pd->py + 1 + pd->s.cy; - } - return (&pd->s); -} - -/* Return parts of the input range which are not obstructed by the popup. */ -static struct visible_ranges * -popup_check_cb(__unused struct client* c, void *data, u_int px, u_int py, - u_int nx) -{ - struct popup_data *pd = data; - struct visible_ranges *r = &pd->r; - - server_client_overlay_range(pd->px, pd->py, pd->sx, pd->sy, px, py, nx, - r); - return (r); -} - -static void -popup_draw_cb(struct client *c, void *data) -{ - struct popup_data *pd = data; - struct tty *tty = &c->tty; - struct screen s; - struct screen_write_ctx ctx; - u_int i, px = pd->px, py = pd->py; - struct grid_cell defaults; - struct tty_style_ctx style_ctx; - - popup_reapply_styles(pd); - - screen_init(&s, pd->sx, pd->sy, 0); - if (pd->s.hyperlinks != NULL) { - hyperlinks_free(s.hyperlinks); - s.hyperlinks = hyperlinks_copy(pd->s.hyperlinks); - } - screen_write_start(&ctx, &s); - screen_write_clearscreen(&ctx, 8); - - if (pd->border_lines == BOX_LINES_NONE) { - screen_write_cursormove(&ctx, 0, 0, 0); - screen_write_fast_copy(&ctx, &pd->s, 0, 0, pd->sx, pd->sy); - } else if (pd->sx > 2 && pd->sy > 2) { - screen_write_box(&ctx, pd->sx, pd->sy, pd->border_lines, - &pd->border_cell, pd->title); - screen_write_cursormove(&ctx, 1, 1, 0); - screen_write_fast_copy(&ctx, &pd->s, 0, 0, pd->sx - 2, - pd->sy - 2); - } - screen_write_stop(&ctx); - - memcpy(&defaults, &pd->defaults, sizeof defaults); - if (defaults.fg == 8) - defaults.fg = pd->palette.fg; - if (defaults.bg == 8) - defaults.bg = pd->palette.bg; - style_ctx.defaults = &defaults; - style_ctx.palette = &pd->palette; - style_ctx.dim = 0; - style_ctx.hyperlinks = s.hyperlinks; - - c->overlay_check = NULL; - c->overlay_data = NULL; - for (i = 0; i < pd->sy; i++) - tty_draw_line(tty, &s, 0, i, pd->sx, px, py + i, &style_ctx); - screen_free(&s); - c->overlay_check = popup_check_cb; - c->overlay_data = pd; -} - -static void -popup_free_cb(__unused struct client *c, void *data) -{ - struct popup_data *pd = data; - struct cmdq_item *item = pd->item; - - if (pd->cb != NULL) - pd->cb(pd->status, pd->arg); - - if (item != NULL) { - if (cmdq_get_client(item) != NULL && - cmdq_get_client(item)->session == NULL) - cmdq_get_client(item)->retval = pd->status; - cmdq_continue(item); - } - - popup_free(pd); -} - -static void -popup_resize_cb(__unused struct client *c, void *data) -{ - struct popup_data *pd = data; - struct tty *tty = &c->tty; - - if (pd == NULL) - return; - - /* Adjust position and size. */ - if (pd->psy > tty->sy) - pd->sy = tty->sy; - else - pd->sy = pd->psy; - if (pd->psx > tty->sx) - pd->sx = tty->sx; - else - pd->sx = pd->psx; - if (pd->ppy + pd->sy > tty->sy) - pd->py = tty->sy - pd->sy; - else - pd->py = pd->ppy; - if (pd->ppx + pd->sx > tty->sx) - pd->px = tty->sx - pd->sx; - else - pd->px = pd->ppx; - - /* Avoid zero size screens. */ - if (pd->border_lines == BOX_LINES_NONE) { - screen_resize(&pd->s, pd->sx, pd->sy, 0); - if (pd->job != NULL) - job_resize(pd->job, pd->sx, pd->sy ); - } else if (pd->sx > 2 && pd->sy > 2) { - screen_resize(&pd->s, pd->sx - 2, pd->sy - 2, 0); - if (pd->job != NULL) - job_resize(pd->job, pd->sx - 2, pd->sy - 2); - } -} - -static void -popup_handle_drag(struct client *c, struct popup_data *pd, - struct mouse_event *m) -{ - u_int px, py; - - if (!MOUSE_DRAG(m->b)) - pd->dragging = OFF; - else if (pd->dragging == MOVE) { - if (m->x < pd->dx) - px = 0; - else if (m->x - pd->dx + pd->sx > c->tty.sx) - px = c->tty.sx - pd->sx; - else - px = m->x - pd->dx; - if (m->y < pd->dy) - py = 0; - else if (m->y - pd->dy + pd->sy > c->tty.sy) - py = c->tty.sy - pd->sy; - else - py = m->y - pd->dy; - pd->px = px; - pd->py = py; - pd->dx = m->x - pd->px; - pd->dy = m->y - pd->py; - pd->ppx = px; - pd->ppy = py; - server_redraw_client(c); - } else if (pd->dragging == SIZE) { - if (pd->border_lines == BOX_LINES_NONE) { - if (m->x < pd->px + 1) - return; - if (m->y < pd->py + 1) - return; - } else { - if (m->x < pd->px + 3) - return; - if (m->y < pd->py + 3) - return; - } - pd->sx = m->x - pd->px; - pd->sy = m->y - pd->py; - pd->psx = pd->sx; - pd->psy = pd->sy; - - if (pd->border_lines == BOX_LINES_NONE) { - screen_resize(&pd->s, pd->sx, pd->sy, 0); - if (pd->job != NULL) - job_resize(pd->job, pd->sx, pd->sy); - } else { - screen_resize(&pd->s, pd->sx - 2, pd->sy - 2, 0); - if (pd->job != NULL) - job_resize(pd->job, pd->sx - 2, pd->sy - 2); - } - server_redraw_client(c); - } -} - -static int -popup_key_cb(struct client *c, void *data, struct key_event *event) -{ - struct popup_data *pd = data; - struct mouse_event *m = &event->m; - const char *buf; - size_t len; - u_int px, py; - enum { NONE, LEFT, RIGHT, TOP, BOTTOM } border = NONE; - - if (KEYC_IS_MOUSE(event->key)) { - if (pd->dragging != OFF) { - popup_handle_drag(c, pd, m); - goto out; - } - if (m->x < pd->px || - m->x > pd->px + pd->sx - 1 || - m->y < pd->py || - m->y > pd->py + pd->sy - 1) { - return (0); - } - if (pd->border_lines != BOX_LINES_NONE) { - if (m->x == pd->px) - border = LEFT; - else if (m->x == pd->px + pd->sx - 1) - border = RIGHT; - else if (m->y == pd->py) - border = TOP; - else if (m->y == pd->py + pd->sy - 1) - border = BOTTOM; - } - if ((m->b & MOUSE_MASK_MODIFIERS) == 0 && - MOUSE_BUTTONS(m->b) == MOUSE_BUTTON_3 && - (border == LEFT || border == TOP)) - goto out; - if (((m->b & MOUSE_MASK_MODIFIERS) == MOUSE_MASK_META) || - (border != NONE && !MOUSE_DRAG(m->lb))) { - if (!MOUSE_DRAG(m->b)) - goto out; - if (MOUSE_BUTTONS(m->lb) == MOUSE_BUTTON_1) - pd->dragging = MOVE; - else if (MOUSE_BUTTONS(m->lb) == MOUSE_BUTTON_3) - pd->dragging = SIZE; - pd->dx = m->lx - pd->px; - pd->dy = m->ly - pd->py; - goto out; - } - } - if ((((pd->flags & (POPUP_CLOSEEXIT|POPUP_CLOSEEXITZERO)) == 0) || - pd->job == NULL) && - (event->key == '\033' || event->key == ('c'|KEYC_CTRL))) - return (1); - if (pd->job == NULL && (pd->flags & POPUP_CLOSEANYKEY) && - !KEYC_IS_MOUSE(event->key) && !KEYC_IS_PASTE(event->key)) - return (1); - if (pd->job != NULL) { - if (KEYC_IS_MOUSE(event->key)) { - /* Must be inside, checked already. */ - if (pd->border_lines == BOX_LINES_NONE) { - px = m->x - pd->px; - py = m->y - pd->py; - } else { - px = m->x - pd->px - 1; - py = m->y - pd->py - 1; - } - if (!input_key_get_mouse(&pd->s, m, px, py, &buf, &len)) - return (0); - bufferevent_write(job_get_event(pd->job), buf, len); - return (0); - } - input_key(&pd->s, job_get_event(pd->job), event->key); - } - return (0); - -out: - pd->lx = m->x; - pd->ly = m->y; - pd->lb = m->b; - return (0); -} - -static void -popup_job_update_cb(struct job *job) -{ - struct popup_data *pd = job_get_data(job); - struct evbuffer *evb = job_get_event(job)->input; - struct client *c = pd->c; - struct screen *s = &pd->s; - void *data = EVBUFFER_DATA(evb); - size_t size = EVBUFFER_LENGTH(evb); - - if (size == 0) - return; - - c->overlay_check = NULL; - c->overlay_data = NULL; - input_parse_screen(pd->ictx, s, popup_init_ctx_cb, pd, data, size); - c->overlay_check = popup_check_cb; - c->overlay_data = pd; - - evbuffer_drain(evb, size); -} - -static void -popup_job_complete_cb(struct job *job) -{ - struct popup_data *pd = job_get_data(job); - int status; - - status = job_get_status(pd->job); - if (WIFEXITED(status)) - pd->status = WEXITSTATUS(status); - else if (WIFSIGNALED(status)) - pd->status = WTERMSIG(status); - else - pd->status = 0; - pd->job = NULL; - - if ((pd->flags & POPUP_CLOSEEXIT) || - ((pd->flags & POPUP_CLOSEEXITZERO) && pd->status == 0)) - server_client_clear_overlay(pd->c); -} - -int -popup_present(struct client *c) -{ - return (c->overlay_draw == popup_draw_cb); -} - -int -popup_modify(struct client *c, const char *title, const char *style, - const char *border_style, enum box_lines lines, int flags) -{ - struct popup_data *pd = c->overlay_data; - struct style sytmp; - - if (title != NULL) { - if (pd->title != NULL) - free(pd->title); - pd->title = xstrdup(title); - } - if (border_style != NULL) { - free(pd->border_style); - pd->border_style = xstrdup(border_style); - style_set(&sytmp, &pd->border_cell); - if (style_parse(&sytmp, &pd->border_cell, border_style) == 0) { - pd->border_cell.fg = sytmp.gc.fg; - pd->border_cell.bg = sytmp.gc.bg; - } - } - if (style != NULL) { - free(pd->style); - pd->style = xstrdup(style); - style_set(&sytmp, &pd->defaults); - if (style_parse(&sytmp, &pd->defaults, style) == 0) { - pd->defaults.fg = sytmp.gc.fg; - pd->defaults.bg = sytmp.gc.bg; - } - } - if (lines != BOX_LINES_DEFAULT) { - if (lines == BOX_LINES_NONE && pd->border_lines != lines) { - screen_resize(&pd->s, pd->sx, pd->sy, 1); - job_resize(pd->job, pd->sx, pd->sy); - } else if (pd->border_lines == BOX_LINES_NONE && - pd->border_lines != lines) { - screen_resize(&pd->s, pd->sx - 2, pd->sy - 2, 1); - job_resize(pd->job, pd->sx - 2, pd->sy - 2); - } - pd->border_lines = lines; - tty_resize(&c->tty); - } - if (flags != -1) - pd->flags = flags; - - server_redraw_client(c); - return (0); -} - -int -popup_display(int flags, enum box_lines lines, struct cmdq_item *item, u_int px, - u_int py, u_int sx, u_int sy, struct environ *env, const char *shellcmd, - int argc, char **argv, const char *cwd, const char *title, struct client *c, - struct session *s, const char *style, const char *border_style, - popup_close_cb cb, void *arg) -{ - struct popup_data *pd; - u_int jx, jy; - struct options *o; - struct style sytmp; - - if (s != NULL) - o = s->curw->window->options; - else - o = c->session->curw->window->options; - - if (lines == BOX_LINES_DEFAULT) - lines = options_get_number(o, "popup-border-lines"); - if (lines == BOX_LINES_NONE) { - if (sx < 1 || sy < 1) - return (-1); - jx = sx; - jy = sy; - } else { - if (sx < 3 || sy < 3) - return (-1); - jx = sx - 2; - jy = sy - 2; - } - if (c->tty.sx < sx || c->tty.sy < sy) - return (-1); - - pd = xcalloc(1, sizeof *pd); - pd->item = item; - pd->flags = flags; - - if (title != NULL) - pd->title = xstrdup(title); - if (style != NULL) - pd->style = xstrdup(style); - if (border_style != NULL) - pd->border_style = xstrdup(border_style); - - pd->c = c; - pd->c->references++; - - pd->cb = cb; - pd->arg = arg; - pd->status = 128 + SIGHUP; - - pd->border_lines = lines; - memcpy(&pd->border_cell, &grid_default_cell, sizeof pd->border_cell); - style_apply(&pd->border_cell, o, "popup-border-style", NULL); - if (border_style != NULL) { - style_set(&sytmp, &grid_default_cell); - if (style_parse(&sytmp, &pd->border_cell, border_style) == 0) { - pd->border_cell.fg = sytmp.gc.fg; - pd->border_cell.bg = sytmp.gc.bg; - } - } - pd->border_cell.attr = 0; - - screen_init(&pd->s, jx, jy, 0); - screen_set_default_cursor(&pd->s, global_w_options); - colour_palette_init(&pd->palette); - colour_palette_from_option(&pd->palette, global_w_options); - - memcpy(&pd->defaults, &grid_default_cell, sizeof pd->defaults); - style_apply(&pd->defaults, o, "popup-style", NULL); - if (style != NULL) { - style_set(&sytmp, &grid_default_cell); - if (style_parse(&sytmp, &pd->defaults, style) == 0) { - pd->defaults.fg = sytmp.gc.fg; - pd->defaults.bg = sytmp.gc.bg; - } - } - pd->defaults.attr = 0; - - pd->px = px; - pd->py = py; - pd->sx = sx; - pd->sy = sy; - - pd->ppx = px; - pd->ppy = py; - pd->psx = sx; - pd->psy = sy; - - pd->job = job_run(shellcmd, argc, argv, env, s, cwd, - popup_job_update_cb, popup_job_complete_cb, NULL, pd, - JOB_NOWAIT|JOB_PTY|JOB_KEEPWRITE|JOB_DEFAULTSHELL, jx, jy); - if (pd->job == NULL) { - popup_free(pd); - return (-1); - } - pd->ictx = input_init(NULL, job_get_event(pd->job), &pd->palette, c); - - server_client_set_overlay(c, 0, popup_check_cb, popup_mode_cb, - popup_draw_cb, popup_key_cb, popup_free_cb, popup_resize_cb, pd); - return (0); -} diff --git a/proc.c b/proc.c index bfe1fbe26..de9e8264a 100644 --- a/proc.c +++ b/proc.c @@ -32,6 +32,7 @@ #endif #ifdef HAVE_JEMALLOC +#define JEMALLOC_MANGLE #include #endif diff --git a/prompt.c b/prompt.c index cc808a54e..300b278d8 100644 --- a/prompt.c +++ b/prompt.c @@ -1,4 +1,4 @@ -/* $OpenBSD: prompt.c,v 1.6 2026/08/17 06:45:16 nicm Exp $ */ +/* $OpenBSD: prompt.c,v 1.7 2026/09/09 07:53:03 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -1493,9 +1493,11 @@ append_key: utf8_set(&tmp, key); if (key <= 0x1f || key == 0x7f) tmp.width = 2; - } else if (KEYC_IS_UNICODE(key)) + } else if (KEYC_IS_UNICODE(key)) { utf8_to_data(key, &tmp); - else + if (tmp.size == 0) + return (PROMPT_KEY_HANDLED); + } else return (PROMPT_KEY_HANDLED); pr->buffer = xreallocarray(pr->buffer, size + 2, diff --git a/regress/Makefile b/regress/Makefile index 7a4387bd3..18518d274 100644 --- a/regress/Makefile +++ b/regress/Makefile @@ -1,33 +1,17 @@ TESTS!= echo *.sh LOGDIR=logs -.PHONY: all -.NOTPARALLEL: all +.PHONY: all prepare $(TESTS) -all: - @mkdir -p "$(LOGDIR)"; \ - rm -f "$(LOGDIR)"/*.log; \ - failed=0; failures=; \ +all: prepare $(TESTS) + @failed=0; failures=; \ for test in $(TESTS); do \ base=$${test##*/}; \ log="$(LOGDIR)/$${base%.sh}.log"; \ - rm -f "$$log"; \ - printf '%-40s ' "$$test"; \ - start=$$(date +%s); \ - env -i LC_CTYPE=C.UTF-8 MallocNanoZone=0 \ - sh -x "$$test" >"$$log" 2>&1; \ - if [ $$? -eq 0 ]; then \ - end=$$(date +%s); \ - rm -f "$$log"; \ - echo "PASS ($$((end - start))s)"; \ - else \ - end=$$(date +%s); \ - echo "FAIL ($$((end - start))s)"; \ - echo " log: $$log"; \ + if [ -f "$$log" ]; then \ failed=1; \ failures="$$failures $$test"; \ fi; \ - sleep 1; \ done; \ if [ "$$failed" -ne 0 ]; then \ echo; \ @@ -39,3 +23,22 @@ all: rmdir "$(LOGDIR)" 2>/dev/null || true; \ fi; \ exit $$failed + +prepare: + @mkdir -p "$(LOGDIR)" + @rm -f "$(LOGDIR)"/*.log + +$(TESTS): prepare + @base="$@"; base=$${base##*/}; \ + log="$(LOGDIR)/$${base%.sh}.log"; \ + start=$$(date +%s); \ + if env -i LC_CTYPE=C.UTF-8 MallocNanoZone=0 \ + sh -x "$@" >"$$log" 2>&1; then \ + end=$$(date +%s); \ + rm -f "$$log"; \ + printf '%-40s PASS (%ss)\n' "$@" "$$((end - start))"; \ + else \ + end=$$(date +%s); \ + printf '%-40s FAIL (%ss)\n log: %s\n' \ + "$@" "$$((end - start))" "$$log"; \ + fi diff --git a/regress/alerts.sh b/regress/alerts.sh index 60d9a05f7..3f97b5328 100644 --- a/regress/alerts.sh +++ b/regress/alerts.sh @@ -2,8 +2,9 @@ # monitor-activity, monitor-bell and monitor-silence: both the # alert-activity, alert-bell and alert-silence hooks and the winlink alert -# flags (window_activity_flag, window_bell_flag, window_silence_flag and -# the #, !, ~ characters in window_flags). The sessions are detached so +# flags (window_activity_flag, window_bell_flag, window_silence_flag, +# their session equivalents and the #, !, ~ characters in window_flags). +# The sessions are detached so # alert flags are set even on the current window; the *-action options # still decide whether the hooks fire. Panes run cat: activity is # generated by the tty echo of send-keys and a bell by sending a BEL and @@ -111,6 +112,24 @@ flags_have() esac } +assert_session_flag() +{ + flag="#{session_${1}_flag}" + expected=$2 + + # Only mon exists here. Check both window contexts and list-sessions. + for target in mon:w0 "mon:$3"; do + value=$($TMUX display -pt "$target" "$flag") || + fail "display $flag failed" + [ "$value" = "$expected" ] || + fail "expected $flag for $target to be '$expected' but got '$value'" + done + value=$($TMUX list-sessions -F "$flag") || + fail "list-sessions $flag failed" + [ "$value" = "$expected" ] || + fail "expected list-sessions $flag to be '$expected' but got '$value'" +} + flags_lack() { target=$1 @@ -150,10 +169,12 @@ $TMUX set-hook -g alert-silence \ # alert-bell and sets the bell flag, shown as ! in window_flags. $TMUX neww -d -t mon: -n bellw cat || fail "new-window bellw failed" assert_fmt_unchanged mon:bellw '#{window_bell_flag}' 0 +assert_session_flag bell 0 bellw flags_lack mon:bellw '!' bell mon:bellw wait_for @log '|alert-bell:mon:bellw' wait_for_fmt mon:bellw '#{window_bell_flag}' 1 +assert_session_flag bell 1 bellw flags_have mon:bellw '!' # Bells are not deduplicated: a second bell fires the hook again even @@ -164,6 +185,7 @@ wait_for @log '|alert-bell:mon:bellw|alert-bell:mon:bellw' # Selecting the window clears the alert flags. $TMUX selectw -t mon:bellw || fail "select-window bellw failed" wait_for_fmt mon:bellw '#{window_bell_flag}' 0 +assert_session_flag bell 0 bellw flags_lack mon:bellw '!' $TMUX selectw -t mon:w0 || fail "select-window w0 failed" @@ -173,6 +195,7 @@ $TMUX set -g @log '' || fail "reset @log failed" bell mon:w0 wait_for @log '|alert-bell:mon:w0' wait_for_fmt mon:w0 '#{window_bell_flag}' 1 +assert_session_flag bell 1 bellw $TMUX selectw -t mon:bellw || fail "select-window bellw failed" $TMUX selectw -t mon:w0 || fail "select-window w0 failed" wait_for_fmt mon:w0 '#{window_bell_flag}' 0 @@ -199,6 +222,7 @@ $TMUX neww -d -t mon: -n actw cat || fail "new-window actw failed" $TMUX set -g @log '' || fail "reset @log failed" activity mon:actw assert_fmt_unchanged mon:actw '#{window_activity_flag}' 0 +assert_session_flag activity 0 actw assert_unchanged @log '' # With monitor-activity on, output fires alert-activity and sets the @@ -208,6 +232,7 @@ $TMUX set -wt mon:actw monitor-activity on || activity mon:actw wait_for @log '|alert-activity:mon:actw' wait_for_fmt mon:actw '#{window_activity_flag}' 1 +assert_session_flag activity 1 actw flags_have mon:actw '#' # While the flag is set further activity does not fire the hook again. @@ -234,6 +259,7 @@ exec 3>"$OUT/fifo" wait_for_fmt mon: '#{session_attached}' 1 $TMUX selectw -t mon:actw || fail "select-window actw failed" wait_for_fmt mon:actw '#{window_activity_flag}' 0 +assert_session_flag activity 0 actw flags_lack mon:actw '#' $TMUX selectw -t mon:w0 || fail "select-window w0 failed" exec 3>&- @@ -250,6 +276,7 @@ $TMUX set -wt mon:w0 monitor-activity on || $TMUX set -g @log '' || fail "reset @log failed" activity mon:w0 wait_for_fmt mon:w0 '#{window_activity_flag}' 1 +assert_session_flag activity 1 actw assert_unchanged @log '' $TMUX set -wut mon:w0 monitor-activity || fail "unset monitor-activity w0 failed" @@ -273,16 +300,19 @@ $TMUX selectw -t mon:w0 || fail "select-window w0 failed" # running. $TMUX neww -d -t mon: -n silw cat || fail "new-window silw failed" assert_fmt_unchanged mon:silw '#{window_silence_flag}' 0 +assert_session_flag silence 0 silw $TMUX set -g @log '' || fail "reset @log failed" $TMUX set -wt mon:silw monitor-silence 1 || fail "set monitor-silence failed" wait_for @log '|alert-silence:mon:silw' wait_for_fmt mon:silw '#{window_silence_flag}' 1 +assert_session_flag silence 1 silw flags_have mon:silw '~' assert_unchanged @log '|alert-silence:mon:silw' $TMUX set -wt mon:silw monitor-silence 0 || fail "reset monitor-silence failed" $TMUX selectw -t mon:silw || fail "select-window silw failed" wait_for_fmt mon:silw '#{window_silence_flag}' 0 +assert_session_flag silence 0 silw flags_lack mon:silw '~' $TMUX selectw -t mon:w0 || fail "select-window w0 failed" diff --git a/regress/alternate-screen-resize.sh b/regress/alternate-screen-resize.sh new file mode 100644 index 000000000..c9016336a --- /dev/null +++ b/regress/alternate-screen-resize.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +# A pending pane resize must still reach the pty when the application enters +# the alternate screen before the resize timer fires. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" + +TMP=$(mktemp) +TMP2=$(mktemp) +trap 'rm -f "$TMP" "$TMP2"; $TMUX kill-server 2>/dev/null' 0 1 15 + +$TMUX new -d -x 80 -y 24 \ + "read x; printf '\033[?1049h'; read x; stty size >$TMP; sleep 100" \ + $TMP2 +cmp -s "$TMP" "$TMP2" || exit 1 + +$TMUX kill-server 2>/dev/null +exit 0 diff --git a/regress/attach-session-redraw.sh b/regress/attach-session-redraw.sh new file mode 100755 index 000000000..985461499 --- /dev/null +++ b/regress/attach-session-redraw.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +# Selecting a pane with attach-session from an already attached client must +# redraw pane contents when window-style and window-active-style differ. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lattach-redraw-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lattach-redraw-outer-$$ -f/dev/null" +BEFORE=$DIR/before +AFTER=$DIR/after + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +LEFT=$($INNER new-session -dPF '#{pane_id}' -s inner -x 40 -y 8 \ + "printf 'LEFT'; exec sleep 100") || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g default-terminal screen || exit 1 +$INNER split-window -h -t "$LEFT" "printf 'RIGHT'; exec sleep 100" || exit 1 +$INNER set-option -w -t "$LEFT" window-style bg=red || exit 1 +$INNER set-option -w -t "$LEFT" window-active-style bg=blue || exit 1 +$INNER bind-key -n x attach-session -t "$LEFT" || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 8 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lattach-redraw-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +sleep 1 +$OUTER send-keys -t outer:0.0 x || exit 1 +sleep 1 +[ "$($INNER display-message -p -t inner '#{pane_id}')" = "$LEFT" ] || + fail "attach-session did not select the target pane" +$OUTER capture-pane -pe -t outer:0.0 >"$BEFORE" || exit 1 + +# A forced redraw produces the correct scene. It must be identical to the +# scene drawn immediately by attach-session. +$INNER refresh-client -t "$CLIENT" || exit 1 +sleep 1 +$OUTER capture-pane -pe -t outer:0.0 >"$AFTER" || exit 1 +cmp -s "$BEFORE" "$AFTER" || + fail "attach-session left stale active/inactive pane styles" + +exit 0 diff --git a/regress/capture-pane-time.sh b/regress/capture-pane-time.sh new file mode 100644 index 000000000..66faa29c3 --- /dev/null +++ b/regress/capture-pane-time.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# capture-pane -I line timestamps + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMP=$(mktemp) +trap '$TMUX kill-server 2>/dev/null; rm -f "$TMP"' 0 1 15 +$TMUX kill-server 2>/dev/null + +before=$(date +%s) +$TMUX new-session -d -x 40 -y 5 'seq 1 12; sleep 10' || exit 1 +sleep 1 +after=$(date +%s) + +$TMUX capture-pane -pILF -S - -E - >"$TMP" || exit 1 +awk -v before="$before" -v after="$after" ' + $1 !~ /^-?[0-9]+$/ || $2 !~ /^[0-9]+$/ { exit 1 } + $4 == "1" { + if ($2 < before || $2 > after) + exit 1 + history = 1 + } + $4 == "9" { + if ($2 != 0) + exit 1 + visible = 1 + } + END { if (!history || !visible) exit 1 } +' "$TMP" || { + cat "$TMP" + exit 1 +} + +exit 0 diff --git a/regress/cfg-client-free.sh b/regress/cfg-client-free.sh new file mode 100644 index 000000000..9069163f6 --- /dev/null +++ b/regress/cfg-client-free.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +# a client lost while the configuration is loading is freed + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +TMPDIR=$(mktemp -d) || exit 1 +TMUX="$TEST_TMUX -S$TMPDIR/tmux.sock" + +cleanup() +{ + touch $TMPDIR/go + $TMUX kill-server 2>/dev/null + rm -rf "$TMPDIR" +} +trap cleanup 0 1 15 + +# Poll until a command succeeds. +wait_for() +{ + i=0 + while ! "$@"; do + [ $i -eq 100 ] && exit 1 + i=$((i + 1)) + sleep 0.1 + done +} + +# The run-shell holds the configuration until the go file exists. +cat <$TMPDIR/conf +new-session -d -s keep +run-shell 'touch $TMPDIR/started; i=0; while [ ! -f $TMPDIR/go ] && [ \$i -lt 100 ]; do sleep 0.1; i=\$((i + 1)); done' +EOF + +cd $TMPDIR || exit 1 +$TMUX -v -f$TMPDIR/conf new-session -d -s first : [cfg_done/" not in contents, contents + finally: + go.touch() + server.terminate() + try: + server.wait(timeout=3) + except subprocess.TimeoutExpired: + server.kill() + server.wait() +PY diff --git a/regress/cfg-client-lost.sh b/regress/cfg-client-lost.sh old mode 100755 new mode 100644 diff --git a/regress/cfg-load-once.sh b/regress/cfg-load-once.sh new file mode 100644 index 000000000..b9f4289e1 --- /dev/null +++ b/regress/cfg-load-once.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +# the configuration is loaded once if the client that started it is lost, and +# the next client still waits for it to finish + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +TMPDIR=$(mktemp -d) || exit 1 +TMUX="$TEST_TMUX -S$TMPDIR/tmux.sock" + +cleanup() +{ + touch $TMPDIR/go + $TMUX kill-server 2>/dev/null + rm -rf "$TMPDIR" +} +trap cleanup 0 1 15 + +# Poll until a command succeeds. +wait_for() +{ + i=0 + while ! "$@"; do + [ $i -eq 100 ] && exit 1 + i=$((i + 1)) + sleep 0.1 + done +} + +# The run-shell holds the configuration until the go file exists. +cat <$TMPDIR/conf +set -ga @runs x +new-session -d -s keep +run-shell 'touch $TMPDIR/started; i=0; while [ ! -f $TMPDIR/go ] && [ \$i -lt 100 ]; do sleep 0.1; i=\$((i + 1)); done' +set -g @late 1 +EOF + +cd $TMPDIR || exit 1 +$TMUX -v -f$TMPDIR/conf new-session -d -s first $TMPDIR/out & +pid=$! +wait_for grep -q "name is client-$pid\$" $log +touch $TMPDIR/go +wait $pid || exit 1 + +[ "$(cat $TMPDIR/out)" = "x 1" ] || exit 1 + +exit 0 diff --git a/regress/check-names.sh b/regress/check-names.sh index be8ad617b..8ab68f497 100644 --- a/regress/check-names.sh +++ b/regress/check-names.sh @@ -5,6 +5,15 @@ PATH=/bin:/usr/bin TERM=screen +# The pane's shell must not be the user's own interactive shell: a custom +# PS1/PROMPT_COMMAND that sets the terminal title (as many do, for tmux/xterm +# TERM types) would redraw over the titles this test sets and checks on +# every prompt, regardless of how long it waits first. +shell= +if command -v bash >/dev/null 2>&1; then + shell='bash --noprofile --norc +o history' +fi + [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" $TMUX kill-server 2>/dev/null @@ -30,7 +39,7 @@ must_equal() [ "$got" = "$want" ] || fail "got '$got', expected '$want'" } -$TMUX new-session -d -x 80 -y 24 || exit 1 +$TMUX new-session -d -x 80 -y 24 -- $shell || exit 1 $TMUX set-option -qg allow-set-title on || exit 1 $TMUX set-option -qg allow-rename on || exit 1 $TMUX set-option -qg automatic-rename off || exit 1 diff --git a/regress/choose-tree.sh b/regress/choose-tree.sh index 132890730..4656963e6 100644 --- a/regress/choose-tree.sh +++ b/regress/choose-tree.sh @@ -290,4 +290,92 @@ wait_count ': G4' 7 fail "window 1 of zzz not killed" exit_mode q +# --- help and information preview -------------------------------------------- +# The complete tree help is taller than the 24-line test terminal, where the +# help renderer deliberately leaves the tree unchanged. +$TMUX2 resize-window -t out:0 -x 80 -y 50 || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + # The inner session has a one-line status, leaving a 49-line window. + [ "$($TMUX display-message -p -t aaa:0 '#{window_height}')" -ge 49 ] && + break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "attached client did not resize for tree help" +$TMUX choose-tree -t aaa:0 -F 'G5' -O index || exit 1 +wait_count ': G5' 7 +$TMUX2 send-keys -t out:0 F1 || fail "send-keys F1 failed" +wait_for 'Swap current and previous window' +# The first key closes help; the second exits the tree. +$TMUX send-keys -t aaa:0 q q || fail "send-keys help exit failed" +wait_mode aaa:0 0 + +$TMUX choose-tree -t aaa:0 -F 'G6' -O index || exit 1 +wait_count ': G6' 7 +$TMUX send-keys -t aaa:0 i || fail "send-keys info failed" +wait_for 'Session' +$TMUX send-keys -t aaa:0 i || fail "send-keys preview failed" +exit_mode q + +# --- window swapping and command prompt for current/tagged items ------------- +# Restrict a window-only tree to two disposable windows in aaa. J (the +# Shift-Down equivalent) swaps their window objects while leaving their indexes +# in place. +$TMUX new-window -d -t aaa: -n swap-a 'cat' || exit 1 +$TMUX new-window -d -t aaa: -n swap-b 'cat' || exit 1 +aidx=$($TMUX list-windows -t aaa -F '#{window_index}:#{window_name}' | \ + awk -F: '$2 == "swap-a" {print $1}') +bidx=$($TMUX list-windows -t aaa -F '#{window_index}:#{window_name}' | \ + awk -F: '$2 == "swap-b" {print $1}') +$TMUX choose-tree -w -t aaa:0 -O index -F 'WT #{window_name}' \ + -f '#{m:swap-*,#{window_name}}' || exit 1 +wait_count 'WT swap-' 2 +$TMUX send-keys -t aaa:0 g j J || fail "window tree swap failed" +i=0 +while [ "$i" -lt 50 ]; do + aname=$($TMUX display-message -p -t "aaa:$aidx" '#{window_name}') + bname=$($TMUX display-message -p -t "aaa:$bidx" '#{window_name}') + [ "$aname:$bname" = 'swap-b:swap-a' ] && break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "window tree did not swap the two windows" + +# ':' runs an entered command for the current item and rebuilds the tree when +# its queued command completes. +$TMUX set-option -g @tree-command '' || exit 1 +$TMUX send-keys -t aaa:0 : || fail "window tree command prompt failed" +wait_for '(current)' +$TMUX send-keys -t aaa:0 -l "set-option -g @tree-command '%%'" || exit 1 +$TMUX send-keys -t aaa:0 Enter || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + value=$($TMUX show-option -gqv @tree-command) + [ -n "$value" ] && break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "window tree entered command did not run" +case "$value" in +=aaa:*.) ;; +*) fail "window tree command target was '$value'" ;; +esac + +# Tag both disposable windows and kill them through the tagged confirmation +# callback. The mode-hosting window is outside the filter and survives. +$TMUX send-keys -t aaa:0 g j t j t X || fail "tagged kill keys failed" +wait_for 'Kill 2 tagged?' +$TMUX send-keys -t aaa:0 y || fail "tagged kill confirmation failed" +i=0 +while [ "$i" -lt 50 ]; do + left=$($TMUX list-windows -t aaa -F '#{window_name}' | \ + grep -c '^swap-' || true) + [ "$left" -eq 0 ] && break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "tagged windows were not killed" +exit_mode q + exit 0 diff --git a/regress/clear-on-attach.sh b/regress/clear-on-attach.sh new file mode 100644 index 000000000..012064660 --- /dev/null +++ b/regress/clear-on-attach.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +# With clear-on-attach disabled tmux must preserve the terminal contents by +# scrolling them away, rather than entering and clearing the alternate screen. +# Capture the bytes written by a real attached client for both settings. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER_ON="$TEST_TMUX -LtestIon$$ -f/dev/null" +OUTER_ON="$TEST_TMUX -LtestOon$$ -f/dev/null" +INNER_OFF="$TEST_TMUX -LtestIoff$$ -f/dev/null" +OUTER_OFF="$TEST_TMUX -LtestOoff$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER_ON kill-server 2>/dev/null + $INNER_ON kill-server 2>/dev/null + $OUTER_OFF kill-server 2>/dev/null + $INNER_OFF kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $current_inner list-clients 2>/dev/null | grep -q . && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +capture_attach() +{ + setting=$1 + output=$2 + go=$DIR/go-$setting + if [ "$setting" = on ]; then + current_inner=$INNER_ON + current_outer=$OUTER_ON + else + current_inner=$INNER_OFF + current_outer=$OUTER_OFF + fi + + $current_inner new-session -d -s inner -x40 -y10 \ + 'exec sleep 100' || exit 1 + $current_inner set-option -g status off || exit 1 + $current_inner set-option -g clear-on-attach "$setting" || exit 1 + $current_outer new-session -d -s outer -x40 -y10 \ + "while [ ! -e '$go' ]; do sleep 0.01; done; exec $current_inner attach-session -t inner" || exit 1 + $current_outer set-option -g status off || exit 1 + $current_outer pipe-pane -O "cat >'$output'" || exit 1 + touch "$go" + wait_for_client + sleep 0.5 + $current_outer pipe-pane -O || exit 1 +} + +on=$DIR/on +off=$DIR/off +capture_attach on "$on" +capture_attach off "$off" + +smcup=$(printf '\033[?1049h') +grep -Fq "$smcup" "$on" || fail "clear-on-attach on did not use smcup" +if grep -Fq "$smcup" "$off"; then + fail "clear-on-attach off used smcup" +fi + +# Some screen terminfo entries have indn (CSI Ps S), while older ones only +# have ind (newline). The preserving path uses indn once or repeats ind once +# for every line in the terminal plus one. +escape=$(printf '\033') +if ! LC_ALL=C grep -Eq "${escape}\\[[0-9]+S" "$off"; then + hex=$(od -An -tx1 -v "$off" | tr -d ' \n') + case "$hex" in + *0a0a0a0a0a0a0a0a0a0a0a*) ;; + *) fail "clear-on-attach off did not scroll the old contents away" ;; + esac +fi + +exit 0 diff --git a/regress/client-key-order-focus.sh b/regress/client-key-order-focus.sh new file mode 100644 index 000000000..4b0c27eb5 --- /dev/null +++ b/regress/client-key-order-focus.sh @@ -0,0 +1,82 @@ +#!/bin/sh + +# send-keys -K injects a key into a client. Commands produced by that key must +# be inserted immediately after send-keys, before later commands in the same +# command list. Focus events arriving between a prefix and its following key +# must be forwarded without cancelling the prefix table. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + client=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$client" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_for_option() +{ + option=$1 + expected=$2 + i=0 + while [ "$i" -lt 50 ]; do + actual=$($INNER show-option -gv "$option" 2>/dev/null) + [ "$actual" = "$expected" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "$option is '$actual', expected '$expected'" +} + +cleanup +$INNER new-session -d -s inner -x40 -y10 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g focus-events on || exit 1 +$OUTER new-session -d -s outer -x40 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +wait_for_client + +# The binding generated by send-keys -K must run before the command which +# follows send-keys in this command list. Before the queue-ordering fix this +# produces AB rather than BA. +$INNER set-option -g @order '' || exit 1 +$INNER bind-key -n a set-option -gF @order '#{@order}B' || exit 1 +$INNER send-keys -K -c "$client" a \; \ + set-option -gF @order '#{@order}A' || exit 1 +wait_for_option @order BA + +# Put the real attached client in the prefix table, deliver FocusIn, then the +# bound key. FocusIn is not bindable and must not reset the prefix table. +$INNER bind-key x set-option -g @focus-prefix yes || exit 1 +$OUTER send-keys C-b || exit 1 +focus_in=$(printf '\033[I') +$OUTER send-keys -l "$focus_in" || exit 1 +$OUTER send-keys x || exit 1 +wait_for_option @focus-prefix yes + +exit 0 diff --git a/regress/clock-mode.sh b/regress/clock-mode.sh new file mode 100644 index 000000000..846e8dc7e --- /dev/null +++ b/regress/clock-mode.sh @@ -0,0 +1,101 @@ +#!/bin/sh + +# Exercise clock-mode initialization, all four styles, its one-second timer, +# resize into the compact renderer, arbitrary-key exit, and cleanup. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_mode() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER display-message -p -t clock:0 '#{pane_mode}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "pane mode is '$got', expected '$want'" +} + +wait_hashes() +{ + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -q '#' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "large clock did not render" +} + +$INNER new-session -d -s clock -x80 -y24 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -w clock-mode-colour red || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t clock" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +for style in 12 24 12-with-seconds 24-with-seconds; do + $INNER set-option -w -t clock:0 clock-mode-style "$style" || exit 1 + $INNER clock-mode -t clock:0 || exit 1 + wait_mode clock-mode + wait_hashes + # Let at least one timer callback observe a new second. + [ "$style" != 12-with-seconds ] || sleep 1.2 + $INNER send-keys -t clock:0 x || exit 1 + wait_mode '' +done + +# Resizing an active mode below the large-glyph threshold selects the compact +# renderer. A visible time contains a colon and no block-clock hash. +$INNER clock-mode -t clock:0 || exit 1 +wait_mode clock-mode +$INNER resize-window -t clock:0 -x20 -y5 || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Eq '[0-9][0-9]?:[0-9][0-9]' && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "compact clock did not render after resize" +printf '%s\n' "$captured" | grep -q '#' && + fail "compact clock still used large glyphs" +$INNER send-keys -t clock:0 Enter || exit 1 +wait_mode '' + +$INNER has-session -t clock || fail "server died during clock-mode tests" +exit 0 diff --git a/regress/cmd-parse-condition.sh b/regress/cmd-parse-condition.sh new file mode 100644 index 000000000..c5487cc2f --- /dev/null +++ b/regress/cmd-parse-condition.sh @@ -0,0 +1,75 @@ +#!/bin/sh + +# Assignments and commands must use the first matching conditional branch. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +CONF=$(mktemp) +trap '$TMUX kill-server 2>/dev/null; rm -f "$CONF"' 0 +trap 'exit 1' 1 2 15 +$TMUX new-session -d || exit 1 + +branch() +{ + printf '%%%s %s%s' "$1" "$2" "$sep" + printf '%sPARSE_BRANCH=%s%sset -g @branch %s%s' \ + "$prefix" "$3" "$sep" "$3" "$sep" +} + +# Cover no, one and two elif branches, with and without else, all condition +# values, and nesting inside both an active and an inactive outer branch. +for mode in normal hidden inline; do + prefix= + sep=' +' + [ "$mode" = hidden ] && prefix='%hidden ' + [ "$mode" = inline ] && sep=' ' + for elifs in 0 1 2; do + bits=0 + while [ "$bits" -lt "$((1 << (elifs + 1)))" ]; do + for otherwise in 0 1; do + for outer in 0 1; do + expected=unchanged + { + printf '%s\n' 'PARSE_BRANCH=unchanged' \ + 'set -g @branch unchanged' + printf '%%if %s\n' "$outer" + n=0 + while [ "$n" -le "$elifs" ]; do + condition=$(((bits >> n) & 1)) + directive=elif + [ "$n" -eq 0 ] && directive=if + branch "$directive" "$condition" "$n" + if [ "$condition" -eq 1 ] && + [ "$expected" = unchanged ]; then + expected=$n + fi + n=$((n + 1)) + done + if [ "$otherwise" -eq 1 ]; then + branch else '' else + [ "$expected" = unchanged ] && expected=else + fi + printf '%s\n' '%endif' '%endif' \ + 'set -g @value "$PARSE_BRANCH"' + } >"$CONF" + [ "$outer" -eq 0 ] && expected=unchanged + $TMUX source-file "$CONF" || exit 1 + out=$($TMUX display-message -p '#{@value}:#{@branch}') + if [ "$out" != "$expected:$expected" ]; then + echo "$mode, $elifs elifs, bits=$bits, else=$otherwise, outer=$outer" + echo "Expected $expected:$expected, got $out" + cat "$CONF" + exit 1 + fi + done + done + bits=$((bits + 1)) + done + done +done + +exit 0 diff --git a/regress/codepoint-widths.sh b/regress/codepoint-widths.sh new file mode 100644 index 000000000..ccf8fcf71 --- /dev/null +++ b/regress/codepoint-widths.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +# Width cache ranges must include their endpoint without overflowing wchar_t. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +SERVER= +WATCHDOG= + +cleanup() +{ + if [ -n "$WATCHDOG" ]; then + kill "$WATCHDOG" 2>/dev/null + wait "$WATCHDOG" 2>/dev/null + fi + [ -n "$SERVER" ] && kill -9 "$SERVER" 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +$TMUX new-session -d 'exec sleep 60' || exit 1 +SERVER=$($TMUX display-message -p '#{pid}') || exit 1 + +# kill-server cannot stop a server stuck rebuilding the width cache. +( + sleep 5 + kill -9 "$SERVER" 2>/dev/null +) & +WATCHDOG=$! + +$TMUX set -g codepoint-widths 'U+1F600=2' || exit 1 + +# Cover both signed and unsigned 32-bit wchar_t. Values above WCHAR_MAX +# are ignored by the parser on platforms where they cannot be represented. +for value in U+7FFFFFFF=1 U+7FFFFFFE-U+7FFFFFFF=2 \ + U+FFFFFFFF=1 U+FFFFFFFE-U+FFFFFFFF=2; do + $TMUX set -g codepoint-widths "$value" || exit 1 + [ "$($TMUX display-message -p alive)" = alive ] || exit 1 +done + +# Check that an ordinary range still includes both endpoints and stops there. +$TMUX set -g codepoint-widths 'U+03B1-U+03B3=2' || exit 1 +$TMUX set -g @text 'αβγδ' || exit 1 +[ "$($TMUX display-message -p '#{w:@text}')" = 7 ] || exit 1 +$TMUX set -gu codepoint-widths || exit 1 +[ "$($TMUX display-message -p '#{w:@text}')" = 4 ] || exit 1 + +$TMUX kill-server || exit 1 +SERVER= +exit 0 diff --git a/regress/confirm-before-lock.sh b/regress/confirm-before-lock.sh new file mode 100644 index 000000000..db570ab10 --- /dev/null +++ b/regress/confirm-before-lock.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +# Exercise confirm-before's waiting and background callbacks, custom keys and +# prompts, default-yes handling and errors. Also cover all three lock command +# entry points with a harmless lock-command which leaves a marker. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_capture() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Fq "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "timed out waiting for '$marker'" +} + +wait_option() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER show-option -gqv @confirmed 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "@confirmed is '$got', expected '$want'" +} + +wait_file() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + [ -f "$marker" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "lock command did not create $marker" +} + +$INNER new-session -d -s test -x80 -y24 'exec sleep 100' || exit 1 +$INNER set-option -g status on || exit 1 +$INNER set-option -g status-position bottom || exit 1 +$INNER set-option -g window-size manual || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t test" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +client=$($INNER list-clients -F '#{client_name}') +[ -n "$client" ] || fail "inner client did not attach" + +$INNER bind-key -n M-a confirm-before \ + 'set-option -g @confirmed accepted' || exit 1 +$INNER bind-key -n M-b confirm-before -b -p 'Background?' \ + 'set-option -g @confirmed background' || exit 1 +$INNER bind-key -n M-c confirm-before -c x -p 'Custom?' \ + 'set-option -g @confirmed custom' || exit 1 +$INNER bind-key -n M-y confirm-before -y -p 'Default?' \ + 'set-option -g @confirmed default' || exit 1 + +# A non-confirming response closes the waiting prompt without running its +# command; reopening and accepting inserts the command after the waiting item. +$INNER set-option -g @confirmed sentinel || exit 1 +$OUTER send-keys M-a || exit 1 +wait_capture "Confirm 'set-option'? (y/n)" +$OUTER send-keys n || exit 1 +sleep 0.2 +wait_option sentinel +$OUTER send-keys M-a || exit 1 +wait_capture "Confirm 'set-option'? (y/n)" +$OUTER send-keys y || exit 1 +wait_option accepted + +# -b has no waiting queue item and appends its command to the client queue. +$OUTER send-keys M-b || exit 1 +wait_capture 'Background?' +$OUTER send-keys y || exit 1 +wait_option background + +# A custom printable key is accepted, and -y makes Enter affirmative. +$OUTER send-keys M-c || exit 1 +wait_capture 'Custom?' +$OUTER send-keys x || exit 1 +wait_option custom +$OUTER send-keys M-y || exit 1 +wait_capture 'Default?' +$OUTER send-keys Enter || exit 1 +wait_option default + +# Reject invalid multi-character and control confirmation keys. +if $INNER confirm-before -b -t "$client" -c xx 'display-message x' \ + >/dev/null 2>&1; then + fail "multi-character confirm key was accepted" +fi +if $INNER confirm-before -b -t "$client" -c "$(printf '\001')" \ + 'display-message x' >/dev/null 2>&1; then + fail "control confirm key was accepted" +fi + +# lock-client, lock-session and lock-server all send lock-command to the real +# attached client. The command returns immediately and unlocks the client. +for kind in client session server; do + marker="$DIR/locked-$kind" + $INNER set-option -t test lock-command "printf locked >$marker" || exit 1 + case "$kind" in + client) $INNER lock-client -t "$client" || exit 1 ;; + session) $INNER lock-session -t test || exit 1 ;; + server) $INNER lock-server || exit 1 ;; + esac + wait_file "$marker" +done + +$INNER has-session -t test || fail "server died during prompt or lock tests" +exit 0 diff --git a/regress/control-client-exit-stalled.sh b/regress/control-client-exit-stalled.sh old mode 100755 new mode 100644 index 431fd66d8..1574c821f --- a/regress/control-client-exit-stalled.sh +++ b/regress/control-client-exit-stalled.sh @@ -17,7 +17,7 @@ PATH=/bin:/usr/bin TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -Ltest" +TMUX="$TEST_TMUX -Ltest$$" $TMUX kill-server 2>/dev/null DIR=$(mktemp -d) diff --git a/regress/control-client-exit.sh b/regress/control-client-exit.sh index 9bbe727f5..d615dec48 100644 --- a/regress/control-client-exit.sh +++ b/regress/control-client-exit.sh @@ -19,8 +19,7 @@ PATH=/bin:/usr/bin TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -SOCKET=$(mktemp -u testXXXXXX) -TMUX="$TEST_TMUX -L$SOCKET" +TMUX="$TEST_TMUX -Ltest$$" $TMUX kill-server 2>/dev/null DIR=$(mktemp -d) diff --git a/regress/control-client-new-layouts.sh b/regress/control-client-new-layouts.sh new file mode 100755 index 000000000..1c5a51ccf --- /dev/null +++ b/regress/control-client-new-layouts.sh @@ -0,0 +1,123 @@ +#!/bin/sh + +# Check that control client flags are available in every format expansion path +# used to produce layouts. The layout strings themselves are tested separately. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +LANG=C.UTF-8 +export PATH TERM LC_ALL LANG + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +DIR=$(mktemp -d) || exit 1 +FIFO=$DIR/input +OUT=$DIR/output +CFG=$DIR/flags.conf +PID= + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + exec 3>&- + [ -n "$PID" ] && kill "$PID" 2>/dev/null + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup EXIT + +wait_for() +{ + pattern=$1 + i=0 + + while [ "$i" -lt 50 ]; do + grep -F -- "$pattern" "$OUT" >/dev/null 2>&1 && return 0 + if [ -n "$PID" ] && ! kill -0 "$PID" 2>/dev/null; then + fail "control client exited waiting for: $pattern" + fi + sleep 0.1 + i=$((i + 1)) + done + fail "missing: $pattern" +} + +send() +{ + printf '%s\n' "$*" >&3 +} + +check_commands() +{ + name=$1 + expected=$2 + match='#{m:*new-layouts*,#{client_flags}}' + + send "display-message -p 'DISPLAY-$name $match'" + wait_for "DISPLAY-$name $expected" + send "list-panes -F 'PANES-$name $match'" + wait_for "PANES-$name $expected" + send "list-windows -F 'WINDOWS-$name $match'" + wait_for "WINDOWS-$name $expected" + send "list-sessions -F 'SESSIONS-$name $match'" + wait_for "SESSIONS-$name $expected" + send "list-clients -F 'CLIENTS-$name $match'" + wait_for "CLIENTS-$name $expected" +} + +check_config() +{ + name=$1 + expected=$2 + + send "source-file '$CFG'" + send "display-message -p 'CONFIG-$name #{@config-new-layouts}'" + wait_for "CONFIG-$name $expected" +} + +cat >"$CFG" <<'EOF' +%if #{m:*new-layouts*,#{client_flags}} +set-option -g @config-new-layouts 1 +%else +set-option -g @config-new-layouts 0 +%endif +EOF + +$TMUX kill-server 2>/dev/null +$TMUX new-session -d -s layouts -x 80 -y 24 || exit 1 +$TMUX split-window -h -t layouts: || exit 1 + +mkfifo "$FIFO" || exit 1 +: >"$OUT" +$TMUX -C attach-session -t layouts <"$FIFO" >"$OUT" 2>&1 & +PID=$! +exec 3>"$FIFO" + +send 'display-message -p READY' +wait_for READY + +check_commands OFF 0 +check_config OFF 0 + +send 'refresh-client -f new-layouts' +check_commands ON 1 +check_config ON 1 + +send 'refresh-client -f !new-layouts' +check_commands OFF-AGAIN 0 +check_config OFF-AGAIN 0 + +# The list commands also run for unattached command clients. Supplying that +# client to formats must not make the session loop dereference a NULL session. +$TMUX list-sessions -F '#{S:all,active}' >/dev/null || + fail "session loop failed for unattached command client" +$TMUX has-session -t layouts || fail "server exited" + +exit 0 diff --git a/regress/control-client-popup.sh b/regress/control-client-popup.sh new file mode 100644 index 000000000..6eb78ae12 --- /dev/null +++ b/regress/control-client-popup.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +# Popups require an attached terminal and cannot be displayed by a control +# client. +# A popup command from control mode must be ignored cleanly, leaving the +# client command queue and server usable. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 +FIFO=$DIR/input +OUT=$DIR/output +RAN=$DIR/popup-ran + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + exec 3>&- + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +mkfifo "$FIFO" || exit 1 +$TMUX new-session -d -s control -x40 -y10 'exec sleep 100' || exit 1 +$TMUX -C attach-session -t control <"$FIFO" >"$OUT" 2>&1 & +control_pid=$! +exec 3>"$FIFO" + +i=0 +while [ "$i" -lt 50 ]; do + $TMUX list-clients -F '#{client_flags}' 2>/dev/null | + grep -q 'control-mode' && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "control client did not attach" + +printf '%s\n' "display-popup -E 'touch $RAN'" >&3 +printf '%s\n' "display-message -p CONTROL-POPUP-ALIVE" >&3 + +i=0 +while [ "$i" -lt 50 ]; do + grep -q 'CONTROL-POPUP-ALIVE' "$OUT" 2>/dev/null && break + if ! kill -0 "$control_pid" 2>/dev/null; then + fail "control client exited after display-popup" + fi + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "command after control-client popup did not run" +$TMUX has-session -t control || fail "server exited after control-client popup" +[ ! -e "$RAN" ] || fail "control client started a popup command" + +exit 0 diff --git a/regress/control-client-run-shell-exit.sh b/regress/control-client-run-shell-exit.sh new file mode 100644 index 000000000..e93dff20a --- /dev/null +++ b/regress/control-client-run-shell-exit.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# A waited run-shell command can complete after a control client has closed its +# input and exited. The command queue still references the client until the job +# callback runs, but control_stop has already destroyed c->control_state. Late +# command output must be discarded instead of dereferencing the stopped control +# state and killing the server. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -Ltest$$" +$TMUX kill-server 2>/dev/null + +OUT=$(mktemp) +trap "$TMUX kill-server 2>/dev/null; rm -f $OUT" 0 1 15 + +$TMUX -f/dev/null new-session -d -s main || exit 1 + +printf '%s\n' 'run-shell "sleep 0.2; echo TEST"' | + $TMUX -f/dev/null -C >"$OUT" 2>&1 + +sleep 1 + +$TMUX has-session -t main 2>/dev/null || { + echo "server exited after late run-shell output" + cat "$OUT" + exit 1 +} + +grep -q '^%exit' "$OUT" || { + echo "control client did not exit" + cat "$OUT" + exit 1 +} + +exit 0 diff --git a/regress/control-client-sanity.sh b/regress/control-client-sanity.sh index 32695e7c0..b85589de6 100644 --- a/regress/control-client-sanity.sh +++ b/regress/control-client-sanity.sh @@ -30,8 +30,11 @@ killw EOF sleep 1 $TMUX has || exit 1 -$TMUX lsp -aF '#{pane_id} #{window_layout}' >$TMP || exit 1 -cat <$TMP || exit 1 +cat </dev/null FIFO=$(mktemp -u) diff --git a/regress/control-notify-guard.sh b/regress/control-notify-guard.sh old mode 100755 new mode 100644 diff --git a/regress/copy-mode-commands.sh b/regress/copy-mode-commands.sh new file mode 100644 index 000000000..aa94e9ef2 --- /dev/null +++ b/regress/copy-mode-commands.sh @@ -0,0 +1,253 @@ +#!/bin/sh + +# Exercise the broad non-mouse copy-mode command surface: navigation, search, +# jumps, state toggles, line/end-of-line copies, append, and pipe variants. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +x() +{ + $TMUX send-keys -t copy:0 -X "$@" || fail "copy command failed: $*" +} + +wait_mode() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($TMUX display-message -p -t copy:0 '#{pane_in_mode}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "pane_in_mode is '$got', expected '$want'" +} + +fresh() +{ + $TMUX copy-mode -t copy:0 || exit 1 + wait_mode 1 + x history-top + x start-of-line +} + +select_text() +{ + fresh + x begin-selection + x cursor-right + x cursor-right +} + +$TMUX new-session -d -s copy -x40 -y8 \ + "sh -c 'i=0; while [ \$i -lt 40 ]; do printf \" line-%02d needle (a[b]c) end\\n\" \$i; i=\$((i + 1)); done; printf \"\\nparagraph two needle\\n\\nparagraph three\\n\"; printf \"\\033]133;A\\007prompt-one\\n\\033]133;C\\007output-one\\n\\033]133;A\\007prompt-two\\n\\033]133;C\\007output-two\\n\"; exec sleep 100'" || + exit 1 +$TMUX set-option -g status off || exit 1 +$TMUX set-option -g history-limit 200 || exit 1 + +i=0 +while [ "$i" -lt 50 ]; do + history=$($TMUX display-message -p -t copy:0 '#{history_size}') + [ "$history" -ge 30 ] && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "history did not fill" + +fresh + +# Copy-mode-specific format callbacks expose the word, line, hyperlink and +# active search match at the cursor. +formats=$($TMUX display-message -p -t copy:0 \ + '#{copy_cursor_word}|#{copy_cursor_line}|#{copy_cursor_hyperlink}|#{search_match}') || + exit 1 +[ -n "$formats" ] || fail "copy-mode cursor formats were empty" + +# Cursor, viewport and paragraph commands. Each is asserted by successful +# dispatch and the final mode-liveness check; detailed word/selection geometry +# is covered by the existing copy-mode tests. +for command in back-to-indentation bottom-line top-line middle-line \ + cursor-centre-vertical cursor-centre-horizontal end-of-line history-bottom \ + history-top halfpage-down halfpage-up page-down page-up next-paragraph \ + previous-paragraph next-matching-bracket previous-matching-bracket; do + x "$command" +done +x goto-line 15 +x set-mark +x cursor-down +x jump-to-mark + +# OSC 133 prompt and output markers support shell-integration navigation. +x history-top +x next-prompt +case "$($TMUX display-message -p -t copy:0 '#{copy_cursor_line}')" in +*prompt-one*) ;; +*) fail "next-prompt did not find prompt-one" ;; +esac +x next-prompt +x previous-prompt +case "$($TMUX display-message -p -t copy:0 '#{copy_cursor_line}')" in +*prompt-one*) ;; +*) fail "previous-prompt did not return to prompt-one" ;; +esac +x next-prompt -o +case "$($TMUX display-message -p -t copy:0 '#{copy_cursor_line}')" in +*output-one*) ;; +*) fail "next-prompt -o did not find output-one" ;; +esac + +# Character jumps remember their direction and character for repeat/reverse. +x jump-forward e +x jump-again +x jump-reverse +x jump-backward e +x jump-to-forward e +x jump-to-backward e + +# Direct, text and incremental searches cover both directions and the saved +# search used by again/reverse. +x search-forward needle +x search-again +x search-reverse +x search-backward needle +x search-forward-text needle +x search-backward-text needle +x search-forward-incremental '=needle' +x search-forward-incremental '+needle' +x search-backward-incremental '=needle' +x search-backward-incremental '-needle' + +# Stateful display and behaviour toggles. +for command in rectangle-on rectangle-off rectangle-toggle \ + line-numbers-on line-numbers-off line-numbers-toggle \ + refresh-on refresh-now refresh-off refresh-toggle \ + scroll-exit-on scroll-exit-off scroll-exit-toggle toggle-position; do + x "$command" +done +wait_mode 1 +x cancel +wait_mode 0 + +# Line and end-of-line copy commands work without an explicit selection. +fresh +x select-line +x copy-selection-and-cancel +case "$($TMUX show-buffer)" in +*line-00*) ;; +*) fail "select-line did not select the current line" ;; +esac + +fresh +x copy-line +[ -n "$($TMUX show-buffer)" ] || fail "copy-line produced an empty buffer" +x cancel + +fresh +x copy-end-of-line +[ -n "$($TMUX show-buffer)" ] || fail "copy-end-of-line produced an empty buffer" +x cancel + +# Append variants retain or close the mode as advertised. +$TMUX set-buffer -b append-buffer prefix || exit 1 +select_text +x append-selection +appended=$($TMUX show-buffer -b append-buffer) +case "$appended" in +prefix*) ;; +*) fail "append-selection produced '$appended'" ;; +esac +x cancel + +$TMUX set-buffer -b append-cancel-buffer prefix || exit 1 +select_text +x append-selection-and-cancel +wait_mode 0 +appended=$($TMUX show-buffer -b append-cancel-buffer) +case "$appended" in +prefix*) ;; +*) fail "append-selection-and-cancel produced '$appended'" ;; +esac + +# The explicit cancel variants close copy mode. +fresh +x copy-line-and-cancel +wait_mode 0 +fresh +x copy-end-of-line-and-cancel +wait_mode 0 +select_text +x copy-selection-and-cancel +wait_mode 0 + +# Pipe wrappers have distinct command handlers. /dev/null keeps the test +# deterministic while exercising asynchronous pipe job creation and cleanup. +fresh +x copy-pipe-line 'cat >/dev/null' +x cancel +fresh +x copy-pipe-line-and-cancel 'cat >/dev/null' +wait_mode 0 +fresh +x copy-pipe-end-of-line 'cat >/dev/null' +x cancel +fresh +x copy-pipe-end-of-line-and-cancel 'cat >/dev/null' +wait_mode 0 + +select_text +x copy-pipe-no-clear 'cat >/dev/null' +x pipe-no-clear 'cat >/dev/null' +x copy-pipe 'cat >/dev/null' +x pipe 'cat >/dev/null' +x cancel +select_text +x copy-pipe-and-cancel 'cat >/dev/null' +wait_mode 0 +select_text +x pipe-and-cancel 'cat >/dev/null' +wait_mode 0 + +# Commands which cancel on downward movement have separate handlers. +fresh +x history-bottom +x bottom-line +x cursor-down-and-cancel +wait_mode 0 +fresh +x history-bottom +x halfpage-down-and-cancel +wait_mode 0 +fresh +x history-bottom +x page-down-and-cancel +wait_mode 0 +fresh +x history-bottom +x bottom-line +x scroll-down-and-cancel +wait_mode 0 + +$TMUX has-session -t copy || fail "server died during copy-mode commands" +exit 0 diff --git a/regress/copy-mode-no-client-scroll.sh b/regress/copy-mode-no-client-scroll.sh new file mode 100644 index 000000000..5dc8577f9 --- /dev/null +++ b/regress/copy-mode-no-client-scroll.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +# copy-mode -S can be called from a hook without an attached client. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +OUT=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$OUT +export TMUX_TMPDIR +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null || true + rm -rf "$OUT" +} +trap cleanup EXIT +trap 'exit 1' 1 2 3 15 + +$TMUX new-session -d -s test 'sleep 300' || fail "new-session failed" +$TMUX set -g remain-on-exit on || fail "setting remain-on-exit failed" +$TMUX set-hook -g pane-died 'copy-mode -S' || + fail "setting pane-died hook failed" +$TMUX respawn-pane -k -t test:0 'exit 0' || fail "respawn-pane failed" + +i=0 +while [ "$i" -lt 50 ]; do + $TMUX has-session -t test 2>/dev/null || \ + fail "server died in copy-mode -S" + [ "$($TMUX display-message -p -t test:0 '#{pane_dead}' 2>/dev/null)" = 1 ] && \ + break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "pane-died hook was not triggered" +sleep 0.5 +$TMUX has-session -t test || fail "server died after copy-mode -S" + +exit 0 diff --git a/regress/copy-mode-selection-mode.sh b/regress/copy-mode-selection-mode.sh new file mode 100644 index 000000000..27dc71b91 --- /dev/null +++ b/regress/copy-mode-selection-mode.sh @@ -0,0 +1,136 @@ +#!/bin/sh + +# Changing an existing character selection to line mode must reset both ends +# to complete lines, including when the cursor subsequently crosses the fixed +# end. An invalid regular-expression search must also discard marks left by a +# previous successful search. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +$TMUX new-session -d -x30 -y6 \ + "printf 'alpha\nbeta\ngamma\ndelta\n'; exec sleep 100" || exit 1 +$TMUX set-option -g window-size manual || exit 1 +sleep 1 + +# A valid regular-expression search creates marks. Replacing it with an +# invalid expression, growing the mode, and scrolling must not leave an old +# mark array indexed with the new geometry (the original bug was an ASan heap +# overflow in this sequence). +$TMUX copy-mode || exit 1 +$TMUX send-keys -X history-top || exit 1 +$TMUX send-keys -X search-forward beta || exit 1 +[ "$($TMUX display-message -p '#{search_present}')" = 1 ] || + fail "successful search did not create marks" +$TMUX send-keys -X search-forward '[' || exit 1 +$TMUX resize-window -x40 -y8 || exit 1 +$TMUX send-keys -X scroll-down || exit 1 +[ "$($TMUX display-message -p '#{search_present}')" = 0 ] || + fail "invalid regular expression left stale marks after resize" +$TMUX send-keys -X cancel || exit 1 + +# Select part of alpha through part of beta, then change the existing +# selection to line mode. Both lines, and no partial columns, must be copied. +$TMUX copy-mode || exit 1 +$TMUX send-keys -X history-top || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X begin-selection || exit 1 +$TMUX send-keys -X cursor-down || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X selection-mode line || exit 1 +[ "$($TMUX display-message -p '#{selection_mode}')" = line ] || + fail "selection did not change to line mode" +$TMUX send-keys -X copy-selection || exit 1 +expected=$(printf 'alpha\nbeta') +[ "$($TMUX show-buffer)" = "$expected" ] || + fail "line-mode selection did not expand to complete lines" + +# Start with the cursor above the fixed end so selection-mode itself takes the +# reverse-direction path, then extend it through another line. +$TMUX copy-mode || exit 1 +$TMUX send-keys -X history-top || exit 1 +$TMUX send-keys -N2 -X cursor-down || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X begin-selection || exit 1 +$TMUX send-keys -X cursor-up || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X selection-mode line || exit 1 +$TMUX send-keys -X cursor-up || exit 1 +$TMUX send-keys -X copy-selection || exit 1 +expected=$(printf 'alpha\nbeta\ngamma') +[ "$($TMUX show-buffer)" = "$expected" ] || + fail "reversed line-mode selection did not keep complete lines" + +# Word mode must initialize its reset boundaries from the existing selection, +# rather than extending it back to the origin on the next cursor movement. +for mode in emacs vi; do + $TMUX send-keys -X cancel || exit 1 + $TMUX set-window-option -g mode-keys "$mode" || exit 1 + $TMUX copy-mode || exit 1 + $TMUX send-keys -X history-top || exit 1 + $TMUX send-keys -X cursor-down || exit 1 + $TMUX send-keys -N2 -X cursor-right || exit 1 + $TMUX send-keys -X begin-selection || exit 1 + $TMUX send-keys -X cursor-right || exit 1 + $TMUX send-keys -X selection-mode word || exit 1 + $TMUX send-keys -X copy-selection-no-clear -C || exit 1 + [ "$($TMUX show-buffer)" = beta ] || + fail "$mode: word-mode selection did not expand to the current word" + $TMUX send-keys -X cursor-right || exit 1 + $TMUX send-keys -X copy-selection-no-clear -C || exit 1 + expected=$(printf 'beta\ngamma') + [ "$($TMUX show-buffer)" = "$expected" ] || + fail "$mode: word-mode movement reset the selection to the origin" + + # Reversing the character selection before changing mode must keep the + # fixed end and its containing word when the cursor moves further back. + $TMUX send-keys -X cancel || exit 1 + $TMUX copy-mode || exit 1 + $TMUX send-keys -X history-top || exit 1 + $TMUX send-keys -N2 -X cursor-down || exit 1 + $TMUX send-keys -N2 -X cursor-right || exit 1 + $TMUX send-keys -X begin-selection || exit 1 + $TMUX send-keys -X cursor-up || exit 1 + $TMUX send-keys -X selection-mode word || exit 1 + $TMUX send-keys -X cursor-left || exit 1 + $TMUX send-keys -X copy-selection-no-clear -C || exit 1 + expected=$(printf 'alpha\nbeta\ngamma') + [ "$($TMUX show-buffer)" = "$expected" ] || + fail "$mode: reversed word-mode selection lost its fixed end" + + # A stopped selection should expand to words without moving its cursor. + $TMUX send-keys -X cancel || exit 1 + $TMUX copy-mode || exit 1 + $TMUX send-keys -X history-top || exit 1 + $TMUX send-keys -X cursor-down || exit 1 + $TMUX send-keys -N2 -X cursor-right || exit 1 + $TMUX send-keys -X begin-selection || exit 1 + $TMUX send-keys -X cursor-right || exit 1 + $TMUX send-keys -X stop-selection || exit 1 + before=$($TMUX display-message -p '#{copy_cursor_x},#{copy_cursor_y}') + $TMUX send-keys -X selection-mode word || exit 1 + [ "$($TMUX display-message -p '#{copy_cursor_x},#{copy_cursor_y}')" = \ + "$before" ] || fail "$mode: word-mode switch moved a stopped cursor" + $TMUX send-keys -X copy-selection-no-clear -C || exit 1 + [ "$($TMUX show-buffer)" = beta ] || + fail "$mode: stopped word-mode selection did not contain the whole word" +done + +exit 0 diff --git a/regress/copy-mode-selection-scroll.sh b/regress/copy-mode-selection-scroll.sh index 7e7aee9ce..cb1152b49 100644 --- a/regress/copy-mode-selection-scroll.sh +++ b/regress/copy-mode-selection-scroll.sh @@ -110,4 +110,92 @@ wheel 64 5 5 $TMUX send-keys -X copy-selection-no-clear || exit 1 expect_buffer "$extended_start" +# Perform a mouse drag across a sequence of col,row coordinates (1-based). +# SGR mouse format: \033[/dev/null + # Keep the button held briefly so auto-scrolling can run, and + # capture copy-mode state before mouse release exits copy mode. + sleep 0.5 + scroll_pos=$($TMUX display -p '#{scroll_position}') + cursor_y=$($TMUX display -p '#{copy_cursor_y}') + # Mouse button 1 up (release) at the final coordinate. + seq=$(printf '\033[<0;%s;%sm' "$col" "$row") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 +} + +# Turn off status line so all 10 pane rows are visible in the outer client, +# and write a 24-char line on the bottom row (row 10, longer than the 18-char +# line 79 above it). +$TMUX set -g status off || exit 1 +$TMUX send-keys -X cancel || exit 1 +$TMUX send-keys -l "line 80 xxxxxxxxxxxxxxxx" || exit 1 +sleep 0.2 + +for mode in emacs vi; do + $TMUX setw -g mode-keys "$mode" || exit 1 + + # In emacs mode the selection end column is exclusive, so selecting all + # characters of an 18- or 24-char line requires placing the cursor one + # column past the last character; in vi mode the end column is inclusive. + if [ "$mode" = emacs ]; then + ecol18=19 + ecol24=25 + else + ecol18=18 + ecol24=24 + fi + + # Drag along the bottom row (row 10) past the end of the line and back + # when already at the bottom of history; the selection must stay on + # line 80 instead of jumping up to line 79. + $TMUX copy-mode || exit 1 + drag 1,10 "$ecol24,10" "$((ecol24 + 1)),10" "$ecol24,10" + expect_buffer "line 80 xxxxxxxxxxxxxxxx" + + # Scroll up 1 line so line 80 is off-screen below, then drag from row 9 + # to the bottom row (row 10) to auto-scroll down and select lines 78..80. + $TMUX copy-mode || exit 1 + $TMUX send-keys -X scroll-up || exit 1 + drag 1,9 23,10 "$ecol24,10" + expect_buffer "$(printf 'line 78 xxxxxxxxxx\nline 79 xxxxxxxxxx\nline 80 xxxxxxxxxxxxxxxx')" + + # Scroll 1 line below the top of history so line 00 is off-screen above, + # then drag from row 2 to the top row (row 1) to auto-scroll up and + # select lines 00..02. + $TMUX copy-mode || exit 1 + $TMUX send-keys -X history-top || exit 1 + $TMUX send-keys -X scroll-down || exit 1 + drag "$ecol18,2" 1,1 + expect_buffer "$(printf 'line 00 xxxxxxxxxx\nline 01 xxxxxxxxxx\nline 02 xxxxxxxxxx')" + + # Scroll up 5 lines, then drag onto the bottom row and keep the button + # held to auto-scroll all the way to the bottom. + $TMUX copy-mode || exit 1 + $TMUX send-keys -N5 -X scroll-up || exit 1 + drag 1,9 5,10 + [ "$scroll_pos" = 0 ] || fail "$mode: drag auto-scroll stopped at $scroll_pos" + + # At the top of history, drag onto the top row and keep the button + # held: the cursor must stay on the top row. + $TMUX copy-mode || exit 1 + $TMUX send-keys -X history-top || exit 1 + drag 5,2 5,1 + [ "$cursor_y" = 0 ] || fail "$mode: cursor left the top row (row $cursor_y)" +done + exit 0 diff --git a/regress/copy-mode-source-blank.sh b/regress/copy-mode-source-blank.sh new file mode 100644 index 000000000..3df09a359 --- /dev/null +++ b/regress/copy-mode-source-blank.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +# Trimming a blank source must leave a visible line for cursor reflow. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 3 15 + +$TMUX new-session -d -x80 -y24 -s test 'sleep 100' || exit 1 +$TMUX set-option -g window-size manual || exit 1 +$TMUX new-window -t test:1 'sleep 100' || exit 1 +$TMUX resize-window -t test:0 -x20 -y24 || exit 1 + +# Exercise widening, narrowing, and equal widths with no source history. +for pair in '0 1' '1 0' '1 2'; do + set -- $pair + if [ "$2" = 2 ]; then + $TMUX new-window -t test:2 'sleep 100' || exit 1 + $TMUX resize-window -t test:2 -x80 -y24 || exit 1 + fi + source=test:$1.0 + target=test:$2.0 + [ "$($TMUX display-message -p -t "$source" '#{history_size}')" = 0 ] || + fail "source has unexpected history" + $TMUX copy-mode -s "$source" -t "$target" || exit 1 + $TMUX has-session -t test || fail "server died copying blank source" + [ "$($TMUX display-message -p -t "$target" \ + '#{pane_in_mode} #{copy_cursor_x} #{copy_cursor_y}')" = '1 0 0' ] || + fail "blank source did not enter copy mode at the first cell" + $TMUX send-keys -t "$target" -X cancel || exit 1 +done + +# Real content still trims trailing empty lines, clamping the cursor to beta. +$TMUX respawn-pane -k -t test:0.0 \ + "printf 'alpha\r\nbeta\r\n\r\n\r\n'; exec sleep 100" || exit 1 +i=0 +while [ "$($TMUX display-message -p -t test:0.0 '#{cursor_y}')" != 4 ]; do + i=$((i + 1)) + [ "$i" -lt 10 ] || fail "source output did not arrive" + sleep 1 +done +$TMUX copy-mode -s test:0.0 -t test:1.0 || exit 1 +[ "$($TMUX display-message -p -t test:1.0 \ + '#{pane_in_mode} #{copy_cursor_y} #{copy_cursor_line}')" = '1 1 beta' ] || + fail "nonblank source content or trailing-line trimming changed" + +exit 0 diff --git a/regress/copy-mode-unicode-whitespace.sh b/regress/copy-mode-unicode-whitespace.sh old mode 100755 new mode 100644 diff --git a/regress/copy-mode-word-redraw.sh b/regress/copy-mode-word-redraw.sh new file mode 100644 index 000000000..b4825395e --- /dev/null +++ b/regress/copy-mode-word-redraw.sh @@ -0,0 +1,149 @@ +#!/bin/sh + +# Word selection can extend beyond the cursor's row. Compare what an attached +# client drew with a full copy-mode repaint after extending and shrinking it. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -Lword-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lword-outer-$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 +exit_status=0 + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 +trap 'exit 1' 1 2 3 15 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +check_buffer() +{ + $INNER send-keys -X copy-selection-no-clear || exit 1 + $INNER save-buffer "$DIR/copied" || exit 1 + cmp -s "$DIR/expected" "$DIR/copied" || fail 'unexpected copied text' +} + +check_redraw() +{ + label=$1 + sleep 0.2 + $OUTER capture-pane -ep -t outer:0.0 >"$DIR/before" || exit 1 + $INNER send-keys -X toggle-position || exit 1 + $INNER send-keys -X toggle-position || exit 1 + sleep 0.2 + $OUTER capture-pane -ep -t outer:0.0 >"$DIR/after" || exit 1 + if ! cmp -s "$DIR/before" "$DIR/after"; then + diff -u "$DIR/before" "$DIR/after" >&2 + echo "$label: stale word-selection highlight" >&2 + exit_status=1 + fi +} + +$INNER new-session -d -s inner -x20 -y8 'sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mode-keys emacs || exit 1 +$INNER set-option -g copy-mode-position-format '' || exit 1 +$INNER set-option -g copy-mode-selection-style 'fg=white,bg=red' || exit 1 +$OUTER new-session -d -s outer -x20 -y8 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 0.5 + +# A wrapped word ending on the penultimate row. In emacs mode end-of-line +# moves onto the exclusive end and extends the selection into the blank row +# below; start-of-line then removes that row from the selection. +awk 'BEGIN { print "PREFIX"; for (i = 0; i < 115; i++) printf "a"; print "" }' \ + >"$DIR/text" || exit 1 +$INNER new-window -t inner:1 "cat '$DIR/text'; exec sleep 100" || exit 1 +$INNER resize-window -x20 -y8 || exit 1 +sleep 0.2 +$INNER copy-mode || exit 1 +$INNER send-keys -X history-top || exit 1 +$INNER send-keys -X cursor-down || exit 1 +$INNER send-keys -X select-word || exit 1 +$INNER send-keys -X end-of-line || exit 1 +[ "$($INNER display-message -p '#{selection_end_y}')" = 7 ] || + fail 'selection did not extend into the blank row' +check_redraw 'extend onto blank row' +awk 'BEGIN { for (i = 0; i < 115; i++) printf "a"; print "" }' \ + >"$DIR/expected" || exit 1 +check_buffer +$INNER send-keys -X start-of-line || exit 1 +[ "$($INNER display-message -p '#{selection_end_y}')" = 6 ] || + fail 'selection did not shrink away from the blank row' +check_redraw 'shrink away from blank row' +awk 'BEGIN { for (i = 0; i < 115; i++) printf "a" }' \ + >"$DIR/expected" || exit 1 +check_buffer + +# The selected next word can itself wrap across several rows even though the +# cursor only moved within one row. All of those rows must be repainted. +awk 'BEGIN { print "PREFIX"; print "one"; + for (i = 0; i < 65; i++) printf "b"; print ""; print "END" }' \ + >"$DIR/text" || exit 1 +$INNER new-window -t inner:2 "cat '$DIR/text'; exec sleep 100" || exit 1 +$INNER resize-window -x20 -y8 || exit 1 +sleep 0.2 +$INNER copy-mode || exit 1 +$INNER send-keys -X history-top || exit 1 +$INNER send-keys -X cursor-down || exit 1 +$INNER send-keys -X select-word || exit 1 +$INNER send-keys -X end-of-line || exit 1 +[ "$($INNER display-message -p '#{selection_end_y}')" = 5 ] || + fail 'selection did not extend over the wrapped next word' +check_redraw 'extend over a wrapped next word' +awk 'BEGIN { print "one"; for (i = 0; i < 65; i++) printf "b" }' \ + >"$DIR/expected" || exit 1 +check_buffer +$INNER send-keys -X start-of-line || exit 1 +check_redraw 'shrink away from a wrapped next word' +printf 'one' >"$DIR/expected" || exit 1 +check_buffer + +# Drag a word selection onto the bottom row and back while there is still +# history below the viewport. A redraw must not read the next backing row +# and paint it onto the last visible row. +awk 'BEGIN { for (i = 0; i < 30; i++) printf "line%02d abc def\n", i }' \ + >"$DIR/text" || exit 1 +$INNER new-window -t inner:3 "cat '$DIR/text'; exec sleep 100" || exit 1 +$INNER resize-window -x20 -y8 || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER unbind-key -Tcopy-mode MouseDragEnd1Pane || exit 1 +sleep 0.2 +$INNER copy-mode || exit 1 +$INNER send-keys -X history-top || exit 1 +$INNER send-keys -X cursor-down || exit 1 +$INNER send-keys -X select-word || exit 1 +$OUTER send-keys -l "$(printf '\033[<0;3;2M')" || exit 1 +sleep 0.1 +$OUTER send-keys -l "$(printf '\033[<32;8;7M')" || exit 1 +sleep 0.1 +$INNER send-keys -X selection-mode word || exit 1 +$OUTER send-keys -l "$(printf '\033[<32;8;8M')" || exit 1 +sleep 0.1 +# Leave the edge to cancel the auto-scroll timer before comparing. +$OUTER send-keys -l "$(printf '\033[<32;8;7M')" || exit 1 +sleep 0.1 +$OUTER send-keys -l "$(printf '\033[<0;8;7m')" || exit 1 +sleep 0.1 +[ "$($INNER display-message -p '#{selection_mode}')" = word ] || + fail 'mouse drag did not retain word selection mode' +[ "$($INNER display-message -p '#{scroll_position}')" -gt 0 ] || + fail 'mouse drag reached the bottom of history' +check_redraw 'drag word selection at bottom of viewport' + +exit "$exit_status" diff --git a/regress/display-message-client.sh b/regress/display-message-client.sh new file mode 100755 index 000000000..c0f52c257 --- /dev/null +++ b/regress/display-message-client.sh @@ -0,0 +1,66 @@ +#!/bin/sh + +# display-message -c must take the client formats from the given client, even +# when it is attached to a different session from the target pane. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +cleanup +$INNER new-session -d -s one -x40 -y10 'exec sleep 100' || exit 1 +$INNER new-session -d -s two -x40 -y10 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 + +# Each client has a distinct TERM so that client_termname identifies it. +attach() +{ + echo "env -i PATH=/bin:/usr/bin TERM=$1 LC_ALL=C.UTF-8 $TEST_TMUX -LtestI$$ -f/dev/null attach-session -t $2" +} +$OUTER new-session -d -s outer -x40 -y10 "$(attach screen one)" || exit 1 +$OUTER new-window -d "$(attach xterm two)" || exit 1 +$OUTER set-option -g status off || exit 1 + +i=0 +while [ "$i" -lt 50 ]; do + n=$($INNER list-clients -F '#{client_name}' 2>/dev/null|wc -l) + [ "$n" -eq 2 ] && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "clients did not attach" + +c1=$($INNER list-clients -F '#{client_name}' -f '#{==:#{client_termname},screen}') +c2=$($INNER list-clients -F '#{client_name}' -f '#{==:#{client_termname},xterm}') +[ -n "$c1" ] && [ -n "$c2" ] || fail "could not find both clients" + +# The target is in session two, but the client is the one in session one. +term=$($INNER display-message -c "$c1" -t two: -p '#{client_termname}') +[ "$term" = screen ] || fail "client_termname was '$term', not screen" + +# The session formats still come from the target. +session=$($INNER display-message -c "$c1" -t two: -p '#{session_name}') +[ "$session" = two ] || fail "session_name was '$session', not two" + +term=$($INNER display-message -c "$c2" -t one: -p '#{client_termname}') +[ "$term" = xterm ] || fail "client_termname was '$term', not xterm" + +exit 0 diff --git a/regress/display-panes.sh b/regress/display-panes.sh index 2d3faccd5..30c983c38 100644 --- a/regress/display-panes.sh +++ b/regress/display-panes.sh @@ -153,11 +153,13 @@ wait_format "$p0" '#{pane_in_mode}' '0' wait_option @picked "$p1" # Commands after display-panes run immediately while the mode remains. +# Record the mode in the same command queue so client startup cannot race +# the 500ms timeout. $TMUX set -g @after none || fail "set @after failed" -$TMUX display-panes -Nd 500 -t "$p0" \; set -g @after fast || +$TMUX display-panes -Nd 500 -t "$p0" \; \ + set -gF -t "$p0" @after '#{pane_mode}' || fail "display-panes immediate command failed" -wait_option @after fast -wait_format "$p0" '#{pane_mode}' 'panes-mode' +wait_option @after 'panes-mode' wait_format "$p0" '#{pane_in_mode}' '0' # Existing zoom is restored on exit. diff --git a/regress/find-window.sh b/regress/find-window.sh new file mode 100644 index 000000000..820a4777d --- /dev/null +++ b/regress/find-window.sh @@ -0,0 +1,118 @@ +#!/bin/sh + +# Exercise find-window's content, name and title filters in every combination, +# glob/regex and case-insensitive suffixes, plus zoom restoration. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_mode() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER display-message -p -t find:base.0 '#{pane_mode}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "pane mode is '$got', expected '$want'" +} + +run_find() +{ + label=$1 + expected=$2 + shift 2 + $INNER find-window -t find:base.0 "$@" || fail "$label failed" + wait_mode tree-mode + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Fq "$expected" && break + sleep 0.1 + i=$((i + 1)) + done + [ "$i" -lt 50 ] || fail "$label did not show '$expected'" + $INNER send-keys -t find:base.0 q || exit 1 + wait_mode '' +} + +$INNER new-session -d -s find -n base -x80 -y24 \ + "sh -c 'printf \"BaseBody\\n\"; exec sleep 100'" || exit 1 +$INNER split-window -d -h -t find:base \ + "sh -c 'printf \"OtherBody\\n\"; exec sleep 100'" || exit 1 +$INNER new-window -d -t find: -n NameNeedle 'exec sleep 100' || exit 1 +$INNER new-window -d -t find: -n body \ + "sh -c 'printf \"BodyNeedle\\n\"; exec sleep 100'" || exit 1 +$INNER new-window -d -t find: -n titled 'exec sleep 100' || exit 1 +$INNER select-pane -t find:titled.0 -T TitleNeedle || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t find:base" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +# Wait until the body text is in the pane history before searching it. +i=0 +while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t find:body.0 | grep -Fq BodyNeedle && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "pane content was not ready" + +# No flags means C+N+T. Then cover all three pairs and all three singletons. +run_find default NameNeedle NameNeedle +run_find content-name NameNeedle -C -N NameNeedle +run_find content-title titled -C -T TitleNeedle +run_find name-title titled -N -T TitleNeedle +run_find content-only body -C BodyNeedle +run_find name-only NameNeedle -N NameNeedle +run_find title-only titled -T TitleNeedle + +# Glob case folding and all regular-expression suffix variants. +run_find insensitive NameNeedle -N -i nameneedle +run_find regex NameNeedle -N -r '^NameNeedle$' +run_find regex-insensitive NameNeedle -N -r -i '^nameneedle$' + +# -Z temporarily zooms the target window and restores its prior state on exit. +$INNER find-window -Z -t find:base.0 -N NameNeedle || exit 1 +wait_mode tree-mode +[ "$($INNER display-message -p -t find:base '#{window_zoomed_flag}')" = 1 ] || + fail "find-window -Z did not zoom" +$INNER send-keys -t find:base.0 q || exit 1 +wait_mode '' +[ "$($INNER display-message -p -t find:base '#{window_zoomed_flag}')" = 0 ] || + fail "find-window -Z did not restore zoom" + +exit 0 diff --git a/regress/floating-pane-border-clients.sh b/regress/floating-pane-border-clients.sh new file mode 100755 index 000000000..917f3b6c1 --- /dev/null +++ b/regress/floating-pane-border-clients.sh @@ -0,0 +1,135 @@ +#!/bin/sh + +# Damage-only redraws must evaluate both active and inactive border styles +# for each client, even though the cached border cells belong to the pane. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lborder-style-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lborder-style-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + count=$($INNER list-clients 2>/dev/null | wc -l) + [ "$count" -eq 2 ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "two inner clients did not attach" +} + +wait_for_marker() +{ + target=$1 + marker=$2 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t "$target" >"$CAPTURE" || exit 1 + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client in $target did not receive $marker" +} + +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $phase (1 .. 2) { + while (!-e "$ENV{TRIGGER}-$phase") { + select undef, undef, undef, 0.01; + } + # Change an unused palette entry and acknowledge it in the pane body. + print "\e]4;200;rgb:11/22/0$phase\a\e[1;1HDAMAGE$phase"; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 20 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$INNER set -g pane-border-status top || exit 1 +$INNER set -g pane-border-format 'CLIENT=#{client_name}' || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 35 -y 6 -X 5 -Y 5 \ + 'sleep 100') || exit 1 + +$OUTER new-session -d -s outer -x 121 -y 20 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +LEFT=$($OUTER display-message -p -t outer:0.0 '#{pane_id}') || exit 1 +RIGHT=$($OUTER split-window -h -PF '#{pane_id}' 'sleep 100') || exit 1 +for target in "$LEFT" "$RIGHT"; do + $OUTER respawn-pane -k -t "$target" "$INNER attach-session -t inner" || + exit 1 +done +wait_for_clients + +NAME1=$($OUTER display-message -p -t "$LEFT" '#{pane_tty}') || exit 1 +NAME2=$($OUTER display-message -p -t "$RIGHT" '#{pane_tty}') || exit 1 +STYLE="fg=#{?#{==:#{client_name},$NAME1},red,blue}" +$INNER set -g pane-border-style "$STYLE" || exit 1 +$INNER set -g pane-active-border-style "$STYLE" || exit 1 + +# Capture just the floating pane's rows, excluding the acknowledgement in +# the tiled pane. Exercise each cache by changing the floating pane's focus. +phase=1 +while [ "$phase" -le 2 ]; do + if [ "$phase" -eq 2 ]; then + $INNER select-pane -t "$FLOAT" || exit 1 + fi + $INNER refresh-client -t "$NAME1" || exit 1 + $INNER refresh-client -t "$NAME2" || exit 1 + sleep 0.5 + for target in "$LEFT" "$RIGHT"; do + $OUTER capture-pane -pe -S 5 -E 10 -t "$target" \ + >"$DIR/before-$target" || exit 1 + done + RED=$(printf '\033[31m') + BLUE=$(printf '\033[34m') + grep -Fq "$RED" "$DIR/before-$LEFT" || fail "missing red border" + grep -Fq "$BLUE" "$DIR/before-$RIGHT" || fail "missing blue border" + + : >"$DIR/trigger-$phase" + wait_for_marker "$LEFT" "DAMAGE$phase" + wait_for_marker "$RIGHT" "DAMAGE$phase" + sleep 0.2 + for target in "$LEFT" "$RIGHT"; do + $OUTER capture-pane -pe -S 5 -E 10 -t "$target" \ + >"$CAPTURE" || exit 1 + diff -u "$DIR/before-$target" "$CAPTURE" || + fail "phase $phase changed $target's border style" + done + phase=$((phase + 1)) +done + +exit 0 diff --git a/regress/floating-pane-covers-tiled-pane.sh b/regress/floating-pane-covers-tiled-pane.sh new file mode 100755 index 000000000..79da614a3 --- /dev/null +++ b/regress/floating-pane-covers-tiled-pane.sh @@ -0,0 +1,137 @@ +#!/bin/sh + +# A floating pane that is larger than a tiled pane on every side (so none of +# its edges fall inside the tiled pane) must still hide that pane: output from +# the covered pane must not be drawn over the floating pane. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lcovers-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lcovers-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +FILLER=$DIR/filler.pl +TRIGGER=$DIR/trigger +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_inner_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t "$2" 2>/dev/null | + grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane $2 did not contain $marker" +} + +# Covered pane: waits for the trigger, then scrolls a small region. +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} +# Scroll a small region: this is redrawn row by row rather than by a large +# region redraw, so it must not draw over the floating pane. +print "\e[1;2r\e[2;1HCOVEREDCOVERED\n"; +sleep 100; +PERL + +# Floating pane: fill it so any overwriting is visible. +cat >"$FILLER" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $row (1 .. 8) { + print "\e[$row;1H", 'F' x 30; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 20 'sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +# Three rows of panes; the middle row is short and split into three so that +# the middle pane has other panes on both sides and above and below. +$INNER split-window -d -v -t inner:0.0 'sleep 100' || exit 1 +$INNER split-window -d -v -t inner:0.1 'sleep 100' || exit 1 +$INNER resize-pane -t inner:0.0 -y 6 || exit 1 +$INNER resize-pane -t inner:0.1 -y 4 || exit 1 +MID=$($INNER split-window -d -h -P -F '#{pane_id}' -l 20 -t inner:0.1 \ + 'sleep 100') || exit 1 +$INNER split-window -d -h -l 20 -t inner:0.1 'sleep 100' || exit 1 + +# The covered pane is the one in the middle of the short row. +set -- $($INNER list-panes -F '#{pane_left} #{pane_id}' -f '#{==:#{pane_top},7}' | + sort -n | awk '{ print $2 }') +[ $# -eq 3 ] || fail "expected three panes in the middle row, got $#" +COVERED=$2 +X=$($INNER display-message -p -t "$COVERED" '#{pane_left}') +Y=$($INNER display-message -p -t "$COVERED" '#{pane_top}') +W=$($INNER display-message -p -t "$COVERED" '#{pane_width}') +H=$($INNER display-message -p -t "$COVERED" '#{pane_height}') + +# Bigger than the covered pane on all four sides. +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x $((W + 6)) -y $((H + 4)) \ + -X $((X - 3)) -Y $((Y - 2)) "perl '$FILLER'") || fail "new-pane failed" + +$INNER respawn-pane -k -t "$COVERED" "TRIGGER='$TRIGGER' perl '$EMITTER'" || + exit 1 + +$OUTER new-session -d -s outer -x 60 -y 20 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lcovers-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_outer_has FFFFFFFFFF +sleep 1 +: >"$TRIGGER" +wait_inner_has COVERED "$COVERED" +sleep 1 +$OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" +grep -q COVERED "$CAPTURE" && fail "covered pane drawn over floating pane" + +exit 0 diff --git a/regress/floating-pane-drag-composite.sh b/regress/floating-pane-drag-composite.sh new file mode 100755 index 000000000..f1fb37faa --- /dev/null +++ b/regress/floating-pane-drag-composite.sh @@ -0,0 +1,88 @@ +#!/bin/sh + +# Each drag motion must draw the floating pane content once. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +cd "$DIR" || exit 1 +INNER="$TEST_TMUX -Ldoublecomp-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Ldoublecomp-outer-$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + cd / + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.15 +} + +$INNER new-session -d -s inner -x 40 -y 15 "printf '\\033[15;1HOUTSIDE'; exec sleep 100" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g status-interval 0 || exit 1 +$INNER set-option -g automatic-rename off || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X 5 -Y 2 \ + "printf 'DRAGMARK'; exec sleep 100") || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 15 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Ldoublecomp-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 +sleep 0.5 + +XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +GRABCOL=$((XOFF + 3)) +BORDERROW=$YOFF + +# Begin capture after focus changes from the initial mouse press have settled. +mouse 0 "$GRABCOL" "$BORDERROW" M +$OUTER pipe-pane -O -t outer:0.0 "cat >'$DIR/output'" || exit 1 +$INNER refresh-client || exit 1 +sleep 0.5 +grep -aq DRAGMARK "$DIR/output" || fail "capture missed floating pane content" +grep -aq OUTSIDE "$DIR/output" || fail "capture missed untouched row" +offset=$(wc -c <"$DIR/output") + +i=0 +steps=6 +while [ "$i" -lt "$steps" ]; do + GRABCOL=$((GRABCOL + 1)) + mouse 32 "$GRABCOL" "$BORDERROW" M + i=$((i + 1)) +done +mouse 0 "$GRABCOL" "$BORDERROW" m +sleep 0.3 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" -eq "$((XOFF + steps))" ] || fail "floating pane did not move six columns" +tail -c +"$((offset + 1))" "$DIR/output" >"$DIR/drag-output" +n=$(perl -0777 -ne '$n = () = /DRAGMARK/g; print "$n\n"' "$DIR/drag-output") +[ "$n" -eq "$steps" ] || + fail "$steps drag motions wrote the floating pane content $n times" + +exit 0 diff --git a/regress/floating-pane-drag-ghost.sh b/regress/floating-pane-drag-ghost.sh new file mode 100755 index 000000000..abc20633d --- /dev/null +++ b/regress/floating-pane-drag-ghost.sh @@ -0,0 +1,108 @@ +#!/bin/sh + +# Regression test for a floating-pane drag bug: cmd_resize_pane_redraw_floating() +# (cmd-resize-pane.c) reported damage for just a dragged floating pane's +# content rectangle, not the one-cell border frame drawn around it (see the +# "floating" case in screen-redraw.c, which draws that frame at +# xoff-1/yoff-1 through xoff+sx/yoff+sy - one cell outside the pane's own +# content area). Damage scoped to only the content area left the frame's +# previous position undrawn as the pane moved, so dragging it left a trail +# of un-erased border frames behind - visible as several "corners" stacked +# up rather than just the pane's current one. +# +# This bug has nothing to do with images - it reproduces with a plain +# floating pane and no image support required. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" + +cleanup() +{ + $TMUX kill-server >/dev/null 2>&1 + $TMUX2 kill-server >/dev/null 2>&1 +} +fail() +{ + echo "$*" >&2 + cleanup + exit 1 +} + +# drag STARTCOL STARTROW ENDCOL ENDROW +# +# Write a plain (unmodified) SGR button-1 press, drag update and release at +# 1-based positions to the outer pane holding the inner client - this +# matches the default MouseDown1Border/MouseDrag1Border bindings used to +# move or resize a floating pane by its border. +drag() +{ + scol="$1" + srow="$2" + ecol="$3" + erow="$4" + + seq=$(printf '\033[<0;%s;%sM' "$scol" "$srow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<32;%s;%sM' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<0;%s;%sm' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 1 +} + +cleanup + +TMP=$(mktemp) +trap "cleanup; rm -f $TMP" 0 1 15 + +$TMUX new-session -d -s inner -x 60 -y 20 'sh -c "sleep 100"' || exit 1 +$TMUX set -g mouse on +$TMUX set -g default-command 'sh -c "sleep 100"' + +FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5) || + fail "new-pane -X -Y failed" +FTOP=$($TMUX display-message -p -t "$FLOAT" '#{pane_top}') +FLEFT=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') +FWIDTH=$($TMUX display-message -p -t "$FLOAT" '#{pane_width}') + +$TMUX2 new-session -d -x 60 -y 20 "$TMUX attach -t inner" || exit 1 +sleep 1 +OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) +[ -n "$OUTER" ] || fail "No outer pane." + +# Sanity check: exactly one floating pane, so exactly one top-left corner, +# before dragging anything. +$TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed" +n=$(grep -o '┌' $TMP | wc -l) +[ "$n" -eq 1 ] || fail "sanity: expected 1 corner before drag, found $n" + +# Drag the floating pane by its top border (row FTOP-1, some column within +# its width) down several rows in a few separate steps, then release. A +# single drag() call already does press/motion/release, so call it several +# times in a row to simulate a multi-step real drag. +GRABCOL=$((FLEFT + FWIDTH / 2)) +STARTROW=$FTOP +i=0 +while [ $i -lt 6 ]; do + newrow=$((STARTROW + i + 1)) + drag $((GRABCOL + 1)) $((STARTROW + i)) $((GRABCOL + 1)) $newrow + i=$((i + 1)) +done + +$TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed" + +# Exactly one top-left corner should remain - the pane's current position. +# This is expected to fail before the fix: multiple corners (a trail of +# un-erased frames) would remain from the intermediate drag positions. +n=$(grep -o '┌' $TMP | wc -l) +[ "$n" -eq 1 ] || fail "expected exactly 1 corner after drag, found $n (ghost frames left behind)" + +exit 0 diff --git a/regress/floating-pane-drag-sb-strip.sh b/regress/floating-pane-drag-sb-strip.sh new file mode 100755 index 000000000..fbdebbc82 --- /dev/null +++ b/regress/floating-pane-drag-sb-strip.sh @@ -0,0 +1,160 @@ +#!/bin/sh + +# Regression test: dragging a floating pane across another pane's ordinary +# content must not redraw that other pane's scrollbar, unless the drag +# actually crosses the scrollbar's own strip. +# +# cmd_resize_pane_redraw_floating() (cmd-resize-pane.c) used to flag +# PANE_REDRAWSCROLLBAR on any pane whose whole *body* intersected the +# floating pane's old or new rectangle, rather than just its narrow +# scrollbar strip - so dragging a floating pane back and forth over an +# ordinary tiled pane's content (never touching its scrollbar) still +# needlessly redrew that pane's scrollbar on every motion step. See +# tmux-image-redraw-known-bugs.md for the full write-up. +# +# This is checked by giving the non-dragged pane a distinctive scrollbar +# colour and counting how many times its SGR code appears in the client's +# raw output while the floating pane is dragged vertically over that pane's +# body, well clear of its scrollbar column: with the fix, it should never +# reappear after the initial draw. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" + +cleanup() +{ + $TMUX kill-server >/dev/null 2>&1 + $TMUX2 kill-server >/dev/null 2>&1 +} +fail() +{ + echo "$*" >&2 + cleanup + exit 1 +} + +# drag STARTCOL STARTROW ENDCOL ENDROW +drag() +{ + scol="$1" + srow="$2" + ecol="$3" + erow="$4" + + seq=$(printf '\033[<0;%s;%sM' "$scol" "$srow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<32;%s;%sM' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<0;%s;%sm' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.5 +} + +cleanup + +TMP=$(mktemp) +trap "cleanup; rm -f $TMP" 0 1 15 + +$TMUX new-session -d -s inner -x 60 -y 20 'sh -c "sleep 100"' || exit 1 +$TMUX set -g status-right '' || fail "set status-right failed" +$TMUX set -g mouse on || fail "set mouse failed" +$TMUX set -g default-command 'sh -c "sleep 100"' || fail "set default-command failed" +$TMUX set -g pane-scrollbars on || fail "set pane-scrollbars failed" + +$TMUX split-window -h -t inner 'sh -c "sleep 100"' || fail "split-window failed" + +PANES=$($TMUX list-panes -t inner -F '#{pane_id} #{pane_left}') +LEFT=$(echo "$PANES" | sort -k2 -n | head -1 | cut -d' ' -f1) +[ -n "$LEFT" ] || fail "could not identify left pane" + +# A distinctive scrollbar colour for the non-dragged (left) pane only. +$TMUX set -p -t "$LEFT" pane-scrollbars-style 'fg=colour201,bg=colour17' || + fail "set pane-scrollbars-style failed" + +ALEFT=$($TMUX display-message -p -t "$LEFT" '#{pane_left}') +ATOP=$($TMUX display-message -p -t "$LEFT" '#{pane_top}') +AWIDTH=$($TMUX display-message -p -t "$LEFT" '#{pane_width}') +AHEIGHT=$($TMUX display-message -p -t "$LEFT" '#{pane_height}') +[ "$AWIDTH" -gt 15 ] || fail "left pane too narrow for this test ($AWIDTH)" + +# A small floating pane placed well inside the left pane's content area, +# clear of its (right-hand) scrollbar column by several columns. +FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 8 -y 5 \ + -X $((ALEFT + 2)) -Y $((ATOP + 2))) || fail "new-pane -X -Y failed" +FTOP=$($TMUX display-message -p -t "$FLOAT" '#{pane_top}') +FLEFT=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') +FWIDTH=$($TMUX display-message -p -t "$FLOAT" '#{pane_width}') +[ $((FLEFT + FWIDTH + 3)) -lt $((ALEFT + AWIDTH)) ] || + fail "sanity: floating pane too close to the scrollbar column" + +# Start the outer session with a plain shell, then start capturing before +# triggering the attach - starting the attach as the outer pane's initial +# command would mean pipe-pane only starts after the attach-driven initial +# redraw (which draws the scrollbars) has already happened, missing it. +$TMUX2 new-session -d -x 60 -y 20 || exit 1 +OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) +[ -n "$OUTER" ] || fail "No outer pane." +$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" +$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed" +$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed" +sleep 1 + +# Sanity check: the distinctive scrollbar colour reaches the client at all. +grep -qa '48;5;201' $TMP || fail "sanity: scrollbar colour never reached the client" +: >$TMP + +# Drag the floating pane straight up and down by its top border, staying at +# a fixed column the whole time - this never crosses the left pane's +# scrollbar strip, only its ordinary content. +GRABCOL=$((FLEFT + FWIDTH / 2)) +row=$FTOP +i=0 +while [ $i -lt 6 ]; do + newrow=$((row + 1)) + drag $GRABCOL $row $GRABCOL $newrow + row=$newrow + i=$((i + 1)) +done +i=0 +while [ $i -lt 6 ]; do + newrow=$((row - 1)) + drag $GRABCOL $row $GRABCOL $newrow + row=$newrow + i=$((i + 1)) +done + +# The scrollbar colour should never reappear - its geometry never changed, +# and the drag never crossed its column. This is expected to fail before +# the fix - see the header comment. +n=$(grep -ac '48;5;201' $TMP) +[ "$n" -eq 0 ] || + fail "left pane's scrollbar was redrawn $n times while dragging over its body only" + +# Cross the scrollbar and return, comparing each damaged redraw with a full +# refresh to check that covering and uncovering its cells leaves no artifacts. +offset=$((ALEFT + AWIDTH - FLEFT - FWIDTH / 2)) +for delta in "$offset" "$((-offset))"; do + oldleft=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') + newcol=$((GRABCOL + delta)) + drag "$GRABCOL" "$row" "$newcol" "$row" + GRABCOL=$newcol + newleft=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') + [ "$newleft" -eq "$((oldleft + delta))" ] || + fail "floating pane did not move across the scrollbar as expected" + before=$($TMUX2 capture-pane -pe -t "$OUTER" -S 0 -E "$((AHEIGHT - 1))") + $TMUX refresh-client || fail "refresh-client failed" + sleep 0.5 + after=$($TMUX2 capture-pane -pe -t "$OUTER" -S 0 -E "$((AHEIGHT - 1))") + [ "$before" = "$after" ] || + fail "scrollbar crossing differs from a full redraw" +done + +exit 0 diff --git a/regress/floating-pane-drag-sync.sh b/regress/floating-pane-drag-sync.sh new file mode 100755 index 000000000..bd63d5adf --- /dev/null +++ b/regress/floating-pane-drag-sync.sh @@ -0,0 +1,93 @@ +#!/bin/sh + +# Synchronized drag output must not cause a full redraw of untouched rows. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +cd "$DIR" || exit 1 +INNER="$TEST_TMUX -Lsyncdefer-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lsyncdefer-outer-$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + cd / + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.15 +} + +$INNER new-session -d -s inner -x 40 -y 15 "printf '\\033[15;1HOUTSIDE'; exec sleep 100" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g status-interval 0 || exit 1 +$INNER set-option -g automatic-rename off || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X 5 -Y 2 \ + "printf 'DRAGMARK'; exec sleep 100") || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 15 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$INNER set-option -as terminal-features ',screen-256color:sync' || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lsyncdefer-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 +sleep 0.5 + +XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +GRABCOL=$((XOFF + 3)) +BORDERROW=$YOFF + +# Begin capture after focus changes from the initial mouse press have settled. +mouse 0 "$GRABCOL" "$BORDERROW" M +$OUTER pipe-pane -O -t outer:0.0 "cat >'$DIR/output'" || exit 1 +$INNER refresh-client || exit 1 +sleep 0.5 +grep -aq DRAGMARK "$DIR/output" || fail "capture missed floating pane content" +grep -aq OUTSIDE "$DIR/output" || fail "capture missed untouched row" +offset=$(wc -c <"$DIR/output") + +i=0 +steps=6 +while [ "$i" -lt "$steps" ]; do + GRABCOL=$((GRABCOL + 1)) + mouse 32 "$GRABCOL" "$BORDERROW" M + i=$((i + 1)) +done +mouse 0 "$GRABCOL" "$BORDERROW" m +sleep 0.3 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" -eq "$((XOFF + steps))" ] || fail "floating pane did not move six columns" +tail -c +"$((offset + 1))" "$DIR/output" >"$DIR/drag-output" +n=$(perl -0777 -ne '$n = () = /DRAGMARK/g; print "$n\n"' "$DIR/drag-output") +[ "$n" -ge "$steps" ] || fail "drag output did not reach the client" +perl -0777 -ne 'exit(/\e\[\?2026h/ ? 0 : 1)' "$DIR/drag-output" || + fail "drag did not use synchronized output" +if grep -aq OUTSIDE "$DIR/drag-output"; then + fail "synchronized drag redrew an untouched row" +fi + +exit 0 diff --git a/regress/floating-pane-drag-wide-character.sh b/regress/floating-pane-drag-wide-character.sh new file mode 100755 index 000000000..cc6854efd --- /dev/null +++ b/regress/floating-pane-drag-wide-character.sh @@ -0,0 +1,200 @@ +#!/bin/sh + +# Damage at a floating pane's vacated edge must always redraw a complete +# grid character. redraw_damage_grow_span_clip() (screen-redraw.c) widens a +# damage rectangle's left edge by one cell whenever it isn't already at the +# span's own edge, to pull in a wide character's base half when the edge +# lands on its padding half - but it did this unconditionally, with no +# check of which half it was actually touching. When the edge instead +# already lands cleanly on a fresh character's base cell, growing left +# walks into the *previous*, unrelated character's padding cell and blanks +# it (tty_draw_line() treats any leading padding cell as proof its own +# range starts mid-character). +# +# This is a general damage-composition bug, not specific to any one kind of +# pane, but the exact column parity needs to be deterministic to actually +# catch it (a lucky parity draws fine). This constructs it by creating the +# floating pane, checking its real position (the border-framing offset +# added to -X is not something to hand-compute), and recreating it one +# column over if necessary until the vacated rectangle's left edge lands on +# a base cell. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lwidechar-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lwidechar-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +BASE=$DIR/base +CAPTURE=$DIR/capture +FLOAT= + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +slice_columns() +{ + # Extract terminal columns [COL1, COL2) from lines [ROW1, ROW2] of + # $1, decoding UTF-8 - the pane's own new position (well clear of + # this range) must not affect the comparison, so this only looks at + # the narrow strip actually vacated, not the whole line. capture-pane + # text has one decoded character per double-width cell pair (every + # character here is width 2), so terminal columns are converted to + # character indices by halving before slicing. + perl -CSD -e ' + my ($row1, $row2, $col1, $col2, $file) = @ARGV; + open my $fh, "<:encoding(UTF-8)", $file or die $!; + my @lines = <$fh>; + my $c1 = int($col1 / 2); + my $c2 = int(($col2 + 1) / 2); + for my $n ($row1 .. $row2) { + my $line = $lines[$n - 1]; + $line =~ s/\R\z//; + print substr($line, $c1, $c2 - $c1), "\n"; + } + ' "$ROW1" "$ROW2" "$COL1" "$COL2" "$1" +} + +wait_old_rows_restored() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + slice_columns "$BASE" >"$DIR/want" + slice_columns "$CAPTURE" >"$DIR/got" + cmp -s "$DIR/want" "$DIR/got" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "wide characters under the floating pane's vacated edge were not restored" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +binmode STDOUT, ':encoding(UTF-8)'; +$| = 1; +for my $row (1 .. 10) { + print "\e[$row;1H", chr(0x754c) x 20; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 40 -y 10 "perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g pane-scrollbars off || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lwidechar-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +wait_outer_has '界界界' +$OUTER capture-pane -p -t outer:0.0 >"$BASE" || exit 1 + +# Create the floating pane, then check its actual resulting position. Try +# adjacent starting columns until the vacated rectangle's left edge +# (xoff - 1) lands on an even (base-cell) column - the odd case is the one +# every earlier manual test happened to land on by chance. +startx=5 +tries=0 +while [ "$tries" -lt 2 ]; do + [ -n "$FLOAT" ] && $INNER kill-pane -t "$FLOAT" 2>/dev/null + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 12 -y 3 -X "$startx" \ + -Y 5 'sh -c "printf FLOATMARK; exec sleep 100"') || exit 1 + sleep 0.2 + XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') + oldleft=$((XOFF - 1)) + if [ $((oldleft % 2)) -eq 0 ]; then + break + fi + startx=$((startx + 1)) + tries=$((tries + 1)) +done +[ $(((XOFF - 1) % 2)) -eq 0 ] || fail "could not find bad-parity starting column" + +wait_outer_has FLOATMARK + +ROW1=$((YOFF + 1)) +ROW2=$((YOFF + 3)) +COL1=$((XOFF - 4)) +COL2=$((XOFF + 4)) + +# Grab the pane's top border a couple of columns in (avoiding the corner +# cells) and drag it well clear of its old rectangle. +GRABCOL=$((XOFF + 3)) +BORDERROW=$((YOFF)) + +seq=$(printf '\033[<0;%s;%sM' "$GRABCOL" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<32;%s;%sM' "$((GRABCOL + 15))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<0;%s;%sm' "$((GRABCOL + 15))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)" + +wait_old_rows_restored + +exit 0 diff --git a/regress/floating-pane-drag-wide-right.sh b/regress/floating-pane-drag-wide-right.sh new file mode 100755 index 000000000..495126a50 --- /dev/null +++ b/regress/floating-pane-drag-wide-right.sh @@ -0,0 +1,206 @@ +#!/bin/sh + +# Mirror of floating-pane-drag-wide-character.sh for the *right* edge of a +# damage rectangle. redraw_damage_grow_span_clip() (screen-redraw.c) widens +# a damage rectangle's right edge by one cell whenever it isn't already at +# the span's own edge, to pull in a wide character's base half when the +# edge lands on its padding half - but it did this unconditionally, with no +# check of which half it was actually touching. When the edge instead +# already lands cleanly on a fresh character's base cell (a character fully +# outside the range), growing right pulls in just that base cell - +# tty_draw_line() sees it can't fit that character's full width in the +# remaining range (tty_draw_line_get_empty()'s gc->data.width > nx check) +# and clears it, exactly as the left-edge bug cleared a neighbouring +# character's padding half. +# +# As with the left-edge test, the exact column parity needs to be +# deterministic to actually catch it. This constructs it by creating the +# floating pane, checking its real position, and recreating it one column +# over if necessary until the vacated rectangle's right edge lands on a +# base cell. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lwidecharr-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lwidecharr-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +BASE=$DIR/base +CAPTURE=$DIR/capture +FLOAT= +PANEWIDTH=12 + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +slice_columns() +{ + # Extract terminal columns [COL1, COL2) from lines [ROW1, ROW2] of + # $1, decoding UTF-8 - the pane's own new position (well clear of + # this range) must not affect the comparison, so this only looks at + # the narrow strip actually vacated, not the whole line. capture-pane + # text has one decoded character per double-width cell pair (every + # character here is width 2), so terminal columns are converted to + # character indices by halving before slicing. + perl -CSD -e ' + my ($row1, $row2, $col1, $col2, $file) = @ARGV; + open my $fh, "<:encoding(UTF-8)", $file or die $!; + my @lines = <$fh>; + my $c1 = int($col1 / 2); + my $c2 = int(($col2 + 1) / 2); + for my $n ($row1 .. $row2) { + my $line = $lines[$n - 1]; + $line =~ s/\R\z//; + print substr($line, $c1, $c2 - $c1), "\n"; + } + ' "$ROW1" "$ROW2" "$COL1" "$COL2" "$1" +} + +wait_old_rows_restored() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + slice_columns "$BASE" >"$DIR/want" + slice_columns "$CAPTURE" >"$DIR/got" + cmp -s "$DIR/want" "$DIR/got" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "wide characters under the floating pane's vacated right edge were not restored" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +binmode STDOUT, ':encoding(UTF-8)'; +$| = 1; +for my $row (1 .. 10) { + print "\e[$row;1H", chr(0x754c) x 20; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 40 -y 10 "perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g pane-scrollbars off || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lwidecharr-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +wait_outer_has '界界界' +$OUTER capture-pane -p -t outer:0.0 >"$BASE" || exit 1 + +# Create the floating pane, then check its actual resulting position. Try +# adjacent starting columns until the vacated rectangle's right edge +# (xoff + PANEWIDTH + 1, the border-grown exclusive end - see +# window_pane_damage_floating(), window.c) lands on an even (base-cell) +# column. +startx=5 +tries=0 +while [ "$tries" -lt 2 ]; do + [ -n "$FLOAT" ] && $INNER kill-pane -t "$FLOAT" 2>/dev/null + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x "$PANEWIDTH" -y 3 \ + -X "$startx" -Y 5 \ + 'sh -c "printf FLOATMARK; exec sleep 100"') || exit 1 + sleep 0.2 + XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') + oldright=$((XOFF + PANEWIDTH + 1)) + if [ $((oldright % 2)) -eq 0 ]; then + break + fi + startx=$((startx + 1)) + tries=$((tries + 1)) +done +[ $(((XOFF + PANEWIDTH + 1) % 2)) -eq 0 ] || + fail "could not find bad-parity starting column" + +wait_outer_has FLOATMARK + +ROW1=$((YOFF + 1)) +ROW2=$((YOFF + 3)) +COL1=$((XOFF + PANEWIDTH - 2)) +COL2=$((XOFF + PANEWIDTH + 2)) + +# Grab the pane's top border a couple of columns in (avoiding the corner +# cells) and drag it well clear of its old rectangle - far enough right +# that its new position starts past the checked columns above (which sit +# just past the pane's *original* right edge). +GRABCOL=$((XOFF + 3)) +BORDERROW=$((YOFF)) + +seq=$(printf '\033[<0;%s;%sM' "$GRABCOL" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<32;%s;%sM' "$((GRABCOL + 20))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<0;%s;%sm' "$((GRABCOL + 20))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)" + +wait_old_rows_restored + +exit 0 diff --git a/regress/floating-pane-geometry.sh b/regress/floating-pane-geometry.sh index b04344ff2..74a3e9e11 100644 --- a/regress/floating-pane-geometry.sh +++ b/regress/floating-pane-geometry.sh @@ -268,5 +268,149 @@ $TMUX kill-pane -t "$floating" || exit 1 $TMUX kill-pane -t "$right" || exit 1 $TMUX kill-pane -t "$lower" || exit 1 +# --- Floating panes clamped when the window shrinks (issue #5581, PR #5582) --- +# +# layout_resize clamps floating panes back inside the window when it shrinks: +# move them and, only if they cannot fit, shrink them (never below +# PANE_MINIMUM). A floating cell is the pane's content, so with the default +# single-line border the clamp counts 1 cell of border per side, exactly as +# layout_floating_args_parse does on creation; with no border it counts 0. +# Each case below gets its own window, since resize-window fixes a window at +# a manual size for the rest of its life. + +# Case 1: a lone floating pane -- break-pane -W on a window's only pane -- +# takes the early-return path in layout_resize, added during PR #5582's +# review round, since there is no tiled tree to walk. +win=$($TMUX new-window -dPF '#{window_id}') || + fail "new-window for lone float failed" + +# -x 20 -y 6 -> pane 18x4; -X 60 -Y 18 -> pane_left 61, pane_top 19: with the +# border, the footprint is columns 60-79, rows 18-23, flush with the 80x24 +# window's right and bottom edges, so the float fits before it is shrunk. +$TMUX break-pane -W -s "$win" -x 20 -y 6 -X 60 -Y 18 || + fail "break-pane -W for lone float failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 61 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 19 + +# Shrink to 40x16. The float still fits at its own size (18 <= 40-2, 4 <= +# 16-2) so only its position moves, flush to the new right/bottom edges: +# xoff = 40 - 18 - 1 = 21; yoff = 16 - 4 - 1 = 11. +$TMUX resize-window -t "$win" -x 40 -y 16 || + fail "resize-window (lone float) failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 21 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 11 + +# Case 5: grow the window back. The clamp must not chase the window back +# outward -- it only ever pulls a float in, never restores where it was. +$TMUX resize-window -t "$win" -x 80 -y 24 || + fail "resize-window grow (lone float) failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 21 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 11 +$TMUX kill-window -t "$win" || exit 1 + +# Case 2: the same float, but with a tiled sibling surviving alongside it, so +# the window's root cell stays tiled and layout_resize takes the normal path +# (the clamp call after layout_fix_offsets) instead of the early return +# above. Same geometry as case 1, so the same numbers should come out. +win=$($TMUX new-window -dPF '#{window_id}') || + fail "new-window for tiled sibling failed" +$TMUX split-window -t "$win" 'sleep 100' || + fail "split-window for tiled sibling failed" +$TMUX break-pane -W -s "$win" -x 20 -y 6 -X 60 -Y 18 || + fail "break-pane -W for tiled sibling failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 61 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 19 + +$TMUX resize-window -t "$win" -x 40 -y 16 || + fail "resize-window (tiled sibling) failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 21 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 11 +$TMUX kill-window -t "$win" || exit 1 + +# Case 3: new-pane float, the original repro from issue #5581 and the PR +# body -- also the normal path, via the tiled base pane new-window creates. +win=$($TMUX new-window -dPF '#{window_id}') || + fail "new-window for new-pane repro failed" +$TMUX resize-window -t "$win" -x 120 -y 40 || + fail "resize-window to 120x40 failed" + +# -x 30 -y 10 -> pane 28x8; -X 85 -Y 25 -> pane_left 86, pane_top 26. +floating=$($TMUX new-pane -t "$win" -dPF '#{pane_id}' \ + -x 30 -y 10 -X 85 -Y 25 'sleep 100') || + fail "new-pane for new-pane repro failed" +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_width}')" 28 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_height}')" 8 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_left}')" 86 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_top}')" 26 + +# Shrink to 60x20: xoff = 60 - 28 - 1 = 31; yoff = 20 - 8 - 1 = 11. +$TMUX resize-window -t "$win" -x 60 -y 20 || + fail "resize-window (new-pane repro) failed" +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_width}')" 28 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_height}')" 8 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_left}')" 31 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_top}')" 11 +$TMUX kill-window -t "$win" || exit 1 + +# Case 4: a float that already fits inside the shrunk window is left alone -- +# assert position and size are both unchanged, not just one of them. +win=$($TMUX new-window -dPF '#{window_id}') || + fail "new-window for untouched float failed" + +# -x 20 -y 6 -> pane 18x4; -X 8 -Y 3 -> pane_left 9, pane_top 4 (as at the top +# of this file). Footprint columns 8-27, rows 3-8: well inside 60x20 too. +floating=$($TMUX new-pane -t "$win" -dPF '#{pane_id}' \ + -x 20 -y 6 -X 8 -Y 3 'sleep 100') || + fail "new-pane for untouched float failed" +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_left}')" 9 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_top}')" 4 + +$TMUX resize-window -t "$win" -x 60 -y 20 || + fail "resize-window (untouched float) failed" +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_width}')" 18 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_height}')" 4 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_left}')" 9 +must_equal "$($TMUX display-message -p -t "$floating" '#{pane_top}')" 4 +$TMUX kill-window -t "$win" || exit 1 + +# Case 6: pad = 0, via the pane-border-lines window option set to none. The +# border arithmetic differs here (no -2/+1 adjustment either on creation or +# in the clamp). +win=$($TMUX new-window -dPF '#{window_id}') || + fail "new-window for pad=0 float failed" +$TMUX set-option -w -t "$win" pane-border-lines none || + fail "set pane-border-lines none failed" + +# No border: -x 20 -y 6 -> pane 20x6 directly; -X 60 -Y 18 -> pane_left 60, +# pane_top 18 directly. Footprint (== content, no border) is columns 60-79, +# rows 18-23: flush right/bottom of the 80x24 window, same as case 1. +$TMUX break-pane -W -s "$win" -x 20 -y 6 -X 60 -Y 18 || + fail "break-pane -W for pad=0 float failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 20 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 6 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 60 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 18 + +# Shrink to 40x16 with pad=0: xoff = 40 - 20 - 0 = 20; yoff = 16 - 6 - 0 = 10. +$TMUX resize-window -t "$win" -x 40 -y 16 || + fail "resize-window (pad=0 float) failed" +must_equal "$($TMUX display-message -p -t "$win" '#{pane_width}')" 20 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_height}')" 6 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_left}')" 20 +must_equal "$($TMUX display-message -p -t "$win" '#{pane_top}')" 10 +$TMUX kill-window -t "$win" || exit 1 + $TMUX kill-server 2>/dev/null exit 0 diff --git a/regress/floating-pane-offscreen-alt.sh b/regress/floating-pane-offscreen-alt.sh new file mode 100755 index 000000000..97dbbe68e --- /dev/null +++ b/regress/floating-pane-offscreen-alt.sh @@ -0,0 +1,102 @@ +#!/bin/sh + +# Returning from the alternate screen must redraw the visible part of a +# floating pane clipped at the left edge, the top edge, or both. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Loffscreen-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Loffscreen-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $row (1 .. 5) { + print "\e[$row;1H", 'A' x 15; +} +while (!-e "$ENV{TRIGGER}-alternate") { + select undef, undef, undef, 0.01; +} +print "\e[?1049h"; +for my $row (1 .. 5) { + print "\e[$row;1H", 'B' x 15; +} +while (!-e "$ENV{TRIGGER}-restore") { + select undef, undef, undef, 0.01; +} +print "\e[?1049l"; +sleep 100; +PERL + +$INNER new-session -d -s inner -x 40 -y 10 'sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Loffscreen-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +for position in left top both; do + case "$position" in + left) x=-5; y=2 ;; + top) x=5; y=-2 ;; + both) x=-5; y=-2 ;; + esac + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X "$x" -Y "$y" \ + "TRIGGER='$DIR/$position' perl '$EMITTER'") || exit 1 + [ "$($INNER display-message -p -t "$FLOAT" '#{pane_left},#{pane_top}')" = "$((x + 1)),$((y + 1))" ] || + fail "$position: floating pane has unexpected position" + wait_outer_has AAAAA + cp "$CAPTURE" "$DIR/primary" + : >"$DIR/$position-alternate" + wait_outer_has BBBBB + : >"$DIR/$position-restore" + wait_outer_has AAAAA + cmp -s "$DIR/primary" "$CAPTURE" || + fail "$position: primary screen was not completely restored" + $INNER kill-pane -t "$FLOAT" || exit 1 +done + +exit 0 diff --git a/regress/floating-pane-prompt-wide-character.sh b/regress/floating-pane-prompt-wide-character.sh new file mode 100755 index 000000000..cdd892a54 --- /dev/null +++ b/regress/floating-pane-prompt-wide-character.sh @@ -0,0 +1,179 @@ +#!/bin/sh + +# A damage rectangle's clip range is grown to avoid splitting a wide +# character, but redraw_damage_grow_span_clip() (screen-redraw.c) only ever +# checks the span's own pane *content* grid (wp->screen) for that. For a +# REDRAW_SPAN_PANE span, that same range is then also handed to +# redraw_damage_draw_pane_prompt() to recompose the pane's separately +# rendered prompt (wp->prompt, e.g. from "command-prompt -P") over the +# damaged sub-range - but the prompt is drawn into its own, freshly +# allocated one-line screen, unrelated to the pane's content grid, so a +# range grown (or left ungrown) against the content is not necessarily +# grown correctly for the prompt's own wide characters. +# +# This is invisible when the pane's own content is plain ASCII (as here): +# redraw_damage_grow_span_clip() never finds anything to grow against, so +# the raw, ungrown geometric range is passed straight through to the +# prompt - and if that range's edge lands mid-character in the *prompt's* +# grid, tty_draw_line() clears the character it cuts through +# (tty_draw_line_get_empty()'s gc->data.width > nx check, for a trailing +# base cell with no room left for its padding half). +# +# The trigger is a palette change (OSC 4) in a tiled pane that is one half +# of a vertical split running the full height of the window - occluded +# under the floating pane, but still geometrically triggering a redraw of +# its own rectangle. Positioned so the split boundary falls inside the +# floating pane's own CJK prompt, this reproduces exactly Codex's report: +# "a floating pane containing a CJK prompt across a tiled-pane boundary - +# a palette update in the tiled pane blanks a prompt character." + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +cd "$DIR" || exit 1 +INNER="$TEST_TMUX -Lpromptwide-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lpromptwide-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +CAPTURE=$DIR/capture +FLOAT= + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + cd / + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_prompt_row_intact() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + line=$(sed -n "${PROMPTROW}p" "$CAPTURE") + case $line in + *"$PROMPTTEXT"*) return 0 ;; + esac + sleep 0.1 + i=$((i + 1)) + done + fail "the CJK prompt was not intact after the palette-triggered damage - got: $line" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +my $line = ; +print "\e]4;1;rgb:11/22/33\e\\"; +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 12 "perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +LEFT=$($INNER list-panes -t inner -F '#{pane_id}') || exit 1 + +# Split so the boundary between the two tiled panes falls at column 17 - +# used below to pick a floating-pane column that lands the boundary +# mid-character inside the prompt. +RIGHT=$($INNER split-window -t inner -h -l 43 -PF '#{pane_id}' \ + 'sleep 100') || exit 1 +RX=$($INNER display-message -p -t "$RIGHT" '#{pane_left}') || exit 1 + +# Create the floating pane, then check its actual resulting position (the +# border-framing offset added to -X is not something to hand-compute). Try +# adjacent starting columns until the split boundary lands on an odd +# (padding-half) column of the prompt's own numbering, and within the +# prompt's 12-column width. +startx=10 +tries=0 +while [ "$tries" -lt 4 ]; do + [ -n "$FLOAT" ] && $INNER kill-pane -t "$FLOAT" 2>/dev/null + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 24 -y 5 -X "$startx" \ + -Y 2 "sh -c 'i=0; while [ \$i -lt 10 ]; do \ +printf AAAAAAAAAAAAAAAAAAAAAA\\\\n; i=\$((i+1)); done; sleep 100'") || + exit 1 + sleep 0.2 + X1=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + Y1=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') + H1=$($INNER display-message -p -t "$FLOAT" '#{pane_height}') + local=$((RX - X1 - 1)) + if [ "$local" -ge 1 ] && [ "$local" -le 11 ] && + [ $((local % 2)) -eq 1 ]; then + break + fi + startx=$((startx + 1)) + tries=$((tries + 1)) +done +local=$((RX - X1 - 1)) +[ "$local" -ge 1 ] && [ "$local" -le 11 ] && [ $((local % 2)) -eq 1 ] || + fail "could not find bad-parity starting column" + +$OUTER new-session -d -s outer -x 60 -y 12 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lpromptwide-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +wait_outer_has AAAAAAAAAAAAAAAAAAAAAA +CLIENT=$($INNER list-clients -F '#{client_name}') || exit 1 + +$INNER select-pane -t "$FLOAT" || exit 1 +PROMPTTEXT=$(printf '\344\270\255' | perl -CSD -ne 'print $_ x 6') +$INNER command-prompt -b -P -t "$CLIENT" -p "$PROMPTTEXT" \ + 'display-message -- %1' || exit 1 +wait_outer_has "$PROMPTTEXT" + +PROMPTROW=$((Y1 + H1)) + +# Trigger the damage: unblock the emitter so it fires the palette change in +# the left tiled pane, which is occluded under (but geometrically overlaps) +# the floating pane's prompt row. +$INNER send-keys -t "$LEFT" Enter || exit 1 + +wait_prompt_row_intact + +exit 0 diff --git a/regress/floating-pane-scroll-behind-margins.sh b/regress/floating-pane-scroll-behind-margins.sh new file mode 100755 index 000000000..622c659f2 --- /dev/null +++ b/regress/floating-pane-scroll-behind-margins.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +# A floating pane that scrolls behind a larger floating pane must not disturb +# the larger pane, with or without left/right margin support in the terminal. +# The larger pane spans the smaller one vertically and overlaps it only at its +# left, so with margins the terminal would otherwise scroll the cells of the +# larger pane (including its scrollbar) along with the smaller pane. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER_NAME=testA$ +OUTER_NAME=testB$ +INNER="$TEST_TMUX -L$INNER_NAME -f/dev/null" +OUTER="$TEST_TMUX -L$OUTER_NAME -f/dev/null" +EMITTER=$DIR/emitter.pl +FILLER=$DIR/filler.pl +TRIGGER=$DIR/trigger +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_inner_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 100 ]; do + $INNER capture-pane -p -t "$2" 2>/dev/null | + grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane $2 did not contain $marker" +} + +# Scrolling pane: wait for the trigger, then scroll it well past its height. +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} +for (1 .. 30) { + print 'd' x 20, "\r\n"; + select undef, undef, undef, 0.02; +} +print "SCROLL-DONE\r\n"; +sleep 100; +PERL + +# Larger pane: numbered rows so any shift or blanking is visible. +cat >"$FILLER" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $row (1 .. 18) { + printf "\e[%d;1Hrow%02d %s", $row, $row, 'F' x 40; +} +sleep 100; +PERL + +for margins in off on; do + if [ "$margins" = on ]; then + INNER_NAME=testC$ + OUTER_NAME=testD$ + INNER="$TEST_TMUX -L$INNER_NAME -f/dev/null" + OUTER="$TEST_TMUX -L$OUTER_NAME -f/dev/null" + fi + + rm -f "$TRIGGER" + $INNER new-session -d -s inner -x 80 -y 24 'sleep 100' || exit 1 + $INNER set -g status off || exit 1 + $INNER set -g window-size manual || exit 1 + $INNER set -g pane-scrollbars on || exit 1 + if [ "$margins" = on ]; then + $INNER set -as terminal-features ',screen-256color:margins' || + exit 1 + fi + + # The small pane is created first so the large one is above it. + SMALL=$($INNER new-pane -d -PF '#{pane_id}' -x 30 -y 10 -X 40 -Y 6 \ + "TRIGGER='$TRIGGER' perl '$EMITTER'") || fail "new-pane failed" + $INNER new-pane -d -x 50 -y 20 -X 2 -Y 2 "perl '$FILLER'" || + fail "new-pane failed" + + $OUTER new-session -d -s outer -x 80 -y 24 'sleep 100' || exit 1 + $OUTER set -g status off || exit 1 + $OUTER set -g window-size manual || exit 1 + $OUTER set -g default-terminal screen-256color || exit 1 + $OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -L$INNER_NAME -f/dev/null attach -t inner" || + exit 1 + + wait_outer_has row18 + sleep 0.5 + $OUTER capture-pane -p -t outer:0.0 >"$DIR/before" + + : >"$TRIGGER" + wait_inner_has SCROLL-DONE "$SMALL" + sleep 0.5 + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" + + # Columns 1-52 hold the whole large pane, its border and scrollbar. + cut -c1-52 "$DIR/before" >"$DIR/before.cut" + cut -c1-52 "$CAPTURE" >"$DIR/after.cut" + if ! cmp -s "$DIR/before.cut" "$DIR/after.cut"; then + echo "large pane changed (margins $margins):" >&2 + diff "$DIR/before.cut" "$DIR/after.cut" >&2 + exit 1 + fi + + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +done + +exit 0 diff --git a/regress/floating-pane-status-cross-client.sh b/regress/floating-pane-status-cross-client.sh new file mode 100755 index 000000000..fadb5e5bf --- /dev/null +++ b/regress/floating-pane-status-cross-client.sh @@ -0,0 +1,151 @@ +#!/bin/sh + +# Damage redraws must show each client's own pane status, even though the +# cached status screen is shared between clients. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lstatuscc-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lstatuscc-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + count=$($INNER list-clients 2>/dev/null | wc -l) + [ "$count" -eq 2 ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "two inner clients did not attach" +} + +wait_for_marker() +{ + target=$1 + marker=$2 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t "$target" >"$CAPTURE" || exit 1 + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client in $target did not receive $marker" +} + +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $phase (1 .. 4) { + while (!-e "$ENV{TRIGGER}-$phase") { + select undef, undef, undef, 0.01; + } + # Change an unused palette entry and acknowledge it in the pane body. + print "\e]4;200;rgb:11/22/0$phase\a\e[1;1HDAMAGE$phase"; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 20 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$INNER set -g pane-border-status top || exit 1 +$INNER set -g pane-border-format 'CLIENT=<#{client_name}>' || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 35 -y 6 -X 5 -Y 5 \ + 'sleep 100') || exit 1 + +$OUTER new-session -d -s outer -x 121 -y 20 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +LEFT=$($OUTER display-message -p -t outer:0.0 '#{pane_id}') || exit 1 +RIGHT=$($OUTER split-window -h -PF '#{pane_id}' 'sleep 100') || exit 1 +for target in "$LEFT" "$RIGHT"; do + $OUTER respawn-pane -k -t "$target" "$INNER attach-session -t inner" || + exit 1 +done +wait_for_clients + +NAME1=$($OUTER display-message -p -t "$LEFT" '#{pane_tty}') || exit 1 +NAME2=$($OUTER display-message -p -t "$RIGHT" '#{pane_tty}') || exit 1 + +# Disable periodic status updates above and trigger damage without a command +# that also requests a status redraw. Each client must keep its own title. +$INNER refresh-client -t "$NAME1" || exit 1 +$INNER refresh-client -t "$NAME2" || exit 1 +sleep 0.5 +for phase in 0 1 2; do + if [ "$phase" -ne 0 ]; then + : >"$DIR/trigger-$phase" + wait_for_marker "$LEFT" "DAMAGE$phase" + wait_for_marker "$RIGHT" "DAMAGE$phase" + sleep 0.2 + fi + for target in "$LEFT" "$RIGHT"; do + if [ "$target" = "$LEFT" ]; then + name=$NAME1 + other=$NAME2 + else + name=$NAME2 + other=$NAME1 + fi + $OUTER capture-pane -p -S 5 -E 10 -t "$target" \ + >"$CAPTURE" || exit 1 + grep -Fq "CLIENT=<$name>" "$CAPTURE" || + fail "phase $phase: missing $name's pane status" + if grep -Fq "CLIENT=<$other>" "$CAPTURE"; then + fail "phase $phase: $name received $other's pane status" + fi + done +done + +# Leave just one client so a cache keyed only by client would remain stale. +$OUTER respawn-pane -k -t "$RIGHT" 'sleep 100' || exit 1 +i=0 +while [ "$($INNER list-clients | wc -l)" -ne 1 ]; do + [ "$i" -lt 50 ] || fail "second client did not detach" + sleep 0.1 + i=$((i + 1)) +done +$INNER set-environment -g TEST_STATUS_VALUE initial || exit 1 +$INNER set -g pane-border-format 'VALUE=#{TEST_STATUS_VALUE}' || exit 1 +$INNER refresh-client -t "$NAME1" || exit 1 +wait_for_marker "$LEFT" VALUE=initial +for phase in 3 4; do + # Changing the environment does not itself request a status redraw. + $INNER set-environment -g TEST_STATUS_VALUE "phase$phase" || exit 1 + : >"$DIR/trigger-$phase" + wait_for_marker "$LEFT" "DAMAGE$phase" + wait_for_marker "$LEFT" "VALUE=phase$phase" +done + +exit 0 diff --git a/regress/floating-pane-status-format.sh b/regress/floating-pane-status-format.sh new file mode 100755 index 000000000..9974b7515 --- /dev/null +++ b/regress/floating-pane-status-format.sh @@ -0,0 +1,102 @@ +#!/bin/sh + +# Resizing a floating pane must refresh session status formats which depend on +# its geometry, not only the pane scene and borders. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lfloating-status-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lfloating-status-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has_status() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + tail -1 "$CAPTURE" | grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client status did not show $marker" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +$INNER new-session -d -s inner -x 50 -y 12 'sleep 100' || exit 1 +FLOAT=$($INNER new-pane -PF '#{pane_id}' -x 10 -y 5 -X 5 -Y 3 \ + 'sleep 100') || fail "could not create floating pane" +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g status on || exit 1 +$INNER set-option -g status-position bottom || exit 1 +$INNER set-option -g status-left 'WIDTH=#{pane_width}' || exit 1 +$INNER set-option -g status-right '' || exit 1 +$INNER set-option -g status-interval 0 || exit 1 + +$OUTER new-session -d -s outer -x 50 -y 12 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lfloating-status-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +OLD_WIDTH=$($INNER display-message -p -t "$FLOAT" '#{pane_width}') +wait_outer_has_status "WIDTH=$OLD_WIDTH" + +RIGHT=$($INNER display-message -p -t "$FLOAT" '#{pane_right}') +TOP=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +X=$((RIGHT + 2)) +Y=$((TOP + 2)) + +# Grab the right frame and enlarge the floating pane. +mouse 0 "$X" "$Y" M +mouse 32 "$((X + 1))" "$Y" M +mouse 32 "$((X + 8))" "$Y" M +mouse 0 "$((X + 8))" "$Y" m + +NEW_WIDTH=$($INNER display-message -p -t "$FLOAT" '#{pane_width}') +[ "$NEW_WIDTH" -ne "$OLD_WIDTH" ] || fail "floating pane was not resized" +wait_outer_has_status "WIDTH=$NEW_WIDTH" + +exit 0 diff --git a/regress/floating-pane-top-border-drag.sh b/regress/floating-pane-top-border-drag.sh new file mode 100755 index 000000000..609d91ee9 --- /dev/null +++ b/regress/floating-pane-top-border-drag.sh @@ -0,0 +1,87 @@ +#!/bin/sh + +# With pane-border-status top, a tiled pane's status line is on the top row of +# the window. A floating pane dragged to the top of the window has its own top +# border on the same row, so it must still be draggable by that border rather +# than the click going to the tiled pane underneath. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +TMUX="$TEST_TMUX -Ltopborder-inner-$$ -f/dev/null" +TMUX2="$TEST_TMUX -Ltopborder-outer-$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX2 kill-server 2>/dev/null + $TMUX kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +# Press, drag and release with SGR mouse sequences, sent as 1-based positions +# to the outer pane holding the inner client. This matches the default +# MouseDown1Border/MouseDrag1Border bindings used to move a floating pane by +# its border. +drag() +{ + seq=$(printf '\033[<0;%s;%sM' "$1" "$2") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<32;%s;%sM' "$3" "$4") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<0;%s;%sm' "$3" "$4") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.5 +} + +pane_top() +{ + $TMUX display-message -p -t "$FLOAT" '#{pane_top}' +} + +$TMUX new-session -d -s inner -x 60 -y 20 'sleep 100' || exit 1 +$TMUX set -g status off || exit 1 +$TMUX set -g window-size manual || exit 1 +$TMUX set -g mouse on || exit 1 +$TMUX set -g pane-border-status top || exit 1 + +FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 20 -y 8 -X 15 -Y 8 'sleep 100') || + fail "new-pane failed" +FLEFT=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') +COL=$((FLEFT + 6)) + +$TMUX2 new-session -d -s outer -x 60 -y 20 "$TMUX attach -t inner" || exit 1 +sleep 1 +OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) +[ -n "$OUTER" ] || fail "no outer pane" + +# Drag the floating pane up by its top border until it reaches the top of the +# window, where the border shares a row with the tiled pane's status line. +i=0 +while [ "$(pane_top)" -gt 1 ] && [ "$i" -lt 10 ]; do + top=$(pane_top) + # The border is on row top-1 (0-based); positions are 1-based. + dest=$((top - 3)) + [ "$dest" -lt 1 ] && dest=1 + drag $((COL + 1)) "$top" $((COL + 1)) "$dest" + i=$((i + 1)) +done +[ "$(pane_top)" -eq 1 ] || fail "could not drag the pane to the top ($(pane_top))" + +# Now drag it back down by the top border in row 0. +drag $((COL + 1)) 1 $((COL + 1)) 4 +[ "$(pane_top)" -gt 1 ] || + fail "floating pane at the top could not be dragged by its top border" + +exit 0 diff --git a/regress/floating-pane-zoom.sh b/regress/floating-pane-zoom.sh new file mode 100644 index 000000000..84e9db00c --- /dev/null +++ b/regress/floating-pane-zoom.sh @@ -0,0 +1,158 @@ +#!/bin/sh + +# Moving, resizing and reordering an over-zoom float must leave the tiled +# pane zoomed, and the changes must survive an explicit unzoom. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +trap '$TMUX kill-server 2>/dev/null' 0 +trap 'exit 1' 1 2 15 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +run() +{ + $TMUX "$@" || fail "failed: $*" +} + +check() +{ + got=$(run display-message -p -t "$1" "$2") || exit 1 + [ "$got" = "$3" ] || fail "$1 $2: got '$got', expected '$3'" +} + +check_zoom() +{ + check "$base" '#{window_zoomed_flag}:#{pane_zoomed_flag}' '1:1' + check "$float" '#{pane_floating_flag}:#{pane_active}' "1:$active" + check "$base" '#{@unzoomed}' '' +} + +run new-session -d -x 80 -y 24 +base=$(run display-message -p '#{pane_id}') || exit 1 +other=$(run split-window -dPF '#{pane_id}') || exit 1 +layout=$(run display-message -p '#{window_layout}') || exit 1 +run set-hook -g window-unzoomed 'set -g @unzoomed 1' + +# Cover creation before and after zoom, with both the float and the tiled +# pane active. Modal panes use the same over-zoom flag. +for mode in before after detached modal; do + if [ "$mode" != before ]; then + run resize-pane -Z -t "$base" + fi + case "$mode" in + detached) flags=-Ad; active=0 ;; + modal) flags=-O; active=1 ;; + *) flags=-A; active=1 ;; + esac + float=$(run new-pane "$flags" -PF '#{pane_id}' -t "$base" \ + -x 20 -y 8 -X 8 -Y 3 '') || exit 1 + if [ "$mode" = before ]; then + run resize-pane -Z -t "$base" + fi + run set -gu @unzoomed + check_zoom + + run move-pane -t "$float" -D + check "$float" '#{pane_top}' 5 + check_zoom + run move-pane -t "$float" -U 2 -R 3 -L 1 + check "$float" '#{pane_left}:#{pane_top}' '11:3' + check_zoom + run move-pane -t "$float" -X 25% -Y 25% + check "$float" '#{pane_left}:#{pane_top}' '21:7' + check_zoom + run move-pane -t "$float" -P bottom-right + check "$float" '#{pane_left}:#{pane_top}' '61:17' + check_zoom + run resize-pane -t "$float" -x 30 -y 10 + check "$float" '#{pane_width}:#{pane_height}' '28:8' + check_zoom + run resize-pane -t "$float" -U 1 -L 2 + check "$float" '#{pane_left}:#{pane_top}:#{pane_width}:#{pane_height}' \ + '59:16:30:9' + check_zoom + run move-pane -t "$float" -P centre + check "$float" '#{pane_left}:#{pane_top}' '25:7' + check_zoom + + # Failed commands must not change zoom either. + for command in 'move-pane -D invalid' 'move-pane -P invalid' \ + 'move-pane -z invalid' 'resize-pane -x invalid' \ + 'resize-pane -D invalid'; do + $TMUX $command -t "$float" 2>/dev/null && + fail "unexpected success: $command" + check_zoom + done + + run resize-pane -Z -t "$base" + check "$float" '#{pane_left}:#{pane_top}:#{pane_width}:#{pane_height}' \ + '25:7:30:9' + run kill-pane -t "$float" + check "$base" '#{window_layout}' "$layout" +done + +# Put an ordinary (hidden while zoomed) float between two over-zoom floats +# in the stacking order. Reordering must skip it when counting visible +# positions, but keep all floats ahead of the tiled panes after unzoom. +back=$(run new-pane -AdPF '#{pane_id}' -t "$base" '') || exit 1 +hidden=$(run new-pane -dPF '#{pane_id}' -t "$base" '') || exit 1 +float=$(run new-pane -AdPF '#{pane_id}' -t "$base" '') || exit 1 +active=0 +run resize-pane -Z -t "$base" +run set -gu @unzoomed + +for position in backward back forward-loop; do + run move-pane -t "$float" -P front + run move-pane -t "$float" -P "$position" + check "$float" '#{pane_z}' 1 + check "$back" '#{pane_z}' 0 + check_zoom +done +for position in forward front backward-loop; do + run move-pane -t "$float" -P back + run move-pane -t "$float" -P "$position" + check "$float" '#{pane_z}' 0 + check "$back" '#{pane_z}' 1 + check_zoom +done +for z in 1 0 99; do + run move-pane -t "$float" -z "$z" + want=$z + [ "$z" = 99 ] && want=1 + check "$float" '#{pane_z}' "$want" + check_zoom +done +run resize-pane -Z -t "$base" +check "$float" '#{pane_z}' 2 +check "$base" '#{pane_z}' 4 +run kill-pane -t "$hidden" +run kill-pane -t "$back" + +# Explicit zoom toggling and operations on tiled panes retain their +# existing behaviour. The over-zoom flag alone does not make a pane a float: +# zooming the float itself gives it a tiled cell until unzoom. +run resize-pane -Z -t "$float" +check "$float" '#{pane_zoomed_flag}:#{pane_floating_flag}' '1:0' +run resize-pane -t "$float" -x 25 +check "$float" '#{window_zoomed_flag}:#{pane_floating_flag}:#{pane_width}' \ + '0:1:23' +run resize-pane -Z -t "$base" +run resize-pane -t "$other" -D 1 +check "$base" '#{window_zoomed_flag}' 0 + +# A hidden ordinary float must still be unzoomed before resizing it. +hidden=$(run new-pane -dPF '#{pane_id}' -t "$base" '') || exit 1 +run resize-pane -Z -t "$base" +run resize-pane -t "$hidden" -x 25 +check "$hidden" '#{window_zoomed_flag}:#{pane_floating_flag}:#{pane_width}' \ + '0:1:23' + +exit 0 diff --git a/regress/format-fuzzy.sh b/regress/format-fuzzy.sh index 00bc28bd4..eabf13ded 100644 --- a/regress/format-fuzzy.sh +++ b/regress/format-fuzzy.sh @@ -7,6 +7,8 @@ TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +trap '$TMUX kill-server 2>/dev/null' 0 +trap 'exit 1' 1 2 15 # test_format $format $expected_result test_format() @@ -68,6 +70,8 @@ test_format '#{m/p:bash$,bash dev}' '' # Inverse terms. Plain inverse terms are exact substring tests, not fuzzy. test_format '#{m/z:!ssh,dev bash}' '1' +test_format '#{m/z:!long,x}' '1' +test_format '#{m/z:!x,}' '1' test_format '#{m/z:!ssh,dev ssh}' '0' test_format '#{m/z:!ssh,s_s_h}' '1' test_format '#{m/z:dev !ssh,dev bash}' '1' diff --git a/regress/format-modifiers.sh b/regress/format-modifiers.sh index 58c02af7c..3fdfb59bd 100644 --- a/regress/format-modifiers.sh +++ b/regress/format-modifiers.sh @@ -18,6 +18,24 @@ export TZ LANG LC_ALL [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +FIFO1="${TMPDIR:-/tmp}/fmt-l-$$-1" +FIFO2="${TMPDIR:-/tmp}/fmt-l-$$-2" +HOLD1= +HOLD2= +CC1= +CC2= + +cleanup() +{ + for pid in $HOLD1 $HOLD2 $CC1 $CC2; do + kill "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + done + $TMUX kill-server 2>/dev/null + rm -f "$FIFO1" "$FIFO2" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 ESC=$(printf '\033') @@ -227,6 +245,19 @@ test_format "#{R:a,3}" "aaa" test_format "#{R:ab,2}" "abab" # A long repeat exercises output-buffer growth during expansion. test_format "#{n:#{R:x,300}}" "300" +# Limit the result size as well as the repeat count, including nested repeats. +test_format "#{n:#{R:abcdefgh,8192}}" "65536" +test_format "#{R:abcdefgh,8193}" "" +test_format "#{R:,10000}" "" +test_format "#{n:#{R:#{R:x,256},256}}" "65536" +test_format "#{R:#{R:x,256},257}" "" +test_format "#{R:#{R:abcdefgh,8192}x,1}" "" +test_format "#{n:#{R:中文中,7281}}" "65529" +test_format "#{R:中文中,7282}" "" +test_format "#{n:#{R:x,10000}}" "10000" +test_format "#{R:x,10001}" "" +# Fuzzy matching must not receive an enormous string from nested repeats. +test_format "#{m/p:a,#{R:#{R:#{R:a,353},38},90}}" "" # --- Width, padding and truncation --------------------------------------- @@ -579,8 +610,6 @@ assert_alive "verbose loop expansion" # L loops over attached clients. Attach two control-mode clients, each held # open by a background process keeping a FIFO's write end open. -FIFO1="${TMPDIR:-/tmp}/fmt-l-$$-1" -FIFO2="${TMPDIR:-/tmp}/fmt-l-$$-2" rm -f "$FIFO1" "$FIFO2" mkfifo "$FIFO1" "$FIFO2" || exit 1 # Hold the write ends open so the control clients stay attached. @@ -605,9 +634,9 @@ test_format "#{L/nr:x}" "xx" test_format "#{L/r:x}" "xx" # Now detach one and confirm the count drops to one. kill $HOLD2 2>/dev/null +wait $HOLD2 2>/dev/null +HOLD2= sleep 1 test_format "#{L:x}" "x" -kill $HOLD1 $CC1 $CC2 2>/dev/null -rm -f "$FIFO1" "$FIFO2" exit 0 diff --git a/regress/format-no-current-window.sh b/regress/format-no-current-window.sh new file mode 100644 index 000000000..d8f19b0fe --- /dev/null +++ b/regress/format-no-current-window.sh @@ -0,0 +1,69 @@ +#!/bin/sh + +# active_window_index, last_window_index, session_stack and +# window_active_clients read the session current window. Expanding them must +# not kill the server. Covers the list-sessions, list-windows and list-panes +# -F form, and commands that unlink the current window. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +trap '$TMUX kill-server 2>/dev/null' 0 +trap 'exit 1' 1 2 15 + +fail() +{ + echo "$*" + exit 1 +} + +# Formats that read the current window, expanded together. +FMT='#{active_window_index} #{last_window_index} #{session_stack}' +FMT="$FMT #{window_active_clients} #{window_active_clients_list}" + +$TMUX kill-server 2>/dev/null + +# No session: the callbacks have nothing to read and must return empty. +out=$($TMUX start\; set -s exit-empty off \; display-message -p "$FMT") || + fail "display with no session" +[ "$out" = " " ] || fail "no session expanded to '$out'" + +$TMUX new-session -d -s src -n one -- sleep 30 || fail "new-session src" +[ "$($TMUX display-message -t src -p '#{active_window_index}')" = 0 ] || + fail "active window index" +[ "$($TMUX display-message -t src -p '#{last_window_index}')" = 0 ] || + fail "last window index" +[ "$($TMUX display-message -t src -p '#{session_stack}')" = 0 ] || + fail "session stack" + +$TMUX new-window -d -t src -n two -- sleep 30 || fail "new-window" +[ "$($TMUX display-message -t src -p '#{active_window_index}')" = 0 ] || + fail "active window index after new-window" +[ "$($TMUX display-message -t src -p '#{last_window_index}')" = 1 ] || + fail "last window index after new-window" + +$TMUX new-session -d -s dst -n only -- sleep 30 || fail "new-session dst" + +# Replace and relink the current window, then expand again. +$TMUX set-hook -g window-linked "list-sessions -F '$FMT'" || fail "set-hook" +$TMUX new-window -k -t src:one -n replaced -- sleep 30 || fail "new-window -k" +$TMUX link-window -k -s src:two -t dst:only || fail "link-window -k" + +[ "$($TMUX display-message -t src -p '#{active_window_index}')" = 0 ] || + fail "active window index after replace" +[ "$($TMUX display-message -t src -p '#{last_window_index}')" = 1 ] || + fail "last window index after replace" +[ "$($TMUX display-message -t dst -p '#{active_window_index}')" = 0 ] || + fail "active window index after link" +[ "$($TMUX display-message -t dst -p '#{window_active_clients}')" = 0 ] || + fail "window active clients" + +$TMUX list-sessions -F "$FMT" >/dev/null || fail "list-sessions" +$TMUX list-windows -a -F "$FMT" >/dev/null || fail "list-windows" +$TMUX list-panes -a -F "$FMT" >/dev/null || fail "list-panes" +$TMUX has-session -t src || fail "src session gone" +$TMUX has-session -t dst || fail "dst session gone" + +exit 0 diff --git a/regress/format-strings.sh b/regress/format-strings.sh index 877e18fec..c06c340e3 100644 --- a/regress/format-strings.sh +++ b/regress/format-strings.sh @@ -7,6 +7,8 @@ TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +trap '$TMUX kill-server 2>/dev/null' 0 +trap 'exit 1' 1 2 15 # test_format $format $expected_result test_format() diff --git a/regress/format-time-range.sh b/regress/format-time-range.sh new file mode 100644 index 000000000..7a8e059a4 --- /dev/null +++ b/regress/format-time-range.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# time modifiers with a value outside the range the system can convert + +PATH=/bin:/usr/bin +TERM=screen +TZ=UTC +export TZ + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +TMP=$(mktemp) +trap "rm -f $TMP; $TMUX kill-server 2>/dev/null" 0 1 15 + +$TMUX new -d +$TMUX set -g @ok 1700000000 +$TMUX set -g @huge 3093527980800 +$TMUX set -g @bad 9223372036854775807 + +# A convertible time still formats, so an empty result below means the value +# was rejected and not that the modifier stopped working. +$TMUX display -p '[#{t:@ok}]' >$TMP +(echo "[Tue Nov 14 22:13:20 2023]"|cmp -s - $TMP) || exit 1 +$TMUX display -p '[#{t/f/zz:@ok}]' >$TMP +(echo "[zz]"|cmp -s - $TMP) || exit 1 +$TMUX display -p '[#{t/p:@ok}]' >$TMP +(echo "[Nov23]"|cmp -s - $TMP) || exit 1 + +# A year with more digits than ctime has room for must not be printed cut +# short, but other modifiers can still format it. +$TMUX display -p '[#{t:@huge}]' >$TMP +grep -Eqx '\[\]|\[Sat Jan 1 00:00:00 100000\]' $TMP || exit 1 +$TMUX display -p '[#{t/f/zz:@huge}]' >$TMP +(echo "[zz]"|cmp -s - $TMP) || exit 1 + +$TMUX display -p '[#{t:@bad}]' >$TMP +(echo "[]"|cmp -s - $TMP) || exit 1 +$TMUX display -p '[#{t/p:@bad}]' >$TMP +(echo "[]"|cmp -s - $TMP) || exit 1 +$TMUX display -p '[#{t/f/zz:@bad}]' >$TMP +(echo "[]"|cmp -s - $TMP) || exit 1 + +exit 0 diff --git a/regress/format-variables.sh b/regress/format-variables.sh index 449712e34..6908ad956 100644 --- a/regress/format-variables.sh +++ b/regress/format-variables.sh @@ -71,7 +71,10 @@ cursor_very_visible cursor_x cursor_y history_all_bytes +history_added history_bytes +history_collected +history_generation history_limit history_size host @@ -103,6 +106,11 @@ pane_at_right pane_at_top pane_bg pane_bottom +pane_command_duration +pane_command_end_time +pane_command_running +pane_command_start_time +pane_command_status pane_current_command pane_current_path pane_dead @@ -120,16 +128,21 @@ pane_index pane_input_off pane_key_mode pane_last +pane_last_output_time +pane_last_prompt_time pane_left pane_marked pane_marked_set +pane_modal_flag pane_mode +pane_output_generation pane_path pane_pb_progress pane_pb_state pane_pid pane_pipe pane_pipe_pid +pane_private_modes pane_right pane_search_string pane_start_command @@ -141,6 +154,8 @@ pane_title pane_top pane_tty pane_unseen_changes +pane_unzoomed_height +pane_unzoomed_width pane_width pane_x pane_y @@ -206,7 +221,10 @@ window_layout window_linked window_linked_sessions window_linked_sessions_list +window_manual_height +window_manual_width window_marked_flag +window_modal_pane window_name window_offset_x window_offset_y diff --git a/regress/hooks-lifecycle.sh b/regress/hooks-lifecycle.sh index 3ec748ff4..8c8a41363 100644 --- a/regress/hooks-lifecycle.sh +++ b/regress/hooks-lifecycle.sh @@ -76,14 +76,14 @@ $TMUX set-hook -g session-closed \ fail "set-hook session-closed failed" # The only pane of the only window of a session exits: pane-exited, then -# window-unlinked, then session-closed, each seeing the dead object in the +# session-closed, then window-unlinked, each seeing the dead object in the # hook formats. pane=$($TMUX new -d -s doomed -n dwin -P -F '#{pane_id}' 'true') || fail "new-session doomed failed" wait_for @log \ - "|pane-exited:$pane|window-unlinked:doomed:dwin|session-closed:doomed" + "|pane-exited:$pane|session-closed:doomed|window-unlinked:doomed:dwin" assert_unchanged @log \ - "|pane-exited:$pane|window-unlinked:doomed:dwin|session-closed:doomed" + "|pane-exited:$pane|session-closed:doomed|window-unlinked:doomed:dwin" # The dead pane, window and session cannot be used as targets but the # server survives. @@ -110,14 +110,14 @@ $TMUX set-hook -g pane-exited \ 'set -gF @log "#{@log}|pane-exited:#{hook_pane}"' || fail "restore pane-exited hook failed" -# kill-window on the last window: window-unlinked then session-closed but +# kill-window on the last window: session-closed then window-unlinked but # no pane-exited for the panes in the killed window. $TMUX set -g @log '' || fail "reset @log failed" $TMUX new -d -s doomed2 -n dwin2 || fail "new-session doomed2 failed" $TMUX splitw -d -t doomed2:0 || fail "split-window doomed2 failed" $TMUX kill-window -t doomed2:0 || fail "kill-window failed" -wait_for @log '|window-unlinked:doomed2:dwin2|session-closed:doomed2' -assert_unchanged @log '|window-unlinked:doomed2:dwin2|session-closed:doomed2' +wait_for @log '|session-closed:doomed2|window-unlinked:doomed2:dwin2' +assert_unchanged @log '|session-closed:doomed2|window-unlinked:doomed2:dwin2' $TMUX has -t main || fail "server died after kill-window chain" # kill-session: session-closed fires first, then window-unlinked for its diff --git a/regress/hooks-notify.sh b/regress/hooks-notify.sh index 6eb6ae49e..6c7029463 100644 --- a/regress/hooks-notify.sh +++ b/regress/hooks-notify.sh @@ -218,11 +218,13 @@ $TMUX set-hook -gu pane-mode-changed || fail "unset pane-mode-changed failed" # pane-exited when a pane's command exits with remain-on-exit off. $TMUX set -g @x 0 || fail "set @x failed" -$TMUX set-hook -g pane-exited 'set -gF @x "#{hook}:#{hook_pane}"' || +$TMUX set-hook -g pane-exited \ + 'set -gF @x "#{hook}:#{hook_pane}:#{hook_exit_status}:#{hook_exit_success}"' || fail "set-hook pane-exited failed" -pane=$($TMUX splitw -d -t main:0 -P -F '#{pane_id}' 'true') || - fail "split-window true failed" -wait_for @x "pane-exited:$pane" +pane=$($TMUX splitw -d -t main:0 -P -F '#{pane_id}' \ + 'trap "" HUP; exec /dev/null 2>&1; sleep 1; exit 42') || + fail "split-window exit 42 failed" +wait_for @x "pane-exited:$pane:42:0" $TMUX set-hook -gu pane-exited || fail "unset pane-exited failed" # pane-died when a pane's command exits with remain-on-exit on. diff --git a/regress/if-shell-error.sh b/regress/if-shell-error.sh index ef9afdd41..ada1bb08b 100644 --- a/regress/if-shell-error.sh +++ b/regress/if-shell-error.sh @@ -13,11 +13,8 @@ $TMUX kill-server 2>/dev/null TMP=$(mktemp) OUT=$(mktemp) -cleanup() { - $TMUX kill-server 2>/dev/null - rm -f "$TMP" "$OUT" -} -trap cleanup 0 1 15 +trap 'rm -f "$TMP" "$OUT"; $TMUX kill-server 2>/dev/null' 0 +trap 'exit 1' 1 2 15 cat <"$TMP" if 'true' 'wibble wobble' diff --git a/regress/input-common.inc b/regress/input-common.inc index ac04dbc24..a7ce572f6 100644 --- a/regress/input-common.inc +++ b/regress/input-common.inc @@ -4,6 +4,9 @@ TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +# Keep panes alive until the next case or cleanup, without reading replies. +INPUT_HOLD='while :; do sleep 3600; done' + TMP=$(mktemp) EXP=$(mktemp) trap 'rm -f "$TMP" "$EXP"; $TMUX kill-server 2>/dev/null' 0 1 15 @@ -37,10 +40,10 @@ start_pane_hlimit() $TMUX kill-server 2>/dev/null sleep 0.1 - $TMUX new-session -d -x 1 -y 1 -s test-setup "sleep 2" || exit 1 + $TMUX new-session -d -x 1 -y 1 -s test-setup "$INPUT_HOLD" || exit 1 $TMUX set-option -g history-limit "$hlimit" || exit 1 $TMUX new-session -d -x "$sx" -y "$sy" -s "$name" \ - "printf '$seq'; sleep 2" || exit 1 + "printf '$seq'; $INPUT_HOLD" || exit 1 $TMUX kill-session -t test-setup sleep 0.3 } diff --git a/regress/input-keys.sh b/regress/input-keys.sh index cb771d19a..76dd5ea73 100644 --- a/regress/input-keys.sh +++ b/regress/input-keys.sh @@ -22,7 +22,19 @@ assert_key () { clear-history -t$W \; \ send-keys -t$W "$key" 'EOL' || exit 1 - actual_code=$($TMUX capturep -pt$W | \ + # cat echoes the keys back asynchronously, so wait for the EOL marker + # to reach the pane instead of capturing straight away. + i=0 + while [ $i -lt 50 ]; do + screen=$($TMUX capturep -pt$W) + case "$screen" in + *EOL*) break ;; + esac + i=$((i + 1)) + sleep 0.1 + done + + actual_code=$(printf '%s\n' "$screen" | \ head -1 | \ sed -e 's/EOL.*$//') diff --git a/regress/input-malformed.sh b/regress/input-malformed.sh index 55a7e38c0..855af9f6c 100644 --- a/regress/input-malformed.sh +++ b/regress/input-malformed.sh @@ -2,6 +2,38 @@ . ./input-common.inc +# Wait for the terminator and following text to reach the parser. +check_discard() +{ + name=$1 + printf 'OK\n' >"$EXP" + i=0 + while [ "$i" -lt 300 ]; do + capture_grid "$name" >"$TMP" + cmp -s "$TMP" "$EXP" && return 0 + sleep 0.05 + i=$((i + 1)) + done + fail "$name (timed out waiting for discard)" +} + +# The missing-terminator timer may expire before the string limit is reached +# on a slow build. Clear any payload printed after the timeout, then check +# that the string did not change the title and normal output has resumed. +test_discard() +{ + name=$1 + prefix=$2 + start_cmd "$name" 8 3 "$INPUT_HOLD" + $TMUX select-pane -T discard-test || exit 1 + $TMUX respawn-pane -k \ + "perl -e 'print qq{$prefix}, q{x} x 1100000, qq{\e\\\\\e[H\e[2JOK}'; $INPUT_HOLD" || exit 1 + check_discard "$name" + $TMUX display-message -p -t "$name:" '#{pane_title}' >"$TMP" || exit 1 + printf 'discard-test\n' >"$EXP" + cmp "$TMP" "$EXP" || fail "$name title" +} + start_cmd csi-param-discard 8 3 \ "perl -e 'print qq{\e[}, q{1} x 80, qq{\030OK}'; sleep 2" check_capture csi-param-discard 'OK' @@ -10,13 +42,8 @@ start_cmd csi-interm-discard 8 3 \ "perl -e 'print qq{\e[ \030OK}'; sleep 2" check_capture csi-interm-discard 'OK' -start_cmd osc-discard 8 3 \ - "perl -e 'print qq{\e]2;}, q{x} x 1100000, qq{\e\\\\OK}'; sleep 2" -check_capture osc-discard 'OK' - -start_cmd apc-discard 8 3 \ - "perl -e 'print qq{\e_}, q{x} x 1100000, qq{\e\\\\OK}'; sleep 2" -check_capture apc-discard 'OK' +test_discard osc-discard '\e]2;' +test_discard apc-discard '\e_' start_pane unknown-csi 8 3 '\033[?9999zOK' check_capture unknown-csi 'OK' diff --git a/regress/input-nowrap-collect.sh b/regress/input-nowrap-collect.sh new file mode 100644 index 000000000..93496fb68 --- /dev/null +++ b/regress/input-nowrap-collect.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +. ./input-common.inc + +# The shortest collected prefix must leave the final cell at the right edge. +start_pane narrow 2 3 '\033[?7lABC' +check_capture narrow 'AC' +check_cursor narrow '1,0' +check_flags narrow '- AC' + +# REP may fill the final cell, but subsequent text still overwrites it. +start_pane repeat 5 3 '\033[?7lA\033[9bYZ' +check_capture repeat 'AAAAZ' +check_cursor repeat '4,0' +check_flags repeat '- AAAAZ' + +# Style changes must flush collected cells without moving the final cursor. +start_pane styled 5 3 '\033[?7l\033[31mAB\033[32mCDE\033[34mZ' +check_capture styled 'ABCDZ' +check_cursor styled '4,0' +check_raw_matches styled \ + 'C 0,0 data=\(1,1,A\).*fg=red\[1\] ' \ + 'C 0,2 data=\(1,1,C\).*fg=green\[2\] ' \ + 'C 0,4 data=\(1,1,Z\).*fg=blue\[4\] ' + +# Overwriting a wide character's padding must clear the old leading cell. +start_pane padding 8 3 '\033[?7lA\343\201\202B\r\033[2CX' +check_capture padding 'A XB' +check_cursor padding '3,0' +check_raw_matches padding \ + 'C 0,1 data=\(1,1, \) flags=NONE\[0\]' \ + 'C 0,2 data=\(1,1,X\) flags=NONE\[0\]' + +# Insert mode must shift existing cells even when autowrap is disabled. +start_pane insert 5 3 '\033[?7lABCD\r\033[C\033[4hXY\033[4l' +check_capture insert 'AXYBC' +check_cursor insert '3,0' + +# Re-enabling wrap after a collected prefix must retain the wrap boundary. +start_pane toggle 5 3 '\033[?7lABCD\033[?7hEF' +check_capture toggle 'ABCDE +F' +check_cursor toggle '1,1' +check_flags toggle 'W ABCDE +- F' + +exit $exit_status diff --git a/regress/input-scroll.sh b/regress/input-scroll.sh index 6ae48e96d..206c4db95 100644 --- a/regress/input-scroll.sh +++ b/regress/input-scroll.sh @@ -51,7 +51,7 @@ $TMUX kill-server 2>/dev/null sleep 0.1 $TMUX new-session -d -x 5 -y 3 -s history \; \ set-option -g history-limit 3 \; \ - respawn-pane -k "printf '01\n02\n03\n04\n05\n06'; sleep 2" || exit 1 + respawn-pane -k "printf '01\n02\n03\n04\n05\n06'; $INPUT_HOLD" || exit 1 sleep 0.3 $TMUX capture-pane -pN -t history: -S - -E - | normalize_capture >"$TMP" printf "%s\n" '01 diff --git a/regress/json.sh b/regress/json.sh new file mode 100755 index 000000000..7a924d1e9 --- /dev/null +++ b/regress/json.sh @@ -0,0 +1,137 @@ +#!/bin/sh + +# Test parsing and printing JSON with display-message -j. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +fail() +{ + echo "$1" + $TMUX kill-server 2>/dev/null + exit 1 +} + +check() +{ + actual=$($TMUX display-message -plj "$1" 2>&1) || + fail "JSON was rejected: $1: $actual" + [ "$actual" = "$2" ] || + fail "JSON output mismatch: $1: expected $2, got $actual" +} + +check_fail() +{ + if $TMUX display-message -plj "$1" >/dev/null 2>&1; then + fail "invalid JSON was accepted: $1" + fi +} + +$TMUX new-session -d || exit 1 + +check '{}' '{}' +check ' { "z":true, "a":-9223372036854775808, "m":[{}, {"x":false}] } ' \ + '{"a":-9223372036854775808,"m":[{},{"x":false}],"z":true}' +check '{"max":9223372036854775807,"min":-9223372036854775808}' \ + '{"max":9223372036854775807,"min":-9223372036854775808}' +check '{" key":" value","{key":"[value"}' \ + '{" key":" value","{key":"[value"}' +check '{"brace":"{value","bracket":"[value","colon":":value",'\ +'"comma":",value","space":" value"}' \ + '{"brace":"{value","bracket":"[value","colon":":value",'\ +'"comma":",value","space":" value"}' +check '{"esc":"\"\\\/\b\f\n\r\t\u0041"}' \ + '{"esc":"\"\\\/\b\f\n\r\t\u0041"}' +check '{"unicode":"\u0123\uabcd\uABCD"}' \ + '{"unicode":"\u0123\uabcd\uABCD"}' + +actual=$($TMUX display-message -palj '{}' 2>&1) || + fail "display-message -aj rejected JSON: $actual" +[ "$actual" = '{}' ] || + fail "display-message -aj did not ignore -a: got $actual" +actual=$($TMUX display-message -pIlj '{}' 2>&1) || + fail "display-message -Ij rejected JSON: $actual" +[ "$actual" = '{}' ] || + fail "display-message -Ij did not ignore -I: got $actual" + +check_fail '' +check_fail ' ' +check_fail '[]' +check_fail 'true' +check_fail '1' +check_fail '"string"' +check_fail '{}{}' + +check_fail '{' +check_fail '{x:1}' +check_fail '{"":1}' +check_fail '{"x" 1}' +check_fail '{"x",1}' +check_fail '{"x":}' +check_fail '{"x":,}' +check_fail '{"x":1' +check_fail '{"x":1 "y":2}' +check_fail '{"x":1,,"y":2}' +check_fail '{"x":1,}' +check_fail '{"x":1,"x":2}' +check_fail '{"x":{}}{}' +check_fail '{"x":{}} trailing' +check_fail '{"x":{' +check_fail '{"x":{}' + +check_fail '{"x":[' +check_fail '{"x":[}' +check_fail '{"x":[{}' +check_fail '{"x":[{},}' +check_fail '{"x":[{},]}' +check_fail '{"x":[{}{}]}' +check_fail '{"x":[{},,{}]}' +check_fail '{"x":[{"y":}]}' +check_fail '{"x":""}' +check_fail '{"x":null}' +check_fail '{"x":[null]}' +check_fail '{"x":[true]}' +check_fail '{"x":["string"]}' +check_fail '{"x":[[]]}' +check_fail '{"x":1.0}' +check_fail '{"x":1e2}' +check_fail '{"x":+1}' +check_fail '{"x":-}' +check_fail '{"x":0x10}' +check_fail '{"x":01}' +check_fail '{"x":-01}' +check_fail '{"x":[1]}' +check_fail '{"x":9223372036854775808}' +check_fail '{"x":-9223372036854775809}' +check_fail '{"x":tru}' +check_fail '{"x":True}' +check_fail '{"x":falsee}' +check_fail '{"x":"bad\q"}' +check_fail '{"x":"bad\x20"}' +check_fail '{"x":"bad\u123"}' +check_fail '{"x":"bad\u12x4"}' +check_fail '{"x":"unterminated}' +check_fail '{"x":"escaped quote\"}' +check_fail '{"x":"line +break"}' + +# The maximum object nesting depth is 200. +json='{}' +n=1 +while [ "$n" -lt 200 ]; do + json='{"x":'"$json"'}' + n=$((n + 1)) +done +check "$json" "$json" +check_fail '{"x":'"$json"'}' + +if $TMUX display-message -pj >/dev/null 2>&1; then + fail "display-message -j accepted a missing message" +fi + +$TMUX kill-server 2>/dev/null +exit 0 diff --git a/regress/kill-session-zoomed.sh b/regress/kill-session-zoomed.sh new file mode 100755 index 000000000..c27135ba2 --- /dev/null +++ b/regress/kill-session-zoomed.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# Killing a session whose window is zoomed must not crash the server: +# window_destroy used to unzoom the window, which resized the panes and +# fired pane-resized with the window in the payload, and releasing that +# reference destroyed the window a second time. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +$TMUX -f/dev/null new -d -sfoo || exit 1 +$TMUX split-window -d -h -tfoo:0 || exit 1 +$TMUX resize-pane -Z -tfoo:0 || exit 1 +$TMUX new -d -sbar || exit 1 +$TMUX kill-session -tfoo || exit 1 +$TMUX has-session -tbar || exit 1 + +# The same with the zoomed window in a session killed by kill-server. +$TMUX new -d -sbaz || exit 1 +$TMUX split-window -d -v -tbaz:0 || exit 1 +$TMUX resize-pane -Z -tbaz:0 || exit 1 +$TMUX kill-server || exit 1 + +exit 0 diff --git a/regress/layout-custom.sh b/regress/layout-custom.sh new file mode 100644 index 000000000..d6e5ae664 --- /dev/null +++ b/regress/layout-custom.sh @@ -0,0 +1,1026 @@ +#!/bin/sh + +# Tests of the custom layout dumper and evaluator in layout-custom.c, and of +# the JSON tokenizer and parser in json.c that the current layout format is +# built on. +# +# layout_dump is reached through the #{window_layout} and +# #{window_visible_layout} formats and layout_parse through +# "select-layout ". json.c has no command of its own either: +# layout_construct sniffs the first non-blank character and hands anything +# starting with '{' to json_parse, so select-layout is the only way into it +# from the shell as well. +# +# Both layout formats are covered: +# - the current (v2) JSON format, which is what every client except an old +# control client sees; +# - the legacy (v1) format, which is still produced for a control client that +# has not asked for the "new-layouts" flag, and which is still accepted by +# the parser (the version is sniffed from the first character). +# +# This exercises: +# - dumping a single pane, a split, the "a" (active) and "l" (last pane) keys +# and the "z" key of a floating pane; +# - #{window_visible_layout} agreeing with #{window_layout}; +# - the JSON syntax itself: insignificant whitespace, backslash escapes inside +# strings, the number and boolean forms, and one failure for each way json.c +# can reject an input that a layout string can carry; +# - a dump being parsed back to exactly the same layout (round trip), after +# another layout has been applied in between, and the same for a layout with +# two floating panes in it; +# - parsing a hand-written v2 layout; +# - "i" deciding which pane goes in which cell, checked with a layout whose +# cells are written in a different order from their indexes; +# - the same layouts with their fields in reversed and scrambled orders, +# including "c" before "t" and "V" after "L", neither of which changes the +# order the fields are read in; +# - a layout with more cells than the window has panes having the bottom right +# cells dropped, in both formats; +# - a layout naming no active or last pane leaving the active pane where it was +# and emptying the last pane stack, whether it leaves "a" out or gives it as +# false; +# - parsing a v1 layout and dumping it back as v1 through a control client, +# with the checksum computed here independently of layout_checksum(), and a +# v1 layout leaving the active pane and last pane stack untouched; +# - the legacy format meeting the floating panes it cannot represent: a v1 dump +# dropping the floating cells, both where that leaves the node they were in +# with one child so that it collapses, where it does not, and where adjacent +# nested floating-only subtrees are dropped, and a v1 layout being applied to +# a window that has floating panes without disturbing them, whether the tiled +# layout it names is a single cell or a split; +# - a window whose only tiled pane has been killed, which leaves it with a +# floating cell as its layout root or with a root node holding nothing but +# floating cells, producing an empty v1 body with a checksum, and being +# parsed as v1; +# - the %layout-change notification, in both formats at once: two control +# clients watching one layout change, only one of which has asked for new +# layouts, and the number of notifications a change produces in each format; +# - failures: a bad v1 header, checksum or body, a wrong version, a missing or +# duplicated root cell, missing sizes, sizes out of range, bad cell types, a +# pane cell missing "i", leaf cells with children and node cells with fewer +# than two, more than one active pane, too few cells for the panes and +# inconsistent sizes. + +PATH=/bin:/usr/bin +TERM=screen +LANG=C.UTF-8 +LC_ALL=C.UTF-8 +export TERM LANG LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +fail() +{ + echo "$*" >&2 + $TMUX kill-server 2>/dev/null + exit 1 +} + +# must_equal $what $got $expected +must_equal() +{ + if [ "$2" != "$3" ]; then + echo "$1 wrong." >&2 + echo "Expected: '$3'" >&2 + echo "But got: '$2'" >&2 + $TMUX kill-server 2>/dev/null + exit 1 + fi +} + +# must_differ $what $got $unwanted +must_differ() +{ + [ "$2" != "$3" ] || fail "$1 unchanged: '$2'" +} + +# must_contain $what $got $wanted +must_contain() +{ + case "$2" in + *"$3"*) ;; + *) fail "$1: '$2' does not contain '$3'";; + esac +} + +# check_ok $cmd... +# +# Run a command and require that it succeeds. +check_ok() +{ + out=$($TMUX "$@" 2>&1) || fail "Command failed (expected success): $* ($out)" +} + +# check_fail $cmd... +# +# Run a command and require that it fails. The error text itself is never +# checked anywhere in this test: the wording of a message is not part of what +# the layout formats promise, so matching on it only makes the test fail when a +# message is reworded. +check_fail() +{ + $TMUX "$@" >/dev/null 2>&1 && + fail "Command succeeded (expected failure): $*" +} + +# layout $target +# +# The layout of a window with pane ids replaced by %N, so that the expected +# strings do not depend on which ids the server handed out. +layout() +{ + $TMUX display-message -p -t "$1" '#{window_layout}' | + sed 's/%[0-9][0-9]*/%N/g' +} + +# visible_layout $target +# +# As layout(), but the visible (zoomed) layout. +visible_layout() +{ + $TMUX display-message -p -t "$1" '#{window_visible_layout}' | + sed 's/%[0-9][0-9]*/%N/g' +} + +# raw_layout $target +# +# The layout of a window with the real pane ids left in place. +raw_layout() +{ + $TMUX display-message -p -t "$1" '#{window_layout}' +} + +# v1_layout $target +# +# The legacy (v1) dump of a window, which is what a control client that has not +# asked for the "new-layouts" flag is sent. A control client wraps its output in +# %begin/%end guard lines, which are dropped here. +v1_layout() +{ + $TMUX -C display-message -p -t "$1" '#{window_layout}' | grep -v '^%' +} + +# v1 $body +# +# Prefix a legacy (v1) layout body with its checksum. This is a separate +# implementation of layout_checksum(): a 16 bit rotate right then add, so a +# mistake in either one shows up as a mismatch. +v1() +{ + awk -v s="$1" 'BEGIN { + for (i = 32; i < 127; i++) + ord[sprintf("%c", i)] = i + csum = 0 + for (i = 1; i <= length(s); i++) { + csum = int(csum / 2) + (csum % 2) * 32768 + csum = (csum + ord[substr(s, i, 1)]) % 65536 + } + printf "%04x,%s\n", csum, s + }' +} + +# A pane cell is dumped as its geometry, then "a" if it is the active pane or +# "l" with its position on the last pane stack if it is on it, then "i" with +# its pane index, then "z" if it is floating, then "I" with its pane id. +ONE='{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"a":true,"i":0,"I":"%N"}}' + +check_ok new-session -d -s L -x 80 -y 24 -n one + +p0=$($TMUX display-message -p -t L:one.0 '#{pane_id}') + +# A single leaf cell filling the window. A pane cell must carry "i", its pane +# index; "I", its pane id, is written by the dumper and is here so that the cell +# is the same shape as a dumped one. The JSON checks below care about the syntax +# around the cell rather than the cell itself. +LEAF='{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$p0"'"}' + +# --------------------------------------------------------------------------- +# Dumping a single pane. + +# The root cell of a new window is the pane itself, and it is the active pane +# so it has "a" rather than "l". +must_equal 'Single pane layout' "$(layout L:one)" "$ONE" + +# Nothing is zoomed, so the visible layout is the same. +must_equal 'Single pane visible layout' "$(visible_layout L:one)" "$ONE" + +# --------------------------------------------------------------------------- +# More cells than panes. + +# The bottom right cells are closed until as many are left as there are panes, +# so a two cell layout applied to a one pane window collapses back to the +# single pane filling the window: the cell that is left takes the space of the +# one that was closed. The window has one pane to name, so the cell that is +# closed carries an id belonging to no pane of it. +check_ok select-layout -t L:one \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$p0"'"},{"t":"p","w":80,"h":12,"x":0,"y":12,"i":1,"I":"%999"}]}}' +must_equal 'Trimmed layout' "$(layout L:one)" "$ONE" + +# --------------------------------------------------------------------------- +# The JSON syntax. +# +# These run on the one pane window and are written so that what they prove +# depends on json.c rather than on the layout evaluation in layout-custom.c: +# an accepted layout is only required to leave the window as its single pane, +# and values that are not part of the layout format are carried on keys +# layout-custom.c never looks at ("n", "b" and so on), which it skips, so +# numbers, booleans and escapes can be exercised on their own. +# +# Objects nested in an array nested in an object are not checked here: every +# split layout below is one. +# +# Two of json.c's rejections cannot be reached from the shell and so are not +# covered: json_parse_tokens() refusing a top level that is not an object, +# because layout_construct() only calls json_parse() once the string already +# starts with '{'; and the maximum object depth, which needs a layout built by a +# program rather than one written out here. + +# check_json_ok $what $layout +# +# select-layout must parse $layout and leave the window as its single pane. +check_json_ok() +{ + check_ok select-layout -t L:one "$2" + must_equal "Layout after '$1'" "$(layout L:one)" "$ONE" +} + +# check_json_fail $what $layout +# +# select-layout must reject $layout. +check_json_fail() +{ + $TMUX select-layout -t L:one "$2" >/dev/null 2>&1 && + fail "$1: select-layout succeeded (expected failure)" +} + +# Whitespace between tokens is skipped. A number is scanned up to the ',', ']', +# '}' or whitespace that ends it, so a space after a number is fine but one +# inside it is not. +check_json_ok 'Spaces between tokens' \ + '{ "V" : 2 , "L" : { "t" : "p" , "w" : 80 , "h" : 24 , "x" : 0 , "y" : 0 , "i" : 0 , "I" : "'"$p0"'" } }' + +check_json_ok 'Newlines and tabs between tokens' "$(printf '{ +\t"V": 2, +\t"L": { +\t\t"t": "p", +\t\t"w": 80, +\t\t"h": 24, +\t\t"x": 0, +\t\t"y": 0, +\t\t"i": 0, +\t\t"I": "%s" +\t} +}' "$p0")" + +check_json_ok 'Carriage returns between tokens' \ + "$(printf '{\r"V":2,\r"L":%s\r}' "$LEAF")" + +# A backslash makes the tokenizer consume the next character whatever it is, so +# an escaped quote does not end the string. The key is not one that +# layout-custom.c looks at, so all that is being checked is that the string +# ended in the right place and the object still parsed. +check_json_ok 'Escaped quote in a string' \ + '{"V":2,"a\"b":0,"L":'"$LEAF"'}' + +# An escaped backslash immediately before the closing quote: the escape has to +# be cleared again so that the quote after it does end the string. +check_json_ok 'Escaped backslash before the closing quote' \ + '{"V":2,"a\\":0,"L":'"$LEAF"'}' + +# Numbers and booleans, again on keys layout-custom.c ignores, so only json.c +# decides whether they are accepted. +check_json_ok 'Zero' '{"V":2,"n":0,"L":'"$LEAF"'}' +check_json_ok 'Several digits' '{"V":2,"n":1234567,"L":'"$LEAF"'}' +check_json_ok 'Negative number' '{"V":2,"n":-42,"L":'"$LEAF"'}' +check_json_ok 'Booleans' '{"V":2,"b":true,"d":false,"L":'"$LEAF"'}' + +# Tokenizer failures. A value that runs to the end of the input has no +# terminator, so it is the tokenizer rather than the parser that gives up. Both +# the number scan and the string scan have to notice this, and with the closing +# quote escaped there is no terminator left either. +check_json_fail 'Unterminated number' '{"V":2' +check_json_fail 'Unterminated string' '{"V":"x' +check_json_fail 'Escaped closing quote' '{"V":2,"L":{"t":"p\"}}' + +# Something that is not a quoted string where a key belongs. +check_json_fail 'Missing key' '{"V":2,,"L":'"$LEAF"'}' + +# A key not followed by ':'. +check_json_fail 'Missing colon' '{"V","L":2}' + +# A bare word that is neither "true", "false" nor a number. This is where +# "null" ends up. +check_json_fail 'Unknown literal' '{"V":null,"L":'"$LEAF"'}' + +# A ':' with no value after it, so the token where the value belongs is one the +# object parser has no case for. +check_json_fail 'Missing value' '{"V":}' + +# A ',' with nothing after it, and a value with no ',' before the next key. +check_json_fail 'Trailing comma in an object' '{"V":2,"L":'"$LEAF"',}' +check_json_fail 'Missing comma in an object' '{"V":2 "L":'"$LEAF"'}' + +# Arrays hold objects and nothing else. +check_json_fail 'Non-object in an array' \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":["x"]}}' +check_json_fail 'Trailing comma in an array' \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":['"$LEAF"',]}}' + +# An empty string is two adjacent quotes with no value token between them, +# which the string parser does not accept. +check_json_fail 'Empty string' '{"V":2,"L":""}' + +# A number token that strtoll does not consume all of. +check_json_fail 'Number with trailing characters' '{"V":8a,"L":'"$LEAF"'}' + +# Anything after the top level object. +check_json_fail 'Data after the top level object' '{"V":2,"L":'"$LEAF"'}{}' + +# None of the rejections touched the layout. +must_equal 'Layout after rejected parses' "$(layout L:one)" "$ONE" + +# --------------------------------------------------------------------------- +# Dumping a split. + +check_ok new-window -d -t L:2 -n two +q0=$($TMUX display-message -p -t L:two.0 '#{pane_id}') + +# -l 12 gives the new (bottom) pane 12 lines, leaving 11 for the top pane and +# one for the border between them. With -d the top pane stays active. +check_ok split-window -d -v -l 12 -t L:two.0 +q1=$($TMUX display-message -p -t L:two.1 '#{pane_id}') + +# Nothing has changed the active pane, so the last pane stack is still empty +# and the bottom pane has neither "a" nor "l". +must_equal 'Split layout' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":12,"x":0,"y":12,"i":1,"I":"%N"}]}}' + +# --------------------------------------------------------------------------- +# The active and last pane keys. + +# Selecting the bottom pane makes it active and pushes the top pane onto the +# last pane stack, where it is at index 0. +check_ok select-pane -t "$q1" +must_equal 'Layout after select-pane' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"l":0,"i":0,"I":"%N"},{"t":"p","w":80,"h":12,"x":0,"y":12,"a":true,"i":1,"I":"%N"}]}}' + +# Selecting the top pane again swaps the two keys over. "i" and "I" do not +# move: they are the pane's position in the window and its id. +check_ok select-pane -t "$q0" +SPLIT='{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":12,"x":0,"y":12,"l":0,"i":1,"I":"%N"}]}}' +must_equal 'Layout after select-pane back' "$(layout L:two)" "$SPLIT" + +# --------------------------------------------------------------------------- +# The visible layout. + +# With nothing zoomed the two layout formats agree. +# +# Zoomed layouts are checked below with floating panes. +must_equal 'Visible layout' "$(visible_layout L:two)" "$SPLIT" + +# --------------------------------------------------------------------------- +# Round trip. + +# Make the two panes obviously uneven so that the layout applied in between +# cannot be mistaken for the saved one. A resize shows up in the dump as the +# new cell sizes and offsets. +check_ok resize-pane -t "$q0" -y 5 +saved=$(raw_layout L:two) +must_equal 'Resized layout' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":5,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":18,"x":0,"y":6,"l":0,"i":1,"I":"%N"}]}}' + +check_ok select-layout -t L:two even-vertical +must_differ 'Layout after even-vertical' "$(raw_layout L:two)" "$saved" + +# Parsing a dump gives back exactly the same dump, pane ids included. The panes +# go back into the cells that named them: the cells are ordered by "i" and then +# given the window's panes in order, so a cell dumped with "i":k must come back +# the k'th. +check_ok select-layout -t L:two "$saved" +must_equal 'Round tripped layout' "$(raw_layout L:two)" "$saved" + +# --------------------------------------------------------------------------- +# Parsing a hand-written layout. + +# Laid out over several lines to keep it readable; that the whitespace is +# skipped at all is json.c's business, what matters here is that the cells come +# out of it in the right shape. +# +# "a" and "l" are given on the cells so that the active pane and the last pane +# stack are pinned by the layout rather than left to whatever a layout that +# names neither happens to produce. +check_ok select-layout -t L:two "$(printf '{ + "V": 2, + "L": { + "t": "h", + "w": 80, + "h": 24, + "x": 0, + "y": 0, + "c": [ + {"t": "p", "w": 30, "h": 24, "x": 0, "y": 0, "a": true, "i": 0, "I": "%s"}, + {"t": "p", "w": 49, "h": 24, "x": 31, "y": 0, "l": 0, "i": 1, "I": "%s"} + ] + } +}' "$q0" "$q1")" +must_equal 'Hand-written layout' "$(layout L:two)" \ + '{"V":2,"L":{"t":"h","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":30,"h":24,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":49,"h":24,"x":31,"y":0,"l":0,"i":1,"I":"%N"}]}}' + +# The panes are assigned to the cells in order. +must_equal 'First pane width' \ + "$($TMUX display-message -p -t "$q0" '#{pane_width}')" '30' +must_equal 'Second pane width' \ + "$($TMUX display-message -p -t "$q1" '#{pane_width}')" '49' + +# --------------------------------------------------------------------------- +# Field order. + +# Fields are looked up by key once the object has been parsed, so the order +# they are written in must give the same layout. Here every object has its keys +# reversed: "c" comes before "t" and "V" comes after "L", neither of which +# changes the order they are read in - the cell type is always read before the +# children and the version before the layout. +check_ok select-layout -t L:two \ + '{"L":{"c":[{"I":"'"$q0"'","i":0,"a":true,"y":0,"x":0,"h":8,"w":80,"t":"p"},{"I":"'"$q1"'","i":1,"l":0,"y":9,"x":0,"h":15,"w":80,"t":"p"}],"y":0,"x":0,"h":24,"w":80,"t":"v"},"V":2}' +must_equal 'Reversed field order' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":8,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":15,"x":0,"y":9,"l":0,"i":1,"I":"%N"}]}}' + +# Keys interleaved rather than simply reversed, with "c" in the middle. This +# time "a" is on the second cell, so the second pane becomes the active one: +# which pane is active comes from the layout, while "i" and "I" still come from +# the window. The first cell names neither "a" nor "l", so its pane is neither +# active nor on the last pane stack and the dump gives it neither key. +check_ok select-layout -t L:two \ + '{"V":2,"L":{"h":24,"c":[{"w":40,"t":"p","y":0,"i":0,"h":24,"I":"'"$q0"'","x":0},{"a":true,"h":24,"I":"'"$q1"'","w":39,"y":0,"t":"p","i":1,"x":41}],"w":80,"y":0,"t":"h","x":0}}' +must_equal 'Scrambled field order' "$(layout L:two)" \ + '{"V":2,"L":{"t":"h","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":40,"h":24,"x":0,"y":0,"i":0,"I":"%N"},{"t":"p","w":39,"h":24,"x":41,"y":0,"a":true,"i":1,"I":"%N"}]}}' + +# --------------------------------------------------------------------------- +# The legacy (v1) format. + +# The layout just applied, in v1: a left/right cell is written with braces and +# a top/bottom cell with brackets, and each leaf carries its pane id without +# the leading %. +v1body="80x24,0,0{40x24,0,0,${q0#%},39x24,41,0,${q1#%}}" + +# A control client that has not asked for new layouts is dumped v1. +must_equal 'v1 dump' "$(v1_layout L:two)" "$(v1 "$v1body")" + +# With the new-layouts flag the same client is dumped v2 instead. The flag is +# set with "attach -f" rather than refresh-client because refresh-client needs +# a current client, which a control client that has not attached has not got. +got=$(printf "display-message -p -t L:two '#{window_layout}'\n" | + $TMUX -C attach -f new-layouts -t L 2>&1 | grep -v '^%') +must_contain 'v2 dump for control client' "$got" '{"V":2,"L":' + +# A v1 layout with a correct checksum is parsed, and dumping v1 again gives +# back the same string. That is the whole of what v1 carries: the cells take +# the sizes and offsets from the body, and the panes are assigned to them in +# order, which is what puts the same two ids back in the same two places. It is +# checked in v1 rather than against a v2 dump so that nothing v1 has no opinion +# on - the active pane, the last pane stack, the pane index - comes into it. +v1vsplit="80x24,0,0[80x11,0,0,${q0#%},80x12,0,12,${q1#%}]" +check_ok select-layout -t L:two "$(v1 "$v1vsplit")" +must_equal 'v1 round trip' "$(v1_layout L:two)" "$(v1 "$v1vsplit")" + +# v1 names no active pane, last pane or z-index and must disturb none of them. +# Applying the v1 form of the layout the window already has therefore leaves +# even the v2 dump the same byte for byte, last pane stack included. +check_ok select-pane -t "$q1" +check_ok select-pane -t "$q0" +before=$(raw_layout L:two) +check_ok select-layout -t L:two "$(v1 "$v1vsplit")" +must_equal 'v1 leaves the active and last panes alone' \ + "$(raw_layout L:two)" "$before" + +# A v1 layout with more cells than the window has panes is trimmed like any +# other: the bottom right cell is closed and the cell above it takes its eight +# rows and the border between them, leaving 16. Pane ids in a v1 body are not +# used to place panes, so the third cell can carry any id. +v1three="80x24,0,0[80x7,0,0,${q0#%},80x7,0,8,${q1#%},80x8,0,16,999]" +check_ok select-layout -t L:two "$(v1 "$v1three")" +must_equal 'v1 layout trimmed' "$(v1_layout L:two)" \ + "$(v1 "80x24,0,0[80x7,0,0,${q0#%},80x16,0,8,${q1#%}]")" + +# --------------------------------------------------------------------------- +# Pane assignment order. + +# "i" is what decides which pane goes into which cell: the cells are ordered by +# it and then handed the window's panes in order, so the cell with "i":0 takes +# the first pane of the window wherever that cell sits in the layout. Here the +# cells are written the other way round from their indexes - the first cell in +# the string is "i":1 and the second "i":0 - so the first pane of the window +# has to come out in the second cell. +# +# Every other layout above lists its cells in the same order as their indexes, +# which is the order the tree is walked in, so this is the only check that can +# tell the two apart. +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":8,"x":0,"y":0,"i":1,"I":"'"$q1"'"},{"t":"p","w":80,"h":15,"x":0,"y":9,"i":0,"I":"'"$q0"'"}]}}' +must_equal 'First pane height' \ + "$($TMUX display-message -p -t "$q0" '#{pane_height}')" '15' +must_equal 'Second pane height' \ + "$($TMUX display-message -p -t "$q1" '#{pane_height}')" '8' + +# So the dump carries the two ids the other way round from every dump above, +# and with them their indexes, which are the panes' positions in the window and +# have not moved. Neither cell named an active or last pane, so the pane that +# was active still is - it is now the one in the second cell. +swapped='{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":8,"x":0,"y":0,"i":1,"I":"'"$q1"'"},{"t":"p","w":80,"h":15,"x":0,"y":9,"a":true,"i":0,"I":"'"$q0"'"}]}}' +must_equal 'Layout with the panes swapped' "$(raw_layout L:two)" "$swapped" + +# And that dump round trips, indexes out of order and all. +check_ok select-layout -t L:two "$swapped" +must_equal 'Round tripped swapped layout' "$(raw_layout L:two)" "$swapped" + +# --------------------------------------------------------------------------- +# Cells that name no active or last pane. + +# "a" and "l" are the only things that decide which pane is active and what is +# on the last pane stack, so a layout naming neither leaves the active pane +# where it was and empties the stack. Here the first pane of the window is +# active and the second is at index 0 of the stack beforehand; afterwards the +# first pane is still active and the stack is empty, so the second pane has no +# "l". +check_ok select-pane -t "$q1" +check_ok select-pane -t "$q0" +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":9,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":14,"x":0,"y":10,"i":1,"I":"'"$q1"'"}]}}' +must_equal 'Layout naming no active pane' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":9,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":14,"x":0,"y":10,"i":1,"I":"%N"}]}}' + +# "a" may be given as false, which says the same as leaving it out: this pane +# is not the active one. A layout where every cell says so names no active pane +# at all and so leaves the active pane alone, exactly as the layout above did. +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":10,"x":0,"y":0,"a":false,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":13,"x":0,"y":11,"a":false,"i":1,"I":"'"$q1"'"}]}}' +must_equal 'Layout with only false active panes' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":10,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":13,"x":0,"y":11,"i":1,"I":"%N"}]}}' + +# --------------------------------------------------------------------------- +# Failures. +# +# Each of these is a different reason for a layout to be rejected, but only the +# rejection itself is checked; the message that comes back with it is not. + +# check_layout_fail $layout +# +# select-layout must reject $layout. +check_layout_fail() +{ + check_fail select-layout -t L:two "$1" +} + +# A rejected layout must leave the window alone, whatever it was. +unchanged=$(raw_layout L:two) + +# Not JSON and not a checksum. +check_layout_fail 'garbage' + +# A v1 body with its checksum left off, and a string of nothing but hex digits. +# A v1 header is four hex digits and a comma; neither of these has one, so there +# is no header and nothing to check a body against. +check_layout_fail '80x24,0,0' +check_layout_fail 'ab' + +# A v1 header with the checksum of a different body. +good=$(v1 '80x24,0,0') +check_layout_fail "${good%%,*},80x24,0,1" + +# A correct checksum over a body that is not a layout: a cell with no offsets, +# and a top to bottom cell closed with '}' instead of ']'. layout_construct_v1 +# returns NULL for both. +check_layout_fail "$(v1 '80x24')" +check_layout_fail "$(v1 '80x24,0,0[80x11,0,0,80x12,0,12}')" + +# Version 1 cell sizes and offsets have the same limits as version 2. In +# particular, a width which overflows an unsigned integer must be rejected +# before it can be used to resize a pane. +check_layout_fail \ + "$(v1 '80x24,0,0{20x24,0,0,39999999999999999999999999999x24,21,0}')" + +# Fewer cells than the window has panes; unlike the other way around this +# cannot be fixed up. +check_layout_fail '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' + +# The children of a top to bottom cell must all be the width of their parent. +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":40,"h":12,"x":0,"y":12,"i":1,"I":"'"$q1"'"}]}}' + +# The rest are valid JSON, so it is layout_parse_json() and +# layout_parse_json_layout() doing the rejecting rather than json.c. Each of +# them is a layout that would be applied but for the one thing being checked. + +# Two root cells. +check_layout_fail \ + '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"},"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' + +# A missing "y". A cell needs all four of "w", "h", "x" and "y". +check_layout_fail '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"i":0,"I":"'"$q0"'"}}' + +# Cell sizes are bounded below by one column or row and above by 10000 of +# either. Both cases are otherwise complete two cell layouts, so the size is +# the only thing wrong with them. +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":0,"h":12,"x":0,"y":12,"i":1,"I":"'"$q1"'"}]}}' +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":10001,"x":0,"y":12,"i":1,"I":"'"$q1"'"}]}}' + +# An unknown cell type: only "h", "v" and "p" exist. +check_layout_fail '{"V":2,"L":{"t":"q","w":80,"h":24,"x":0,"y":0}}' + +# A pane cell needs "i", its pane index. It is "i" that says which pane goes in +# the cell; "I" is the pane id the cell was dumped with and is not read back. +check_layout_fail '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"I":"'"$q0"'"}}' + +# A node cell must have more than one child and a leaf cell must have none. A +# node is written with no "c" at all, with an empty one and with a single child. +check_layout_fail '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0}}' +check_layout_fail '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[]}}' +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}]}}' +check_layout_fail \ + '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'","c":[{"t":"p","w":80,"h":24,"x":0,"y":0,"i":1,"I":"'"$q1"'"}]}}' + +# Only one cell may be the active pane. +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"a":true,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":12,"x":0,"y":12,"a":true,"i":1,"I":"'"$q1"'"}]}}' + +# The same rejections apply whatever order the fields are written in: a leaf +# with children when "c" comes first, and a node with no children and a bad cell +# type when "t" comes last. +check_layout_fail \ + '{"V":2,"L":{"c":[{"t":"p","w":80,"h":24,"x":0,"y":0,"i":1,"I":"'"$q1"'"}],"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' +check_layout_fail '{"V":2,"L":{"w":80,"h":24,"x":0,"y":0,"t":"v"}}' +check_layout_fail '{"V":2,"L":{"w":80,"h":24,"x":0,"y":0,"t":"q"}}' + +# A child that fails after a sibling has already been parsed and added to the +# parent. This is the case the cleanup at the end of layout_parse_json_layout +# exists for: the children built so far have to be freed along with the parent +# that is never returned. The second child has no "y". +check_layout_fail \ + '{"V":2,"L":{"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":12,"x":0,"i":1,"I":"'"$q1"'"}],"t":"v","w":80,"h":24,"x":0,"y":0}}' + +# No root cell at all. Every other rejection above comes from a cell that +# failed to parse; this one is the check for "L" itself. +check_layout_fail '{"V":2}' + +# The wrong version, with "V" before and after "L". Fields are looked up by +# key, so the version is read before the layout either way and the position of +# "V" in the string makes no difference. +check_layout_fail '{"V":1,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' +check_layout_fail '{"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"},"V":1}' + +# None of that touched the layout. +must_equal 'Layout after failures' "$(raw_layout L:two)" "$unchanged" + +# --------------------------------------------------------------------------- +# Floating panes. + +check_ok new-window -d -t L:3 -n float +check_ok select-window -t L:float +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 'sleep 100' +check_ok new-pane -d -x 30 -y 8 -X 30 -Y 10 'sleep 100' + +# The tiled pane and the two floating ones. A floating pane goes on the end of +# the window's pane list, so the pane indexes are in the order the panes were +# made whatever order their cells end up in. +f0=$($TMUX display-message -p -t L:float.0 '#{pane_id}') +fa=$($TMUX display-message -p -t L:float.1 '#{pane_id}') +fb=$($TMUX display-message -p -t L:float.2 '#{pane_id}') + +# A floating cell is dumped with its z-index, which is what marks it as +# floating when the layout is parsed back. Two of them, so that there is an +# order between them to get wrong: the newer floating pane is in front, and a +# cell's "z" is its place in that order counting from the front. +floating=$(raw_layout L:float) +must_contain 'Floating layout front z-index' "$floating" '"z":0' +must_contain 'Floating layout back z-index' "$floating" '"z":1' + +# Each floating cell goes in after the cell of the pane that was current when +# it was made, which is the tiled pane both times, so the newer floating cell +# is written before the older one while its pane comes after in the window. +# The dump therefore has its cells in one order and their indexes in another, +# and only comes back the same if the panes go by index. +check_ok select-layout -t L:float "$floating" +must_equal 'Floating layout after round trip' "$(raw_layout L:float)" "$floating" + +# A dump taken while zoomed must retain the unzoomed floating z-indexes. +# Check hidden floats, floats above zoom, and a mixture of the two. +for flags in '' A mixed; do + check_ok new-window -d -t L: -n zoom + zt=$($TMUX display-message -p -t L:zoom '#{pane_id}') + check_ok split-window -d -h -t "$zt" + case "$flags" in + A|mixed) first=-Ad ;; + *) first=-d ;; + esac + case "$flags" in + A) second=-Ad ;; + *) second=-d ;; + esac + check_ok new-pane "$first" -t "$zt" -x 20 -y 6 '' + check_ok new-pane "$second" -t "$zt" -x 30 -y 8 '' + for pane in 0 2 3; do + check_ok select-pane -t "L:zoom.$pane" + before=$(raw_layout L:zoom) + legacy=$(v1_layout L:zoom) + check_ok resize-pane -Z -t "L:zoom.$pane" + during=$(raw_layout L:zoom) + must_equal 'Layout while zoomed' "$during" "$before" + must_equal 'Legacy layout while zoomed' \ + "$(v1_layout L:zoom)" "$legacy" + must_equal 'Zoom after dumping layout' \ + "$($TMUX display-message -p -t L:zoom '#{window_zoomed_flag}')" 1 + must_differ 'Visible layout while zoomed' \ + "$(visible_layout L:zoom)" "$(layout L:zoom)" + check_ok select-layout -t L:zoom "$during" + must_equal 'Round trip from zoomed layout' \ + "$(raw_layout L:zoom)" "$before" + done + check_ok kill-window -t L:zoom +done + +# --------------------------------------------------------------------------- +# Floating panes and the legacy (v1) format. +# +# v1 has no way to write a floating pane down, so the two formats cannot say the +# same thing about a window that has one. Dumping v1 takes a copy of the layout, +# deletes the floating cells from the copy and dumps what is left; parsing v1 +# rearranges the tiled panes and leaves the floating ones where they are. None +# of this is reached above: every v1 check so far runs on a window that has no +# floating panes, and every floating pane check so far is in v2. + +# float_state $target +# +# Everything about a floating pane that a v1 layout has no way to carry, so that +# applying one can be checked against all of it at once. +float_state() +{ + $TMUX display-message -p -t "$1" \ + '#{pane_floating_flag} #{pane_width}x#{pane_height} #{pane_left},#{pane_top} #{pane_z}' +} + +# Deleting both floating cells from the copy leaves the root node with a single +# child, and a node with a single child collapses into it, so the root of the +# copy is the tiled cell and the dump is that cell on its own filling the +# window. +must_equal 'v1 dump with floating panes' "$(v1_layout L:float)" \ + "$(v1 "80x24,0,0,${f0#%}")" + +# The cells are deleted from the copy, so the window itself comes through a v1 +# dump untouched - floating panes, z-indexes and all. +must_equal 'Layout after a v1 dump' "$(raw_layout L:float)" "$floating" + +# The same with a split, where deleting the floating cell still leaves two +# children behind and the node it was in does not collapse. +check_ok new-window -d -t L:4 -n mixed +m0=$($TMUX display-message -p -t L:mixed.0 '#{pane_id}') +check_ok split-window -d -v -l 12 -t L:mixed.0 +m1=$($TMUX display-message -p -t L:mixed.1 '#{pane_id}') +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 -t L:mixed.0 'sleep 100' +mf=$($TMUX display-message -p -t L:mixed.2 '#{pane_id}') + +# A floating pane takes no space from the tiled layout, so the two tiled cells +# are the same 11 and 12 rows the split gave them. +must_equal 'v1 dump with a split and a floating pane' "$(v1_layout L:mixed)" \ + "$(v1 "80x24,0,0[80x11,0,0,${m0#%},80x12,0,12,${m1#%}]")" + +# A v1 layout applied to a window that has a floating pane rearranges the tiled +# panes and must leave the floating one exactly as it was: v1 names no floating +# pane, so there is nothing in it for one to be changed by. The top pane goes +# from 11 rows to 7 and the bottom one from 12 to 16. +v1mixed="80x24,0,0[80x7,0,0,${m0#%},80x16,0,8,${m1#%}]" +mfbefore=$(float_state "$mf") +check_ok select-layout -t L:mixed "$(v1 "$v1mixed")" +must_equal 'v1 layout with a floating pane' "$(v1_layout L:mixed)" \ + "$(v1 "$v1mixed")" +must_equal 'Floating pane after a v1 layout' "$(float_state "$mf")" "$mfbefore" +must_equal 'Panes after a v1 layout' \ + "$($TMUX display-message -p -t L:mixed '#{window_panes}')" '3' + +# When the tiled layout a v1 string names is a single cell there is no node in +# the new layout for the floating cells to go back into, so one is made: the +# root cell is replaced by a top to bottom node holding it and the floating +# cells go on the end. Nothing else here reaches that. +fabefore=$(float_state "$fa") +fbbefore=$(float_state "$fb") +check_ok select-layout -t L:float "$(v1 "80x24,0,0,${f0#%}")" +must_equal 'v1 single cell layout with floating panes' "$(v1_layout L:float)" \ + "$(v1 "80x24,0,0,${f0#%}")" +must_equal 'Front floating pane after a v1 layout' "$(float_state "$fb")" \ + "$fbbefore" +must_equal 'Back floating pane after a v1 layout' "$(float_state "$fa")" \ + "$fabefore" +must_equal 'Panes after a v1 single cell layout' \ + "$($TMUX display-message -p -t L:float '#{window_panes}')" '3' + +# Adjacent subtrees containing only floating panes used to be a distinct case: +# dumping v1 made a copy of the v2 tree and deleted floating cells from the +# copy, but deleting the last floating cell in the first subtree collapsed the +# parent and could leave the outer traversal holding a stale pointer to the +# second subtree. +check_ok new-window -d -t L:5 -n nested +n0=$($TMUX display-message -p -t L:nested.0 '#{pane_id}') +check_ok split-window -d -v -l 12 -t L:nested.0 +n1=$($TMUX display-message -p -t L:nested.1 '#{pane_id}') +check_ok split-window -d -v -l 6 -t L:nested.1 +n2=$($TMUX display-message -p -t L:nested.2 '#{pane_id}') +check_ok split-window -d -v -l 3 -t L:nested.2 +n3=$($TMUX display-message -p -t L:nested.3 '#{pane_id}') +check_ok split-window -d -v -l 2 -t L:nested.3 +n4=$($TMUX display-message -p -t L:nested.4 '#{pane_id}') +check_ok select-layout -t L:nested \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"h","w":30,"h":10,"x":0,"y":0,"c":[{"t":"p","w":10,"h":5,"x":2,"y":2,"i":0,"z":0},{"t":"p","w":12,"h":6,"x":5,"y":5,"i":1,"z":1}]},{"t":"h","w":30,"h":10,"x":0,"y":0,"c":[{"t":"p","w":14,"h":7,"x":8,"y":8,"i":2,"z":2},{"t":"p","w":16,"h":8,"x":11,"y":11,"i":3,"z":3}]},{"t":"p","w":80,"h":24,"x":0,"y":0,"i":4}]}}' +must_equal 'v1 dump with nested floating-only subtrees' \ + "$(v1_layout L:nested)" "$(v1 "80x24,0,0,${n4#%}")" +check_ok kill-window -t L:nested + +# --------------------------------------------------------------------------- +# A window with no tiled panes. +# +# Killing the last tiled pane of a window that has floating panes does not kill +# the window: that only happens when the pane being killed is the last one +# counting the floating ones. What is left is a window whose layout root is +# either a floating cell on its own, or a node holding nothing but floating +# cells, depending on how many are left. v1 has no way to write either down, so +# it must not try: a layout with no tiled panes in it produces no v1 dump at +# all, and #{window_layout} comes back empty for a client being sent v1. What v2 +# makes of such a window is a separate question and is not checked here. +# +# A dead server dumps nothing either, so the checks below have to establish that +# the server is still there before an empty dump is allowed to mean anything. + +# no_hang $cmd... +# +# Run a command whose result is not being checked, but which has to come back: +# only the server surviving it is checked afterwards, and a server wedged rather +# than killed would otherwise show up as the test never finishing. +no_hang() +{ + if command -v timeout >/dev/null 2>&1; then + timeout 10 "$@" >/dev/null 2>&1 + else + "$@" >/dev/null 2>&1 + fi + return 0 +} + +# One floating pane left. The node it and the tiled cell were in is down to a +# single child, so it collapses and the floating cell becomes the root. +check_ok new-window -d -t L:5 -n gone1 +g0=$($TMUX display-message -p -t L:gone1.0 '#{pane_id}') +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 -t L:gone1.0 'sleep 100' +check_ok kill-pane -t "$g0" +must_equal 'Panes left with one floating pane' \ + "$($TMUX display-message -p -t L:gone1 '#{window_panes}')" '1' + +# The floating cell is the root and there is nothing tiled under it, so there is +# only an empty v1 body to dump. The floating cell must not be written out on +# its own, which would be a layout claiming the window is the size and position +# of the floating pane with no pane in it at all. +got=$(v1_layout L:gone1) +check_ok display-message -p alive +must_equal 'v1 dump with one floating pane and no tiled panes' "$got" '0000,' + +# Two floating panes left, so the node keeps two children, does not collapse, +# and stays the root with nothing but floating cells in it. +check_ok new-window -d -t L:6 -n gone2 +h0=$($TMUX display-message -p -t L:gone2.0 '#{pane_id}') +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 -t L:gone2.0 'sleep 100' +check_ok new-pane -d -x 30 -y 8 -X 30 -Y 10 -t L:gone2.0 'sleep 100' +check_ok kill-pane -t "$h0" +must_equal 'Panes left with two floating panes' \ + "$($TMUX display-message -p -t L:gone2 '#{window_panes}')" '2' + +# The node is the root this time rather than the floating cell, but it has no +# tiled cell anywhere under it either, so the v1 body is still empty - +# and making one must not take the server with it. +got=$(v1_layout L:gone2) +check_ok display-message -p alive +must_equal 'v1 dump with two floating panes and no tiled panes' "$got" '0000,' + +# Nor must parsing a v1 layout against it. There is no tiled pane for the +# layout to name, so whether it is applied or rejected is the format's business; +# it just has to be one of the two. +no_hang $TMUX select-layout -t L:gone2 "$(v1 '80x24,0,0,999')" +check_ok display-message -p alive + +check_ok kill-window -t L:gone1 +check_ok kill-window -t L:gone2 + +# --------------------------------------------------------------------------- +# Control mode notifications. +# +# %layout-change is what a control client actually reads a layout from, and it +# carries both #{window_layout} and #{window_visible_layout}. Its template is +# expanded once per client (control-notify.c), so two clients watching the same +# window must be told about the same change in different formats: v1 for the +# one that has not asked for new layouts, v2 for the one that has. +# +# The dumps above go through "-C display-message", which only ever reaches the +# format callbacks for the client asking. This needs clients that stay +# attached while something else changes the layout, so they go on the end of +# fifos and the change is made from outside. + +DIR=$(mktemp -d) || fail 'Could not make a temporary directory' +OLDIN="$DIR/old-in" +OLDOUT="$DIR/old-out" +NEWIN="$DIR/new-in" +NEWOUT="$DIR/new-out" +OLDPID= +NEWPID= + +cleanup() +{ + [ -n "$OLDPID" ] && kill "$OLDPID" 2>/dev/null + [ -n "$NEWPID" ] && kill "$NEWPID" 2>/dev/null + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup EXIT + +# wait_for $file $text +# +# Wait for $text to appear in a control client's output. +wait_for() +{ + i=0 + while [ "$i" -lt 6 ]; do + grep -F -- "$2" "$1" >/dev/null 2>&1 && return 0 + sleep 1 + i=$((i + 1)) + done + echo "missing from $1: $2" >&2 + cat "$1" >&2 + return 1 +} + +mkfifo "$OLDIN" "$NEWIN" || fail 'Could not make the control client fifos' +: >"$OLDOUT" +: >"$NEWOUT" + +$TMUX -C attach -t L <"$OLDIN" >"$OLDOUT" 2>&1 & +OLDPID=$! +exec 4>"$OLDIN" +$TMUX -C attach -f new-layouts -t L <"$NEWIN" >"$NEWOUT" 2>&1 & +NEWPID=$! +exec 5>"$NEWIN" + +# Both clients have to be attached before the layout changes, or they miss the +# notification entirely. +printf 'display-message -p ready\n' >&4 +printf 'display-message -p ready\n' >&5 +wait_for "$OLDOUT" ready || fail 'Control client without new-layouts did not attach' +wait_for "$NEWOUT" ready || fail 'Control client with new-layouts did not attach' + +wid=$($TMUX display-message -p -t L:two '#{window_id}') + +# One layout change, made by a third client so that neither of the two is the +# one running the command. 8 lines for the top pane leaves 15 for the bottom +# and one for the border. +check_ok resize-pane -t "$q0" -y 8 + +# Nothing is zoomed, so both fields of the notification carry the same layout. +# The v2 one is compared against the dump rather than a literal so that it is +# the two formats being checked and not the geometry again. +v2now=$(raw_layout L:two) +v1now=$(v1 "80x24,0,0[80x8,0,0,${q0#%},80x15,0,9,${q1#%}]") +wait_for "$NEWOUT" "%layout-change $wid $v2now $v2now " || + fail 'No v2 %layout-change for the client with new-layouts' +wait_for "$OLDOUT" "%layout-change $wid $v1now $v1now " || + fail 'No v1 %layout-change for the client without new-layouts' + +# How many notifications one layout change produces, which differs by format +# on purpose. cmd_select_layout_exec() fires window-layout-changed for any +# layout it applies, and layout_parse() fires it again for a v1 one, so v1 +# arrives twice - which is what master does for every layout, and what control +# clients written against it expect. v2 is new and has no such clients, so it +# gets the single notification. Counting the delta rather than the total, with +# a settle in between, keeps this independent of what has already been sent. +n1=$(grep -c "%layout-change $wid " "$OLDOUT") +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":9,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":14,"x":0,"y":10,"i":1,"I":"'"$q1"'"}]}}' +sleep 2 +n2=$(grep -c "%layout-change $wid " "$OLDOUT") +must_equal 'Notifications for a v2 layout' "$((n2 - n1))" '1' + +check_ok select-layout -t L:two "$(v1 "$v1vsplit")" +sleep 2 +n3=$(grep -c "%layout-change $wid " "$OLDOUT") +must_equal 'Notifications for a v1 layout' "$((n3 - n2))" '2' + +# And the client that did not ask for new layouts must never have been sent +# one, in that notification or any other. +grep -F '{"V":2,' "$OLDOUT" >/dev/null 2>&1 && + fail 'Control client without new-layouts was sent a v2 layout' + +if [ "$($TMUX display-message -p alive 2>&1)" != "alive" ]; then + echo "Server died." >&2 + exit 1 +fi + +$TMUX kill-server 2>/dev/null +exit 0 diff --git a/regress/layout-nesting-limit.sh b/regress/layout-nesting-limit.sh new file mode 100644 index 000000000..7d0594d9c --- /dev/null +++ b/regress/layout-nesting-limit.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +# Version 1 layouts must reject excessive nesting without losing the session or +# changing its layout. Check both split types and the depth limit boundary. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX="$TEST_TMUX -S$DIR/socket -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +# Generate a checksum-valid v1 layout with one child in every nested group. +layout() +{ + awk -v depth="$1" -v open="$2" 'BEGIN { + closing = (open == "{" ? "}" : "]") + body = "" + for (i = 0; i < depth; i++) + body = body "1x1,0,0" open + body = body "1x1,0,0" + for (i = 0; i < depth; i++) + body = body closing + + ord["0"] = 48; ord["1"] = 49; ord["x"] = 120 + ord[","] = 44; ord["{"] = 123; ord["}"] = 125 + ord["["] = 91; ord["]"] = 93 + csum = 0 + for (i = 1; i <= length(body); i++) { + c = substr(body, i, 1) + bit = csum % 2 + csum = int(csum / 2) + bit * 32768 + csum = (csum + ord[c]) % 65536 + } + printf "%04x,%s", csum, body + }' +} + +$TMUX new-session -d -s deep -x80 -y24 'exec sleep 100' || exit 1 + +for open in '{' '['; do + # A valid checksum and nesting up to the limit must still be accepted. + value=$(layout 1000 "$open") || fail "could not generate layout" + $TMUX select-layout -t deep "$value" || + fail "layout at the depth limit was rejected ($open)" + before=$($TMUX display-message -p -t deep \ + '#{pane_id} #{pane_width} #{pane_height} #{window_layout}') || exit 1 + + for depth in 1001 1500; do + value=$(layout "$depth" "$open") || fail "could not generate layout" + $TMUX select-layout -t deep "$value" >/dev/null 2>&1 && + fail "excessive nesting was accepted ($open, $depth)" + $TMUX has-session -t deep || + fail "server died on excessive nesting ($open, $depth)" + after=$($TMUX display-message -p -t deep \ + '#{pane_id} #{pane_width} #{pane_height} #{window_layout}') || exit 1 + [ "$before" = "$after" ] || + fail "pane or layout changed after rejection ($open, $depth)" + done +done + +exit 0 diff --git a/regress/layout-set.sh b/regress/layout-set.sh new file mode 100644 index 000000000..ea02195a9 --- /dev/null +++ b/regress/layout-set.sh @@ -0,0 +1,126 @@ +#!/bin/sh + +# Exercise every predefined layout, next/previous cycling, lookup and restore +# paths, one-pane early returns, minimum-size handling and floating panes. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" +SEEN="$DIR/layouts" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +layout() +{ + $TMUX display-message -p -t layouts:many '#{window_layout}' +} + +check_layout() +{ + name=$1 + $TMUX select-layout -t layouts:many "$name" || + fail "select-layout $name failed" + value=$(layout) + [ -n "$value" ] || fail "$name produced an empty layout" + if grep -Fxq "$value" "$SEEN"; then + fail "$name produced the same layout as an earlier preset" + fi + printf '%s\n' "$value" >>"$SEEN" + [ "$($TMUX list-panes -t layouts:many -F x | wc -l)" -eq 5 ] || + fail "$name lost a pane" + [ "$($TMUX list-panes -t layouts:many -F '#{pane_floating_flag}' | \ + grep -c '^1$')" -eq 1 ] || fail "$name did not preserve the floating pane" +} + +$TMUX new-session -d -s layouts -n many -x100 -y40 'exec sleep 100' || exit 1 +$TMUX set-option -g window-size manual || exit 1 +$TMUX split-window -d -h -t layouts:many 'exec sleep 100' || exit 1 +$TMUX split-window -d -v -t layouts:many 'exec sleep 100' || exit 1 +$TMUX split-window -d -h -t layouts:many 'exec sleep 100' || exit 1 +floating=$($TMUX new-pane -dP -F '#{pane_id}' -t layouts:many \ + -x20 -y8 -X10 -Y5 'exec sleep 100') || exit 1 +: >"$SEEN" + +# Options exercise percentage and explicit secondary-size calculations. +$TMUX set-option -w -t layouts:many main-pane-height 30% || exit 1 +$TMUX set-option -w -t layouts:many other-pane-height 12 || exit 1 +$TMUX set-option -w -t layouts:many main-pane-width 35% || exit 1 +$TMUX set-option -w -t layouts:many other-pane-width 20 || exit 1 + +for name in even-horizontal even-vertical main-horizontal \ + main-horizontal-mirrored main-vertical main-vertical-mirrored tiled; do + check_layout "$name" +done + +# Exact and unique-prefix lookup work; ambiguous and invalid names fail. +$TMUX select-layout -t layouts:many even-h || fail "unique layout prefix failed" +$TMUX select-layout -t layouts:many main >/dev/null 2>&1 && + fail "ambiguous layout prefix succeeded" +$TMUX select-layout -t layouts:many no-such-layout >/dev/null 2>&1 && + fail "invalid layout name succeeded" + +# Cycle through all layouts in both directions, including wraparound. The +# aliases and select-layout flags share layout_set_next/previous. +first=$(layout) +i=0 +while [ "$i" -lt 7 ]; do + $TMUX next-layout -t layouts:many || exit 1 + i=$((i + 1)) +done +[ "$(layout)" = "$first" ] || fail "next-layout did not wrap" +i=0 +while [ "$i" -lt 7 ]; do + $TMUX previous-layout -t layouts:many || exit 1 + i=$((i + 1)) +done +[ "$(layout)" = "$first" ] || fail "previous-layout did not wrap" +$TMUX select-layout -t layouts:many -n || exit 1 +$TMUX select-layout -t layouts:many -p || exit 1 + +# No argument reapplies the last preset; -o restores the previous layout; -E +# spreads the current cell without changing the pane set. +$TMUX kill-pane -t "$floating" || exit 1 +$TMUX select-layout -t layouts:many tiled || exit 1 +tiled=$(layout) +$TMUX select-layout -t layouts:many even-horizontal || exit 1 +$TMUX select-layout -t layouts:many -o || exit 1 +[ "$(layout)" = "$tiled" ] || fail "select-layout -o did not restore layout" +$TMUX select-layout -t layouts:many || exit 1 +$TMUX select-layout -t layouts:many -E || exit 1 + +# Each arranger has a deliberate one-pane early return. +$TMUX new-window -d -t layouts: -n one 'exec sleep 100' || exit 1 +one=$($TMUX display-message -p -t layouts:one '#{window_layout}') +for name in even-horizontal even-vertical main-horizontal \ + main-horizontal-mirrored main-vertical main-vertical-mirrored tiled; do + $TMUX select-layout -t layouts:one "$name" || exit 1 + [ "$($TMUX display-message -p -t layouts:one '#{window_layout}')" = "$one" ] || + fail "$name changed a one-pane layout" +done + +# Force the minimum-size paths without requiring a terminal client. +$TMUX resize-window -t layouts:many -x10 -y6 || exit 1 +for name in even-horizontal even-vertical main-horizontal \ + main-horizontal-mirrored main-vertical main-vertical-mirrored tiled; do + $TMUX select-layout -t layouts:many "$name" || exit 1 +done + +$TMUX has-session -t layouts || fail "server died during layout tests" +exit 0 diff --git a/regress/list-commands.sh b/regress/list-commands.sh new file mode 100644 index 000000000..9e1463bea --- /dev/null +++ b/regress/list-commands.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +# Exercise list-commands for the full table, a single command, aliases, custom +# and empty formats, and lookup errors. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +# Keep a server alive across commands. Otherwise a STARTSERVER-only command +# may exit between invocations and leave a short socket-cleanup race. +$TMUX new-session -d -s list-commands 'exec sleep 100' || exit 1 + +all=$($TMUX list-commands) || exit 1 +printf '%s\n' "$all" | grep -q '^attach-session (attach)' || exit 1 +printf '%s\n' "$all" | grep -q '^list-commands (lscm)' || exit 1 + +one=$($TMUX list-commands -F \ + '#{command_list_name}|#{command_list_alias}|#{command_list_usage}' \ + list-commands) || exit 1 +case "$one" in +'list-commands|lscm|'*) ;; +*) exit 1 ;; +esac + +# Aliases resolve to their command, while an empty expansion prints no line. +[ "$($TMUX list-commands -F '#{command_list_name}' lscm)" = list-commands ] || + exit 1 +[ -z "$($TMUX list-commands -F '' list-commands)" ] || exit 1 + +$TMUX list-commands tmux-no-command-$$ >/dev/null 2>&1 && exit 1 +$TMUX list-commands list >/dev/null 2>&1 && exit 1 +exit 0 diff --git a/regress/list-keys-sort-name.sh b/regress/list-keys-sort-name.sh new file mode 100644 index 000000000..ed8555e1b --- /dev/null +++ b/regress/list-keys-sort-name.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +# list-keys -O name orders by table name, then by key within each table + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestLKSN$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +TMP=$(mktemp) +trap "rm -f $TMP*; $TMUX kill-server 2>/dev/null" 0 1 15 + +$TMUX new -d || exit 1 + +F='#{key_table} #{key_string}' + +# Expected: each table in name order, keys in the table's own (key) order. +for t in $($TMUX list-keys -F '#{key_table}' | LC_ALL=C sort -fu); do + $TMUX list-keys -T $t -F "$F" +done >$TMP.exp +[ -s $TMP.exp ] || exit 1 + +$TMUX list-keys -O name -F "$F" >$TMP.out +cmp -s $TMP.exp $TMP.out || exit 1 + +# Reversed is the exact reverse. +$TMUX list-keys -O name -r -F "$F" | + awk '{ l[NR] = $0 } END { for (i = NR; i > 0; i--) print l[i] }' \ + >$TMP.out +cmp -s $TMP.exp $TMP.out || exit 1 + +exit 0 diff --git a/regress/menu-live-output.sh b/regress/menu-live-output.sh new file mode 100644 index 000000000..3e127ea32 --- /dev/null +++ b/regress/menu-live-output.sh @@ -0,0 +1,164 @@ +#!/bin/sh + +# Inspect an attached client's terminal while panes write behind a menu. +# Incremental output must agree with the scene renderer, including floating +# panes and a client viewport smaller than the window. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" +TMP=$(mktemp -d) + +cleanup() { + $TMUX kill-server 2>/dev/null + $TMUX2 kill-server 2>/dev/null + rm -f "$TMP/output" "$TMP/float" "$TMP/actual" "$TMP/expected" + rmdir "$TMP" +} +trap cleanup 0 1 15 + +fail() { + echo "$*" >&2 + exit 1 +} + +# A full scene redraw is the reference for each incremental update. Check an +# uncovered marker before refreshing, so a frozen pane cannot pass the test. +check_update() { + printf '%b' "$2" >&3 + printf '\033[1;1H\033[2K%s' "$1" >&3 + sleep 1 + $TMUX capturep -p >"$TMP/actual" || exit 1 + grep -q "^$1" "$TMP/actual" || fail "$1: pane stopped updating" + $TMUX capturep -pe >"$TMP/actual" || exit 1 + $TMUX2 refresh-client || exit 1 + sleep 1 + $TMUX capturep -pe >"$TMP/expected" || exit 1 + if ! cmp -s "$TMP/actual" "$TMP/expected"; then + diff -u "$TMP/expected" "$TMP/actual" >&2 + fail "$1: incremental output differs from scene redraw" + fi +} + +menu() { + $TMUX2 display-menu -b simple -T live -C 0 "$@" \ + alpha a "" beta b "" || exit 1 + sleep 1 + $TMUX capturep -p >"$TMP/actual" || exit 1 + grep -q alpha "$TMP/actual" || fail "menu did not appear" +} + +mkfifo "$TMP/output" "$TMP/float" || exit 1 +$TMUX2 new -d -x40 -y14 "exec cat '$TMP/output'" || exit 1 +$TMUX2 set -g status off || exit 1 +$TMUX2 set -g window-size manual || exit 1 +$TMUX2 resizew -x40 -y14 || exit 1 +$TMUX2 set -as terminal-features ',screen:clipboard' || exit 1 +$TMUX2 set -g set-clipboard on || exit 1 +exec 3>"$TMP/output" + +$TMUX new -d -x40 -y14 "$TMUX2 attach" || exit 1 +$TMUX set -g status off || exit 1 +$TMUX set -g window-size manual || exit 1 +$TMUX resizew -x40 -y14 || exit 1 +$TMUX set -g set-clipboard on || exit 1 +sleep 1 + +menu -x6 -y8 +check_update text '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' +check_update clear-line '\033[6;1H\033[2K' +check_update clear-end-line '\033[6;1H\033[K' +check_update clear-start-line '\033[6;40H\033[1K' +check_update erase-character '\033[6;1H\033[40X' +check_update clear-screen '\033[2J' +check_update clear-end-screen '\033[3;1H\033[J' +check_update clear-start-screen '\033[10;40H\033[1J' +check_update scroll '\033[14;1Hone\r\ntwo\r\nthree\r\nfour\r\n' +check_update reverse-scroll '\033[1;1H\033M' +check_update insert-character '\033[6;1Habcdefghijklmnopqrstuvwxyz\033[6;1H\033[3@' +check_update delete-character '\033[6;1Habcdefghijklmnopqrstuvwxyz\033[6;1H\033[3P' +check_update insert-line '\033[5;1H\033[2L' +check_update delete-line '\033[5;1H\033[2M' +check_update wide-text '\033[6;6H\347\225\214\347\225\214\347\225\214' +check_update overwrite-wide '\033[6;5H\347\225\214\033[6;6Hx' +check_update insert-mode '\033[6;1H\033[4habc\033[4l' +check_update sync '\033[?2026h\033[6;1Habcdefghijklmnopqrstuvwxyz\033[?2026l' +check_update sync-scroll '\033[?2026h\033[14;1Hone\r\ntwo\r\nthree\r\n\033[?2026l' + +# Text on the right of the menu crosses the client viewport edge. A terminal +# fallback which redraws the entire pane line would overwrite the menu. +$TMUX2 resizew -x60 -y14 || exit 1 +sleep 1 +check_update viewport '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwx' +$TMUX2 resizew -x40 -y14 || exit 1 + +# Window coordinates must still be used when the status line is above it. +$TMUX2 set -g status on || exit 1 +$TMUX2 set -g status-position top || exit 1 +$TMUX2 set -g status-format[0] '' || exit 1 +$TMUX2 resizew -y13 || exit 1 +sleep 1 +check_update top-status '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' +$TMUX2 set -g status off || exit 1 +$TMUX2 resizew -y14 || exit 1 + +# Both the menu and floating panes obscure the base pane. The menu also +# obscures incremental output from the floating pane itself. +FLOAT=$($TMUX2 new-pane -dPF '#{pane_id}' -x24 -y8 -X4 -Y2 \ + "exec cat '$TMP/float'") || exit 1 +exec 4>"$TMP/float" +sleep 1 +check_update behind-float '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' +printf '\033[4;1HABCDEFGHIJKLMNOPQRSTUVWX' >&4 +check_update float-text '' +printf '\033[4;1H\033[4hxyz\033[4l' >&4 +check_update float-insert '' +printf '\033[8;1Hone\r\ntwo\r\nthree\r\n' >&4 +check_update float-scroll '' +$TMUX2 kill-pane -t "$FLOAT" || exit 1 + +# Without a menu, the same whole-line and insert-mode paths must continue to +# respect floating panes. This also exercises split visible ranges. +$TMUX send Escape || exit 1 +FLOAT=$($TMUX2 new-pane -dPF '#{pane_id}' -x12 -y6 -X6 -Y3 \ + 'exec sleep 100') || exit 1 +$TMUX2 resizew -x60 -y14 || exit 1 +sleep 1 +check_update float-viewport '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwx' +check_update float-insert-mode '\033[6;1H\033[4habc\033[4l' +$TMUX2 kill-pane -t "$FLOAT" || exit 1 +$TMUX2 resizew -x40 -y14 || exit 1 + +# A menu covering an entire line leaves an empty visible range. Borderless +# menus and menus larger than the window must use their actual screen size. +menu -b none -x0 -y8 +check_update borderless '\033[7;1Habcdefghijklmnopqrstuvwxyz0123456789' +$TMUX send Escape || exit 1 +$TMUX2 display-menu -b simple -T live -C 0 -x0 -y8 \ + 'This menu is wider than the whole window' a '' beta b '' || exit 1 +sleep 1 +check_update full-width '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' + +# Nonvisual output is still delivered while a menu is open. +printf '\033]52;c;bWVudS1jbGlwYm9hcmQ=\007' >&3 +sleep 1 +[ "$($TMUX show-buffer 2>/dev/null)" = menu-clipboard ] || + fail "menu blocked the clipboard update" + +# The pane grid must keep updating under the menu, both for capture-pane and +# for the redraw when the menu closes. +printf '\033[2J\033[6;7Hlatest content' >&3 +sleep 1 +$TMUX2 capturep -p -t%0 >"$TMP/actual" || exit 1 +grep -q 'latest content' "$TMP/actual" || fail "underlying pane is stale" +$TMUX send Escape || exit 1 +sleep 1 +$TMUX capturep -p >"$TMP/actual" || exit 1 +grep -q 'latest content' "$TMP/actual" || fail "menu close left stale content" + +exit 0 diff --git a/regress/modal-pane.sh b/regress/modal-pane.sh index b4217aa0a..16718ddc3 100644 --- a/regress/modal-pane.sh +++ b/regress/modal-pane.sh @@ -337,9 +337,9 @@ $TMUX set -g @modal-prefix no $TMUX set -g @modal-root no $TMUX bind -n z set -g @modal-root yes -modal=$($TMUX new-pane -OKPF '#{pane_id}' -t "$p0" \ +modal=$($TMUX new-pane -ODKPF '#{pane_id}' -t "$p0" \ -x 20 -y 5 -X 20 -Y 10 'cat') || - fail "new-pane -OK failed" + fail "new-pane -ODK failed" sleep 1 $TMUX2 send-keys -t "$OUTER" C-b x z Enter sleep 1 @@ -356,9 +356,151 @@ new_left=$(fmt "$modal" '#{pane_left}') new_top=$(fmt "$modal" '#{pane_top}') [ "$new_left" -gt "$left" ] || [ "$new_top" -gt "$top" ] || fail "key-capturing modal pane did not move" +$TMUX2 send-keys -t "$OUTER" Escape +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +modal=$($TMUX new-pane -ODKPF '#{pane_id}' -t "$p0" \ + -x 20 -y 5 -X 20 -Y 10 'trap "" INT; exec cat') || + fail "new-pane -ODK failed" +sleep 1 +$TMUX2 send-keys -t "$OUTER" C-c +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +# A dead modal does not close on Escape or C-c without -D. +modal=$($TMUX new-pane -OPF '#{pane_id}' -t "$p0" \ + -x 20 -y 5 -X 20 -Y 10 'sleep 1') || + fail "new-pane -O failed" +check_ok set-option -p -t "$modal" remain-on-exit on +sleep 2 +must_equal "$(fmt "$modal" '#{pane_dead}:#{pane_modal_flag}')" 1:1 +$TMUX2 send-keys -t "$OUTER" Escape +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" "$modal" check_ok kill-pane -t "$modal" sleep 1 +# failed-key closes successful panes and retains failed panes until a key. +modal=$($TMUX new-pane -OPF '#{pane_id}' -t "$p0" \ + -x 20 -y 5 -X 20 -Y 10 'sleep 1') || + fail "new-pane -O failed" +check_ok set-option -p -t "$modal" remain-on-exit failed-key +sleep 2 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +modal=$($TMUX new-pane -OPF '#{pane_id}' -t "$p0" \ + -x 20 -y 5 -X 20 -Y 10 'sleep 1; exit 1') || + fail "new-pane -O failed" +check_ok set-option -p -t "$modal" remain-on-exit failed-key +sleep 2 +must_equal "$(fmt "$modal" '#{pane_dead}:#{pane_modal_flag}')" 1:1 +must_equal "$($TMUX show-options -pv -t "$modal" remain-on-exit)" failed-key +$TMUX2 send-keys -t "$OUTER" a +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +$TMUX bind P display-popup -E -t "$p0" -w 20 -h 5 -T popup-title 'cat' +$TMUX2 send-keys -t "$OUTER" C-b P +sleep 1 +modal=$(fmt modal:0 '#{window_modal_pane}') +[ -n "$modal" ] || fail "display-popup did not create a modal pane" +must_equal "$(fmt "$modal" '#{pane_title}')" popup-title +must_equal "$($TMUX show-options -pv -t "$modal" pane-border-status)" top +must_equal "$($TMUX show-options -pv -t "$modal" pane-border-format)" \ + '#{pane_title}' +$TMUX2 send-keys -t "$OUTER" C-b x z Enter +sleep 1 +must_equal "$($TMUX show -gv @modal-prefix)" no +must_equal "$($TMUX show -gv @modal-root)" no +case "$($TMUX capture-pane -pt "$modal")" in +*xz*) ;; +*) fail "keys did not reach display-popup pane" ;; +esac +$TMUX2 send-keys -t "$OUTER" Escape +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" "$modal" +$TMUX2 send-keys -t "$OUTER" C-c +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +# Creating a popup pane must not fire the split-window hook. +check_ok set-hook -t modal after-split-window \ + "set-option -g @popup-after-split yes" +check_ok set-option -g @popup-after-split no +check_ok display-popup -E -t "$p0" true +must_equal "$($TMUX show-option -gv @popup-after-split)" no +check_ok set-hook -u -t modal after-split-window + +# A borderless popup must not create a zero-sized pane in a tiny window. +check_ok new-window -d -t modal: -n popup-small 'cat' +check_ok set-option -w -t modal:popup-small window-size manual +check_ok resize-window -t modal:popup-small -x 1 -y 1 +small=$(fmt modal:popup-small '#{pane_id}') +check_ok bind Z display-popup -B -t "$small" 'cat' +$TMUX2 send-keys -t "$OUTER" C-b Z +sleep 1 +must_equal "$(fmt modal:popup-small '#{window_panes}')" 1 +must_equal "$(fmt modal:popup-small '#{window_modal_pane}')" '' + +$TMUX bind D display-popup -t "$p0" -w 20 -h 5 'printf done' +$TMUX2 send-keys -t "$OUTER" C-b D +sleep 2 +modal=$(fmt modal:0 '#{window_modal_pane}') +[ -n "$modal" ] || fail "retained display-popup was not created" +must_equal "$(fmt "$modal" '#{pane_dead}')" 1 +case "$($TMUX capture-pane -pt "$modal")" in +*'Pane is dead'*) fail "display-popup showed remain-on-exit message" ;; +esac +$TMUX2 send-keys -t "$OUTER" a +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" "$modal" +$TMUX2 send-keys -t "$OUTER" Escape +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +$TMUX bind K display-popup -k -t "$p0" -w 20 -h 5 'printf done' +$TMUX2 send-keys -t "$OUTER" C-b K +sleep 2 +modal=$(fmt modal:0 '#{window_modal_pane}') +[ -n "$modal" ] || fail "display-popup -k was not created" +must_equal "$(fmt "$modal" '#{pane_dead}')" 1 +$TMUX2 send-keys -t "$OUTER" a +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +$TMUX bind F display-popup -EE -t "$p0" -w 20 -h 5 'exit 1' +$TMUX2 send-keys -t "$OUTER" C-b F +sleep 2 +modal=$(fmt modal:0 '#{window_modal_pane}') +[ -n "$modal" ] || fail "failed display-popup -EE did not remain" +must_equal "$(fmt "$modal" '#{pane_dead}')" 1 +$TMUX2 send-keys -t "$OUTER" a +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" "$modal" +$TMUX2 send-keys -t "$OUTER" Escape +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" "$modal" +check_ok kill-pane -t "$modal" +sleep 1 + +check_ok display-popup -EE -t "$p0" true +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +$TMUX bind G display-popup -EE -k -t "$p0" -w 20 -h 5 'exit 1' +$TMUX2 send-keys -t "$OUTER" C-b G +sleep 2 +modal=$(fmt modal:0 '#{window_modal_pane}') +[ -n "$modal" ] || fail "failed display-popup -EE -k did not remain" +must_equal "$(fmt "$modal" '#{pane_dead}')" 1 +must_equal "$($TMUX show-options -pv -t "$modal" remain-on-exit)" failed-key +$TMUX2 send-keys -t "$OUTER" a +sleep 1 +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + +check_ok display-popup -EE -k -t "$p0" true +must_equal "$(fmt modal:0 '#{window_modal_pane}')" '' + # A nonmodal floating pane may remain above zoom, and switching between it and # the zoomed tiled pane must not unzoom the window. check_ok new-window -d -t modal: -n float-over-zoom 'cat' diff --git a/regress/mode-kill.sh b/regress/mode-kill.sh old mode 100755 new mode 100644 diff --git a/regress/mode-tree-menu-position.sh b/regress/mode-tree-menu-position.sh new file mode 100644 index 000000000..f90dbb9fe --- /dev/null +++ b/regress/mode-tree-menu-position.sh @@ -0,0 +1,71 @@ +#!/bin/sh + +# Chooser menus are centred on the mouse using their displayed width. A +# hidden title must not move a borderless menu away from the mouse. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" + +cleanup() +{ + $TMUX kill-server >/dev/null 2>&1 + $TMUX2 kill-server >/dev/null 2>&1 +} +trap cleanup 0 1 15 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +$TMUX new-session -d -s short -x 100 -y 30 'sleep 100' || exit 1 +$TMUX set -g mouse on || exit 1 +$TMUX set -g status off || exit 1 +$TMUX2 new-session -d -x 100 -y 30 "$TMUX attach" || exit 1 +sleep 1 + +for name in short aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + $TMUX rename-session "$name" || exit 1 + for lines in none single padded; do + $TMUX set -g menu-border-lines "$lines" || exit 1 + $TMUX choose-tree -s || exit 1 + sleep 1 + + # Right-button press at window (60, 0), away from either edge. + seq=$(printf '\033[<2;61;1M') + $TMUX2 send-keys -l "$seq" || exit 1 + sleep 1 + + row=2 + column=54 + if [ "$lines" = none ]; then + row=1 + elif [ "$name" != short ]; then + column=31 + fi + text=$($TMUX2 capture-pane -p | awk -v row="$row" \ + -v column="$column" 'NR == row { + # Keep byte offsets equal to columns with non-Unicode awk. + gsub(/│/, "|") + print substr($0, column, 7) + }') + [ "$text" = 'Select ' ] || \ + fail "$lines menu for $name: expected Select at ($column, $row), got '$text'" + + # Close the menu, release the button, then leave the chooser. + $TMUX2 send-keys Escape || exit 1 + seq=$(printf '\033[<2;61;1m') + $TMUX2 send-keys -l "$seq" || exit 1 + $TMUX2 send-keys q || exit 1 + sleep 1 + done +done + +exit 0 diff --git a/regress/mode-tree-scroll.sh b/regress/mode-tree-scroll.sh new file mode 100644 index 000000000..de3d17db5 --- /dev/null +++ b/regress/mode-tree-scroll.sh @@ -0,0 +1,168 @@ +#!/bin/sh + +# Tests that the scroll position of a mode tree list (mode-tree.c) is kept +# when the list is rebuilt, using choose-buffer. Buffer mode rebuilds its list +# every time the status line is redrawn, which used to move the view so the +# selected line was on the bottom row (or the list jumped back to the top) +# even if the selected line was already visible. Also checks that when the +# list shrinks the view does not leave empty lines at the bottom. +# +# The list is drawn on a mode screen which capture-pane does not show, so - as +# in choose-buffer.sh - a second server provides a client: an inner "tmux +# attach" runs inside a pane of the second server, and that pane is captured +# to read what the inner client rendered. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMP=$(mktemp -d) || exit 1 +TMUX_TMPDIR="$TMP" +export TMUX_TMPDIR +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" + +cleanup() +{ + $TMUX kill-server 2>/dev/null + $TMUX2 kill-server 2>/dev/null + rm -rf "$TMP" +} +trap cleanup EXIT + +fail() +{ + echo "$1" >&2 + exit 1 +} + +# capture the screen rendered by the inner client +capture() +{ + $TMUX2 capture-pane -p -t out:0 2>/dev/null +} + +# wait_for $marker +# +# Wait (up to ~10s) until the rendered screen contains $marker. The matching +# capture is left in CAPTURED. +wait_for() +{ + i=0 + while [ "$i" -lt 50 ]; do + CAPTURED=$(capture) + if printf '%s\n' "$CAPTURED" | grep -F -q "$1"; then + return 0 + fi + sleep 0.2 + i=$((i + 1)) + done + fail "timed out waiting for '$1'" +} + +# wait_gone $marker +# +# Wait (up to ~10s) until the rendered screen no longer contains $marker. The +# matching capture is left in CAPTURED. +wait_gone() +{ + i=0 + while [ "$i" -lt 50 ]; do + CAPTURED=$(capture) + if ! printf '%s\n' "$CAPTURED" | grep -F -q "$1"; then + return 0 + fi + sleep 0.2 + i=$((i + 1)) + done + fail "timed out waiting for '$1' to disappear" +} + +# wait_clients $n +# +# Wait (up to ~10s) until the test server has exactly $n clients attached. +wait_clients() +{ + i=0 + while [ "$i" -lt 10 ]; do + c=$($TMUX list-clients -F x 2>/dev/null | grep -c x) + [ "$c" -eq "$1" ] && return 0 + sleep 1 + i=$((i + 1)) + done + return 1 +} + +# first_line +# +# Print the name of the first buffer shown in CAPTURED. +first_line() +{ + printf '%s\n' "$CAPTURED" | grep -F ': S1' | head -1 | \ + sed 's/.*\(buf[0-9a-z]*\): S1.*/\1/' +} + +$TMUX new-session -d -s aaa -x 80 -y 24 'cat' || exit 1 + +$TMUX2 new-session -d -s out -x 80 -y 24 "$TMUX attach -t aaa" || exit 1 +wait_clients 1 || fail "no client attached to test server" + +# Forty buffers, more than fit on the screen. +n=0 +while [ "$n" -lt 40 ]; do + $TMUX set-buffer -b "$(printf 'buf%02d' "$n")" "buffer $n" || exit 1 + n=$((n + 1)) +done + +# Without a preview the list has the whole pane (23 lines with the status +# line), so buf00 to buf22 are visible. +$TMUX choose-buffer -t aaa:0 -N -O name -F 'S1' || exit 1 +wait_for 'buf00: S1' + +# --- scrolling down moves the view ------------------------------------------- +# +# Selecting buf30 scrolls so it is on the bottom row: buf08 is at the top. +$TMUX send-keys -t aaa:0 -N 30 Down || exit 1 +wait_for 'buf30: S1' +top=$(first_line) +[ "$top" = "buf08" ] || fail "top line after scrolling is $top, not buf08" + +# Moving up to buf20 stays inside the visible lines so the view does not move. +$TMUX send-keys -t aaa:0 -N 10 Up || exit 1 + +# --- a rebuild keeps the view where it is ------------------------------------ +# +# Add a buffer that sorts into the visible part of the list and redraw the +# status line so the list is rebuilt. Once buf15a appears the rebuild has +# happened; buf08 must still be the top line. It used to jump back to buf00. +$TMUX set-buffer -b buf15a "new buffer" || exit 1 +$TMUX refresh-client -S || exit 1 +wait_for 'buf15a: S1' +top=$(first_line) +[ "$top" = "buf08" ] || fail "top line after rebuild is $top, not buf08" + +# --- a shrinking list does not leave empty lines ----------------------------- +# +# Go to the end of the list (buf39 is on the bottom row) and delete all but +# the last ten buffers. They now all fit, so the view goes to the top. +$TMUX send-keys -t aaa:0 End || exit 1 +wait_for 'buf39: S1' +for b in $($TMUX list-buffers -F '#{buffer_name}'); do + case "$b" in + buf3?) + ;; + *) + $TMUX delete-buffer -b "$b" || exit 1 + ;; + esac +done +$TMUX refresh-client -S || exit 1 +wait_gone 'buf29: S1' +top=$(first_line) +[ "$top" = "buf30" ] || fail "top line after shrinking is $top, not buf30" +[ "$(printf '%s\n' "$CAPTURED" | grep -F -c ': S1')" -eq 10 ] || \ + fail "expected 10 buffers after shrinking" + +$TMUX send-keys -t aaa:0 q + +exit 0 diff --git a/regress/new-pane-mouse-status.sh b/regress/new-pane-mouse-status.sh new file mode 100644 index 000000000..88fa312f9 --- /dev/null +++ b/regress/new-pane-mouse-status.sh @@ -0,0 +1,118 @@ +#!/bin/sh + +# A Ctrl-drag starting on the status line must not create a floating pane. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -Lmouse-status-inner-$$ -f/dev/null" +TMUX2="$TEST_TMUX -Lmouse-status-outer-$$ -f/dev/null" + +cleanup() +{ + $TMUX2 kill-server >/dev/null 2>&1 + $TMUX kill-server >/dev/null 2>&1 +} +trap cleanup 0 1 15 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +mouse() +{ + seq=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $TMUX2 send-keys -t outer:0.0 -l "$seq" || fail "send mouse failed" + sleep 0.2 +} + +check_drag() +{ + start=$1 + end=$2 + + # First move along the status line, then into the pane. The first + # movement establishes the drag origin without dispatching new-pane. + mouse 16 10 "$start" M + mouse 48 11 "$start" M + mouse 48 16 "$end" M + mouse 48 18 "$end" M + mouse 16 18 "$end" m + + panes=$($TMUX list-panes -F '#{pane_id}') || fail "server exited" + [ "$panes" = "$BASE" ] || fail "status-line drag created a pane: $panes" +} + +$TMUX new-session -d -s inner -x 60 -y 20 'sleep 100' || exit 1 +$TMUX set -g mouse on || exit 1 +$TMUX set -g default-command 'sleep 100' || exit 1 +BASE=$($TMUX list-panes -F '#{pane_id}') || exit 1 +$TMUX2 new-session -d -s outer -x 60 -y 20 'sleep 100' || exit 1 +$TMUX2 set -g status off || exit 1 +$TMUX2 respawn-pane -k -t outer:0.0 "$TMUX attach -t inner" || exit 1 +sleep 1 + +check_drag 20 15 + +$TMUX set -g status 2 || exit 1 +sleep 0.5 +check_drag 20 14 + +$TMUX set -g status-position top || exit 1 +sleep 0.5 +check_drag 2 8 + +$TMUX set -g status on || exit 1 +sleep 0.5 +check_drag 1 7 + +# Window and pane ranges on different status rows still dispatch Status +# bindings; a control range dispatches its Control binding. +$TMUX set -g status 3 || exit 1 +$TMUX set -g 'status-format[0]' '#[range=window|0]WINDOW WINDOW WINDOW#[norange]' || exit 1 +$TMUX set -g 'status-format[1]' "#[range=pane|$BASE]PANE PANE PANE PANE#[norange]" || exit 1 +$TMUX set -g 'status-format[2]' '#[range=control|0]CONTROL CONTROL CONTROL#[norange]' || exit 1 +$TMUX bind -n MouseDown1Status set -gF @status-range '#{mouse_status_range}' || exit 1 +$TMUX bind -n MouseDown1Control0 set -gF @status-range '#{mouse_status_range}' || exit 1 +$TMUX bind -n C-MouseDrag1Status set -g @status-drag status || exit 1 +$TMUX bind -n C-MouseDrag1Control0 set -g @status-drag control || exit 1 +sleep 0.5 + +row=1 +for range in window pane control; do + mouse 0 10 "$row" M + mouse 0 10 "$row" m + got=$($TMUX show -gv @status-range) + [ "$got" = "$range" ] || fail "got range '$got', expected '$range'" + $TMUX set -g @status-drag '' || exit 1 + check_drag "$row" 9 + got=$($TMUX show -gv @status-drag) + want=status + [ "$range" = control ] && want=control + [ "$got" = "$want" ] || fail "got drag '$got', expected '$want'" + row=$((row + 1)) +done + +# A new drag inside the pane must still use the default Pane binding. +$TMUX set -g status on || exit 1 +sleep 0.5 +mouse 16 3 4 M +mouse 48 10 8 M +mouse 16 10 8 m +panes=$($TMUX list-panes -F '#{pane_floating_flag}') || fail "server exited" +[ "$panes" = "$(printf '0\n1')" ] || fail "pane drag did not create a pane" + +# Starting on the status line must not dispatch Empty bindings either. +id=$($TMUX list-panes -F '#{pane_id}' | tail -n 1) +$TMUX kill-pane -t "$id" || exit 1 +$TMUX break-pane -W -s "$BASE" || exit 1 +$TMUX resize-pane -t "$BASE" -x 10 -y 4 || exit 1 +$TMUX move-pane -t "$BASE" -P top-left || exit 1 +sleep 0.5 +check_drag 1 10 + +exit 0 diff --git a/regress/options-values.sh b/regress/options-values.sh index be08cc136..e87b878ff 100644 --- a/regress/options-values.sh +++ b/regress/options-values.sh @@ -86,6 +86,11 @@ check_value "-gv status-keys" "vi" check_fail "unknown value: bogus" set -g status-keys bogus check_value "-gv status-keys" "vi" +# pane-border-lines accepts rounded as a pane border style. +check_ok set -gw pane-border-lines rounded +check_value "-gwv pane-border-lines" "rounded" +check_ok set -gw pane-border-lines single + # --- flag options --------------------------------------------------------- # # focus-events is an on/off flag. Setting with no value toggles it; explicit diff --git a/regress/pane-ops.sh b/regress/pane-ops.sh index c69362cee..25795c026 100644 --- a/regress/pane-ops.sh +++ b/regress/pane-ops.sh @@ -104,6 +104,25 @@ assert_alive() fi } +# Compare layout geometry and pane ordering, ignoring the active pane and +# last-pane history in the JSON layout. Selection is checked separately. +pane_layout() +{ + $TMUX display-message -p -t "$1" '#{window_layout}' | + sed 's/,"a":true//g; s/,"l":[0-9][0-9]*//g' +} + +check_layout() +{ + out=$(pane_layout "$1") + if [ "$out" != "$2" ]; then + echo "Layout for '$1' wrong." + echo "Expected: '$2'" + echo "But got: '$out'" + exit 1 + fi +} + # --------------------------------------------------------------------------- # split-window geometry. @@ -309,12 +328,12 @@ check_ok kill-pane -t "$p6" # Zoom and unzoom preserve the exact tiled layout. Selecting another pane # without -Z unzooms, while -Z transfers zoom to the selected pane. -layout=$($TMUX display-message -p -t P:0 '#{window_layout}') +layout=$(pane_layout P:0) check_ok select-pane -t "$p0" check_ok resize-pane -Z -t "$p0" check_ok select-pane -t "$p2" check_fmt "$p2" '#{window_zoomed_flag}:#{pane_active}' '0:1' -check_fmt P:0 '#{window_layout}' "$layout" +check_layout P:0 "$layout" check_ok select-pane -t "$p0" check_ok resize-pane -Z -t "$p0" @@ -322,7 +341,7 @@ check_ok select-pane -Z -t "$p2" check_fmt "$p2" '#{window_zoomed_flag}:#{pane_zoomed_flag}:#{pane_active}' \ '1:1:1' check_ok resize-pane -Z -t "$p2" -check_fmt P:0 '#{window_layout}' "$layout" +check_layout P:0 "$layout" # Directional selection temporarily restores the full layout to find its # neighbour, then follows the same unzoom or -Z transfer rules. @@ -340,7 +359,7 @@ check_ok select-pane -D -Z -t "$p0" check_fmt "$p2" '#{window_zoomed_flag}:#{pane_zoomed_flag}:#{pane_active}' \ '1:1:1' check_ok resize-pane -Z -t "$p2" -check_fmt P:0 '#{window_layout}' "$layout" +check_layout P:0 "$layout" # The last-pane path has separate zoom handling, both with and without -Z. check_ok select-pane -t "$p0" @@ -357,7 +376,7 @@ check_ok select-pane -l -Z -t P:0 check_fmt "$p0" '#{window_zoomed_flag}:#{pane_zoomed_flag}:#{pane_active}' \ '1:1:1' check_ok resize-pane -Z -t "$p0" -check_fmt P:0 '#{window_layout}' "$layout" +check_layout P:0 "$layout" # Killing either a hidden ordinary pane or the zoom target unzooms. check_ok new-window -d -t P:12 -n zoom-kill 'cat' diff --git a/regress/prompt-keys.sh b/regress/prompt-keys.sh index 6fde51b7b..508787b6c 100644 --- a/regress/prompt-keys.sh +++ b/regress/prompt-keys.sh @@ -217,7 +217,36 @@ $OUT send-keys M-r || exit 1 settle status_line | grep -q '>' || fail "status-line prompt not drawn on the status line" -# --- 11. emacs cursor-marker edit, accept recovers the exact buffer. --- +# --- 11. Invalid UTF-8 input must not desynchronize the prompt buffer. --- +$OUT send-keys -H e6 85 5f || exit 1 # invalid UTF-8, then "_" +settle +$IN display-message -p '#{version}' >/dev/null 2>&1 || \ + fail "invalid UTF-8 append killed inner tmux" +status_line | grep -qF "> _" || \ + fail "invalid UTF-8 append did not keep prompt usable (got '$(status_line)')" +$OUT send-keys Enter || exit 1 +settle +[ "$($IN show -gv @r)" = "_" ] || \ + fail "invalid UTF-8 append recovered '$($IN show -gv @r)', wanted '_'" + +$IN set -g @r "SENTINEL" || exit 1 +$OUT send-keys M-r || exit 1 +settle +$OUT send-keys -H e6 85 04 || exit 1 # invalid UTF-8, then C-d +settle +$IN display-message -p '#{version}' >/dev/null 2>&1 || \ + fail "invalid UTF-8 delete killed inner tmux" +status_line | grep -q '>' || \ + fail "invalid UTF-8 delete closed the prompt (got '$(status_line)')" +$OUT send-keys Escape || exit 1 +settle +[ "$($IN show -gv @r)" = "SENTINEL" ] || \ + fail "invalid UTF-8 delete accepted the prompt" + +# --- 12. emacs cursor-marker edit, accept recovers the exact buffer. --- +$IN set -g @r "" || exit 1 +$OUT send-keys M-r || exit 1 +settle $OUT send-keys -l "abc" || exit 1 $OUT send-keys Home || exit 1 $OUT send-keys -l "X" || exit 1 @@ -229,7 +258,7 @@ settle [ "$($IN show -gv @r)" = "Xabc" ] || \ fail "status-line accept recovered '$($IN show -gv @r)', wanted 'Xabc'" -# --- 12. Unicode on the status line: insert, move, delete wide char. --- +# --- 13. Unicode on the status line: insert, move, delete wide char. --- $IN set -g @r "" || exit 1 $OUT send-keys M-r || exit 1 settle @@ -249,7 +278,7 @@ settle [ "$($IN show -gv @r)" = "Za" ] || \ fail "status-line wide edit recovered '$($IN show -gv @r)', wanted 'Za'" -# --- 13. Overflow: more text than fits stays within the line and is kept. --- +# --- 14. Overflow: more text than fits stays within the line and is kept. --- big="0123456789012345678901234567890123456789012345678901234567890123456789ABCDEFGHIJ" $IN set -g @r "" || exit 1 $OUT send-keys M-r || exit 1 @@ -264,7 +293,7 @@ settle # The whole buffer was kept despite only part being visible. [ "$($IN show -gv @r)" = "$big" ] || fail "overflowing prompt lost buffer content" -# --- 14. Escape closes the status-line prompt cleanly. --- +# --- 15. Escape closes the status-line prompt cleanly. --- $IN set -g @r "SENTINEL" || exit 1 $OUT send-keys M-r || exit 1 settle diff --git a/regress/prompt-words-history.sh b/regress/prompt-words-history.sh new file mode 100644 index 000000000..e3a6882dd --- /dev/null +++ b/regress/prompt-words-history.sh @@ -0,0 +1,173 @@ +#!/bin/sh + +# Cover prompt word movement in emacs and vi modes, ambiguous inline command +# completion, and the show/clear-prompt-history command surface. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_result() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER show-option -gqv @result 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "prompt result is '$got', expected '$want'" +} + +bind_prompt() +{ + initial=$1 + $INNER bind-key -n M-r command-prompt -I "$initial" -p '(word)' \ + "set-option -g @result '%%'" || exit 1 +} + +run_prompt() +{ + want=$1 + shift + $INNER set-option -g @result sentinel || exit 1 + $OUTER send-keys M-r || exit 1 + sleep 0.2 + $OUTER send-keys "$@" || exit 1 + $OUTER send-keys Enter || exit 1 + wait_result "$want" +} + +run_vi_prompt() +{ + want=$1 + shift + $INNER set-option -g @result sentinel || exit 1 + $OUTER send-keys M-r || exit 1 + sleep 0.2 + # Send Escape separately so the terminal's escape-time handling does not + # combine it with the first vi command as a Meta key. + $OUTER send-keys Escape || exit 1 + # The inner client must see Escape as a complete key, not the prefix of a + # Meta sequence (the default escape-time is 500 milliseconds). + sleep 0.7 + $OUTER send-keys "$@" || exit 1 + $OUTER send-keys Enter || exit 1 + wait_result "$want" +} + +$INNER new-session -d -s prompt -x80 -y24 'exec sleep 100' || exit 1 +$INNER set-option -g status on || exit 1 +$INNER set-option -g status-position bottom || exit 1 +$INNER set-option -g window-size manual || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t prompt" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +# Emacs Meta-f stops after the first word; Meta-b returns to the start of the +# previous word. Inserting a marker makes the cursor position observable. +$INNER set-option -g status-keys emacs || exit 1 +bind_prompt 'one two' +run_prompt 'oneX two' Home M-f X +bind_prompt 'one two' +run_prompt 'one Xtwo' M-b X + +# Vi translation and the distinct separator-aware and WORD motions. +$INNER set-option -g status-keys vi || exit 1 +bind_prompt 'one-two three' +run_vi_prompt 'one-two Xthree' b i X +bind_prompt 'one-two three' +run_vi_prompt 'one-two Xthree' B i X +bind_prompt 'one-two three' +run_vi_prompt 'oneX-two three' 0 w i X +bind_prompt 'one-two three' +run_vi_prompt 'one-two Xthree' 0 W i X +bind_prompt 'one-two three' +run_vi_prompt 'oneX-two three' 0 e a X +bind_prompt 'one-two three' +run_vi_prompt 'one-twoX three' 0 E a X + +# "show-" has several command matches and no longer common prefix. Tab keeps +# the input and draws the sorted candidates inline. +$INNER set-option -g status-keys emacs || exit 1 +bind_prompt '' +$OUTER send-keys M-r || exit 1 +sleep 0.2 +$OUTER send-keys -l 'show-' || exit 1 +$OUTER send-keys Tab || exit 1 +sleep 0.2 +captured=$(capture) +printf '%s\n' "$captured" | grep -Fq 'show-buffer' || + fail "ambiguous completion list was not drawn" +printf '%s\n' "$captured" | grep -Fq 'show-environment' || + fail "ambiguous completion list was incomplete" +$OUTER send-keys Escape || exit 1 +# Wait until Escape has closed the prompt before sending another Meta key. +# Otherwise the two can be parsed together as a single escape sequence. +i=0 +while capture | grep -Fq '(word)'; do + [ "$i" -lt 50 ] || fail "completion prompt did not close" + sleep 0.1 + i=$((i + 1)) +done + +# Add entries to both history rings through real prompts. +bind_prompt 'history-command' +run_prompt 'history-command' +$INNER bind-key -n M-s command-prompt -T search -I history-search \ + -p '(search-history)' "set-option -g @result '%%'" || exit 1 +$OUTER send-keys M-s || exit 1 +$OUTER send-keys Enter || exit 1 +wait_result history-search + +command_history=$($INNER show-prompt-history -T command) || exit 1 +printf '%s\n' "$command_history" | grep -Fq 'history-command' || + fail "command history entry missing" +search_history=$($INNER show-prompt-history -T search) || exit 1 +printf '%s\n' "$search_history" | grep -Fq 'history-search' || + fail "search history entry missing" +all_history=$($INNER show-prompt-history) || exit 1 +printf '%s\n' "$all_history" | grep -Fq 'History for command:' || exit 1 +printf '%s\n' "$all_history" | grep -Fq 'History for search:' || exit 1 + +$INNER show-prompt-history -T invalid >/dev/null 2>&1 && + fail "invalid show history type succeeded" +$INNER clear-prompt-history -T invalid >/dev/null 2>&1 && + fail "invalid clear history type succeeded" +$INNER clear-prompt-history -T command || exit 1 +$INNER show-prompt-history -T command | grep -Fq history-command && + fail "type-specific history clear failed" +$INNER clear-prompt-history || exit 1 +$INNER show-prompt-history | grep -Fq history-search && + fail "all-history clear failed" + +exit 0 diff --git a/regress/redraw-damage-accumulate.sh b/regress/redraw-damage-accumulate.sh new file mode 100644 index 000000000..3577cf603 --- /dev/null +++ b/regress/redraw-damage-accumulate.sh @@ -0,0 +1,113 @@ +#!/bin/sh + +# Many wrapped rows crossing a panned viewport produce disjoint damage. +# Every row must survive the rectangle-count limit and subsequent merging. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Laccumulate-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Laccumulate-outer-$$ -f/dev/null" +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 +fail() +{ + echo "$*" >&2 + exit 1 +} +wait_marker() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$DIR/capture" || exit 1 + grep -q "$1" "$DIR/capture" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client did not receive $1" +} +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; +$| = 1; +for my $row (1 .. 80) { + printf "\e[%d;1HROW%02d-%s", $row, $row, 'abcdefghij' x 6; +} +print "\e[1;1H"; +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} +my $output = ''; +# Each wrapped second row needs a separate damage rectangle. Keep the +# batch below the PTY read size so all 18 arrive in the same input pass. +for my $region (0 .. 17) { + my $top = 1 + $region * 4; + $output .= "\e[$top;1H" . ('A' x 80) . ('B' x 24) . + sprintf('DAMAGE%02d', $region) . ('B' x 48); +} +$output .= "\e[80;21HDONE"; +syswrite STDOUT, $output; +while (!-e "$ENV{TRIGGER}-merge") { + select undef, undef, undef, 0.01; +} +# Returning from the alternate screen requests a full-pane rectangle. A +# later wrapped-row rectangle overlaps it and must not shrink that damage. +$output = "\e[?1049h\e[40;21HALT-VISIBLE\e[?1049l"; +$output .= "\e[1;1H" . ('C' x 80) . ('D' x 24) . 'MERGE00' . ('D' x 49); +$output .= "\e[80;21HMERGED"; +syswrite STDOUT, $output; +sleep 100; +PERL +$INNER new-session -d -s inner -x 80 -y 80 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -as terminal-features ',screen:sync' || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$OUTER new-session -d -s outer -x 40 -y 80 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 "$INNER attach -t inner" || exit 1 +wait_marker ROW80 +CLIENT=$($OUTER display -p -t outer:0.0 '#{pane_tty}') || exit 1 +$INNER refresh-client -t "$CLIENT" -R 20 || exit 1 +# Keep the emitter inactive so its writes use a synchronized frame. Otherwise +# its own queued output can force a full redraw and hide lost rectangles. +$INNER new-pane -x 6 -y 3 -X 65 -Y 74 'sleep 100' || exit 1 +sleep 0.2 +: >"$DIR/trigger" +wait_marker DONE +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/before" || exit 1 +region=0 +while [ "$region" -lt 18 ]; do + marker=$(printf 'DAMAGE%02d' "$region") + sed -n "$((2 + region * 4))p" "$DIR/before" | grep -q "$marker" || + fail "missing damage for region $region" + region=$((region + 1)) +done +$INNER refresh-client -t "$CLIENT" || exit 1 +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/after" || exit 1 +diff -u "$DIR/before" "$DIR/after" || fail "accumulated redraw differs from full redraw" +: >"$DIR/trigger-merge" +wait_marker MERGED +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/before" || exit 1 +sed -n '2p' "$DIR/before" | grep -q MERGE00 || fail "merged damage lost the wrapped row" +$INNER refresh-client -t "$CLIENT" || exit 1 +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/after" || exit 1 +diff -u "$DIR/before" "$DIR/after" || fail "merged redraw differs from full redraw" +exit 0 diff --git a/regress/redraw-damage-blocked-client.sh b/regress/redraw-damage-blocked-client.sh new file mode 100644 index 000000000..e9c15ef95 --- /dev/null +++ b/regress/redraw-damage-blocked-client.sh @@ -0,0 +1,140 @@ +#!/bin/sh + +# One terminal stops reading while another consumes floating-pane damage. +# The slow client must catch up after the window's shared damage is cleared. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lblocked-inner-$$ -f/dev/null" +FAST="$TEST_TMUX -Lblocked-fast-$$ -f/dev/null" +SLOW="$TEST_TMUX -Lblocked-slow-$$ -f/dev/null" +STOPPED= +cleanup() +{ + [ -n "$STOPPED" ] && kill -CONT "$STOPPED" 2>/dev/null + $FAST kill-server 2>/dev/null + $SLOW kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 +fail() +{ + echo "$*" >&2 + exit 1 +} +wait_marker() +{ + terminal=$1 + marker=$2 + i=0 + while [ "$i" -lt 100 ]; do + $terminal capture-pane -p >"$DIR/capture" || exit 1 + grep -q "$marker" "$DIR/capture" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client did not receive $marker" +} +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $FAST send-keys -l "$sequence" || exit 1 + sleep 0.2 +} +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; +$| = 1; +print 'READY'; +while (!-e "$ENV{TRIGGER}-flood") { + select undef, undef, undef, 0.01; +} +my $frame = 0; +while (!-e "$ENV{TRIGGER}-stop") { + my $output = ''; + for my $row (1 .. 20) { + $output .= "\e[$row;1H" . join('', map { chr(33 + ($_ + $frame) % 80) } 0 .. 77); + } + print $output; + $frame++; + select undef, undef, undef, 0.01; +} +for my $row (1 .. 20) { + printf "\e[%d;1HFINAL%02d-%s", $row, $row, '0123456789' x 7; +} +print "\e[20;1HDONE"; +sleep 100; +PERL +$INNER new-session -d -s inner -x 160 -y 80 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g mouse on || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +FLOAT=$($INNER new-pane -PF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5 \ + 'printf FLOAT; exec sleep 100') || exit 1 +for terminal in "$FAST" "$SLOW"; do + $terminal new-session -d -x 160 -y 80 'sleep 100' || exit 1 + $terminal set -g status off || exit 1 + $terminal set -g window-size manual || exit 1 + $terminal set -g default-terminal screen || exit 1 + $terminal respawn-pane -k "$INNER attach -t inner" || exit 1 + wait_marker "$terminal" READY +done +FASTCLIENT=$($FAST display -p '#{pane_tty}') || exit 1 +SLOWCLIENT=$($SLOW display -p '#{pane_tty}') || exit 1 +SLOWPID=$($SLOW display -p '#{pid}') || exit 1 +before=$($INNER display -p -c "$FASTCLIENT" '#{client_written}') || exit 1 +slowbefore=$($INNER display -p -c "$SLOWCLIENT" '#{client_written}') || exit 1 +# The large terminal keeps this backlog below the discard threshold, so +# automatic recovery from discarded output cannot mask lost damage. +# Stop only our outer server, leaving its inner client attached to a PTY +# whose master is no longer read. This creates real terminal backpressure. +STOPPED=$SLOWPID +kill -STOP "$SLOWPID" || exit 1 +: >"$DIR/trigger-flood" +i=0 +while :; do + written=$($INNER display -p -c "$SLOWCLIENT" '#{client_written}') || exit 1 + fastwritten=$($INNER display -p -c "$FASTCLIENT" '#{client_written}') || exit 1 + # More than a PTY can buffer has been queued for the stopped terminal, + # while the other terminal is still receiving the same output. + [ "$written" -gt "$((slowbefore + 65536))" ] && + [ "$fastwritten" -gt "$((before + 65536))" ] && break + [ "$i" -lt 100 ] || fail "slow client did not become blocked" + sleep 0.1 + i=$((i + 1)) +done +: >"$DIR/trigger-stop" +wait_marker "$FAST" DONE +[ "$($INNER display -p -c "$FASTCLIENT" '#{client_discarded}')" -eq 0 ] || + fail "fast client also became blocked" +mouse 0 12 6 M +mouse 32 42 6 M +mouse 0 42 6 m +[ "$($INNER display -p -t "$FLOAT" '#{pane_left}')" -eq 36 ] || fail "pane did not move" +$FAST capture-pane -p >"$DIR/fast-before" || exit 1 +# Ensure the fast client already restored the vacated frame. +sed -n '6p' "$DIR/fast-before" | grep -q '^FINAL06-0123456789' || + fail "fast client did not restore old footprint" +kill -CONT "$SLOWPID" || exit 1 +STOPPED= +wait_marker "$SLOW" DONE +# Allow queued terminal output and deferred redraws to drain. +sleep 0.5 +[ "$($INNER display -p -c "$SLOWCLIENT" '#{client_discarded}')" -eq 0 ] || + fail "discard recovery could mask lost deferred damage" +$SLOW capture-pane -p >"$DIR/slow-before" || exit 1 +diff -u "$DIR/fast-before" "$DIR/slow-before" || fail "slow client did not catch up" +$INNER refresh-client -t "$FASTCLIENT" || exit 1 +sleep 0.2 +$FAST capture-pane -p >"$DIR/after" || exit 1 +diff -u "$DIR/fast-before" "$DIR/after" || fail "damage redraw differed from full redraw" +exit 0 diff --git a/regress/redraw-damage-only.sh b/regress/redraw-damage-only.sh new file mode 100755 index 000000000..a01824019 --- /dev/null +++ b/regress/redraw-damage-only.sh @@ -0,0 +1,112 @@ +#!/bin/sh + +# Check a redraw callback which has no accompanying client redraw flags. A +# wrapped row crossing a panned viewport cannot use the direct tty path. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Ldamage-only-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Ldamage-only-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +TRIGGER=$DIR/trigger +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_inner_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t inner:0.0 2>/dev/null | + grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane did not contain $marker" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $row (1 .. 12) { + print "\e[$row;1H", 'o' x 79; +} +print "\e[1;1H"; +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} + +my $second = ('B' x 24) . 'DAMAGE-ONLY' . ('B' x 45); +print "\e[5;1H", ('A' x 80), $second; +sleep 100; +PERL + +$INNER new-session -d -s inner -x 80 -y 12 \ + "TRIGGER='$TRIGGER' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 12 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Ldamage-only-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +$INNER refresh-client -t "$CLIENT" -R 20 || exit 1 +wait_outer_has oooooooooo + +: >"$TRIGGER" +wait_inner_has DAMAGE-ONLY +wait_outer_has DAMAGE-ONLY + +exit 0 diff --git a/regress/redraw-damage-viewports.sh b/regress/redraw-damage-viewports.sh new file mode 100644 index 000000000..2cfe1cffc --- /dev/null +++ b/regress/redraw-damage-viewports.sh @@ -0,0 +1,112 @@ +#!/bin/sh + +# Moving and interactively resizing floating panes must restore both clients, +# including a smaller client panned horizontally and vertically. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lviewports-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lviewports-outer-$$ -f/dev/null" + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 +fail() +{ + echo "$*" >&2 + exit 1 +} +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t wide:0.0 -l "$sequence" || exit 1 + sleep 0.2 +} +assert_scene() +{ + # Compare what each terminal actually received with a fresh full redraw. + # Capture both first: refreshing one client must not repair the other. + for target in wide small; do + $OUTER capture-pane -p -t "$target:0.0" >"$DIR/$target-before" || exit 1 + done + $INNER refresh-client -t "$WIDE" || exit 1 + $INNER refresh-client -t "$SMALL" || exit 1 + sleep 0.2 + for target in wide small; do + $OUTER capture-pane -p -t "$target:0.0" >"$DIR/$target-after" || exit 1 + diff -u "$DIR/$target-before" "$DIR/$target-after" || + fail "$1: $target client differed from a full redraw" + done +} + +cat >"$DIR/background.pl" <<'PERL' +$| = 1; +for my $row (1 .. 24) { + printf "\e[%d;1HROW%02d-", $row, $row; + print '0123456789' x 7; +} +sleep 100; +PERL +$INNER new-session -d -s inner -x 80 -y 24 "perl '$DIR/background.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g mouse on || exit 1 +$INNER set -g default-command 'sleep 100' || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$OUTER new-session -d -s wide -x 80 -y 24 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +$OUTER new-session -d -s small -x 40 -y 12 'sleep 100' || exit 1 +for target in wide small; do + $OUTER respawn-pane -k -t "$target:0.0" "$INNER attach -t inner" || exit 1 +done +i=0 +while [ "$($INNER list-clients | wc -l)" -ne 2 ]; do + [ "$i" -lt 50 ] || fail "two clients did not attach" + sleep 0.1 + i=$((i + 1)) +done +WIDE=$($OUTER display -p -t wide:0.0 '#{pane_tty}') || exit 1 +SMALL=$($OUTER display -p -t small:0.0 '#{pane_tty}') || exit 1 +$INNER refresh-client -t "$SMALL" -R 20 || exit 1 +$INNER refresh-client -t "$SMALL" -D 6 || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 16 -y 6 -X 25 -Y 9) || exit 1 +sleep 0.3 +assert_scene initial +# Confirm that the two clients really have different viewports. +[ "$(head -1 "$DIR/wide-before" | cut -c1-6)" = ROW01- ] || fail "wrong wide viewport" +[ "$(head -1 "$DIR/small-before" | cut -c1-6)" != ROW01- ] || fail "small client was not panned" + +# Meta-drag the body using move-pane -M, including a partially clipped position. +mouse 8 30 12 M +mouse 40 48 15 M +[ "$($INNER display -p -t "$FLOAT" '#{pane_left}')" -eq 44 ] || fail "Meta-drag did not move pane" +assert_scene move-right +mouse 40 28 10 M +assert_scene move-back +mouse 8 28 10 m + +# Ctrl-drag creates a new floating pane, then changes its size while held. +mouse 16 52 14 M +mouse 48 75 23 M +NEW=$($INNER display -p '#{pane_id}') || exit 1 +[ "$NEW" != "$FLOAT" ] || fail "Ctrl-drag did not create a pane" +[ "$($INNER display -p -t "$NEW" '#{pane_floating_flag}')" -eq 1 ] || fail "new pane is not floating" +assert_scene create +mouse 48 64 19 M +[ "$($INNER display -p -t "$NEW" '#{pane_width}')" -eq 11 ] || fail "Ctrl-drag did not shrink pane" +assert_scene shrink +mouse 16 64 19 m +assert_scene release +exit 0 diff --git a/regress/redraw-multiclient.sh b/regress/redraw-multiclient.sh new file mode 100755 index 000000000..cdcbe01ff --- /dev/null +++ b/regress/redraw-multiclient.sh @@ -0,0 +1,186 @@ +#!/bin/sh + +# Redraw a moved floating pane on both attached clients viewing the same +# window. Window redraw work must not be consumed by only one client. +# +# Uses ASCII pane borders (rather than the default UTF-8 box-drawing) because +# this test nests a real tmux client inside another tmux's pane to get a +# genuine terminal to capture from; that nested-tmux relay has been observed +# to mis-render a cell that previously held a multi-byte UTF-8 border +# character being overwritten later by plain content, on the outer instance's +# own interpretation, independent of anything the inner tmux sends. That is a +# nested-test-harness artifact, not a real tmux bug - confirmed by replaying +# the exact same drag sequence against a real terminal (xterm), where it +# never reproduces. ASCII borders avoid the artifact entirely. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lredraw-multi-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lredraw-multi-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + count=$($INNER list-clients 2>/dev/null | wc -l) + [ "$count" -eq 2 ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "two inner clients did not attach" +} + +wait_outer_has() +{ + target=$1 + marker=$2 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t "$target" >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer pane $target did not show $marker" +} + +wait_float_left() +{ + comparison=$1 + limit=$2 + i=0 + while [ "$i" -lt 50 ]; do + left=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + if [ "$comparison" = gt ] && [ "$left" -gt "$limit" ]; then + return 0 + fi + if [ "$comparison" = lt ] && [ "$left" -lt "$limit" ]; then + return 0 + fi + sleep 0.1 + i=$((i + 1)) + done + fail "floating pane did not move" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$2" "$3" "$4" "$5") + $OUTER send-keys -t "$1" -l "$sequence" || exit 1 + sleep 0.1 +} + +drag_float() +{ + target=$1 + startcol=$2 + startrow=$3 + endcol=$4 + mouse "$target" 0 "$startcol" "$startrow" M + mouse "$target" 32 "$endcol" "$startrow" M + mouse "$target" 0 "$endcol" "$startrow" m +} + +assert_scene() +{ + target=$1 + base=$2 + firstcol=$3 + lastcol=$4 + + $OUTER capture-pane -p -t "$target" >"$CAPTURE" || exit 1 + + # With ASCII (simple) borders every corner and junction is the same + # '+', so one rectangular floating pane always draws exactly 4 of + # them; more means a stale frame was left behind somewhere. + corners=$(grep -o '+' "$CAPTURE" | wc -l) + [ "$corners" -eq 4 ] || + fail "outer pane $target had $corners floating frames" + + sed -n '6,11p' "$base" | cut -c"$firstcol-$lastcol" >"$DIR/want" + sed -n '6,11p' "$CAPTURE" | cut -c"$firstcol-$lastcol" >"$DIR/got" + cmp -s "$DIR/want" "$DIR/got" || + fail "outer pane $target did not restore the old floating area" +} + +C="sh -c 'i=0; while [ \$i -lt 20 ]; do printf \"\\033[%d;1HBG-ROW-%02d-abcdefghijklmnopqrstuvwxyz0123456789\" \$((i + 1)) \$i; i=\$((i + 1)); done; exec sleep 100'" + +$INNER new-session -d -s inner -x 60 -y 20 "$C" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g default-command 'sleep 100' || exit 1 +$INNER set-option -g pane-border-lines simple || exit 1 + +$OUTER new-session -d -s outer -x 121 -y 20 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER split-window -h -t outer:0.0 'sleep 100' || exit 1 + +PANES=$($OUTER list-panes -t outer:0 -F '#{pane_id} #{pane_left}') +LEFT=$(echo "$PANES" | sort -k2 -n | head -1 | cut -d' ' -f1) +RIGHT=$(echo "$PANES" | sort -k2 -n | tail -1 | cut -d' ' -f1) +[ -n "$LEFT" ] && [ -n "$RIGHT" ] || fail "could not find outer panes" + +$OUTER respawn-pane -k -t "$LEFT" \ + "$TEST_TMUX -Lredraw-multi-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 +$OUTER respawn-pane -k -t "$RIGHT" \ + "$TEST_TMUX -Lredraw-multi-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_clients +wait_outer_has "$LEFT" BG-ROW-19 +wait_outer_has "$RIGHT" BG-ROW-19 +$OUTER capture-pane -p -t "$LEFT" >"$DIR/base-left" || exit 1 +$OUTER capture-pane -p -t "$RIGHT" >"$DIR/base-right" || exit 1 + +FLOAT=$($INNER new-pane -dPF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5) || + fail "could not create floating pane" +wait_outer_has "$LEFT" '+' +wait_outer_has "$RIGHT" '+' + +FTOP=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +FLEFT=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +FWIDTH=$($INNER display-message -p -t "$FLOAT" '#{pane_width}') +GRABCOL=$((FLEFT + FWIDTH / 2 + 1)) + +# Move right through one client and require both clients to restore the old +# left-hand footprint. +drag_float "$LEFT" "$GRABCOL" "$FTOP" $((GRABCOL + 30)) +wait_float_left gt 30 +assert_scene "$LEFT" "$DIR/base-left" 1 20 +assert_scene "$RIGHT" "$DIR/base-right" 1 20 + +# Move back through the other client and check the old right-hand footprint. +FLEFT=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +GRABCOL=$((FLEFT + FWIDTH / 2 + 1)) +drag_float "$RIGHT" "$GRABCOL" "$FTOP" $((GRABCOL - 30)) +wait_float_left lt 10 +assert_scene "$LEFT" "$DIR/base-left" 35 60 +assert_scene "$RIGHT" "$DIR/base-right" 35 60 + +exit 0 diff --git a/regress/redraw-screen-write.sh b/regress/redraw-screen-write.sh new file mode 100755 index 000000000..0d007f609 --- /dev/null +++ b/regress/redraw-screen-write.sh @@ -0,0 +1,208 @@ +#!/bin/sh + +# Check that full and region screen-write fallbacks update an attached client, +# not only tmux's internal pane grid. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +EMITTER=$DIR/emitter.pl +CAPTURE=$DIR/capture +INNER= +OUTER= +N=0 + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + [ -n "$OUTER" ] && $OUTER kill-server 2>/dev/null + [ -n "$INNER" ] && $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_outer_lacks() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" || return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client still showed $marker" +} + +wait_inner_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t inner:0.0 2>/dev/null | + grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane did not contain $marker" +} + +wait_inner_lacks() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t inner:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" || return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane still contained $marker" +} + +setup() +{ + mode=$1 + [ -n "$OUTER" ] && $OUTER kill-server 2>/dev/null + [ -n "$INNER" ] && $INNER kill-server 2>/dev/null + N=$((N + 1)) + INNER="$TEST_TMUX -Lredraw-write-inner-$$-$N -f/dev/null" + OUTER="$TEST_TMUX -Lredraw-write-outer-$$-$N -f/dev/null" + + $INNER new-session -d -s inner -x 40 -y 12 \ + "MODE=$mode READY='$DIR/ready-$N' TRIGGER='$DIR/trigger-$N' perl '$EMITTER'" || + exit 1 + $INNER set-option -g status off || exit 1 + $INNER set-option -g window-size manual || exit 1 + + $OUTER new-session -d -s outer -x 40 -y 12 'sleep 100' || exit 1 + $OUTER set-option -g status off || exit 1 + $OUTER set-option -g window-size manual || exit 1 + $OUTER set-option -g default-terminal screen || exit 1 + $OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lredraw-write-inner-$$-$N -f/dev/null attach-session -t inner" || + exit 1 +} + +trigger() +{ + : >"$DIR/trigger-$N-${1:-1}" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +my $mode = $ENV{MODE}; +my $ready = $ENV{READY}; +my $trigger = $ENV{TRIGGER}; + +sub fill_screen { + my ($prefix) = @_; + print "\e[2J\e[H"; + for my $row (0 .. 11) { + printf "\e[%d;1H%s-ROW-%02d", $row + 1, $prefix, $row; + } +} + +if ($mode eq 'ris') { + fill_screen('RIS'); +} elsif ($mode eq 'alternate') { + fill_screen('BASE'); +} elsif ($mode eq 'scroll') { + fill_screen('SCROLL'); +} else { + die "unknown mode $mode\n"; +} + +open my $fh, '>', $ready or die "$ready: $!\n"; +close $fh; +while (!-e "$trigger-1") { + select undef, undef, undef, 0.01; +} + +if ($mode eq 'ris') { + print "\ec"; +} elsif ($mode eq 'alternate') { + print "\e[?1049h"; + fill_screen('ALT'); + while (!-e "$trigger-2") { + select undef, undef, undef, 0.01; + } + print "\e[?1049l"; +} else { + print "\e[12;1H\r\nSCROLL-NEW"; +} +sleep 100; +PERL + +# RIS clears the complete screen. The source pane and attached client must both +# lose every old row. +setup ris +wait_outer_has RIS-ROW-11 +trigger +wait_inner_lacks RIS-ROW +wait_outer_lacks RIS-ROW + +# Leaving the alternate screen restores every row of the base screen. +setup alternate +wait_outer_has BASE-ROW-11 +trigger +wait_inner_has ALT-ROW-11 +wait_outer_has ALT-ROW-11 +wait_outer_lacks BASE-ROW +trigger 2 +wait_inner_has BASE-ROW-11 +wait_outer_has BASE-ROW-11 +wait_outer_lacks ALT-ROW + +# Scrolling a pane which is narrower than the terminal redraws its complete +# region. Check the physical client row by row after the source grid shifts. +setup scroll +$INNER split-window -h -t inner:0 'sleep 100' || exit 1 +wait_outer_has SCROLL-ROW-11 +trigger +wait_inner_has SCROLL-NEW +wait_outer_has SCROLL-NEW +row=1 +while [ "$row" -le 11 ]; do + expected=$(printf 'SCROLL-ROW-%02d' "$row") + actual=$(sed -n "${row}p" "$CAPTURE") + case "$actual" in + "$expected"*) ;; + *) fail "outer row $row was not redrawn as $expected" ;; + esac + row=$((row + 1)) +done +actual=$(sed -n '12p' "$CAPTURE") +case "$actual" in +SCROLL-NEW*) ;; +*) fail "outer bottom row was not redrawn as SCROLL-NEW" ;; +esac + +exit 0 diff --git a/regress/renumber-window-limit.sh b/regress/renumber-window-limit.sh new file mode 100644 index 000000000..a0ca8a828 --- /dev/null +++ b/regress/renumber-window-limit.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +# renumber-windows with more windows than fit above base-index + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestRWLA$$" +$TMUX kill-server 2>/dev/null + +TMP=$(mktemp) +CONF=$(mktemp) +trap "rm -f $TMP $CONF" 0 1 15 + +cat <$CONF +set -g base-index 10 +set -g renumber-windows on +EOF + +$TMUX -f$CONF new -d -s a +$TMUX neww -d -t a +$TMUX neww -d -t a +echo $($TMUX lsw -t a -F'#{window_index}') >$TMP +(echo "10 11 12"|cmp -s - $TMP) || exit 1 +$TMUX killw -t a:11 +echo $($TMUX lsw -t a -F'#{window_index}') >$TMP +(echo "10 11"|cmp -s - $TMP) || exit 1 +$TMUX kill-server 2>/dev/null + +# Use a different socket because the previous server may still be exiting. +TMUX="$TEST_TMUX -LtestRWLB$$" + +cat <$CONF +set -g base-index 2147483646 +set -g renumber-windows on +EOF + +$TMUX -f$CONF new -d -s a +$TMUX neww -d -t a +$TMUX neww -d -t a +$TMUX neww -d -t a +echo $($TMUX lsw -t a -F'#{window_index}') >$TMP +(echo "0 1 2147483646 2147483647"|cmp -s - $TMP) || exit 1 + +# Three windows do not fit from 2147483646, so the indexes stay as they are. +$TMUX killw -t a:2147483647 +echo $($TMUX lsw -t a -F'#{window_index}') >$TMP +(echo "0 1 2147483646"|cmp -s - $TMP) || exit 1 +$TMUX kill-server 2>/dev/null + +exit 0 diff --git a/regress/respawn-pane-control-lag.sh b/regress/respawn-pane-control-lag.sh index b71ce1946..8ca8146ed 100644 --- a/regress/respawn-pane-control-lag.sh +++ b/regress/respawn-pane-control-lag.sh @@ -15,7 +15,7 @@ PATH=/bin:/usr/bin TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -Ltest" +TMUX="$TEST_TMUX -Ltest$$" $TMUX kill-server 2>/dev/null DIR=$(mktemp -d) diff --git a/regress/run-shell-cwd.sh b/regress/run-shell-cwd.sh new file mode 100644 index 000000000..0371a43df --- /dev/null +++ b/regress/run-shell-cwd.sh @@ -0,0 +1,55 @@ +#!/bin/sh + +# run-shell -c should expand formats using the target pane. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +TMP=$(mktemp -d) +trap '$TMUX kill-server 2>/dev/null; rm -rf "$TMP"' 0 1 15 +TMP=$(cd "$TMP" && pwd -P) +mkdir "$TMP/first" "$TMP/second dir" || exit 1 + +check_directory() +{ + actual=$(cat "$TMP/out") + if [ "$actual" != "$1" ]; then + echo "Expected directory '$1', got '$actual'" + exit 1 + fi +} + +# The start path is available immediately, before the child is scheduled. +# Use it to test the format context in the same command queue as creation. +$TMUX new-session -d -s test -c "$TMP/first" 'sleep 60' \; \ + run-shell -c '#{pane_start_path}' "pwd >'$TMP/out'" || exit 1 +check_directory "$TMP/first" + +# An explicit target must supply the format context, including with a delay. +pane=$($TMUX new-window -d -P -F '#{pane_id}' -t test \ + -c "$TMP/second dir" 'sleep 60') || exit 1 +# The current path depends on the operating system finding the child process. +i=0 +while [ "$($TMUX display-message -p -t "$pane" '#{pane_current_path}')" != \ + "$TMP/second dir" ]; do + if [ "$i" -ge 100 ]; then + echo "Timed out waiting for pane current directory" + exit 1 + fi + sleep 0.05 + i=$((i + 1)) +done +$TMUX run-shell -t "$pane" -d 0.1 -c '#{pane_current_path}' \ + "pwd >'$TMP/out'" || exit 1 +check_directory "$TMP/second dir" + +# Literal directories and the default client directory still work. +$TMUX run-shell -c "$TMP/second dir" "pwd >'$TMP/out'" || exit 1 +check_directory "$TMP/second dir" +(cd "$TMP/first" && $TMUX run-shell "pwd >'$TMP/out'") || exit 1 +check_directory "$TMP/first" + +exit 0 diff --git a/regress/screen-redraw-floating.sh b/regress/screen-redraw-floating.sh index a7b389fef..d19232da5 100644 --- a/regress/screen-redraw-floating.sh +++ b/regress/screen-redraw-floating.sh @@ -29,7 +29,25 @@ fail() { exit 1 } +wait_for_panes() { + tries=0 + while :; do + panes=$($TMUX2 list-panes -t"$window" -F '#{pane_dead}') || exit 1 + case "$panes" in + *0*) + ;; + *) + return + ;; + esac + tries=$((tries + 1)) + [ "$tries" -lt 100 ] || fail "timed out waiting for panes to exit" + sleep 0.1 + done +} + compare() { + wait_for_panes sleep 1 $TMUX capturep -p $2 >$TMP || exit 1 if [ -n "$GENERATE" ]; then @@ -43,14 +61,16 @@ compare() { # new_scene : fresh inner window of the given window size. new_scene() { - $TMUX2 neww -d "sh -c 'printf base; exec sleep 100'" || exit 1 - $TMUX2 selectw -t:\$ || exit 1 + window=$($TMUX2 neww -dP -F '#{window_id}' \ + "sh -c 'printf base'") || exit 1 + $TMUX2 selectw -t"$window" || exit 1 + $TMUX2 killw -a -t"$window" || exit 1 $TMUX2 resizew -x$1 -y$2 || exit 1 } # tile_2x2: split the current window into a 2x2 grid of tiled panes. tile_2x2() { - C="sh -c 'exec sleep 100'" + C="" $TMUX2 splitw -h "$C" || exit 1 $TMUX2 splitw -v "$C" || exit 1 $TMUX2 selectp -t0 || exit 1 @@ -60,13 +80,13 @@ tile_2x2() { # fill_base_pattern: make clipped floating panes visibly cover every column. fill_base_pattern() { - $TMUX2 respawnp -k "sh -c 'i=0; while [ \$i -lt 12 ]; do printf \"%02d:abcdefghijklmnopqrstuvwxyz0123456789\n\" \$i; i=\$((i + 1)); done; exec sleep 100'" || exit 1 + $TMUX2 respawnp -k "sh -c 'i=0; while [ \$i -lt 12 ]; do printf \"%02d:abcdefghijklmnopqrstuvwxyz0123456789\n\" \$i; i=\$((i + 1)); done'" || exit 1 } # fill_all_panes: make each pane visibly nonblank. fill_all_panes() { for pane in $($TMUX2 list-panes -F '#{pane_id}'); do - $TMUX2 respawnp -k -t "$pane" "sh -c 'i=0; while [ \$i -lt 5 ]; do printf \"TILE%02d abcdefghij\n\" \$i; i=\$((i + 1)); done; exec sleep 100'" || exit 1 + $TMUX2 respawnp -k -t "$pane" "sh -c 'i=0; while [ \$i -lt 5 ]; do printf \"TILE%02d abcdefghij\n\" \$i; i=\$((i + 1)); done'" || exit 1 done } @@ -74,6 +94,8 @@ $TMUX kill-server 2>/dev/null $TMUX2 kill-server 2>/dev/null $TMUX2 new -d -x40 -y12 "sh -c 'printf base; exec sleep 100'" || exit 1 +$TMUX2 set -g remain-on-exit on || exit 1 +$TMUX2 set -g remain-on-exit-format "" || exit 1 $TMUX2 set -g status off || exit 1 $TMUX2 set -g window-size manual || exit 1 $TMUX2 set -g pane-border-format " #{pane_title} " || exit 1 @@ -88,7 +110,7 @@ sleep 1 # Basic floating pane, well inside the window. new_scene 40 12 -$TMUX2 new-pane -x20 -y6 -X8 -Y3 "sh -c 'printf FLOAT; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X8 -Y3 "sh -c 'printf FLOAT'" || exit 1 compare floating-basic # Floating pane with a title on its border. pane-border-status also draws the @@ -98,65 +120,71 @@ new_scene 40 12 $TMUX2 setw pane-border-status top || exit 1 $TMUX2 selectp -t:.0 -T base || exit 1 $TMUX2 new-pane -x20 -y6 -X8 -Y2 -T title \ - "sh -c 'printf FLOAT; exec sleep 100'" || exit 1 + "sh -c 'printf FLOAT'" || exit 1 compare floating-title # Larger floating pane with double border lines. new_scene 40 12 $TMUX2 new-pane -x28 -y8 -X4 -Y1 -B double \ - "sh -c 'printf FLOAT; exec sleep 100'" || exit 1 + "sh -c 'printf FLOAT'" || exit 1 compare floating-border-double +# Larger floating pane with rounded border lines. +new_scene 40 12 +$TMUX2 new-pane -x28 -y8 -X4 -Y1 -B rounded \ + "sh -c 'printf FLOAT'" || exit 1 +compare floating-border-rounded + # Floating pane with no border lines: redraw_mark_pane_borders returns early so # the float has no border at all, only its (clipped) content over the base pane. new_scene 40 12 $TMUX2 new-pane -x20 -y6 -X10 -Y3 -B none \ - "sh -c 'printf NOBORDER; exec sleep 100'" || exit 1 + "sh -c 'printf NOBORDER'" || exit 1 compare floating-noborder # Floating pane positioned past the right and bottom edges: must clip. new_scene 40 12 -$TMUX2 new-pane -x20 -y6 -X30 -Y8 "sh -c 'printf CLIP; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X30 -Y8 "sh -c 'printf CLIP'" || exit 1 compare floating-clip-edge # Window smaller than the client (outside area filled), with a floating pane # that overlaps the boundary into the outside region. new_scene 28 8 -$TMUX2 new-pane -x16 -y5 -X18 -Y4 "sh -c 'printf OUT; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x16 -y5 -X18 -Y4 "sh -c 'printf OUT'" || exit 1 compare floating-outside # Floating pane whose right border is exactly on the window right edge. The # floating pane is clipped so the tiled pane's right border remains unbroken. new_scene 28 8 -$TMUX2 respawnp -k "sh -c 'i=0; while [ \$i -lt 8 ]; do printf \"%02d:abcdefghijklmnopq\n\" \$i; i=\$((i + 1)); done; exec sleep 100'" || exit 1 -$TMUX2 new-pane -x10 -y5 -X19 -Y2 "sh -c 'printf EDGE; exec sleep 100'" || exit 1 +$TMUX2 respawnp -k "sh -c 'i=0; while [ \$i -lt 8 ]; do printf \"%02d:abcdefghijklmnopq\n\" \$i; i=\$((i + 1)); done'" || exit 1 +$TMUX2 new-pane -x10 -y5 -X19 -Y2 "sh -c 'printf EDGE'" || exit 1 compare floating-clip-window-edge # Floating pane clipped at the top-left corner (negative offsets). new_scene 40 12 -$TMUX2 new-pane -x18 -y6 -X-4 -Y-2 "sh -c 'printf TL; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x18 -y6 -X-4 -Y-2 "sh -c 'printf TL'" || exit 1 compare floating-clip-topleft # Floating panes clipped at each edge. The patterned base pane makes sure the # clipped edge still obscures both content and borders underneath. new_scene 40 12 fill_base_pattern -$TMUX2 new-pane -x20 -y6 -X-10 -Y3 "sh -c 'printf LEFT; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X-10 -Y3 "sh -c 'printf LEFT'" || exit 1 compare floating-clip-left new_scene 40 12 fill_base_pattern -$TMUX2 new-pane -x20 -y6 -X30 -Y3 "sh -c 'printf RIGHT; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X30 -Y3 "sh -c 'printf RIGHT'" || exit 1 compare floating-clip-right new_scene 40 12 fill_base_pattern -$TMUX2 new-pane -x20 -y6 -X10 -Y-3 "sh -c 'printf TOP; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X10 -Y-3 "sh -c 'printf TOP'" || exit 1 compare floating-clip-top new_scene 40 12 fill_base_pattern -$TMUX2 new-pane -x20 -y6 -X10 -Y9 "sh -c 'printf BOTTOM; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X10 -Y9 "sh -c 'printf BOTTOM'" || exit 1 compare floating-clip-bottom # Floating pane over a tiled 2x2 grid: the float draws a complete box and must @@ -164,7 +192,7 @@ compare floating-clip-bottom new_scene 40 12 tile_2x2 fill_all_panes -$TMUX2 new-pane -x16 -y6 -X11 -Y3 "sh -c 'printf FLT; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x16 -y6 -X11 -Y3 "sh -c 'printf FLT'" || exit 1 compare floating-over-tiled # Same, but the float uses double border lines while the tiled panes use single: @@ -173,13 +201,13 @@ new_scene 40 12 tile_2x2 fill_all_panes $TMUX2 new-pane -x16 -y6 -X11 -Y3 -B double \ - "sh -c 'printf FLT; exec sleep 100'" || exit 1 + "sh -c 'printf FLT'" || exit 1 compare floating-over-tiled-double # Two overlapping floating panes: the later (top) float draws over the earlier. new_scene 40 12 -$TMUX2 new-pane -x16 -y6 -X4 -Y2 "sh -c 'printf AAA; exec sleep 100'" || exit 1 -$TMUX2 new-pane -x16 -y6 -X14 -Y6 "sh -c 'printf BBB; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x16 -y6 -X4 -Y2 "sh -c 'printf AAA'" || exit 1 +$TMUX2 new-pane -x16 -y6 -X14 -Y6 "sh -c 'printf BBB'" || exit 1 compare floating-overlap # Two floating panes with different per-pane configuration: one has its status on @@ -188,11 +216,11 @@ compare floating-overlap new_scene 40 12 $TMUX2 setw pane-border-format " #{pane_title} " || exit 1 $TMUX2 new-pane -x16 -y4 -X3 -Y1 -T one \ - "sh -c 'printf ONE; exec sleep 100'" || exit 1 + "sh -c 'printf ONE'" || exit 1 $TMUX2 set -p pane-border-status top || exit 1 $TMUX2 set -p pane-border-lines single || exit 1 $TMUX2 new-pane -x16 -y4 -X18 -Y6 -T two \ - "sh -c 'printf TWO; exec sleep 100'" || exit 1 + "sh -c 'printf TWO'" || exit 1 $TMUX2 set -p pane-border-status bottom || exit 1 $TMUX2 set -p pane-border-lines heavy || exit 1 compare floating-mixed-config @@ -202,7 +230,7 @@ compare floating-mixed-config new_scene 40 12 $TMUX2 setw pane-scrollbars on || exit 1 $TMUX2 setw pane-scrollbars-style "bg=black,fg=white,width=1,pad=0" || exit 1 -$TMUX2 new-pane -x16 -y6 -X22 -Y3 "sh -c 'printf OVERSB; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x16 -y6 -X22 -Y3 "sh -c 'printf OVERSB'" || exit 1 compare floating-over-scrollbar -e # Floating pane overlapping the client status line: the status line is not part @@ -211,7 +239,7 @@ new_scene 40 11 $TMUX2 set status on || exit 1 $TMUX2 set status-position bottom || exit 1 $TMUX2 set status-format[0] "" || exit 1 -$TMUX2 new-pane -x20 -y6 -X8 -Y7 "sh -c 'printf OVERST; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X8 -Y7 "sh -c 'printf OVERST'" || exit 1 compare floating-over-status # A window left with only a floating pane: killing the single tiled pane removes @@ -219,7 +247,7 @@ compare floating-over-status # Use a visible fill character for the EMPTY cells around the float. $TMUX2 set -g fill-character '#[acs]~' || exit 1 new_scene 40 12 -$TMUX2 new-pane -x20 -y6 -X8 -Y3 "sh -c 'printf FLOAT; exec sleep 100'" || exit 1 +$TMUX2 new-pane -x20 -y6 -X8 -Y3 "sh -c 'printf FLOAT'" || exit 1 tiled=$($TMUX2 list-panes -F '#{pane_floating_flag} #{pane_id}' | \ awk '$1==0{print $2; exit}') || exit 1 $TMUX2 kill-pane -t "$tiled" || exit 1 diff --git a/regress/screen-redraw-menus.sh b/regress/screen-redraw-menus.sh index 1bd3bc99b..823b03c88 100644 --- a/regress/screen-redraw-menus.sh +++ b/regress/screen-redraw-menus.sh @@ -106,9 +106,28 @@ compare menu-over-split # Menu with no border lines. setup 40 14 -menu -b none -x6 -y8 +$TMUX2 display-menu -T "This title must not reserve width" -C 1 \ + -b none -x6 -y8 \ + "Alpha item" a "" \ + "Beta item" b "" \ + "" "" "" \ + "Gamma item" g "" || exit 1 +sleep 1 compare menu-noborder +# Menu border style follows the explicitly targeted window, not the current +# window when a different pane is selected. +setup 40 14 +$TMUX2 set menu-border-lines none || exit 1 +$TMUX2 neww || exit 1 +$TMUX2 display-menu -t%0 -T "Menu" -C 1 \ + "Alpha item" a "" \ + "Beta item" b "" \ + "" "" "" \ + "Gamma item" g "" || exit 1 +$TMUX2 selectw -t%0 || exit 1 +compare menu-target-window-noborder + # Menu with double border lines. setup 40 14 menu -b double -x6 -y8 diff --git a/regress/screen-redraw-outside.sh b/regress/screen-redraw-outside.sh index 3eeb94938..cebca2c74 100644 --- a/regress/screen-redraw-outside.sh +++ b/regress/screen-redraw-outside.sh @@ -93,6 +93,15 @@ $TMUX2 splitw -v "$C" || exit 1 $TMUX2 select-layout tiled || exit 1 compare outside-both-2x2 +# Top pane status supplies internal horizontal borders, but there is no pane +# below the window to supply its bottom edge. +new_scene 28 9 +$TMUX2 setw pane-border-status top || exit 1 +$TMUX2 setw pane-border-format "" || exit 1 +compare outside-both-status-top-single +$TMUX2 splitw -v "$C" || exit 1 +compare outside-both-status-top-split + # Window BIGGER than the client: only part of the window is viewed and the view # can be panned (refresh-client). This exercises a non-zero scene offset. # A 2x2 grid in a 60x20 window viewed through the 40x14 client. diff --git a/regress/screen-redraw-popups.sh b/regress/screen-redraw-popups.sh deleted file mode 100644 index 26e0f7175..000000000 --- a/regress/screen-redraw-popups.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/sh - -# Exercise drawing of popups (display-popup) over the window scene. A popup is an -# overlay drawn on top of the redraw scene (the overlay_draw path in -# screen-redraw.c), so this guards against regressions in how popups appear. -# -# A popup is modal and stays open until its command exits, so each scene fully -# re-creates the servers and re-attaches; the popup is opened in the background -# (display-popup blocks the client that runs it) and the outer pane is captured -# while it is open. -# -# Run with GENERATE=1 to (re)create the golden files. - -PATH=/bin:/usr/bin -TERM=screen -LC_ALL=C.UTF-8 -export TERM LC_ALL - -[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX= -TMUX2= -SETUP=0 -RESULTS=screen-redraw-results - -TMP=$(mktemp) - -cleanup() { - rm -f "$TMP" - [ -n "$TMUX" ] && $TMUX kill-server 2>/dev/null - [ -n "$TMUX2" ] && $TMUX2 kill-server 2>/dev/null -} -trap cleanup 0 1 15 - -fail() { - echo "$*" >&2 - exit 1 -} - -compare() { - sleep 1 - $TMUX capturep -p >$TMP || exit 1 - if [ -n "$GENERATE" ]; then - cp $TMP "$RESULTS/$1.result" || exit 1 - echo "generated $1" - else - cmp -s $TMP "$RESULTS/$1.result" || \ - fail "scene $1 differs from $RESULTS/$1.result" - fi -} - -C="sh -c 'i=0; while [ \$i -lt 13 ]; do printf \"POP%02d abcdefghij\n\" \$i; i=\$((i + 1)); done; exec sleep 100'" - -# setup: fresh inner window attached inside a fresh outer pane, 40x14. -setup() { - [ -n "$TMUX" ] && $TMUX kill-server 2>/dev/null - [ -n "$TMUX2" ] && $TMUX2 kill-server 2>/dev/null - SETUP=$((SETUP + 1)) - TMUX="$TEST_TMUX -LtestA$$-$SETUP -f/dev/null" - TMUX2="$TEST_TMUX -LtestB$$-$SETUP -f/dev/null" - $TMUX2 new -d -x40 -y14 "$C" || exit 1 - $TMUX2 set -g status off || exit 1 - $TMUX2 set -g window-size manual || exit 1 - $TMUX2 resizew -x40 -y14 || exit 1 - $TMUX new -d -x40 -y14 || exit 1 - $TMUX set -g status off || exit 1 - $TMUX set -g window-size manual || exit 1 - $TMUX set -g default-terminal "tmux-256color" || exit 1 - $TMUX send -l "$TMUX2 attach" || exit 1 - $TMUX send Enter || exit 1 - sleep 1 -} - -# popup : open a popup running a fixed command, in the background (it stays -# open because the command sleeps; the servers are killed at the next setup). -popup() { - $TMUX2 display-popup "$@" -E "sh -c 'printf POPUP; exec sleep 100'" & - sleep 1 -} - -# Basic popup over a single pane. -setup -popup -w20 -h6 -x6 -y3 -compare popup-basic - -# Popup over a split: drawn on top of the pane border. -setup -$TMUX2 splitw -h "$C" || exit 1 -popup -w24 -h8 -x8 -y3 -compare popup-over-split - -# Popup with no border lines (-B). -setup -popup -B -w20 -h6 -x6 -y3 -compare popup-noborder - -# Popup with double border lines. -setup -popup -b double -w20 -h6 -x6 -y3 -compare popup-double - -exit 0 diff --git a/regress/screen-redraw-results/floating-border-rounded.result b/regress/screen-redraw-results/floating-border-rounded.result new file mode 100644 index 000000000..77a2f0c1c --- /dev/null +++ b/regress/screen-redraw-results/floating-border-rounded.result @@ -0,0 +1,12 @@ +base + ╭──────────────────────────╮ + │FLOAT │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────╯ + + + diff --git a/regress/screen-redraw-results/menu-noborder.result b/regress/screen-redraw-results/menu-noborder.result index d6479edba..d0f0dc58a 100644 --- a/regress/screen-redraw-results/menu-noborder.result +++ b/regress/screen-redraw-results/menu-noborder.result @@ -1,11 +1,11 @@ MENU00 abcdefghij MENU01 abcdefghij -MENU02 Menu  -MENU03  Alpha item (a)   -MENU04  Beta item (b)   -MENU05  -MENU06  Gamma item (g)   -MENU07  +MENU02 abcdefghij +MENU03 abcdefghij +MENU04 Alpha item (a)  +MENU05 Beta item (b)  +MENU06  +MENU07 Gamma item (g)  MENU08 abcdefghij MENU09 abcdefghij MENU10 abcdefghij diff --git a/regress/screen-redraw-results/menu-target-window-noborder.result b/regress/screen-redraw-results/menu-target-window-noborder.result new file mode 100644 index 000000000..a7ac507a1 --- /dev/null +++ b/regress/screen-redraw-results/menu-target-window-noborder.result @@ -0,0 +1,13 @@ +MENU00 abcdefghij +MENU01 abcdefghij +MENU02 abcdefghij +MENU03 abcdefghij +MENU04 abcd Alpha item (a)  +MENU05 abcd Beta item (b)  +MENU06 abcd  +MENU07 abcd Gamma item (g)  +MENU08 abcdefghij +MENU09 abcdefghij +MENU10 abcdefghij +MENU11 abcdefghij +MENU12 abcdefghij diff --git a/regress/screen-redraw-results/outside-both-status-top-single.result b/regress/screen-redraw-results/outside-both-status-top-single.result new file mode 100644 index 000000000..5f1395ea3 --- /dev/null +++ b/regress/screen-redraw-results/outside-both-status-top-single.result @@ -0,0 +1,14 @@ +────────────────────────────┐··········· +OUT01 abcdefghij │··········· +OUT02 abcdefghij │··········· +OUT03 abcdefghij │··········· +OUT04 abcdefghij │··········· +OUT05 abcdefghij │··········· +OUT06 abcdefghij │··········· +OUT07 abcdefghij │··········· + │··········· +────────────────────────────┘··········· +········································ +········································ +········································ +········································ diff --git a/regress/screen-redraw-results/outside-both-status-top-split.result b/regress/screen-redraw-results/outside-both-status-top-split.result new file mode 100644 index 000000000..ae8ca37d6 --- /dev/null +++ b/regress/screen-redraw-results/outside-both-status-top-split.result @@ -0,0 +1,14 @@ +────────────────────────────┐··········· +OUT06 abcdefghij │··········· +OUT07 abcdefghij │··········· + │··········· +────────────────────────────┤··········· +PAN05 abcdefghij │··········· +PAN06 abcdefghij │··········· +PAN07 abcdefghij │··········· + │··········· +────────────────────────────┘··········· +········································ +········································ +········································ +········································ diff --git a/regress/screen-redraw-results/popup-basic.result b/regress/screen-redraw-results/popup-basic.result deleted file mode 100644 index 7b05d3a61..000000000 --- a/regress/screen-redraw-results/popup-basic.result +++ /dev/null @@ -1,14 +0,0 @@ -POP00 ┌──────────────────┐ -POP01 │POPUP │ -POP02 │ │ -POP03 │ │ -POP04 │ │ -POP05 └──────────────────┘ -POP06 abcdefghij -POP07 abcdefghij -POP08 abcdefghij -POP09 abcdefghij -POP10 abcdefghij -POP11 abcdefghij -POP12 abcdefghij - diff --git a/regress/screen-redraw-results/popup-double.result b/regress/screen-redraw-results/popup-double.result deleted file mode 100644 index 70b38c8c3..000000000 --- a/regress/screen-redraw-results/popup-double.result +++ /dev/null @@ -1,14 +0,0 @@ -POP00 ╔══════════════════╗ -POP01 ║POPUP ║ -POP02 ║ ║ -POP03 ║ ║ -POP04 ║ ║ -POP05 ╚══════════════════╝ -POP06 abcdefghij -POP07 abcdefghij -POP08 abcdefghij -POP09 abcdefghij -POP10 abcdefghij -POP11 abcdefghij -POP12 abcdefghij - diff --git a/regress/screen-redraw-results/popup-noborder.result b/regress/screen-redraw-results/popup-noborder.result deleted file mode 100644 index fc641e781..000000000 --- a/regress/screen-redraw-results/popup-noborder.result +++ /dev/null @@ -1,14 +0,0 @@ -POP00 POPUP -POP01 -POP02 -POP03 -POP04 -POP05 -POP06 abcdefghij -POP07 abcdefghij -POP08 abcdefghij -POP09 abcdefghij -POP10 abcdefghij -POP11 abcdefghij -POP12 abcdefghij - diff --git a/regress/screen-redraw-results/popup-over-split.result b/regress/screen-redraw-results/popup-over-split.result deleted file mode 100644 index 558efc92e..000000000 --- a/regress/screen-redraw-results/popup-over-split.result +++ /dev/null @@ -1,14 +0,0 @@ -POP00 ab┌──────────────────────┐fghij -POP01 ab│POPUP │fghij -POP02 ab│ │fghij -POP03 ab│ │fghij -POP04 ab│ │fghij -POP05 ab│ │fghij -POP06 ab│ │fghij -POP07 ab└──────────────────────┘fghij -POP08 abcdefghij │POP08 abcdefghij -POP09 abcdefghij │POP09 abcdefghij -POP10 abcdefghij │POP10 abcdefghij -POP11 abcdefghij │POP11 abcdefghij -POP12 abcdefghij │POP12 abcdefghij - │ diff --git a/regress/screen-redraw-scrollbars-auto-hide.sh b/regress/screen-redraw-scrollbars-auto-hide.sh new file mode 100644 index 000000000..7357b5cbe --- /dev/null +++ b/regress/screen-redraw-scrollbars-auto-hide.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +# Auto-hide scrollbars appear on activity or pointer hover, then disappear +# after pane-scrollbars-timeout. Compare captures of the same copy-mode view so +# only the overlay scrollbar changes. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +OUTER="$TEST_TMUX -LtestA$$ -f/dev/null" +INNER="$TEST_TMUX -LtestB$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 +HIDDEN=$DIR/hidden +VISIBLE=$DIR/visible +AFTER=$DIR/after + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -pe -t outer:0.0 >"$1" || exit 1 +} + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients 2>/dev/null | grep -q . && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +$INNER new-session -d -s inner -x40 -y12 \ + "sh -c 'seq 50; exec sleep 100'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-window-option pane-scrollbars auto-hide || exit 1 +$INNER set-window-option pane-scrollbars-timeout 3000 || exit 1 +$INNER set-window-option pane-scrollbars-style \ + 'bg=colour196,fg=colour231,width=1,pad=0' || exit 1 + +$OUTER new-session -d -s outer -x40 -y12 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +# Enter copy mode and choose a stable view. The initial show timer is allowed +# to expire before taking the hidden reference capture. +$INNER copy-mode -H || exit 1 +$INNER send-keys -X history-top || exit 1 +sleep 3.5 +capture "$HIDDEN" + +# A copy-mode page movement shows the overlay and starts its timer. Capture +# that view immediately, then again after the timeout without moving it. +$INNER send-keys -X page-down || exit 1 +sleep 0.1 +capture "$VISIBLE" +sleep 3.5 +capture "$AFTER" +cmp -s "$VISIBLE" "$AFTER" && fail "scrollbar did not hide after timeout" + +# Use the post-movement hidden scene as the reference for pointer hover. SGR +# mouse column 40 is the right-hand scrollbar; moving to column 5 restarts the +# timer without changing the copy-mode view. +cp "$AFTER" "$HIDDEN" +hover=$(printf '\033[<35;40;5M') +$OUTER send-keys -l "$hover" || exit 1 +sleep 0.1 +capture "$VISIBLE" +cmp -s "$HIDDEN" "$VISIBLE" && fail "scrollbar did not appear on hover" +away=$(printf '\033[<35;5;5M') +$OUTER send-keys -l "$away" || exit 1 +sleep 3.5 +capture "$AFTER" +cmp -s "$HIDDEN" "$AFTER" || fail "scrollbar did not hide after hover" + +exit 0 diff --git a/regress/screen-redraw-tiled.sh b/regress/screen-redraw-tiled.sh index de7c57aff..809b4d4c4 100644 --- a/regress/screen-redraw-tiled.sh +++ b/regress/screen-redraw-tiled.sh @@ -95,7 +95,8 @@ layout_tee_down() { # top/bottom, bottom split: a top tee $TMUX kill-server 2>/dev/null $TMUX2 kill-server 2>/dev/null -$TMUX2 new -d -x40 -y14 "sh -c 'exec sleep 100'" || exit 1 +# Keep the anchor window alive until cleanup, even on slow test runs. +$TMUX2 new -d -x40 -y14 'exec cat' || exit 1 $TMUX2 set -g status off || exit 1 $TMUX2 set -g window-size manual || exit 1 $TMUX2 set -g pane-border-format " #{pane_index} " || exit 1 diff --git a/regress/server-access.sh b/regress/server-access.sh new file mode 100644 index 000000000..a6e44efca --- /dev/null +++ b/regress/server-access.sh @@ -0,0 +1,112 @@ +#!/bin/sh + +# Exercise the unprivileged server-access surface: user and group ACL entries, +# read-only/writable transitions, listing, removal, implied addition, and +# command errors. Actual connections from another UID require privileges and +# deliberately do not form part of this test. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +OUT=$(mktemp) || exit 1 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -f "$OUT" +} +trap cleanup 0 1 15 + +expect_fail() +{ + message=$1 + shift + if $TMUX "$@" >"$OUT" 2>&1; then + fail "$message: command unexpectedly succeeded" + fi +} + +assert_entry() +{ + entry=$1 + $TMUX server-access -l >"$OUT" || exit 1 + grep -Fxq "$entry" "$OUT" || + fail "missing ACL entry '$entry'" +} + +assert_no_entry() +{ + entry=$1 + $TMUX server-access -l >"$OUT" || exit 1 + if grep -Fxq "$entry" "$OUT"; then + fail "unexpected ACL entry '$entry'" + fi +} + +owner_uid=$(id -u) +owner_name=$(id -un) + +# nobody is the non-owner test identity. Derive its primary group rather than +# assuming whether the system calls it nobody, nogroup, or something else. +acl_user=nobody +acl_group=$(id -gn "$acl_user" 2>/dev/null) || + fail "the nobody account is unavailable" +[ "$owner_name" != "$acl_user" ] || fail "test cannot run as nobody" + +$TMUX new-session -d -s access 'exec sleep 100' || exit 1 + +# The owner is installed as writable at server startup (root is intentionally +# omitted from display output). +if [ "$owner_uid" != 0 ]; then + assert_entry "$owner_name (U,W)" +fi + +# User ACL lifecycle, including duplicate and missing-entry errors. +$TMUX server-access -a "$acl_user" || exit 1 +assert_entry "$acl_user (U,W)" +expect_fail "duplicate user addition" server-access -a "$acl_user" +$TMUX server-access -r "$acl_user" || exit 1 +assert_entry "$acl_user (U,R)" +$TMUX server-access -w "$acl_user" || exit 1 +assert_entry "$acl_user (U,W)" +$TMUX server-access -d "$acl_user" || exit 1 +assert_no_entry "$acl_user (U,W)" +expect_fail "removing absent user" server-access -d "$acl_user" + +# -r and -w imply -a when an entry is absent. +$TMUX server-access -r "$acl_user" || exit 1 +assert_entry "$acl_user (U,R)" +$TMUX server-access -d "$acl_user" || exit 1 + +# The same lifecycle for a group exercises the separate ACL key space. +$TMUX server-access -g -a -r "$acl_group" || exit 1 +assert_entry "$acl_group (G,R)" +$TMUX server-access -g -w "$acl_group" || exit 1 +assert_entry "$acl_group (G,W)" +expect_fail "duplicate group addition" server-access -g -a "$acl_group" +$TMUX server-access -g -d "$acl_group" || exit 1 +assert_no_entry "$acl_group (G,W)" +expect_fail "removing absent group" server-access -g -d "$acl_group" + +# Parser and lookup errors, plus the immutable owner/root entries. +expect_fail "missing ACL subject" server-access -a +expect_fail "unknown user" server-access -a "tmux-no-user-$$" +expect_fail "unknown group" server-access -g -a "tmux-no-group-$$" +expect_fail "conflicting add/delete" server-access -a -d "$acl_user" +expect_fail "conflicting read/write" server-access -r -w "$acl_user" +expect_fail "changing server owner" server-access -r "$owner_name" +if [ "$owner_name" != root ]; then + expect_fail "changing root access" server-access -a root +fi + +$TMUX has-session -t access || fail "server exited during ACL updates" +exit 0 diff --git a/regress/server-socket-error.sh b/regress/server-socket-error.sh old mode 100755 new mode 100644 diff --git a/regress/set-hook-E.sh b/regress/set-hook-E.sh old mode 100755 new mode 100644 diff --git a/regress/socket-path.sh b/regress/socket-path.sh new file mode 100644 index 000000000..eb3b2dbfe --- /dev/null +++ b/regress/socket-path.sh @@ -0,0 +1,57 @@ +#!/bin/sh + +# The socket directory must be an existing absolute path without "..". An +# empty $TMUX_TMPDIR is skipped. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +unset TMUX TMUX_TMPDIR + +TMP=$(mktemp -d) +TMP=$(cd "$TMP" && pwd -P) +L="testSP$$" +DEFAULT="$(cd /tmp && pwd -P)/tmux-$(id -u)/$L" + +cleanup() +{ + "$TEST_TMUX" -S "$DEFAULT" kill-server 2>/dev/null + "$TEST_TMUX" -S "$TMP/tmux-$(id -u)/$L" kill-server 2>/dev/null + rm -rf "$TMP" "$DEFAULT" "$DEFAULT.lock" +} +trap cleanup 0 1 15 + +# check_path expected env... +check_path() +{ + expected=$1; shift + env "$@" "$TEST_TMUX" -L"$L" -f/dev/null new -d 'sleep 60' || exit 1 + actual=$(env "$@" "$TEST_TMUX" -L"$L" display -p '#{socket_path}') + env "$@" "$TEST_TMUX" -L"$L" kill-server + [ "$actual" = "$expected" ] || exit 1 +} + +# check_error expected env... +check_error() +{ + expected=$1; shift + if actual=$(env "$@" "$TEST_TMUX" -L"$L" -f/dev/null new -d \ + 'sleep 60' 2>&1); then + env "$@" "$TEST_TMUX" -L"$L" kill-server + exit 1 + fi + [ "$actual" = "$expected" ] || exit 1 +} + +check_path "$DEFAULT" TMUX_TMPDIR= +check_path "$TMP/tmux-$(id -u)/$L" TMUX_TMPDIR="$TMP" + +check_error "socket directory tmp is not an absolute path" TMUX_TMPDIR=tmp +check_error "couldn't resolve socket directory $TMP/missing (No such file or directory)" \ + TMUX_TMPDIR="$TMP/missing" +check_error "socket directory $TMP/.. contains .." TMUX_TMPDIR="$TMP/.." +check_error "socket directory /../tmp contains .." TMUX_TMPDIR=/../tmp +check_error "socket directory $TMP/../.. contains .." TMUX_TMPDIR="$TMP/../.." + +exit 0 diff --git a/regress/split-window-wait-kill.sh b/regress/split-window-wait-kill.sh new file mode 100644 index 000000000..3387eec55 --- /dev/null +++ b/regress/split-window-wait-kill.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +# split-window -W exit status when the pane is killed + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestSWK$$ -f/dev/null" +$TMUX kill-server 2>/dev/null +trap "$TMUX kill-server 2>/dev/null" 0 1 15 + +$TMUX new -d + +# A command that exits on its own still reports its own status, so a failure +# below is the kill path and not -W being broken altogether. +$TMUX splitw -W 'sh -c "exit 7"' +[ "$?" = 7 ] || exit 1 + +$TMUX splitw -W 'sleep 60' & +pid=$! + +i=0 +target= +while [ -z "$target" ]; do + [ "$i" -eq 50 ] && exit 1 + i=$((i + 1)) + sleep 0.1 + target=$($TMUX lsp -F'#{pane_id} #{pane_start_command}' 2>/dev/null| + awk '/sleep/{print $1; exit}') +done + +$TMUX killp -t "$target" +wait "$pid" +[ "$?" = 129 ] || exit 1 + +exit 0 diff --git a/regress/swap-pane-zoom-error.sh b/regress/swap-pane-zoom-error.sh new file mode 100644 index 000000000..0d85f72f1 --- /dev/null +++ b/regress/swap-pane-zoom-error.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# swap-pane refusing a floating pane must leave the zoom alone + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +TMP=$(mktemp) +trap "rm -f $TMP; $TMUX kill-server 2>/dev/null" 0 1 15 + +$TMUX new -d +$TMUX splitw -d +$TMUX new-pane -d + +echo $($TMUX lsp -F'#{pane_id}:#{pane_floating_flag}') >$TMP +(echo "%0:0 %1:0 %2:1"|cmp -s - $TMP) || exit 1 + +$TMUX resize-pane -Z -t %0 +[ "$($TMUX display -p '#{window_zoomed_flag}')" = 1 ] || exit 1 + +$TMUX swap-pane -Z -D -t %2 +[ "$?" = 0 ] && exit 1 +[ "$($TMUX display -p '#{window_zoomed_flag}')" = 1 ] || exit 1 + +$TMUX swap-pane -Z -U -t %2 +[ "$?" = 0 ] && exit 1 +[ "$($TMUX display -p '#{window_zoomed_flag}')" = 1 ] || exit 1 + +# A swap that is allowed still happens. +$TMUX swap-pane -D -t %0 || exit 1 +echo $($TMUX lsp -F'#{pane_id}') >$TMP +(echo "%1 %0 %2"|cmp -s - $TMP) || exit 1 + +$TMUX kill-server 2>/dev/null + +exit 0 diff --git a/regress/switch-client-redraw.sh b/regress/switch-client-redraw.sh new file mode 100755 index 000000000..a03c382dd --- /dev/null +++ b/regress/switch-client-redraw.sh @@ -0,0 +1,81 @@ +#!/bin/sh + +# Switching windows in the same session must redraw the attached client. The +# session object is shared, so its current winlink cannot be compared after it +# has already been changed. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lswitch-redraw-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lswitch-redraw-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +$INNER new-session -d -s inner -x 40 -y 8 \ + "printf '\033[2J\033[HA-WINDOW'; exec sleep 100" || exit 1 +$INNER new-window -d -t inner:1 \ + "printf '\033[2J\033[HB-WINDOW'; exec sleep 100" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 8 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lswitch-redraw-inner-$$ -f/dev/null attach-session -t inner:0" || + exit 1 + +wait_for_client +wait_outer_has A-WINDOW + +$INNER switch-client -c "$CLIENT" -t inner:1.0 || exit 1 +[ "$($INNER display-message -p -t inner '#{window_index}')" -eq 1 ] || + fail "server did not select window 1" +wait_outer_has B-WINDOW + +exit 0 diff --git a/regress/switch-mode.sh b/regress/switch-mode.sh new file mode 100644 index 000000000..5f3fe8c12 --- /dev/null +++ b/regress/switch-mode.sh @@ -0,0 +1,203 @@ +#!/bin/sh + +# Exercise switch-mode's session and window lists with a real attached client: +# fuzzy filtering, movement and selection, the default and custom commands, +# no-match cancellation, resize, zoom restoration, and -k mode teardown. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_capture() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Fq "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "timed out waiting for '$marker'" +} + +wait_count() +{ + marker=$1 + want=$2 + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + count=$(printf '%s\n' "$captured" | grep -Fc "$marker") + [ "$count" -eq "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "found $count '$marker' rows, expected $want" +} + +wait_format() +{ + target=$1 + format=$2 + want=$3 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER display-message -p -t "$target" "$format" \ + 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "$target format $format is '$got', expected '$want'" +} + +wait_client_session() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER list-clients -F '#{client_session}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client session is '$got', expected '$want'" +} + +# Three alphabetically ordered sessions. alpha:main has two panes so -Z can be +# observed; the two pick-* windows make a deterministic two-row window filter. +$INNER new-session -d -s alpha -n main -x60 -y15 'exec sleep 100' || exit 1 +$INNER split-window -d -h -t alpha:main 'exec sleep 100' || exit 1 +$INNER new-window -d -t alpha: -n pick-one 'exec sleep 100' || exit 1 +$INNER new-session -d -s bravo -n pick-two -x60 -y15 \ + 'exec sleep 100' || exit 1 +$INNER new-session -d -s charlie -n last -x60 -y15 \ + 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER select-window -t alpha:main || exit 1 + +$OUTER new-session -d -s outer -x60 -y15 "$INNER attach -t alpha" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_client_session alpha + +# Session rows are sorted alpha, bravo, charlie. Up from the first row wraps to +# charlie; Enter runs a custom command with the selected session target. +$INNER set-option -g @picked '' || exit 1 +$INNER switch-mode -s -t alpha:main.0 -F 'SESSION #{session_name}' \ + "set-option -g @picked '%%'" || exit 1 +wait_count 'SESSION ' 3 +$INNER send-keys -t alpha:main.0 Up Enter || exit 1 +wait_format alpha:main.0 '#{@picked}' '=charlie:' +wait_format alpha:main.0 '#{pane_in_mode}' 0 + +# Fuzzy filtering keeps only bravo for "brv". The default command switches the +# real client to that session and closes the mode. +$INNER switch-mode -s -t alpha:main.0 -F 'SESSION #{session_name}' || exit 1 +wait_count 'SESSION ' 3 +$INNER send-keys -t alpha:main.0 -l brv || exit 1 +wait_count 'SESSION ' 1 +printf '%s\n' "$captured" | grep -Fq 'SESSION bravo' || + fail "fuzzy session filter did not select bravo" +$INNER send-keys -t alpha:main.0 Enter || exit 1 +wait_client_session bravo +client=$($INNER list-clients -F '#{client_name}') +$INNER switch-client -c "$client" -t alpha || exit 1 +wait_client_session alpha + +# Window mode sees pick-one then pick-two for the "pick" filter. Down selects +# the second row, and the custom command receives its window target. +$INNER set-option -g @picked '' || exit 1 +$INNER switch-mode -w -t alpha:main.0 -F \ + 'WINDOW #{session_name}:#{window_name}' \ + "set-option -g @picked '%%'" || exit 1 +wait_capture 'WINDOW ' +$INNER send-keys -t alpha:main.0 -l pick || exit 1 +wait_count 'WINDOW ' 2 +$INNER send-keys -t alpha:main.0 Down Enter || exit 1 +picked=$($INNER show-option -gv @picked) +case "$picked" in +=bravo:0.) ;; +*) fail "window selection produced '$picked', expected '=bravo:0.'" ;; +esac + +# Navigation with an empty result set must be harmless, and Escape cancels +# without running the custom command. +$INNER set-option -g @picked unchanged || exit 1 +$INNER switch-mode -s -t alpha:main.0 -F 'NONE #{session_name}' \ + "set-option -g @picked '%%'" || exit 1 +wait_capture 'NONE ' +$INNER send-keys -t alpha:main.0 -l no-such-session || exit 1 +wait_count 'NONE ' 0 +$INNER send-keys -t alpha:main.0 Up Down PPage NPage Home End || exit 1 +wait_format alpha:main.0 '#{pane_mode}' switch-mode +$INNER send-keys -t alpha:main.0 Escape || exit 1 +wait_format alpha:main.0 '#{pane_in_mode}' 0 +[ "$($INNER show-option -gv @picked)" = unchanged ] || + fail "cancelled switch-mode ran its command" + +# -Z temporarily zooms an unzoomed window. Resizing while the mode is open +# rebuilds and redraws it; exiting restores the original unzoomed state. +$INNER switch-mode -Zs -t alpha:main.0 -F 'RESIZE #{session_name}' || exit 1 +wait_capture 'RESIZE alpha' +wait_format alpha:main.0 '#{window_zoomed_flag}' 1 +$INNER resize-window -t alpha:main -x50 -y12 || exit 1 +wait_format alpha:main.0 '#{window_width}x#{window_height}' 50x12 +wait_format alpha:main.0 '#{pane_mode}' switch-mode +$INNER send-keys -t alpha:main.0 Escape || exit 1 +wait_format alpha:main.0 '#{window_zoomed_flag}' 0 + +# If the window was already zoomed, leaving -Z mode must keep it zoomed. +$INNER resize-pane -Z -t alpha:main.0 || exit 1 +$INNER switch-mode -Zs -t alpha:main.0 -F 'ZOOMED #{session_name}' || exit 1 +wait_capture 'ZOOMED alpha' +$INNER send-keys -t alpha:main.0 Escape || exit 1 +wait_format alpha:main.0 '#{window_zoomed_flag}' 1 +$INNER resize-pane -Z -t alpha:main.0 || exit 1 + +# -k uses the shared mode teardown flag. Put it on a disposable pane and make +# sure exiting the mode removes that pane. +kill_pane=$($INNER split-window -d -P -F '#{pane_id}' -t alpha:main \ + 'exec sleep 100') || exit 1 +$INNER switch-mode -ks -t "$kill_pane" -F 'KILL #{session_name}' || exit 1 +wait_format "$kill_pane" '#{pane_mode}' switch-mode +$INNER send-keys -t "$kill_pane" Escape || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + $INNER list-panes -s -t alpha -F '#{pane_id}' 2>/dev/null | \ + grep -q -x "$kill_pane" || break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "switch-mode -k did not kill its pane" + +exit 0 diff --git a/regress/sync-output-copy-cursor.sh b/regress/sync-output-copy-cursor.sh new file mode 100755 index 000000000..06a88342a --- /dev/null +++ b/regress/sync-output-copy-cursor.sh @@ -0,0 +1,141 @@ +#!/bin/sh + +# A synchronized update on the base screen must not defer cursor movement on +# the copy-mode screen displayed in its place. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR + +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +CLIENT_BYTES=$DIR/client-bytes +CONTROL=$DIR/control +EMITTER=$DIR/emitter.pl + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients -F '#{client_termfeatures}' 2>/dev/null | + grep -q 'sync' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "sync-capable client did not attach" +} + +wait_for_sync() +{ + i=0 + while [ "$i" -lt 50 ]; do + [ "$($INNER display-message -p \ + '#{synchronized_output_flag}' 2>/dev/null)" = 1 ] && + return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "application did not enter synchronized output" +} + +wait_for_cursor_position() +{ + position=$(printf '\033[%d;%dH' "$2" "$1") + i=0 + while [ "$i" -lt 20 ]; do + grep -Fq "$position" "$CLIENT_BYTES" 2>/dev/null && + return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "copy-mode cursor position did not reach client" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +my $control = $ENV{CONTROL}; +(my $dir = $control) =~ s{/[^/]+$}{}; + +# Leave the application cursor away from the left edge so copy mode can move +# it left. +syswrite STDOUT, "copy cursor"; +open my $ready, '>', "$dir/ready" or die "$dir/ready: $!\n"; +close $ready; +while (!-e $control) { + select undef, undef, undef, 0.01; +} + +# Keep a real application synchronized update open while the separately +# displayed copy-mode screen moves its cursor. Repeating DECSET also keeps the +# one-second safety timer from ending the update during the assertion. +for (1 .. 200) { + syswrite STDOUT, "\e[?2026h"; + select undef, undef, undef, 0.05; +} +PERL + +$INNER new-session -d -s inner -x 80 -y 24 \ + "CONTROL='$CONTROL' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -as terminal-features '*:sync' || exit 1 + +$OUTER new-session -d -s outer -x 80 -y 24 \ + "$TEST_TMUX -Li$$ -f/dev/null attach-session -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +i=0 +while [ "$i" -lt 50 ] && [ ! -e "$DIR/ready" ]; do + sleep 0.1 + i=$((i + 1)) +done +[ -e "$DIR/ready" ] || fail "application emitter did not become ready" + +$INNER copy-mode -t inner:0.0 || exit 1 +before=$($INNER display-message -p -t inner:0.0 '#{copy_cursor_x}') || + exit 1 +[ "$before" -gt 0 ] || fail "copy-mode cursor started at left edge" + +: >"$CONTROL" +wait_for_sync +sleep 0.5 + +$OUTER pipe-pane -O -t outer:0.0 "cat >'$CLIENT_BYTES'" || exit 1 +sleep 0.1 +$INNER send-keys -t inner:0.0 -X cursor-left || exit 1 +after=$($INNER display-message -p -t inner:0.0 '#{copy_cursor_x}') || + exit 1 +after_y=$($INNER display-message -p -t inner:0.0 '#{copy_cursor_y}') || + exit 1 +[ "$after" -eq $((before - 1)) ] || + fail "copy-mode cursor did not move logically: $before to $after" +[ "$($INNER display-message -p '#{synchronized_output_flag}')" = 1 ] || + fail "application synchronized output ended unexpectedly" + +wait_for_cursor_position $((after + 1)) $((after_y + 1)) +$OUTER pipe-pane -t outer:0.0 || exit 1 diff --git a/regress/sync-output-cursor.sh b/regress/sync-output-cursor.sh new file mode 100644 index 000000000..e805f373f --- /dev/null +++ b/regress/sync-output-cursor.sh @@ -0,0 +1,160 @@ +#!/bin/sh + +# A synchronized update elsewhere in the pane must not hide a stationary +# cursor while tmux waits for the end of the update. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +if [ "$#" -eq 0 ]; then + TEST_TMUX="$TEST_TMUX" sh "$0" pane || exit 1 + TEST_TMUX="$TEST_TMUX" sh "$0" prompt || exit 1 + exit 0 +fi +case $1 in +pane|prompt) MODE=$1 ;; +*) echo "usage: $0 [pane|prompt]" >&2; exit 1 ;; +esac + +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR + +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +CLIENT_BYTES=$DIR/client-bytes +CONTROL=$DIR/control +EMITTER=$DIR/emitter.pl +ASSERT=$DIR/assert-cursor.pl + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients -F '#{client_termfeatures}' 2>/dev/null | + grep -q 'sync' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "sync-capable client did not attach" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +my $control = $ENV{CONTROL}; +(my $dir = $control) =~ s{/[^/]+$}{}; + +# Draw a prompt and leave its cursor stationary on the last line. +syswrite STDOUT, "\e[2J\e[HWorking .\e[24;1H> "; +open my $ready, '>', "$dir/ready" or die "$dir/ready: $!\n"; +close $ready; +while (!-e $control) { + select undef, undef, undef, 0.01; +} + +# Deliberately split BSU, content, and ESU across pane reads. This mirrors an +# animation producer which begins a frame before it has finished rendering it. +for my $spinner (qw(| / - \\)) { + syswrite STDOUT, "\e[?2026h"; + select undef, undef, undef, 0.05; + syswrite STDOUT, "\e[1;9H$spinner\e[24;3H"; + select undef, undef, undef, 0.01; + syswrite STDOUT, "\e[?2026l"; + select undef, undef, undef, 0.15; +} +select undef, undef, undef, 5; +PERL + +cat >"$ASSERT" <<'PERL' +use strict; +use warnings; + +my $path = shift; +open my $fh, '<:raw', $path or die "$path: $!\n"; +local $/; +my $bytes = <$fh>; +my ($bsu, $esu, $hide, $show) = + ("\e[?2026h", "\e[?2026l", "\e[?25l", "\e[?25h"); +my ($sync, $outside, $hides, $shows) = (0, 0, 0, 0); + +for (my $i = 0; $i < length($bytes);) { + if (substr($bytes, $i, length($bsu)) eq $bsu) { + $sync++; + $i += length($bsu); + next; + } + if (substr($bytes, $i, length($esu)) eq $esu) { + $sync-- if $sync; + $i += length($esu); + next; + } + if (substr($bytes, $i, length($hide)) eq $hide) { + $hides++; + $outside++ unless $sync; + $i += length($hide); + next; + } + if (substr($bytes, $i, length($show)) eq $show) { + $shows++; + $outside++ unless $sync; + $i += length($show); + next; + } + $i++; +} + +printf "%s cursor hides: %d; cursor shows: %d; changes outside sync: %d\n", + $ENV{MODE}, $hides, $shows, $outside; +die "stationary cursor changed outside synchronized output\n" if $outside; +PERL + +$INNER new-session -d -s inner -x 80 -y 24 \ + "CONTROL='$CONTROL' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -as terminal-features '*:sync' || exit 1 + +$OUTER new-session -d -s outer -x 80 -y 24 \ + "$TEST_TMUX -Li$$ -f/dev/null attach-session -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +if [ "$MODE" = prompt ]; then + $OUTER send-keys -t outer:0.0 C-b : || exit 1 + sleep 0.2 +fi + +i=0 +while [ "$i" -lt 50 ] && [ ! -e "$DIR/ready" ]; do + sleep 0.1 + i=$((i + 1)) +done +[ -e "$DIR/ready" ] || fail "application emitter did not become ready" + +$OUTER pipe-pane -O -t outer:0.0 "cat >'$CLIENT_BYTES'" || exit 1 +: >"$CONTROL" +sleep 2 +$OUTER pipe-pane -t outer:0.0 || exit 1 + +MODE=$MODE perl "$ASSERT" "$CLIENT_BYTES" diff --git a/regress/sync-output-scroll-clients.sh b/regress/sync-output-scroll-clients.sh new file mode 100755 index 000000000..dd85f7cf8 --- /dev/null +++ b/regress/sync-output-scroll-clients.sh @@ -0,0 +1,123 @@ +#!/bin/sh + +# A scroll held back by a synchronized update may be sent to all clients while +# a later client is being redrawn. An earlier client which cannot scroll the +# pane itself must still end up with the right contents. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR + +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +CONTROL=$DIR/control +EMITTER=$DIR/emitter.pl + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_file() +{ + i=0 + while [ "$i" -lt 50 ] && [ ! -e "$1" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -e "$1" ] || fail "$2" +} + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + n=$($INNER list-clients 2>/dev/null | wc -l) + [ "$n" -eq "$1" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client $1 did not attach" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +my $control = $ENV{CONTROL}; +(my $dir = $control) =~ s{/[^/]+$}{}; + +my @rows; +for my $row (1 .. 24) { + push @rows, sprintf("INIT_ROW_%02d_", $row) . ('X' x 16); +} +syswrite STDOUT, "\e[H" . join("\r\n", @rows); + +open my $painted, '>', "$dir/painted" or die "$dir/painted: $!\n"; +close $painted; +while (!-e $control) { + select undef, undef, undef, 0.01; +} + +# Start a synchronized update and scroll, but do not end it. +syswrite STDOUT, "\e[?2026h\e[24;1H\r\nSCROLLED_LINE_"; +open my $scrolled, '>', "$dir/scrolled" or die "$dir/scrolled: $!\n"; +close $scrolled; +select undef, undef, undef, 3; +PERL + +$INNER new-session -d -s inner -x 80 -y 24 \ + "CONTROL='$CONTROL' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -as terminal-features '*:sync' || exit 1 + +# The pane is not full width, so clients cannot scroll it. +$INNER split-window -h -d -t inner:0.0 'sleep 30' || exit 1 + +for name in a b; do + $OUTER new-session -d -s $name -x 80 -y 24 \ + "$TEST_TMUX -Li$$ -f/dev/null attach-session -t inner" || exit 1 + $OUTER set-option -t $name status off || exit 1 + if [ "$name" = a ]; then + wait_for_clients 1 + fi +done +$OUTER set-option -g window-size manual || exit 1 +wait_for_clients 2 +B=$($OUTER display-message -p -t b: '#{pane_tty}') || exit 1 + +wait_for_file "$DIR/painted" "application did not paint" +sleep 1 +: >"$CONTROL" +wait_for_file "$DIR/scrolled" "application did not scroll" +sleep 0.2 + +# Redraw only the second client while the update is still in progress. +$INNER refresh-client -t "$B" || exit 1 +sleep 1 + +$OUTER capture-pane -p -t a: >"$DIR/a" || exit 1 +$OUTER capture-pane -p -t b: >"$DIR/b" || exit 1 +sed -n 1p "$DIR/b" | grep -q '^INIT_ROW_02_' || + fail "second client not scrolled" +sed -n 24p "$DIR/b" | grep -q '^SCROLLED_LINE_' || + fail "second client missing scrolled line" +cmp -s "$DIR/a" "$DIR/b" || fail "first client differs from second client" +exit 0 diff --git a/regress/sync-output-scroll.sh b/regress/sync-output-scroll.sh new file mode 100755 index 000000000..275e9ea57 --- /dev/null +++ b/regress/sync-output-scroll.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +# Check that synchronized scrolling does not redraw unchanged lines. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR + +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +CLIENT_BYTES=$DIR/client-bytes +CONTROL=$DIR/control +EMITTER=$DIR/emitter.pl + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_file() +{ + i=0 + while [ "$i" -lt 50 ] && [ ! -e "$1" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -e "$1" ] || fail "$2" +} + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients -F '#{client_termfeatures}' 2>/dev/null | + grep -q 'sync' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "sync-capable client did not attach" +} + +wait_for_stable_bytes() +{ + previous=-1 + stable=0 + i=0 + while [ "$i" -lt 50 ]; do + current=$(wc -c <"$CLIENT_BYTES" 2>/dev/null) || current=0 + if [ "$current" -gt 0 ] && [ "$current" -eq "$previous" ]; then + stable=$((stable + 1)) + [ "$stable" -eq 5 ] && return 0 + else + stable=0 + fi + previous=$current + sleep 0.1 + i=$((i + 1)) + done + fail "client byte stream did not become stable" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +my $control = $ENV{CONTROL}; +(my $dir = $control) =~ s{/[^/]+$}{}; + +# Fill the screen outside a synchronized update. +my @rows; +for my $row (1 .. 24) { + push @rows, substr(sprintf("INIT_ROW_%02d_", $row) . ('X' x 79), 0, 79); +} +syswrite STDOUT, "\e[H" . join("\r\n", @rows); + +open my $painted, '>', "$dir/painted" or die "$dir/painted: $!\n"; +close $painted; +while (!-e $control) { + select undef, undef, undef, 0.01; +} + +# Scroll one line and overwrite text in a synchronized update. +my $frame = "\e[?2026h" . + "\e[24;1H\r\nSCROLLED_LINE_" . + "\e[23;60HCHANGED_" . + "\e[?2026l"; +my $written = syswrite STDOUT, $frame; +die "short synchronized frame write\n" + unless defined $written && $written == length $frame; +select undef, undef, undef, 3; +PERL + +$INNER new-session -d -s inner -x 80 -y 24 \ + "CONTROL='$CONTROL' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -as terminal-features '*:sync' || exit 1 + +$OUTER new-session -d -s outer -x 80 -y 24 \ + "$TEST_TMUX -Li$$ -f/dev/null attach-session -t inner" || + exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +wait_for_file "$DIR/painted" "application did not paint" +sleep 1 +$OUTER pipe-pane -O -t outer:0.0 "cat >'$CLIENT_BYTES'" || exit 1 +: >"$CONTROL" +i=0 +while [ "$i" -lt 50 ]; do + grep -q 'SCROLLED_LINE_' "$CLIENT_BYTES" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) +done +grep -q 'SCROLLED_LINE_' "$CLIENT_BYTES" || fail "scrolled line not sent" +wait_for_stable_bytes +$OUTER pipe-pane -t outer:0.0 || exit 1 + +# Check that only changed lines are redrawn. +grep -q 'CHANGED_' "$CLIENT_BYTES" || fail "changed cell not sent" +if grep -q 'INIT_ROW_05_' "$CLIENT_BYTES"; then + fail "unchanged line redrawn after synchronized scroll" +fi + +# The client must show the scrolled screen. +$OUTER capture-pane -p -t outer:0.0 >"$DIR/screen" || exit 1 +sed -n 1p "$DIR/screen" | grep -q '^INIT_ROW_02_' || + fail "first line not scrolled" +sed -n 23p "$DIR/screen" | grep -q 'INIT_ROW_24_.*CHANGED_' || + fail "changed cell not on screen" +sed -n 24p "$DIR/screen" | grep -q '^SCROLLED_LINE_' || + fail "scrolled line not on screen" +exit 0 diff --git a/regress/sync-output-wrapped.sh b/regress/sync-output-wrapped.sh new file mode 100755 index 000000000..0038eb913 --- /dev/null +++ b/regress/sync-output-wrapped.sh @@ -0,0 +1,116 @@ +#!/bin/sh + +# When only some lines are redrawn after a synchronized update, a line +# following a wrapped line must be drawn at its own position, not where the +# cursor happens to be. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR + +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +CONTROL=$DIR/control +EMITTER=$DIR/emitter.pl + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_file() +{ + i=0 + while [ "$i" -lt 50 ] && [ ! -e "$1" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -e "$1" ] || fail "$2" +} + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients -F '#{client_termfeatures}' 2>/dev/null | + grep -q 'sync' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "sync-capable client did not attach" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +my $control = $ENV{CONTROL}; +(my $dir = $control) =~ s{/[^/]+$}{}; + +# Fill the screen with 80 column lines and no newlines so every line wraps. +my $out = "\e[H\e[2J"; +for my $row (1 .. 24) { + $out .= substr(sprintf("ROW%02d_", $row) . ('x' x 80), 0, 80); +} +syswrite STDOUT, $out . "\e[1;1H"; + +open my $painted, '>', "$dir/painted" or die "$dir/painted: $!\n"; +close $painted; +while (!-e $control) { + select undef, undef, undef, 0.01; +} + +# Change rows 2 and 6 in one synchronized update. +my $frame = "\e[?2026h" . + "\e[2;1H" . substr("NEW02_" . ('#' x 80), 0, 80) . + "\e[6;1H" . substr("NEW06_" . ('%' x 80), 0, 80) . + "\e[24;1H" . + "\e[?2026l"; +my $written = syswrite STDOUT, $frame; +die "short synchronized frame write\n" + unless defined $written && $written == length $frame; +open my $done, '>', "$dir/done" or die "$dir/done: $!\n"; +close $done; +select undef, undef, undef, 3; +PERL + +$INNER new-session -d -s inner -x 80 -y 24 \ + "CONTROL='$CONTROL' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -as terminal-features '*:sync' || exit 1 + +$OUTER new-session -d -s outer -x 80 -y 24 \ + "$TEST_TMUX -Li$$ -f/dev/null attach-session -t inner" || + exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +wait_for_file "$DIR/painted" "application did not paint" +sleep 1 +: >"$CONTROL" +wait_for_file "$DIR/done" "application did not finish" +sleep 1 + +$OUTER capture-pane -p -t outer:0.0 >"$DIR/screen" || exit 1 +sed -n 2p "$DIR/screen" | grep -q '^NEW02_#' || fail "row 2 not redrawn" +sed -n 3p "$DIR/screen" | grep -q '^ROW03_x' || fail "row 3 overwritten" +sed -n 6p "$DIR/screen" | grep -q '^NEW06_%' || fail "row 6 not redrawn" +exit 0 diff --git a/regress/tab-cell-background.sh b/regress/tab-cell-background.sh old mode 100755 new mode 100644 diff --git a/regress/targets-panes.sh b/regress/targets-panes.sh index 9ea936173..6ec5ffe7d 100644 --- a/regress/targets-panes.sh +++ b/regress/targets-panes.sh @@ -97,6 +97,10 @@ check "p:.{top-left}" "%0" check_ok select-pane -t p:0.%0 check "p:0.+" "1" '#{pane_index}' # next pane check "p:0.-" "3" '#{pane_index}' # previous pane (wraps) +check_fail "can't find pane: +0" "p:0.+0" +check_fail "can't find pane: +foo" "p:0.+foo" +check_fail "can't find pane: -0" "p:0.-0" +check_fail "can't find pane: -foo" "p:0.-foo" # --- last pane (!) -------------------------------------------------------- check_ok select-pane -t p:0.%2 diff --git a/regress/targets.sh b/regress/targets.sh index 75ee3dbb8..768feda07 100644 --- a/regress/targets.sh +++ b/regress/targets.sh @@ -154,6 +154,13 @@ check "alpha:{previous}" "3" check "alpha:!" "2" # last window check "alpha:{last}" "2" +# Malformed relative offsets must fail rather than silently selecting the +# current window. +check_fail "can't find window: +0" "alpha:+0" +check_fail "can't find window: +foo" "alpha:+foo" +check_fail "can't find window: -0" "alpha:-0" +check_fail "can't find window: -foo" "alpha:-foo" + # --- combined and empty forms --------------------------------------------- # # Empty targets use the current pane from TMUX_PANE when there is no client. diff --git a/regress/terminal-feature-utf8.sh b/regress/terminal-feature-utf8.sh new file mode 100644 index 000000000..b1e0a1fbd --- /dev/null +++ b/regress/terminal-feature-utf8.sh @@ -0,0 +1,58 @@ +#!/bin/sh + +# The utf8 terminal feature must mark an attached client as UTF-8 even when +# neither its environment nor -u does so. It must also be reported by the +# client terminal-feature format lookup. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +cleanup +$INNER new-session -d -s inner -x40 -y10 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 + +# A clean C-locale environment avoids the ordinary UTF-8 detection paths. +# -T utf8 is therefore solely responsible for setting client_utf8. +client_command="env -i PATH=/bin:/usr/bin TERM=screen LC_ALL=C $TEST_TMUX -T utf8 -LtestI$$ -f/dev/null attach-session -t inner" +$OUTER new-session -d -s outer -x40 -y10 "$client_command" || exit 1 +$OUTER set-option -g status off || exit 1 + +i=0 +while [ "$i" -lt 50 ]; do + client=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + utf8=$($INNER list-clients -F '#{client_utf8}' 2>/dev/null) + [ -n "$client" ] && [ "$utf8" = 1 ] && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "utf8 terminal feature did not set client_utf8" + +features=$($INNER list-clients -F '#{client_termfeatures}') +case ",$features," in +*,utf8,*) ;; +*) fail "utf8 is missing from client_termfeatures: $features" ;; +esac + +present=$($INNER display-message -c "$client" -p '#{I/f:utf8}') +[ "$present" = 1 ] || fail "utf8 terminal feature lookup returned '$present'" + +exit 0 diff --git a/regress/theme-report.sh b/regress/theme-report.sh old mode 100755 new mode 100644 diff --git a/regress/tty-keys.sh b/regress/tty-keys.sh index 19b1623dd..914c15c84 100644 --- a/regress/tty-keys.sh +++ b/regress/tty-keys.sh @@ -9,14 +9,37 @@ $TMUX kill-server 2>/dev/null TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" $TMUX2 kill-server 2>/dev/null -TMP=$(mktemp) -trap "rm -f $TMP" 0 1 15 +TMP=$(mktemp) || exit 1 +prompt_pid= + +cleanup () { + if [ -n "$prompt_pid" ]; then + kill "$prompt_pid" 2>/dev/null + wait "$prompt_pid" 2>/dev/null + fi + $TMUX kill-server 2>/dev/null + $TMUX2 kill-server 2>/dev/null + rm -f "$TMP" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +fail () { + echo "[FAIL] $*" >&2 + exit 1 +} $TMUX2 -f/dev/null new -d || exit 1 $TMUX -f/dev/null new -d "$TMUX2 attach" || exit 1 -sleep 1 +i=0 +while [ -z "$($TMUX2 list-clients -F '#{client_name}')" ]; do + [ "$i" -lt 100 ] || fail "inner client did not attach" + sleep 0.05 + i=$((i + 1)) +done exit_status=0 +assertion=0 format_string () { case $1 in @@ -34,12 +57,33 @@ assert_key () { expected_name=$2 format_string=$(format_string "$expected_name") - $TMUX2 command-prompt -k 'display-message -pl '"$format_string" > "$TMP" & - sleep 0.05 + # Use a different prompt each time so a stale redraw cannot look ready. + assertion=$((assertion + 1)) + prompt="tty-keys-$assertion:" + $TMUX2 command-prompt -k -p "$prompt" \ + 'display-message -pl '"$format_string" > "$TMP" & + prompt_pid=$! + i=0 + while ! $TMUX capture-pane -p | grep -Fq "$prompt"; do + kill -0 "$prompt_pid" 2>/dev/null || \ + fail "$keys -> $expected_name: prompt exited before becoming ready" + [ "$i" -lt 100 ] || \ + fail "$keys -> $expected_name: timed out waiting for prompt" + sleep 0.05 + i=$((i + 1)) + done - $TMUX send-keys $keys + $TMUX send-keys $keys || fail "could not send $keys" - wait + i=0 + while kill -0 "$prompt_pid" 2>/dev/null; do + [ "$i" -lt 100 ] || \ + fail "$keys -> $expected_name: timed out waiting for key" + sleep 0.05 + i=$((i + 1)) + done + wait "$prompt_pid" || fail "$keys -> $expected_name: prompt failed" + prompt_pid= keys=$(printf '%s' "$keys" | sed -e 's/Escape/\\\\033/g' | tr -d '[:space:]') actual_name=$(tr -d '[:space:]' < "$TMP") @@ -293,6 +337,16 @@ assert_key 'Escape [24@' 'C-S-F12' assert_key 'Escape [I' 'FocusIn' assert_key 'Escape [O' 'FocusOut' +# Focus events do not leave the prefix table. +$TMUX2 bind C-c set -g @focus-prefix yes +$TMUX2 set -g focus-events on +$TMUX send-keys C-b Escape '[O' Escape '[I' C-c +sleep 0.05 +if [ "$($TMUX2 show -gv @focus-prefix)" != yes ]; then + echo "[FAIL] FocusIn and FocusOut left the prefix table" + exit_status=1 +fi + # Paste keys assert_key 'Escape [200~' 'PasteStart' assert_key 'Escape [201~' 'PasteEnd' @@ -355,7 +409,4 @@ assert_key 'Escape [32;2u' 'S-Space' assert_key 'Escape [9;5u' 'C-Tab' assert_key 'Escape [1;5Z' 'C-S-Tab' -$TMUX kill-server 2>/dev/null -$TMUX2 kill-server 2>/dev/null - exit $exit_status diff --git a/regress/tty-margins-sb.sh b/regress/tty-margins-sb.sh new file mode 100755 index 000000000..6f573af92 --- /dev/null +++ b/regress/tty-margins-sb.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +# With left/right margins, a pane narrowed by a scrollbar can scroll without +# retransmitting its existing rows. Both paths must produce the same screen. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; +$| = 1; +print join("\r\n", map { "KEEP0$_" } 1 .. 6); +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} +print "\r\nNEWROW"; +sleep 100; +PERL + +j=0 +for margins in on off; do + INNER="$TEST_TMUX -LA$j$$ -f/dev/null" + OUTER="$TEST_TMUX -LB$j$$ -f/dev/null" + + $INNER new-session -d -s inner -x 40 -y 6 \ + "TRIGGER='$DIR/trigger-$margins' perl '$DIR/emitter.pl'" || exit 1 + $INNER set -g status off || exit 1 + $INNER set -g status-interval 0 || exit 1 + $INNER set -g automatic-rename off || exit 1 + $INNER set -g window-size manual || exit 1 + $INNER set -g pane-scrollbars on || exit 1 + if [ "$margins" = on ]; then + $INNER set -as terminal-features ',screen-256color:margins' || exit 1 + fi + + $OUTER new-session -d -s outer -x 40 -y 6 'sleep 100' || exit 1 + $OUTER set -g status off || exit 1 + $OUTER set -g window-size manual || exit 1 + $OUTER set -g default-terminal screen-256color || exit 1 + $OUTER respawn-pane -k -t outer:0.0 "$INNER attach -t inner" || exit 1 + sleep 0.5 + $OUTER pipe-pane -O -t outer:0.0 "cat >'$DIR/output-$margins'" || exit 1 + $INNER refresh-client || exit 1 + sleep 0.5 + grep -aq KEEP02 "$DIR/output-$margins" || fail "initial rows not captured" + offset=$(wc -c <"$DIR/output-$margins") + : >"$DIR/trigger-$margins" + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$DIR/screen-$margins" || exit 1 + grep -q NEWROW "$DIR/screen-$margins" && break + sleep 0.1 + i=$((i + 1)) + done + [ "$i" -lt 50 ] || fail "$margins: scroll did not reach the terminal" + sleep 0.2 + tail -c +"$((offset + 1))" "$DIR/output-$margins" >"$DIR/scroll-$margins" + grep -aq NEWROW "$DIR/scroll-$margins" || fail "scroll output not captured" + if [ "$margins" = on ]; then + if grep -aq KEEP02 "$DIR/scroll-$margins"; then + fail "scroll with margins retransmitted an existing row" + fi + else + grep -aq KEEP02 "$DIR/scroll-$margins" || + fail "scroll without margins did not exercise the redraw fallback" + fi + printf 'KEEP02\nKEEP03\nKEEP04\nKEEP05\nKEEP06\nNEWROW\n' >"$DIR/expected" + cmp -s "$DIR/expected" "$DIR/screen-$margins" || + fail "$margins: terminal did not contain the expected scrolled rows" + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + j=$((j + 1)) +done + +exit 0 diff --git a/regress/wait-for-client-lost.sh b/regress/wait-for-client-lost.sh new file mode 100644 index 000000000..cb36b22a4 --- /dev/null +++ b/regress/wait-for-client-lost.sh @@ -0,0 +1,164 @@ +#!/bin/sh + +# A disconnected waiter must release both its wait and its command queue. +PATH=/bin:/usr/bin +TERM=screen +export TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +OUT=$(mktemp -d) +TMUX="$TEST_TMUX -S$OUT/socket -f/dev/null" +pids= + +cleanup() +{ + for pid in $pids; do + kill "$pid" 2>/dev/null || true + done + $TMUX kill-server 2>/dev/null || true + rm -rf "$OUT" +} +trap cleanup EXIT + +fail() +{ + echo "$*" >&2 + exit 1 +} + +wait_list() +{ + expected=$1 + shift + i=0 + while [ $i -lt 100 ]; do + value=$($TMUX wait-for -l "$@") || fail "list waiters failed" + [ "$value" = "$expected" ] && return + i=$((i + 1)) + sleep 0.05 + done + fail "expected waiters '$expected', got '$value'" +} + +wait_free() +{ + client_pid=$1 + i=0 + while [ $i -lt 100 ]; do + awk -v pid="$client_pid" ' + $4 == "IDENTIFY_CLIENTPID" && $5 == pid { address = $3 } + address != "" && $2 == "free" && $3 == "client" && + $4 == address && $5 == "(0" { freed = 1 } + END { exit !freed } + ' "$OUT"/tmux-server-*.log && return + i=$((i + 1)) + sleep 0.05 + done + fail "client $client_pid was not freed" +} + +cd "$OUT" || exit 1 +$TMUX -vv new-session -d -s test || fail "new-session failed" +$TMUX set -g @continued 0 || fail "set option failed" + +for signal in TERM KILL; do + for kind in channel lock event; do + name="$kind-$signal" + case $kind in + channel) args="$name"; list_args="$name" ;; + lock) + $TMUX wait-for -L "$name" || fail "lock failed" + args="-L $name"; list_args="$name" + ;; + event) args="-E @$name"; list_args="-E @$name" ;; + esac + + $TMUX wait-for $args \; set -g @continued 1 & + dead=$! + pids="$dead" + wait_list "client-$dead" $list_args + + $TMUX wait-for $args & + live=$! + pids="$dead $live" + wait_list "$(printf 'client-%s\nclient-%s' "$dead" "$live")" \ + $list_args + + kill -"$signal" "$dead" || fail "kill failed" + wait "$dead" 2>/dev/null || true + pids="$live" + wait_list "client-$live" $list_args + wait_free "$dead" + [ "$($TMUX show -gqv @continued)" = 0 ] || + fail "disconnected client's next command ran" + + case $kind in + channel) $TMUX wait-for -S "$name" || fail "signal failed" ;; + lock) $TMUX wait-for -U "$name" || fail "unlock failed" ;; + event) + $TMUX wait-for -E -w "client-$live" "@$name" || + fail "wake event waiter failed" + ;; + esac + wait_list "" $list_args + wait_free "$live" + wait "$live" || fail "surviving waiter failed" + pids= + if [ "$kind" = lock ]; then + $TMUX wait-for -U "$name" || fail "final unlock failed" + fi + done +done + +# Removing the last waiter must remove its otherwise unused channel, and must +# not create a pending signal. Unrelated pending signals must be preserved. +$TMUX wait-for -S saved || fail "save signal failed" +$TMUX wait-for last & +dead=$! +pids="$dead" +wait_list "client-$dead" last +kill -KILL "$dead" || fail "kill last waiter failed" +wait "$dead" 2>/dev/null || true +pids= +wait_list "" last +wait_free "$dead" +grep -F 'remove empty wait channel last' "$OUT"/tmux-server-*.log \ + >/dev/null || fail "empty channel was not removed" + +$TMUX wait-for saved \; wait-for last & +live=$! +pids="$live" +wait_list "client-$live" last +$TMUX wait-for -S last || fail "signal last failed" +wait_free "$live" +wait "$live" || fail "pending signal was not preserved" +pids= + +# Source-file leaves a cleanup callback behind the blocked command. +printf 'wait-for sourced\nset -g @continued 1\n' >"$OUT/source.conf" +$TMUX source-file "$OUT/source.conf" & +dead=$! +pids="$dead" +wait_list "client-$dead" sourced +kill -KILL "$dead" || fail "kill source-file waiter failed" +wait "$dead" 2>/dev/null || true +pids= +wait_list "" sourced +wait_free "$dead" +[ "$($TMUX show -gqv @continued)" = 0 ] || + fail "disconnected source-file client's next command ran" + +# Firing an event after cancellation must not call the removed event sink. +$TMUX wait-for -E window-renamed & +dead=$! +pids="$dead" +wait_list "client-$dead" -E window-renamed +kill -KILL "$dead" || fail "kill event waiter failed" +wait "$dead" 2>/dev/null || true +pids= +wait_list "" -E window-renamed +wait_free "$dead" +$TMUX rename-window -t test:0 renamed || fail "rename-window failed" +$TMUX display-message -p '#{pid}' >/dev/null || fail "server exited" + +exit 0 diff --git a/regress/window-copy-sb-drag-focus-style.sh b/regress/window-copy-sb-drag-focus-style.sh new file mode 100755 index 000000000..77f478955 --- /dev/null +++ b/regress/window-copy-sb-drag-focus-style.sh @@ -0,0 +1,123 @@ +#!/bin/sh + +# Dragging an inactive pane's scrollbar slider changes which pane is +# active (window_copy_scroll(), window-copy.c). Every other place in the +# codebase that changes the active pane pairs it with +# window_redraw_active_switch() (or falls back to a full window redraw) so +# the old/new active panes' window-active-style/window-style colours get +# repainted - window_copy_scroll() was the one caller that did neither, so +# a scrollbar-slider drag changed focus but left both panes' body colours +# stale. This used to be masked by window_set_active_pane() itself doing +# an unconditional full redraw on every active-pane change, until that was +# narrowed to borders/status-only for the common case. +# +# This creates two tiled panes with clearly distinguishable +# window-active-style/window-style backgrounds, puts the *inactive* one in +# copy mode, drags its scrollbar slider, and checks - via an attached +# client's own received bytes, since window-style is applied during +# redraw composition rather than stored in the grid, so capture-pane +# alone would not reflect it - that the newly active pane's marker text +# immediately shows the active-style colour, not the stale one. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lsbfocus-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lsbfocus-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture +ESC=$(printf '\033') + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat -A "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +get_mark_color() +{ + $OUTER capture-pane -e -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + LC_ALL=C grep -a "MARK1" "$CAPTURE" | + LC_ALL=C grep -aoE "${ESC}\[4[12]m" | tail -1 +} + +wait_mark_color() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + [ "$(get_mark_color)" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "MARK1's pane never showed the expected background (wanted '$want', got '$(get_mark_color)')" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +$INNER new-session -d -s inner -x 40 -y 10 'sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g pane-scrollbars on || exit 1 +$INNER setw pane-scrollbars-position right || exit 1 +$INNER setw pane-scrollbars-style 'width=1,pad=0' || exit 1 +$INNER set-option -g window-active-style 'bg=red' || exit 1 +$INNER set-option -g window-style 'bg=green' || exit 1 +$INNER split-window -h -t inner -d 'printf MARK1; sleep 100' || exit 1 + +PANE1=$($INNER list-panes -t inner -F '#{pane_id}' | sed -n 2p) +[ "$($INNER display-message -p -t "$PANE1" '#{pane_active}')" = 0 ] || + fail "sanity: MARK1's pane is already active before the drag" + +# Put the inactive pane in copy mode without switching focus (copy-mode -t +# targets a pane without making it active). +$INNER copy-mode -t "$PANE1" || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lsbfocus-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_mark_color "${ESC}[42m" + +XOFF=$($INNER display-message -p -t "$PANE1" '#{pane_left}') +YOFF=$($INNER display-message -p -t "$PANE1" '#{pane_top}') +SX=$($INNER display-message -p -t "$PANE1" '#{pane_width}') +SBCOL=$((XOFF + SX + 1)) +SBROW=$((YOFF + 1)) + +# With no scrollback beyond the pane's own content, the slider fills the +# whole scrollbar track, so any point on it (here, its very first row) is +# on the slider. +mouse 0 "$SBCOL" "$SBROW" M +mouse 32 "$SBCOL" "$((SBROW + 1))" M +mouse 0 "$SBCOL" "$((SBROW + 1))" m + +[ "$($INNER display-message -p -t "$PANE1" '#{pane_active}')" = 1 ] || + fail "sanity: dragging the scrollbar slider did not change focus" + +wait_mark_color "${ESC}[41m" + +exit 0 diff --git a/regress/window-index-limit.sh b/regress/window-index-limit.sh new file mode 100644 index 000000000..6467adfe3 --- /dev/null +++ b/regress/window-index-limit.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# inserting a window next to the highest index + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestWIL$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +TMP=$(mktemp) +trap "rm -f $TMP" 0 1 15 + +$TMUX new -d -s one +$TMUX set -g base-index 2147483647 +$TMUX neww -d -t one + +$TMUX neww -d -a -t one:2147483647 +$TMUX neww -d -b -t one:2147483647 + +echo $($TMUX lsw -t one -F'#{window_index}') >$TMP +(echo "0 2147483647"|cmp -s - $TMP) || exit 1 +$TMUX kill-server 2>/dev/null + +exit 0 diff --git a/regress/window-ops.sh b/regress/window-ops.sh index 25cbd8db0..c385a3328 100644 --- a/regress/window-ops.sh +++ b/regress/window-ops.sh @@ -8,7 +8,7 @@ # This exercises: # - new-window placement: next free index, explicit index, index in use with # and without -k, -a (after) and -b (before) insertion with shuffling, and -# -S selecting an existing window by name instead of creating; +# -S selecting an existing window by name or target instead of creating; # - move-window to a free index, to an occupied index with and without -k, # -a insertion and -r renumbering (including base-index); # - renumber-windows closing gaps; @@ -140,6 +140,36 @@ check_ok new-window -S -t W: -n w3 check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' check_fmt 'W:' '#{window_index}:#{window_name}' '5:w3' +# -S with a target-window that already exists selects it directly, ignoring +# -n (with -d it does not switch). +check_ok select-window -t W:0 +check_ok new-window -S -t W:9 -n ignored +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' +check_fmt 'W:' '#{window_index}:#{window_name}' '9:w9k' +check_ok select-window -t W:0 +check_ok new-window -S -d -t W:9 -n ignored +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' +check_fmt 'W:' '#{window_index}:#{window_name}' '0:wB' + +# -S with a target-window that does not yet exist still creates it normally. +check_ok new-window -S -t W:20 -n w20 +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k 20:w20' +check_fmt 'W:' '#{window_index}:#{window_name}' '20:w20' +check_ok kill-window -t W:20 + +# -S also works with a relative target-window (+N): selects the window at +# that offset if it exists, or creates it there if not. +check_ok select-window -t W:0 +check_ok new-window -S -t W:+1 +check_fmt 'W:' '#{window_index}:#{window_name}' '1:w0' +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' + +check_ok select-window -t W:0 +check_ok new-window -S -t W:+6 -n rel6 +check_fmt 'W:' '#{window_index}:#{window_name}' '6:rel6' +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 6:rel6 9:w9k' +check_ok kill-window -t W:6 + # Clean up to a known arrangement. check_ok kill-window -t W:wB check_ok kill-window -t W:wA diff --git a/screen-redraw.c b/screen-redraw.c index 7ea30a8a4..e4fb93281 100644 --- a/screen-redraw.c +++ b/screen-redraw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-redraw.c,v 1.157 2026/07/24 08:49:23 nicm Exp $ */ +/* $OpenBSD: screen-redraw.c,v 1.163 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -33,7 +33,7 @@ * this is done at various points, such as when a pane is moved or resized. The * scene only includes the part of the client used for the window: panes, pane * status lines, borders, scrollbars, and any area outside the window. The - * client status line and overlay are not included. + * client status line is not included. * * A scene is made from spans. A span is a horizontal run of cells on one * visible line that can be drawn in the same way. Each span has a type, for @@ -93,7 +93,6 @@ enum redraw_span_type { #define REDRAW_PANE_SCROLLBAR 0x20 #define REDRAW_STATUS 0x40 #define REDRAW_MENU 0x80 -#define REDRAW_OVERLAY 0x100 /* Draw everything. */ #define REDRAW_ALL 0x7fffffff @@ -205,14 +204,36 @@ struct redraw_scene { u_int oy; }; +/* A damaged rectangle in a window. */ +struct redraw_damage { + u_int x; + u_int y; + u_int sx; + u_int sy; + + TAILQ_ENTRY(redraw_damage) entry; +}; + +/* + * If there are more damage rectangles than this, they are collapsed into + * one. + */ +#define REDRAW_DAMAGE_MAX 16 + /* Cell for building the scene. */ struct redraw_build_cell { struct redraw_span_data data; }; - static struct redraw_build_cell *redraw_cells; static size_t redraw_ncells; +/* + * We can reuse the same pane status lines during one damage redraw, but when + * we enter a new one, the client or format variables may have changed, so we + * need to make them again. The generation is increased so this happens. + */ +static u_int redraw_status_generation; + /* Context for building the scene. */ struct redraw_build_ctx { struct client *c; @@ -264,8 +285,6 @@ redraw_flags_to_string(int flags) strlcat(s, "scrollbar ", sizeof s); if (flags & REDRAW_MENU) strlcat(s, "menu ", sizeof s); - if (flags & REDRAW_OVERLAY) - strlcat(s, "overlay ", sizeof s); if (REDRAW_IS_ALL(flags)) strlcat(s, "all ", sizeof s); if (*s != '\0') @@ -713,7 +732,7 @@ redraw_mark_pane_borders(struct redraw_build_ctx *bctx, struct window_pane *wp, } else { mark_right = (right <= (int)bctx->w->sx); mark_bottom = (bottom <= (int)bctx->w->sy); - if (pane_status == PANE_STATUS_TOP) + if (pane_status == PANE_STATUS_TOP && bottom < (int)bctx->w->sy) mark_bottom = 0; else if (pane_status == PANE_STATUS_BOTTOM) mark_top = 0; @@ -1057,6 +1076,13 @@ redraw_free_scene(struct redraw_scene *scene) free(scene); } +/* Does a client's cached scene show this window? */ +int +redraw_client_has_window(struct client *c, struct window *w) +{ + return (c->redraw_scene != NULL && c->redraw_scene->w == w); +} + /* Mark a window's cached redraw scenes as out of date. */ void redraw_invalidate_scene(struct window *w) @@ -1064,6 +1090,104 @@ redraw_invalidate_scene(struct window *w) w->redraw_scene_generation++; } +/* Free all pending damage for a window. */ +void +redraw_free_damage(struct window *w) +{ + struct redraw_damage *rd, *rd1; + + TAILQ_FOREACH_SAFE(rd, &w->damage, entry, rd1) { + TAILQ_REMOVE(&w->damage, rd, entry); + free(rd); + } + w->damage_count = 0; +} + +/* Collapse all pending damage for a window into one rectangle. */ +static void +redraw_collapse_damage(struct window *w) +{ + struct redraw_damage *rd, *rd1, *first; + u_int x0, y0, x1, y1; + + first = TAILQ_FIRST(&w->damage); + if (first == NULL) + return; + x0 = first->x; + y0 = first->y; + x1 = first->x + first->sx; + y1 = first->y + first->sy; + + TAILQ_FOREACH_SAFE(rd, &w->damage, entry, rd1) { + if (rd->x < x0) + x0 = rd->x; + if (rd->y < y0) + y0 = rd->y; + if (rd->x + rd->sx > x1) + x1 = rd->x + rd->sx; + if (rd->y + rd->sy > y1) + y1 = rd->y + rd->sy; + if (rd != first) { + TAILQ_REMOVE(&w->damage, rd, entry); + free(rd); + } + } + + first->x = x0; + first->y = y0; + first->sx = x1 - x0; + first->sy = y1 - y0; + w->damage_count = 1; +} + +/* Record window damage, merging nearby rectangles and limiting the count. */ +void +redraw_damage_window(struct window *w, u_int x, u_int y, u_int sx, u_int sy) +{ + struct redraw_damage *rd; + u_int x0, y0, x1, y1, area, union_area; + + if (x >= w->sx || y >= w->sy) + return; + if (x + sx > w->sx) + sx = w->sx - x; + if (y + sy > w->sy) + sy = w->sy - y; + if (sx == 0 || sy == 0) + return; + + TAILQ_FOREACH(rd, &w->damage, entry) { + if (x > rd->x + rd->sx || rd->x > x + sx || + y > rd->y + rd->sy || rd->y > y + sy) + continue; + + x0 = (x < rd->x) ? x : rd->x; + y0 = (y < rd->y) ? y : rd->y; + x1 = (x + sx > rd->x + rd->sx) ? x + sx : rd->x + rd->sx; + y1 = (y + sy > rd->y + rd->sy) ? y + sy : rd->y + rd->sy; + + area = sx * sy + rd->sx * rd->sy; + union_area = (x1 - x0) * (y1 - y0); + if (union_area > 2 * area) + continue; + + rd->x = x0; + rd->y = y0; + rd->sx = x1 - x0; + rd->sy = y1 - y0; + return; + } + + rd = xcalloc(1, sizeof *rd); + rd->x = x; + rd->y = y; + rd->sx = sx; + rd->sy = sy; + TAILQ_INSERT_TAIL(&w->damage, rd, entry); + if (++w->damage_count > REDRAW_DAMAGE_MAX) + redraw_collapse_damage(w); +} + /* Mark all cached redraw scenes as out of date. */ void redraw_invalidate_all_scenes(void) @@ -1381,45 +1505,30 @@ static void redraw_draw_span(struct redraw_draw_ctx *dctx, struct redraw_span *span, u_int y) { - struct redraw_scene *scene = dctx->scene; struct redraw_span_data *data = &span->data; enum redraw_span_type type = data->type; - struct client *c = scene->c; - struct tty *tty = &c->tty; - struct visible_ranges *r; - struct visible_range *rr; - u_int i, x, n; if (type == REDRAW_SPAN_STATUS && ~data->st.wp->flags & PANE_NEWSTATUS) return; - r = tty_check_overlay_range(tty, span->x, y, span->width); - for (i = 0; i < r->used; i++) { - rr = &r->ranges[i]; - if (rr->nx == 0) - continue; - x = rr->px; - n = rr->nx; - - switch (span->data.type) { - case REDRAW_SPAN_PANE: - redraw_draw_pane_span(dctx, span, x, y, n); - break; - case REDRAW_SPAN_BORDER: - case REDRAW_SPAN_EMPTY: - case REDRAW_SPAN_OUTSIDE: - redraw_draw_border_span(dctx, span, x, y, n); - break; - case REDRAW_SPAN_STATUS: - redraw_draw_status_span(dctx, span, x, y, n); - break; - case REDRAW_SPAN_SCROLLBAR: - redraw_draw_scrollbar_span(dctx, span, x, y, n); - break; - case REDRAW_SPAN_MENU: - redraw_draw_menu_span(dctx, span, x, y, n); - break; - } + switch (span->data.type) { + case REDRAW_SPAN_PANE: + redraw_draw_pane_span(dctx, span, span->x, y, span->width); + break; + case REDRAW_SPAN_BORDER: + case REDRAW_SPAN_EMPTY: + case REDRAW_SPAN_OUTSIDE: + redraw_draw_border_span(dctx, span, span->x, y, span->width); + break; + case REDRAW_SPAN_STATUS: + redraw_draw_status_span(dctx, span, span->x, y, span->width); + break; + case REDRAW_SPAN_SCROLLBAR: + redraw_draw_scrollbar_span(dctx, span, span->x, y, span->width); + break; + case REDRAW_SPAN_MENU: + redraw_draw_menu_span(dctx, span, span->x, y, span->width); + break; } } @@ -1621,6 +1730,24 @@ redraw_set_draw_context(struct redraw_draw_ctx *dctx, dctx->flags |= REDRAW_ISOLATES; } +/* Build a pane prompt. */ +static void +redraw_make_pane_prompt(struct window_pane *wp, struct screen *screen) +{ + struct screen_write_ctx ctx; + struct prompt_draw_data pdd; + + screen_init(screen, wp->sx, 1, 0); + screen_write_start(&ctx, screen); + pdd.ctx = &ctx; + pdd.cursor_x = &wp->prompt_cx; + pdd.area_x = 0; + pdd.area_width = wp->sx; + pdd.prompt_line = 0; + prompt_draw(wp->prompt, &pdd); + screen_write_stop(&ctx); +} + /* Draw a pane's prompt over its content. */ static void redraw_draw_pane_prompt(struct redraw_draw_ctx *dctx, struct window_pane *wp) @@ -1629,8 +1756,6 @@ redraw_draw_pane_prompt(struct redraw_draw_ctx *dctx, struct window_pane *wp) struct client *c = scene->c; struct tty *tty = &c->tty; struct screen screen; - struct screen_write_ctx ctx; - struct prompt_draw_data pdd; int ox = scene->ox, oy = scene->oy; int sx = scene->sx, sy = scene->sy; int line, cy, px, offset, width, wy; @@ -1663,17 +1788,8 @@ redraw_draw_pane_prompt(struct redraw_draw_ctx *dctx, struct window_pane *wp) if (px + width > sx) width = sx - px; - screen_init(&screen, wp->sx, 1, 0); - screen_write_start(&ctx, &screen); - pdd.ctx = &ctx; - pdd.cursor_x = &wp->prompt_cx; - pdd.area_x = 0; - pdd.area_width = wp->sx; - pdd.prompt_line = 0; - prompt_draw(wp->prompt, &pdd); - screen_write_stop(&ctx); - - tty_draw_line(tty, &screen, 0, offset, width, px, cy, NULL); + redraw_make_pane_prompt(wp, &screen); + tty_draw_line(tty, &screen, offset, 0, width, px, cy, NULL); screen_free(&screen); } @@ -1688,10 +1804,8 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) struct screen *sl; struct redraw_scene *scene; struct window_pane *loop; - u_int width, i, y, lines, j; + u_int width, i, y, lines; struct redraw_span *first; - struct visible_ranges *r; - struct visible_range *rr; int redraw; if (c->flags & CLIENT_SUSPENDED) @@ -1704,7 +1818,9 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) redraw = status_prompt_redraw(c); else redraw = status_redraw(c); - if (!redraw && !REDRAW_IS_ALL(flags)) { + if (!redraw && + (~c->flags & CLIENT_REDRAWSTATUSALWAYS) && + !REDRAW_IS_ALL(flags)) { flags &= ~REDRAW_STATUS; if (flags == 0) return; @@ -1738,8 +1854,7 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) else loop->flags &= ~PANE_NEWSTATUS; - width = redraw_pane_status_width(&dctx, loop, - &first); + width = redraw_pane_status_width(&dctx, loop, &first); if (width == 0) continue; @@ -1759,18 +1874,18 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) if (wp != NULL) { if (wp->base.mode & MODE_SYNC) screen_write_stop_sync(wp); - screen_write_clear_dirty(wp); + screen_write_sync_clear_dirty(wp); } else { TAILQ_FOREACH(loop, &scene->w->panes, entry) { if (!window_pane_is_visible(loop)) continue; if (loop->base.mode & MODE_SYNC) screen_write_stop_sync(loop); - screen_write_clear_dirty(loop); + screen_write_sync_clear_dirty(loop); } } } - tty_sync_start(tty); + tty_sync_start(tty); /* end in server_client_reset_state */ tty_update_mode(tty, tty->mode & ~CURSOR_MODES, NULL); if (wp != NULL) @@ -1800,22 +1915,11 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) else y = c->tty.sy - lines; sl = c->status.active; - for (i = 0; i < lines; i++) { - r = tty_check_overlay_range(tty, 0, y + i, tty->sx); - for (j = 0; j < r->used; j++) { - rr = &r->ranges[j]; - if (rr->nx == 0) - continue; - tty_draw_line(tty, sl, rr->px, i, rr->nx, - rr->px, y + i, NULL); - } - } + for (i = 0; i < lines; i++) + tty_draw_line(tty, sl, 0, i, tty->sx, 0, y + i, NULL); } - if (c->overlay_draw != NULL && (flags & REDRAW_OVERLAY)) - c->overlay_draw(c, c->overlay_data); tty_reset(tty); - tty_sync_end(tty); #ifdef ENABLE_SIXEL if (wp != NULL) @@ -1869,18 +1973,13 @@ redraw_screen(struct client *c) { int flags = 0; - if (c->flags & CLIENT_REDRAWWINDOW) { - if (c->flags & CLIENT_REDRAWOVERLAY) - redraw_draw(c, NULL, REDRAW_ALL); - else - redraw_draw(c, NULL, REDRAW_ALL & ~REDRAW_OVERLAY); - } else { + if (c->flags & CLIENT_REDRAWWINDOW) + redraw_draw(c, NULL, REDRAW_ALL); + else { if (c->flags & CLIENT_REDRAWBORDERS) flags |= (REDRAW_PANE_BORDER|REDRAW_PANE_STATUS); - if (c->flags & CLIENT_REDRAWSTATUS) + if (c->flags & (CLIENT_REDRAWSTATUS|CLIENT_REDRAWSTATUSALWAYS)) flags |= (REDRAW_STATUS|REDRAW_PANE_STATUS); - if (c->flags & CLIENT_REDRAWOVERLAY) - flags |= REDRAW_OVERLAY; if (c->flags & CLIENT_REDRAWMENU) flags |= REDRAW_MENU; if (c->session->curw->window->menu != NULL) @@ -1905,3 +2004,138 @@ redraw_pane_scrollbar(struct client *c, struct window_pane *wp) { redraw_draw(c, wp, REDRAW_PANE_SCROLLBAR); } + +/* Rebuild damaged pane status. */ +static void +redraw_damage_refresh_status(struct redraw_draw_ctx *dctx, + struct window_pane *wp) +{ + struct redraw_span *first; + u_int g = wp->status_generation, width; + + if ((wp->flags & PANE_NEWSTATUS) && g == redraw_status_generation) + return; + width = redraw_pane_status_width(dctx, wp, &first); + if (width != 0) { + window_make_pane_status(wp, dctx->scene->c, width, first); + wp->flags |= PANE_NEWSTATUS; + wp->status_generation = redraw_status_generation; + } +} + +/* Draw a pane's prompt over a damaged span. */ +static void +redraw_damage_draw_pane_prompt(struct redraw_draw_ctx *dctx, + struct redraw_span *span, u_int y) +{ + struct redraw_scene *scene = dctx->scene; + struct window_pane *wp = span->data.p.wp; + struct tty *tty = &scene->c->tty; + struct screen screen; + u_int px = span->data.p.px, width, prompt_y; + + if (wp->prompt == NULL || wp->sx == 0 || wp->sy == 0) + return; + if (dctx->flags & REDRAW_STATUS_TOP) + prompt_y = 0; + else + prompt_y = wp->sy - 1; + if (span->data.p.py != prompt_y) + return; + redraw_make_pane_prompt(wp, &screen); + if (px < screen_size_x(&screen)) { + width = span->width; + if (width > screen_size_x(&screen) - px) + width = screen_size_x(&screen) - px; + tty_draw_line(tty, &screen, px, 0, width, span->x, y, NULL); + } + screen_free(&screen); +} + +/* Draw the spans intersecting a damaged rectangle. */ +static void +redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y, + u_int sx, u_int sy) +{ + struct redraw_scene *scene = dctx->scene; + struct redraw_line *line; + struct redraw_spans *spans; + struct redraw_span *span; + u_int cy, yy, type; + + if (x >= scene->sx || y >= scene->sy) + return; + if (x + sx > scene->sx) + sx = scene->sx - x; + if (y + sy > scene->sy) + sy = scene->sy - y; + if (sx == 0 || sy == 0) + return; + + for (yy = y; yy < y + sy; yy++) { + line = &scene->lines[yy]; + if (dctx->flags & REDRAW_STATUS_TOP) + cy = dctx->status_lines + yy; + else + cy = yy; + for (type = 0; type < REDRAW_SPAN_TYPES; type++) { + spans = &line->spans[type]; + TAILQ_FOREACH(span, spans, entry) { + if (span->x >= x + sx) + continue; + if (span->x + span->width <= x) + continue; + if (type == REDRAW_SPAN_STATUS) { + redraw_damage_refresh_status(dctx, + span->data.st.wp); + } + redraw_draw_span(dctx, span, cy); + if (type == REDRAW_SPAN_PANE) { + redraw_damage_draw_pane_prompt(dctx, + span, cy); + } + } + } + } +} + +/* Draw pending window damage on this client. */ +void +redraw_client_damage(struct client *c) +{ + struct window *w = c->session->curw->window; + struct window_pane *wp; + struct redraw_scene *scene; + struct redraw_draw_ctx dctx; + struct redraw_damage *rd; + u_int ox, oy, sx, sy, x0, y0, x1, y1; + + if (TAILQ_EMPTY(&w->damage)) + return; + redraw_status_generation++; + + scene = redraw_get_scene(c); + if (scene == NULL) + return; + redraw_set_draw_context(&dctx, scene); + redraw_get_window_offset(c, &ox, &oy, &sx, &sy); + + TAILQ_FOREACH(wp, &w->panes, entry) { + wp->border_gc_set = 0; + wp->active_border_gc_set = 0; + } + + tty_sync_start(&c->tty); + tty_update_mode(&c->tty, c->tty.mode & ~CURSOR_MODES, NULL); + + TAILQ_FOREACH(rd, &w->damage, entry) { + x0 = (rd->x > ox) ? rd->x : ox; + y0 = (rd->y > oy) ? rd->y : oy; + x1 = (rd->x + rd->sx < ox + sx) ? rd->x + rd->sx : ox + sx; + y1 = (rd->y + rd->sy < oy + sy) ? rd->y + rd->sy : oy + sy; + if (x0 < x1 && y0 < y1) { + redraw_draw_damage_rectangle(&dctx, x0 - ox, y0 - oy, + x1 - x0, y1 - y0); + } + } +} diff --git a/screen-write.c b/screen-write.c index ab55cd705..fa6c6534a 100644 --- a/screen-write.c +++ b/screen-write.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-write.c,v 1.290 2026/08/24 15:05:26 nicm Exp $ */ +/* $OpenBSD: screen-write.c,v 1.299 2026/10/05 08:41:23 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -38,7 +38,9 @@ static int screen_write_overwrite(struct screen_write_ctx *, struct grid_cell *, u_int); static int screen_write_combine(struct screen_write_ctx *, const struct grid_cell *); -static void screen_write_flush_dirty(struct window_pane *); +static void screen_write_sync_flush_dirty(struct window_pane *); +static void screen_write_sync_apply_scroll(struct screen_write_ctx *, + struct tty_ctx *); struct screen_write_citem { u_int x; @@ -120,14 +122,26 @@ screen_write_set_cursor(struct screen_write_ctx *ctx, int cx, int cy) evtimer_add(&w->offset_timer, &tv); } -/* Do a full redraw. */ +/* Redraw lines. */ static void -screen_write_redraw_cb(const struct tty_ctx *ttyctx) +screen_write_redraw_cb(const struct tty_ctx *ttyctx, u_int py, u_int ny) { struct window_pane *wp = ttyctx->arg; + int x0, y0, x1, y1; - if (wp != NULL) - wp->flags |= PANE_REDRAW; + if (wp == NULL) + return; + + x0 = wp->xoff; + y0 = wp->yoff + (int)py; + x1 = x0 + (int)wp->sx; + y1 = y0 + (int)ny; + if (x0 < 0) + x0 = 0; + if (y0 < 0) + y0 = 0; + if (x1 > x0 && y1 > y0) + redraw_damage_window(wp->window, x0, y0, x1 - x0, y1 - y0); } /* Update context for client. */ @@ -175,11 +189,12 @@ screen_write_set_client_cb(struct tty_ctx *ttyctx, struct client *c) return (1); } -/* Return 1 if there is a floating window pane overlapping this pane. */ +/* Return 1 if a menu or floating pane overlaps this pane. */ static int screen_write_pane_is_obscured(struct screen_write_ctx *ctx) { struct window_pane *wp = ctx->wp; + struct menu_data *md; if (ctx->wp == NULL) return (0); @@ -190,6 +205,16 @@ screen_write_pane_is_obscured(struct screen_write_ctx *ctx) } ctx->flags |= SCREEN_WRITE_CHECKED_IF_OBSCURED; + md = wp->window->menu; + if (md != NULL && + (int)menu_x(md) < wp->xoff + (int)wp->sx && + (int)(menu_x(md) + menu_width(md)) > wp->xoff && + (int)menu_y(md) < wp->yoff + (int)wp->sy && + (int)(menu_y(md) + menu_height(md)) > wp->yoff) { + ctx->flags |= SCREEN_WRITE_OBSCURED; + return (1); + } + if (ctx->wp->xoff < 0 || ctx->wp->yoff < 0 || ctx->wp->xoff + ctx->wp->sx > ctx->wp->window->sx || @@ -199,15 +224,7 @@ screen_write_pane_is_obscured(struct screen_write_ctx *ctx) } while ((wp = TAILQ_PREV(wp, window_panes, zentry)) != NULL) { - if (window_pane_is_floating(wp) && - ((wp->yoff >= ctx->wp->yoff && - wp->yoff <= ctx->wp->yoff + (int)ctx->wp->sy) || - (wp->yoff + (int)wp->sy >= ctx->wp->yoff && - wp->yoff + wp->sy <= ctx->wp->yoff + ctx->wp->sy)) && - ((wp->xoff >= ctx->wp->xoff && - wp->xoff <= ctx->wp->xoff + (int)ctx->wp->sx) || - (wp->xoff + (int)wp->sx >= ctx->wp->xoff && - wp->xoff + wp->sx <= ctx->wp->xoff + ctx->wp->sx))) { + if (window_pane_floating_overlaps(wp, ctx->wp)) { ctx->flags |= SCREEN_WRITE_OBSCURED; return (1); } @@ -215,6 +232,27 @@ screen_write_pane_is_obscured(struct screen_write_ctx *ctx) return (0); } +/* Allocate dirty lines for this screen size. */ +static bitstr_t * +screen_write_sync_allocate_dirty(struct window_pane *wp, u_int sy) +{ + bitstr_t *bs = wp->sync_dirty; + + if (bs != NULL && wp->sync_dirty_size == sy) + return (bs); + + free(bs); + bs = wp->sync_dirty = bit_alloc(sy); + if (bs == NULL) + fatal("bit_alloc failed"); + if (wp->sync_dirty_size != 0) { + bit_nset(bs, 0, sy - 1); + wp->sync_scrolled = 0; + } + wp->sync_dirty_size = sy; + return (bs); +} + /* Should we draw to the TTY? */ static int screen_write_should_draw_lines(struct screen_write_ctx *ctx, u_int y, u_int ny) @@ -228,21 +266,9 @@ screen_write_should_draw_lines(struct screen_write_ctx *ctx, u_int y, u_int ny) return (0); if (s->mode & MODE_SYNC) { if (wp != NULL && y < sy && ny != 0) { - bs = wp->sync_dirty; if (ny > sy - y) ny = sy - y; - if (bs == NULL || wp->sync_dirty_size != sy) { - if (bs != NULL && wp->sync_dirty_size != sy) { - y = 0; - ny = sy; - } - free(bs); - - bs = wp->sync_dirty = bit_alloc(sy); - if (bs == NULL) - fatal("bit_alloc failed"); - wp->sync_dirty_size = sy; - } + bs = screen_write_sync_allocate_dirty(wp, sy); bit_nset(bs, y, y + ny - 1); } return (0); @@ -305,21 +331,16 @@ screen_write_initctx(struct screen_write_ctx *ctx, struct tty_ctx *ttyctx, if (~ctx->flags & SCREEN_WRITE_SYNC) { /* - * For the active pane showing its base screen or for an - * overlay (no pane), only use synchronized updates if - * requested (commands that move the cursor); for other panes - * or a pane in a mode, always use it, since the cursor will - * have to move. + * For the active pane showing its base screen, only use + * synchronized updates if requested (commands that move the + * cursor); for other panes or a pane in a mode, always use it, + * since the cursor will have to move. */ if (ctx->wp != NULL && (ctx->wp != ctx->wp->window->active || ctx->wp->screen != &ctx->wp->base)) ttyctx->flags |= TTY_CTX_SYNC; - else { - if (ctx->wp == NULL) - ttyctx->flags |= TTY_CTX_OVERLAY_SYNC; - if (is_sync) - ttyctx->flags |= TTY_CTX_SYNC; - } + else if (is_sync) + ttyctx->flags |= TTY_CTX_SYNC; tty_write(tty_cmd_syncstart, ttyctx); ctx->flags |= SCREEN_WRITE_SYNC; } @@ -731,7 +752,7 @@ screen_write_fast_copy(struct screen_write_ctx *ctx, struct screen *src, if (!window_position_is_visible(r, xoff + s->cx)) break; ttyctx.cell = &gc; - ttyctx.flags &= (TTY_CTX_OVERLAY_SYNC|TTY_CTX_SYNC); + ttyctx.flags &= TTY_CTX_SYNC; tty_write(tty_cmd_cell, &ttyctx); ttyctx.ocx++; @@ -855,34 +876,39 @@ screen_write_menu(struct screen_write_ctx *ctx, struct menu *menu, int choice, struct screen *s = ctx->s; struct grid_cell default_gc; const struct grid_cell *gc = &default_gc; - u_int cx, cy, i, j, width = menu->width; + u_int border, cx = s->cx, cy = s->cy, i, j, width; const char *name; - cx = s->cx; - cy = s->cy; - memcpy(&default_gc, menu_gc, sizeof default_gc); - screen_write_box(ctx, menu->width + 4, menu->count + 2, lines, - border_gc, menu->title); + if (lines == BOX_LINES_NONE) { + border = 0; + width = menu->item_width; + } else { + border = 1; + width = menu->width; + screen_write_box(ctx, width + 4, menu->count + 2, lines, + border_gc, menu->title); + } for (i = 0; i < menu->count; i++) { name = menu->items[i].name; if (name == NULL) { - screen_write_cursormove(ctx, cx, cy + 1 + i, 0); - screen_write_hline(ctx, width + 4, 1, 1, lines, - border_gc); + screen_write_cursormove(ctx, cx, cy + border + i, 0); + screen_write_hline(ctx, width + 2 + (2 * border), 1, 1, + lines, border_gc); continue; } if (choice >= 0 && i == (u_int)choice && *name != '-') gc = choice_gc; - screen_write_cursormove(ctx, cx + 1, cy + 1 + i, 0); + screen_write_cursormove(ctx, cx + border, cy + border + i, 0); for (j = 0; j < width + 2; j++) screen_write_putc(ctx, gc, ' '); - screen_write_cursormove(ctx, cx + 2, cy + 1 + i, 0); + screen_write_cursormove(ctx, cx + border + 1, cy + border + i, + 0); if (*name == '-') { default_gc.attr |= GRID_ATTR_DIM; format_draw(ctx, gc, width, name + 1, NULL, 0); @@ -1049,7 +1075,7 @@ screen_write_sync_callback(__unused int fd, __unused short events, void *arg) if (wp->base.mode & MODE_SYNC) { wp->base.mode &= ~MODE_SYNC; - screen_write_flush_dirty(wp); + screen_write_sync_flush_dirty(wp); } } @@ -1081,7 +1107,7 @@ screen_write_stop_sync(struct window_pane *wp) evtimer_del(&wp->sync_timer); wp->base.mode &= ~MODE_SYNC; - screen_write_flush_dirty(wp); + screen_write_sync_flush_dirty(wp); log_debug("%s: %%%u stopped sync mode", __func__, wp->id); } @@ -1289,7 +1315,7 @@ screen_write_redraw_line(struct screen_write_ctx *ctx, struct tty_ctx *ttyctx, /* Redraw dirty lines. */ static void -screen_write_flush_dirty(struct window_pane *wp) +screen_write_sync_flush_dirty(struct window_pane *wp) { struct screen_write_ctx ctx; struct tty_ctx ttyctx; @@ -1302,29 +1328,143 @@ screen_write_flush_dirty(struct window_pane *wp) screen_write_start_pane(&ctx, wp, s); screen_write_initctx(&ctx, &ttyctx, 1, 1); - for (y = 0; y < sy; y++) { - if (bit_test(wp->sync_dirty, y)) { - screen_write_redraw_line(&ctx, &ttyctx, y); - lines++; + if (wp->sync_scrolled != 0) + screen_write_sync_apply_scroll(&ctx, &ttyctx); + + if (~wp->flags & PANE_REDRAW) { + for (y = 0; y < sy; y++) { + if (bit_test(wp->sync_dirty, y)) { + screen_write_redraw_line(&ctx, &ttyctx, y); + lines++; + } } } log_debug("%s: %%%u had %u dirty lines", __func__, wp->id, lines); screen_write_stop(&ctx); - screen_write_clear_dirty(wp); + screen_write_sync_clear_dirty(wp); } -/* Clear any dirty lines. */ +/* Clear pending synchronized output. */ void -screen_write_clear_dirty(struct window_pane *wp) +screen_write_sync_clear_dirty(struct window_pane *wp) { - if (wp != NULL && wp->sync_dirty != NULL) { + if (wp != NULL) { free(wp->sync_dirty); wp->sync_dirty = NULL; wp->sync_dirty_size = 0; + wp->sync_scrolled = 0; } } +/* Defer scrolling until the synchronized update ends. */ +static void +screen_write_sync_scroll_dirty(struct screen_write_ctx *ctx) +{ + struct window_pane *wp = ctx->wp; + struct screen *s = ctx->s; + u_int n = ctx->scrolled, y, sy = screen_size_y(s); + u_int ry = s->rlower + 1 - s->rupper; + bitstr_t *bs; + + if (n > ry) + n = ry; + if (wp == NULL || + n == ry || + s->rlower >= sy || + window_pane_scrollbar_overlay_visible(wp)) { + screen_write_should_draw_lines(ctx, s->rupper, ry); + return; + } + if (wp->flags & (PANE_REDRAW|PANE_DROP)) + return; + bs = screen_write_sync_allocate_dirty(wp, sy); + + if (wp->sync_scrolled != 0 && + (wp->sync_rupper != s->rupper || + wp->sync_rlower != s->rlower || + wp->sync_bg != ctx->bg)) { + log_debug("%s: %%%u region changed, redrawing all", __func__, + wp->id); + bit_nset(bs, 0, sy - 1); + wp->sync_scrolled = 0; + return; + } + + /* Keep dirty lines aligned with the scrolled grid. */ + for (y = s->rupper; y + n <= s->rlower; y++) { + if (bit_test(bs, y + n)) + bit_set(bs, y); + else + bit_clear(bs, y); + } + bit_nset(bs, s->rlower + 1 - n, s->rlower); + + wp->sync_rupper = s->rupper; + wp->sync_rlower = s->rlower; + wp->sync_bg = ctx->bg; + wp->sync_scrolled += n; + if (wp->sync_scrolled >= ry) { + bit_nset(bs, s->rupper, s->rlower); + wp->sync_scrolled = 0; + } + window_pane_scrollbar_redraw(wp); + + log_debug("%s: %%%u deferred scroll of %u (region %u-%u)", __func__, + wp->id, wp->sync_scrolled, s->rupper, s->rlower); +} + +/* Redraw the scrolled lines for a client which cannot scroll them. */ +static void +screen_write_sync_redraw_cb(const struct tty_ctx *ttyctx, u_int py, u_int ny) +{ + struct window_pane *wp = ttyctx->arg; + + bit_nset(wp->sync_dirty, py, py + ny - 1); +} + +/* Send the deferred scroll to the client. */ +static void +screen_write_sync_apply_scroll(struct screen_write_ctx *ctx, + struct tty_ctx *ttyctx) +{ + struct window_pane *wp = ctx->wp; + struct screen *s = ctx->s; + u_int sy = screen_size_y(s), n = wp->sync_scrolled; + tty_ctx_redraw_cb redraw_cb; + + wp->sync_scrolled = 0; + if (wp->flags & PANE_REDRAW) + return; + if (wp->sync_rlower >= sy || wp->sync_rupper > wp->sync_rlower) { + bit_nset(wp->sync_dirty, 0, sy - 1); + return; + } + if ((ttyctx->flags & TTY_CTX_PANE_OBSCURED) || + window_pane_scrollbar_overlay_visible(wp)) { + bit_nset(wp->sync_dirty, wp->sync_rupper, wp->sync_rlower); + return; + } + + log_debug("%s: %%%u scroll of %u (region %u-%u)", __func__, wp->id, n, + wp->sync_rupper, wp->sync_rlower); + + ttyctx->orupper = wp->sync_rupper; + ttyctx->orlower = wp->sync_rlower; + if (wp->yoff + wp->sy > wp->window->sy) + ttyctx->orlower -= (wp->yoff + wp->sy - wp->window->sy); + ttyctx->n = n; + ttyctx->bg = wp->sync_bg; + redraw_cb = ttyctx->redraw_cb; + ttyctx->redraw_cb = screen_write_sync_redraw_cb; + tty_write(tty_cmd_scrollup, ttyctx); + + ttyctx->redraw_cb = redraw_cb; + ttyctx->orupper = s->rupper; + ttyctx->orlower = s->rlower; + ttyctx->n = 0; +} + /* Redraw all visible cells in a pane. */ static void screen_write_redraw_pane(struct screen_write_ctx *ctx, struct tty_ctx *ttyctx) @@ -2172,7 +2312,7 @@ screen_write_fullredraw(struct screen_write_ctx *ctx) screen_write_initctx(ctx, &ttyctx, 1, 0); if (ttyctx.redraw_cb != NULL) - ttyctx.redraw_cb(&ttyctx); + ttyctx.redraw_cb(&ttyctx, 0, ttyctx.sy); } /* Trim collected items. */ @@ -2410,13 +2550,18 @@ screen_write_collect_flush_line(struct screen_write_ctx *ctx, u_int y) ttyctx.n = w_length; tty_write(tty_cmd_clearcharacter, &ttyctx); } else { - screen_write_initctx(ctx, &ttyctx, 0, 0); - ttyctx.cell = &ci->gc; - if (ci->wrapped) - ttyctx.flags |= TTY_CTX_WRAPPED; - ttyctx.data.data = cl->data + w_start; - ttyctx.data.size = w_length; - tty_write(tty_cmd_cells, &ttyctx); + screen_write_initctx(ctx, &ttyctx, 0, 1); + if (ttyctx.flags & TTY_CTX_PANE_OBSCURED) { + ttyctx.n = w_length; + tty_write(tty_cmd_redrawline, &ttyctx); + } else { + ttyctx.cell = &ci->gc; + if (ci->wrapped) + ttyctx.flags |= TTY_CTX_WRAPPED; + ttyctx.data.data = cl->data + w_start; + ttyctx.data.size = w_length; + tty_write(tty_cmd_cells, &ttyctx); + } } items++; written = 1; @@ -2444,10 +2589,8 @@ screen_write_collect_flush(struct screen_write_ctx *ctx, int scroll_only, if (wp != NULL && (wp->flags & (PANE_REDRAW|PANE_DROP))) goto discard; if (s->mode & MODE_SYNC) { - if (ctx->scrolled != 0) { - screen_write_should_draw_lines(ctx, s->rupper, - s->rlower + 1 - s->rupper); - } + if (ctx->scrolled != 0) + screen_write_sync_scroll_dirty(ctx); for (y = 0; y < screen_size_y(s); y++) { cl = &s->write_list[y]; if (!TAILQ_EMPTY(&cl->items)) @@ -2646,6 +2789,10 @@ screen_write_collect_add(struct screen_write_ctx *ctx, * Don't need to check that the attributes and whatnot are still the * same - input_parse will end the collection when anything that isn't * a plain character is encountered. + * + * Without wrapping, collect up to but not including the last column. + * Leave that cell to screen_write_cell so repeated writes overwrite it + * and the cursor remains at the right edge. */ collect = 1; @@ -2655,7 +2802,8 @@ screen_write_collect_add(struct screen_write_ctx *ctx, collect = 0; else if (gc->attr & GRID_ATTR_CHARSET) collect = 0; - else if (~s->mode & MODE_WRAP) + else if ((~s->mode & MODE_WRAP) && + s->cx + ctx->item->used >= sx - 1) collect = 0; else if (s->mode & MODE_INSERT) collect = 0; @@ -2821,6 +2969,11 @@ screen_write_cell(struct screen_write_ctx *ctx, const struct grid_cell *gc) /* Create space for character in insert mode. */ if (s->mode & MODE_INSERT) { screen_write_collect_flush(ctx, 0, __func__); + if (wp != NULL && screen_write_pane_is_obscured(ctx)) { + if (screen_write_should_draw_line(ctx, s->cy)) + screen_write_redraw_line(ctx, &ttyctx, s->cy); + return; + } ttyctx.n = width; if (screen_write_should_draw_line(ctx, s->cy)) tty_write(tty_cmd_insertcharacter, &ttyctx); @@ -3170,6 +3323,7 @@ screen_write_alternateon(struct screen_write_ctx *ctx, struct grid_cell *gc, { struct tty_ctx ttyctx; struct window_pane *wp = ctx->wp; + int pending; if (wp != NULL && !options_get_number(wp->options, "alternate-screen")) return; @@ -3179,11 +3333,12 @@ screen_write_alternateon(struct screen_write_ctx *ctx, struct grid_cell *gc, return; if (wp != NULL) { + pending = !TAILQ_EMPTY(&wp->resize_queue); window_pane_clear_resizes(wp, NULL); if (event_initialized(&wp->resize_timer)) evtimer_del(&wp->resize_timer); layout_fix_panes(wp->window, NULL); - if (!TAILQ_EMPTY(&wp->resize_queue)) { + if (pending || !TAILQ_EMPTY(&wp->resize_queue)) { window_pane_send_resize(wp, wp->sx, wp->sy); window_pane_clear_resizes(wp, NULL); } @@ -3192,7 +3347,7 @@ screen_write_alternateon(struct screen_write_ctx *ctx, struct grid_cell *gc, screen_write_initctx(ctx, &ttyctx, 1, 0); if (ttyctx.redraw_cb != NULL) - ttyctx.redraw_cb(&ttyctx); + ttyctx.redraw_cb(&ttyctx, 0, ttyctx.sy); } /* Turn alternate screen off. */ @@ -3217,5 +3372,5 @@ screen_write_alternateoff(struct screen_write_ctx *ctx, struct grid_cell *gc, screen_write_initctx(ctx, &ttyctx, 1, 0); if (ttyctx.redraw_cb != NULL) - ttyctx.redraw_cb(&ttyctx); + ttyctx.redraw_cb(&ttyctx, 0, ttyctx.sy); } diff --git a/server-client.c b/server-client.c index f6c69533b..4d23eb708 100644 --- a/server-client.c +++ b/server-client.c @@ -1,4 +1,4 @@ -/* $OpenBSD: server-client.c,v 1.509 2026/08/28 07:36:01 nicm Exp $ */ +/* $OpenBSD: server-client.c,v 1.517 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -67,77 +67,6 @@ server_client_how_many(void) return (n); } -/* Overlay timer callback. */ -static void -server_client_overlay_timer(__unused int fd, __unused short events, void *data) -{ - server_client_clear_overlay(data); -} - -/* Set an overlay on client. */ -void -server_client_set_overlay(struct client *c, u_int delay, - overlay_check_cb checkcb, overlay_mode_cb modecb, - overlay_draw_cb drawcb, overlay_key_cb keycb, overlay_free_cb freecb, - overlay_resize_cb resizecb, void *data) -{ - struct timeval tv; - - if (c->overlay_draw != NULL) - server_client_clear_overlay(c); - - tv.tv_sec = delay / 1000; - tv.tv_usec = (delay % 1000) * 1000L; - - if (event_initialized(&c->overlay_timer)) - evtimer_del(&c->overlay_timer); - evtimer_set(&c->overlay_timer, server_client_overlay_timer, c); - if (delay != 0) - evtimer_add(&c->overlay_timer, &tv); - - c->overlay_check = checkcb; - c->overlay_mode = modecb; - c->overlay_draw = drawcb; - c->overlay_key = keycb; - c->overlay_free = freecb; - c->overlay_resize = resizecb; - c->overlay_data = data; - - if (c->overlay_check == NULL) - c->tty.flags |= TTY_FREEZE; - if (c->overlay_mode == NULL) - c->tty.flags |= TTY_NOCURSOR; - window_update_focus(c->session->curw->window); - server_redraw_client(c); -} - -/* Clear overlay mode on client. */ -void -server_client_clear_overlay(struct client *c) -{ - if (c->overlay_draw == NULL) - return; - - if (event_initialized(&c->overlay_timer)) - evtimer_del(&c->overlay_timer); - - if (c->overlay_free != NULL) - c->overlay_free(c, c->overlay_data); - - c->overlay_check = NULL; - c->overlay_mode = NULL; - c->overlay_draw = NULL; - c->overlay_key = NULL; - c->overlay_free = NULL; - c->overlay_resize = NULL; - c->overlay_data = NULL; - - c->tty.flags &= ~(TTY_FREEZE|TTY_NOCURSOR); - if (c->session != NULL) - window_update_focus(c->session->curw->window); - server_redraw_client(c); -} - /* Are these ranges empty? That is, nothing is visible. */ int server_client_ranges_is_empty(struct visible_ranges *r) @@ -161,52 +90,6 @@ server_client_ensure_ranges(struct visible_ranges *r, u_int n) r->size = n; } -/* - * Given overlay position and dimensions, return parts of the input range which - * are visible. - */ -void -server_client_overlay_range(u_int x, u_int y, u_int sx, u_int sy, u_int px, - u_int py, u_int nx, struct visible_ranges *r) -{ - u_int ox, onx; - - /* Trivial case of no overlap in the y direction. */ - if (py < y || py > y + sy - 1) { - server_client_ensure_ranges(r, 1); - r->ranges[0].px = px; - r->ranges[0].nx = nx; - r->used = 1; - return; - } - server_client_ensure_ranges(r, 2); - - /* Visible bit to the left of the popup. */ - if (px < x) { - r->ranges[0].px = px; - r->ranges[0].nx = x - px; - if (r->ranges[0].nx > nx) - r->ranges[0].nx = nx; - } else { - r->ranges[0].px = 0; - r->ranges[0].nx = 0; - } - - /* Visible bit to the right of the popup. */ - ox = x + sx; - if (px > ox) - ox = px; - onx = px + nx; - if (onx > ox) { - r->ranges[1].px = ox; - r->ranges[1].nx = onx - ox; - } else { - r->ranges[1].px = 0; - r->ranges[1].nx = 0; - } - r->used = 2; -} - /* Check if this client is inside this server. */ int server_client_check_nested(struct client *c) @@ -473,7 +356,13 @@ server_client_set_session(struct client *c, struct session *s) tty_update_client_offset(c); status_timer_start(c); server_client_fire_session_changed(c, old); - server_redraw_client(c); + + /* + * Redraw if the session or displayed window changed. Use the + * cached scene because the session's current window is already set. + */ + if (old != s || !redraw_client_has_window(c, s->curw->window)) + server_redraw_client(c); } server_check_unattached(); @@ -486,11 +375,9 @@ server_client_lost(struct client *c) { struct client_file *cf, *cf1; - if (cfg_client == c) - cfg_client = NULL; + cfg_client_lost(c); c->flags |= CLIENT_DEAD; - server_client_clear_overlay(c); status_prompt_clear(c); status_message_clear(c); @@ -502,6 +389,9 @@ server_client_lost(struct client *c) TAILQ_REMOVE(&clients, c, entry); log_debug("lost client %p", c); + cmd_wait_for_client_lost(c); + cmdq_next(c); + if (c->flags & CLIENT_ATTACHED) { server_client_attached_lost(c); events_fire_client("client-detached", c); @@ -1391,15 +1281,19 @@ server_client_repeat_time(struct client *c, struct key_binding *bd) return (repeat); } -/* Handle a key press on a dead pane waiting for a key. */ +/* Handle a key press which closes a dead pane. */ static int server_client_handle_dead_key(struct window_pane *wp, key_code key) { + int remain_on_exit; + if (wp == NULL || (~wp->flags & PANE_EXITED) || KEYC_IS_MOUSE(key) || - KEYC_IS_PASTE(key) || - options_get_number(wp->options, "remain-on-exit") != 3) + KEYC_IS_PASTE(key)) + return (0); + remain_on_exit = options_get_number(wp->options, "remain-on-exit"); + if (remain_on_exit != 3 && remain_on_exit != 4) return (0); options_set_number(wp->options, "remain-on-exit", 0); server_destroy_pane(wp, 0); @@ -1460,10 +1354,11 @@ server_client_key_callback(struct cmdq_item *item, void *data) m->key = key; /* - * Mouse drag is in progress, so fire the callback (now that - * the mouse event is valid). + * Synchronize direct drag output with the later damage redraw + * before invoking the drag callback. */ if ((key & KEYC_MASK_KEY) == KEYC_DRAGGING) { + tty_sync_start(&c->tty); c->tty.mouse_drag_update(c, m); goto out; } @@ -1742,10 +1637,6 @@ server_client_handle_key0(struct client *c, struct key_event *event, if (s == NULL || (c->flags & CLIENT_UNATTACHEDFLAGS)) return (0); - /* - * Handle theme reporting keys before overlays so they work even when a - * popup is open. - */ if (event->key == KEYC_REPORT_LIGHT_THEME) { server_client_report_theme(c, THEME_LIGHT); return (0); @@ -1756,9 +1647,9 @@ server_client_handle_key0(struct client *c, struct key_event *event, } /* - * Key presses in overlay mode, for panes capturing all keys and in the - * command prompt are a special case. The queue might be blocked so they - * need to be processed immediately rather than queued. + * Dead panes waiting for a key, modal cancel keys, panes capturing all keys + * and the command prompt are special cases. The queue might be blocked so + * they need to be processed immediately rather than queued. */ if (~c->flags & CLIENT_READONLY) { if (c->message_string != NULL) { @@ -1767,25 +1658,20 @@ server_client_handle_key0(struct client *c, struct key_event *event, status_message_clear(c); } - if (c->overlay_key != NULL) { - switch (c->overlay_key(c, c->overlay_data, event)) { - case 0: - return (0); - case 1: - server_client_clear_overlay(c); - return (0); - } - } - - server_client_clear_overlay(c); - wp = s->curw->window->active; + if (server_client_handle_dead_key(wp, event->key)) + return (0); + if (wp != NULL && + wp == wp->window->modal && + (wp->flags & PANE_CLOSEONCANCEL) && + (event->key == '\033' || event->key == ('c'|KEYC_CTRL))) { + server_kill_pane(wp); + return (0); + } if (wp != NULL && (wp->flags & PANE_CAPTUREALLKEYS) && TAILQ_EMPTY(&wp->modes) && !KEYC_IS_MOUSE(event->key)) { - if (server_client_handle_dead_key(wp, event->key)) - return (0); if (~wp->flags & PANE_EXITED) { window_pane_key(wp, c, s, s->curw, event->key, &event->m); @@ -1899,8 +1785,8 @@ server_client_loop(void) } /* - * Any windows will have been redrawn as part of clients, so clear - * their flags now. + * Clear window redraw state after processing all clients. Deferred + * redraws are preserved in client flags. */ RB_FOREACH(w, windows, &windows) { TAILQ_FOREACH(wp, &w->panes, entry) { @@ -1911,6 +1797,8 @@ server_client_loop(void) wp->flags &= ~(PANE_REDRAW|PANE_REDRAWSCROLLBAR| PANE_ACTIVITY); } + redraw_free_damage(w); + check_window_name(w); } @@ -2166,11 +2054,8 @@ server_client_reset_state(struct client *c) flags = (tty->flags & TTY_BLOCK); tty->flags &= ~TTY_BLOCK; - /* Get mode from overlay if any, else from screen. */ - if (c->overlay_draw != NULL) { - if (c->overlay_mode != NULL) - s = c->overlay_mode(c, c->overlay_data, &cx, &cy); - } else if (w->menu != NULL) { + /* Get mode from the menu if any, else from the screen. */ + if (w->menu != NULL) { menu_get_cursor(w->menu, &cx, &cy); s = menu_screen(w->menu); } else if (wp != NULL && c->prompt == NULL) @@ -2192,7 +2077,7 @@ server_client_reset_state(struct client *c) if (c->prompt != NULL) { prompt = 1; status_prompt_cursor(c, &cx, &cy); - } else if (wp != NULL && c->overlay_draw == NULL) { + } else if (wp != NULL) { if (w->menu != NULL) { tty_window_offset(tty, &ox, &oy, &sx, &sy); if (cx < ox || cx >= ox + sx || @@ -2211,7 +2096,7 @@ server_client_reset_state(struct client *c) } if (!prompt) { cursor = 0; - pane_mode = wp->base.mode; + pane_mode = s->mode; tty_window_offset(tty, &ox, &oy, &sx, &sy); if (wp->xoff + (int)s->cx >= (int)ox && @@ -2244,14 +2129,18 @@ server_client_reset_state(struct client *c) cy += status_line_size(c); } - if ((pane_mode & MODE_SYNC) || !cursor) + if (!cursor) mode &= ~MODE_CURSOR; } - } else if (c->overlay_mode == NULL || s == NULL) + } else if (s == NULL) mode &= ~MODE_CURSOR; if (~pane_mode & MODE_SYNC) { log_debug("%s: cursor to %u,%u", __func__, cx, cy); tty_cursor(tty, cx, cy); + } else { + mode &= ~CURSOR_MODES; + mode |= tty->mode & CURSOR_MODES; + s = NULL; } /* @@ -2260,7 +2149,7 @@ server_client_reset_state(struct client *c) * movement events. */ if (options_get_number(oo, "mouse")) { - if (c->overlay_draw == NULL && w->menu == NULL) { + if (w->menu == NULL) { mode &= ~ALL_MOUSE_MODES; TAILQ_FOREACH(loop, &w->panes, entry) { if (loop->screen->mode & MODE_MOUSE_ALL) @@ -2276,7 +2165,7 @@ server_client_reset_state(struct client *c) } /* Clear bracketed paste mode if at the prompt. */ - if (c->overlay_draw == NULL && prompt) + if (prompt) mode &= ~MODE_BRACKETPASTE; /* Set the terminal mode and reset attributes. */ @@ -2455,6 +2344,8 @@ server_client_any_pane_redraw(struct client *c) if (c->flags & CLIENT_REDRAWWINDOW) return (1); + if (!TAILQ_EMPTY(&w->damage)) + return (1); TAILQ_FOREACH(wp, &w->panes, entry) { if (wp->flags & (PANE_REDRAW|PANE_REDRAWSCROLLBAR)) return (1); @@ -2471,6 +2362,7 @@ server_client_check_redraw(struct client *c) struct window *w = s->curw->window; struct window_pane *wp; int needed, tflags, mode = tty->mode; + int damaged = !TAILQ_EMPTY(&w->damage); struct timeval tv = { .tv_usec = 1000 }; static struct event ev; size_t n; @@ -2478,11 +2370,10 @@ server_client_check_redraw(struct client *c) if (c->flags & (CLIENT_CONTROL|CLIENT_SUSPENDED)) return; if (c->flags & CLIENT_ALLREDRAWFLAGS) { - log_debug("%s: redraw%s%s%s%s%s", c->name, + log_debug("%s: redraw%s%s%s%s", c->name, (c->flags & CLIENT_REDRAWWINDOW) ? " window" : "", (c->flags & CLIENT_REDRAWSTATUS) ? " status" : "", (c->flags & CLIENT_REDRAWBORDERS) ? " borders" : "", - (c->flags & CLIENT_REDRAWOVERLAY) ? " overlay" : "", (c->flags & CLIENT_REDRAWMENU) ? " menu" : ""); } @@ -2497,12 +2388,12 @@ server_client_check_redraw(struct client *c) return; } - /* - * If there is outstanding data, defer the redraw until it has been - * consumed. We can just add a timer to get out of the event loop and - * end up back here. - */ + /* Ignore output queued within the current synchronized frame. */ n = EVBUFFER_LENGTH(tty->out); + if ((tty->flags & TTY_SYNCING) && n > tty->sync_offset) + n = tty->sync_offset; + + /* Defer until output drains, preserving damage in client flags. */ if (n != 0 || (tty->flags & TTY_BLOCK)) { if (n != 0) log_debug("%s: redraw deferred (%zu left)", c->name, n); @@ -2514,6 +2405,10 @@ server_client_check_redraw(struct client *c) log_debug("redraw timer started"); evtimer_add(&ev, &tv); } + if (damaged) { + c->flags |= CLIENT_REDRAWWINDOW; + return; + } TAILQ_FOREACH(wp, &w->panes, entry) { if (wp->flags & PANE_REDRAW) { c->flags |= CLIENT_REDRAWWINDOW; @@ -2547,6 +2442,10 @@ server_client_check_redraw(struct client *c) redraw_pane_scrollbar(c, wp); } } + + /* Draw damage here if no client redraw flags will handle it. */ + if (damaged && (c->flags & CLIENT_ALLREDRAWFLAGS) == 0) + redraw_client_damage(c); } /* @@ -2560,6 +2459,7 @@ server_client_check_redraw(struct client *c) } server_client_set_progress_bar(c); redraw_screen(c); + redraw_client_damage(c); } /* Put the tty back how it was. */ @@ -2690,10 +2590,6 @@ server_client_dispatch(struct imsg *imsg, void *arg) tty_resize(&c->tty); tty_repeat_requests(&c->tty, 0); recalculate_sizes(); - if (c->overlay_resize == NULL) - server_client_clear_overlay(c); - else - c->overlay_resize(c, c->overlay_data); server_redraw_client(c); if (c->session != NULL) server_client_fire_resized(c, old_sx, old_sy); @@ -3055,6 +2951,8 @@ server_client_control_flags(struct client *c, const char *next) return (CLIENT_CONTROL_NOOUTPUT); if (strcmp(next, "wait-exit") == 0) return (CLIENT_CONTROL_WAITEXIT); + if (strcmp(next, "new-layouts") == 0) + return (CLIENT_CONTROL_NEWLAYOUTS); return (0); } @@ -3121,6 +3019,8 @@ server_client_get_flags(struct client *c) strlcat(s, "no-output,", sizeof s); if (c->flags & CLIENT_CONTROL_WAITEXIT) strlcat(s, "wait-exit,", sizeof s); + if (c->flags & CLIENT_CONTROL_NEWLAYOUTS) + strlcat(s, "new-layouts,", sizeof s); if (c->flags & CLIENT_CONTROL_PAUSEAFTER) { xsnprintf(tmp, sizeof tmp, "pause-after=%u,", c->pause_age / 1000); diff --git a/server-fn.c b/server-fn.c index 424d4a1c1..e9db5a887 100644 --- a/server-fn.c +++ b/server-fn.c @@ -1,4 +1,4 @@ -/* $OpenBSD: server-fn.c,v 1.151 2026/08/20 09:19:24 nicm Exp $ */ +/* $OpenBSD: server-fn.c,v 1.154 2026/09/21 12:43:36 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -310,8 +310,8 @@ server_link_window(struct session *src, struct winlink *srcwl, } if (killflag) { /* - * Can't use session_detach as it will destroy session - * if this makes it empty. + * Can't use session_detach as it won't detach the only + * window. */ events_fire_winlink("window-unlinked", dstwl); dstwl->flags &= ~WINLINK_ALERTFLAGS; @@ -379,13 +379,14 @@ server_destroy_pane(struct window_pane *wp, int notify) wp->pipe_fd = -1; } - remain_on_exit = options_get_number(wp->options, "remain-on-exit"); - if (remain_on_exit != 0 && (~wp->flags & PANE_STATUSREADY)) + if (~wp->flags & PANE_STATUSREADY) return; + remain_on_exit = options_get_number(wp->options, "remain-on-exit"); switch (remain_on_exit) { case 0: break; case 2: + case 4: if (WIFEXITED(wp->status) && WEXITSTATUS(wp->status) == 0) break; /* FALLTHROUGH */ diff --git a/server.c b/server.c index 27ff80c29..be4427954 100644 --- a/server.c +++ b/server.c @@ -448,6 +448,10 @@ server_signal(int sig) server_child_signal(); break; case SIGUSR1: +#ifdef HAVE_SYSTEMD + if (systemd_activated()) + break; +#endif event_del(&server_ev_accept); fd = server_create_socket(server_client_flags, NULL); if (fd != -1) { diff --git a/session.c b/session.c index 491f48904..02d20e60e 100644 --- a/session.c +++ b/session.c @@ -1,4 +1,4 @@ -/* $OpenBSD: session.c,v 1.107 2026/08/05 07:35:35 nicm Exp $ */ +/* $OpenBSD: session.c,v 1.109 2026/09/25 08:46:06 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -336,10 +336,17 @@ session_attach(struct session *s, struct window *w, int idx, char **cause) return (wl); } -/* Detach a window from a session. */ +/* + * Detach a window from a session. Returns 1 if the window has not been + * detached - the caller must destroy the session. + */ int session_detach(struct session *s, struct winlink *wl) { + if (RB_MIN(winlinks, &s->windows) == wl && + RB_MAX(winlinks, &s->windows) == wl) + return (1); + if (s->curw == wl && session_last(s) != 0 && session_previous(s, 0) != 0) @@ -352,8 +359,6 @@ session_detach(struct session *s, struct winlink *wl) session_group_synchronize_from(s); - if (RB_EMPTY(&s->windows)) - return (1); return (0); } @@ -742,14 +747,21 @@ session_renumber_windows(struct session *s) struct winlink *wl, *wl1, *wl_new; struct winlinks old_wins; struct winlink_stack old_lastw; + u_int n; int new_idx, new_curw_idx, marked_idx = -1; + /* Start renumbering from the base-index if it's set. */ + new_idx = options_get_number(s->options, "base-index"); + + /* Leave the indexes alone if they do not all fit from there. */ + n = winlink_count(&s->windows); + if (n > (u_int)INT_MAX - (u_int)new_idx + 1) + return; + /* Save and replace old window list. */ memcpy(&old_wins, &s->windows, sizeof old_wins); RB_INIT(&s->windows); - /* Start renumbering from the base-index if it's set. */ - new_idx = options_get_number(s->options, "base-index"); new_curw_idx = 0; /* Go through the winlinks and assign new indexes. */ @@ -764,7 +776,8 @@ session_renumber_windows(struct session *s) if (wl == s->curw) new_curw_idx = wl_new->idx; - new_idx++; + if (RB_NEXT(winlinks, &old_wins, wl) != NULL) + new_idx++; } /* Fix the stack of last windows now. */ diff --git a/sort.c b/sort.c index 1b71db494..ab3f5905b 100644 --- a/sort.c +++ b/sort.c @@ -1,4 +1,4 @@ -/* $OpenBSD: sort.c,v 1.10 2026/08/25 07:23:30 nicm Exp $ */ +/* $OpenBSD: sort.c,v 1.12 2026/09/28 10:42:01 nicm Exp $ */ /* * Copyright (c) 2026 Dane Jensen @@ -25,6 +25,16 @@ static struct sort_criteria *sort_criteria; +static int +sort_number_cmp(unsigned long long a, unsigned long long b) +{ + if (a < b) + return (-1); + if (a > b) + return (1); + return (0); +} + static void sort_qsort(void *l, u_int len, u_int size, int (*cmp)(const void *, const void *), struct sort_criteria *sort_crit) @@ -73,7 +83,7 @@ sort_buffer_cmp(const void *a0, const void *b0) result = 0; break; case SORT_SIZE: - result = pa->size - pb->size; + result = sort_number_cmp(pa->size, pb->size); break; case SORT_ACTIVITY: case SORT_INDEX: @@ -107,9 +117,9 @@ sort_client_cmp(const void *a0, const void *b0) result = strcmp(ca->name, cb->name); break; case SORT_SIZE: - result = ca->tty.sx - cb->tty.sx; + result = sort_number_cmp(ca->tty.sx, cb->tty.sx); if (result == 0) - result = ca->tty.sy - cb->tty.sy; + result = sort_number_cmp(ca->tty.sy, cb->tty.sy); break; case SORT_CREATION: if (timercmp(&ca->creation_time, &cb->creation_time, >)) @@ -151,7 +161,7 @@ sort_session_cmp(const void *a0, const void *b0) switch (sort_crit->order) { case SORT_INDEX: - result = sa->id - sb->id; + result = sort_number_cmp(sa->id, sb->id); break; case SORT_CREATION: if (timercmp(&sa->creation_time, &sb->creation_time, >)) { @@ -203,18 +213,18 @@ sort_pane_cmp(const void *a0, const void *b0) switch (sort_crit->order) { case SORT_ACTIVITY: - result = a->active_point - b->active_point; + result = sort_number_cmp(a->active_point, b->active_point); break; case SORT_CREATION: - result = a->id - b->id; + result = sort_number_cmp(a->id, b->id); break; case SORT_SIZE: - result = a->sx * a->sy - b->sx * b->sy; + result = sort_number_cmp(a->sx * a->sy, b->sx * b->sy); break; case SORT_INDEX: window_pane_index(a, &ai); window_pane_index(b, &bi); - result = ai - bi; + result = sort_number_cmp(ai, bi); break; case SORT_NAME: result = strcmp(a->screen->title, b->screen->title); @@ -222,7 +232,7 @@ sort_pane_cmp(const void *a0, const void *b0) case SORT_Z: window_pane_zindex(a, &ai); window_pane_zindex(b, &bi); - result = ai - bi; + result = sort_number_cmp(ai, bi); break; case SORT_MODIFIER: case SORT_ORDER: @@ -278,7 +288,7 @@ sort_winlink_cmp(const void *a0, const void *b0) result = strcmp(wa->name, wb->name); break; case SORT_SIZE: - result = wa->sx * wa->sy - wb->sx * wb->sy; + result = sort_number_cmp(wa->sx * wa->sy, wb->sx * wb->sy); break; case SORT_MODIFIER: case SORT_ORDER: @@ -302,17 +312,19 @@ sort_key_binding_cmp(const void *a0, const void *b0) const struct key_binding *a = *(struct key_binding **)a0; const struct key_binding *b = *(struct key_binding **)b0; int result = 0; + key_code ak, bk; switch (sort_crit->order) { case SORT_INDEX: - result = a->key - b->key; + result = sort_number_cmp(a->key, b->key); break; case SORT_MODIFIER: - result = (a->key & KEYC_MASK_MODIFIERS) - - (b->key & KEYC_MASK_MODIFIERS); + ak = (a->key & KEYC_MASK_MODIFIERS); + bk = (b->key & KEYC_MASK_MODIFIERS); + result = sort_number_cmp(ak, bk); break; case SORT_NAME: - result = strcasecmp(a->tablename, b->tablename) == 0; + result = strcasecmp(a->tablename, b->tablename); break; case SORT_ACTIVITY: case SORT_CREATION: @@ -324,7 +336,9 @@ sort_key_binding_cmp(const void *a0, const void *b0) } if (result == 0) - result = strcasecmp(a->tablename, b->tablename) == 0; + result = strcasecmp(a->tablename, b->tablename); + if (result == 0) + result = sort_number_cmp(a->key, b->key); if (sort_crit->reversed) result = -result; diff --git a/spawn.c b/spawn.c index 361d207e5..5a6aeea20 100644 --- a/spawn.c +++ b/spawn.c @@ -1,4 +1,4 @@ -/* $OpenBSD: spawn.c,v 1.52 2026/08/20 09:19:24 nicm Exp $ */ +/* $OpenBSD: spawn.c,v 1.54 2026/10/02 13:20:42 nicm Exp $ */ /* * Copyright (c) 2019 Nicholas Marriott @@ -166,8 +166,8 @@ spawn_window(struct spawn_context *sc, char **cause) } if (wl != NULL) { /* - * Can't use session_detach as it will destroy session - * if this makes it empty. + * Can't use session_detach as it won't detach the only + * window. */ wl->flags &= ~WINLINK_ALERTFLAGS; events_fire_winlink("window-unlinked", wl); @@ -465,14 +465,14 @@ spawn_pane(struct spawn_context *sc, char **cause) new_wp->flags &= ~PANE_EMPTY; /* Store current working directory and change to new one. */ - if (getcwd(path, sizeof path) != NULL) { - if (chdir(new_wp->cwd) == 0) - actual_cwd = new_wp->cwd; - else if (home != NULL && chdir(home) == 0) - actual_cwd = home; - else if (chdir("/") == 0) - actual_cwd = "/"; - } + if (getcwd(path, sizeof path) == NULL) + strlcpy(path, "/", sizeof path); + if (chdir(new_wp->cwd) == 0) + actual_cwd = new_wp->cwd; + else if (home != NULL && chdir(home) == 0) + actual_cwd = home; + else if (chdir("/") == 0) + actual_cwd = "/"; /* Fork the new process. */ new_wp->pid = fdforkpty(ptm_fd, &new_wp->fd, new_wp->tty, NULL, &ws); diff --git a/status.c b/status.c index 2f3c8f11e..3be348f0d 100644 --- a/status.c +++ b/status.c @@ -1,4 +1,4 @@ -/* $OpenBSD: status.c,v 1.273 2026/07/06 14:29:10 nicm Exp $ */ +/* $OpenBSD: status.c,v 1.274 2026/09/21 10:22:31 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -577,8 +577,6 @@ status_prompt_set(struct client *c, struct cmd_find_state *fs, struct prompt_create_data pd; struct status_prompt_data *spd; - server_client_clear_overlay(c); - status_message_clear(c); status_prompt_clear(c); status_push_screen(c); diff --git a/tmux.1 b/tmux.1 index 9fdfb5749..4cf0615b0 100644 --- a/tmux.1 +++ b/tmux.1 @@ -1,4 +1,4 @@ -.\" $OpenBSD: tmux.1,v 1.1163 2026/09/01 12:49:49 nicm Exp $ +.\" $OpenBSD: tmux.1,v 1.1175 2026/10/06 08:01:54 nicm Exp $ .\" .\" Copyright (c) 2007 Nicholas Marriott .\" @@ -14,7 +14,7 @@ .\" IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING .\" OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. .\" -.Dd $Mdocdate: September 1 2026 $ +.Dd $Mdocdate: October 6 2026 $ .Dt TMUX 1 .Os .Sh NAME @@ -1016,13 +1016,19 @@ Will run: /bin/sh \-c \[aq]vi \[ti]/.tmux.conf\[aq] .Ed .Pp +Unless specified, tmux uses the value of +.Ic default\-shell +in place of +.Pa /bin/sh . +.Pp Additionally, the .Ic new\-window , .Ic new\-session , .Ic split\-window , -.Ic respawn\-window -and +.Ic respawn\-window , .Ic respawn\-pane +and +.Ic display\-popup commands allow .Ar shell\-command to be given as multiple arguments and executed directly (without @@ -1138,6 +1144,8 @@ The flags are: .Bl -tag -width Ds .It ignore\-size the client does not affect the size of other clients +.It new\-layouts +use the new layout string format .It no\-detach\-on\-destroy do not detach the client when the session it is attached to is destroyed if there are any other sessions @@ -2721,8 +2729,8 @@ For example: .Bd -literal -offset indent $ tmux list\-windows 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} -$ tmux select\-layout \[aq]bb62,159x48,0,0{79x48,0,0,79x48,80,0}\[aq] + layout: {"V":2,"L":{"t":"h","w":159,"h":48,"x":0,"y":0,"c":[{"t":"p","w":79,"h":48,"x":0,"y":0,"l":0,"i":0,"I":"%0"},{"t":"p","w":79,"h":48,"x":80,"y":0,"a":true,"i":1,"I":"%2"}]}} +$ tmux select\-layout \[aq]{"V":2,"L":{"t":"h","w":159,"h":48,"x":0,"y":0,"c":[{"t":"p","w":79,"h":48,"x":0,"y":0,"l":0,"i":0,"I":"%0"},{"t":"p","w":79,"h":48,"x":80,"y":0,"a":true,"i":1,"I":"%2"}]}}\[aq] .Ed .Pp .Nm @@ -2810,7 +2818,7 @@ The pane must not already be floating or hidden, and the window must not be zoomed. .Tg capturep .It Xo Ic capture\-pane -.Op Fl aeFHLpPRqCJMN +.Op Fl aeFHILpPRqCJMN .Op Fl b Ar buffer\-name .Op Fl E Ar end\-line .Op Fl S Ar start\-line @@ -2852,6 +2860,8 @@ captures only any output that the pane has received that is the beginning of an as-yet incomplete escape sequence. .Fl L includes the line number at the start of each line and +.Fl I +includes the time each line entered the history, or zero if not available. .Fl F includes the flags (where .Ql - @@ -3612,13 +3622,20 @@ represents the window to be created; if the target already exists an error is shown, unless the .Fl k flag is used, in which case it is destroyed. +.Ar window\-name +is the name given to the new window, if any. +.Pp If .Fl S -is given and a window named -.Ar window\-name -already exists, it is selected (unless +is given, an existing window is selected instead of creating a new one +(unless .Fl d -is also given in which case the command does nothing). +is also given in which case the command does nothing): +.Ar target\-window +is preferred if it already identifies an existing window; otherwise a +window named +.Ar window\-name +is selected if one exists. .Pp .Ar shell\-command is the command to execute. @@ -3666,7 +3683,7 @@ but a different format may be specified with .Fl F . .Tg newp .It Xo Ic new\-pane -.Op Fl AbCdefhIkKLMOPvWZ +.Op Fl AbCDefhIkKLMOPvWZ .Op Fl B Ar border\-lines .Op Fl c Ar start\-directory .Op Fl e Ar environment @@ -3728,6 +3745,13 @@ all keys, including the prefix key, are passed directly to the modal pane. With .Fl C , the modal pane is closed when the mouse is clicked outside it. +With +.Fl D , +the modal pane is closed when +.Ql Escape +or +.Ql C-c +is pressed. .Pp The .Fl L @@ -4680,6 +4704,9 @@ flag unsets an option, so a session inherits the option from the global options (or with .Fl g , restores a global option to the default). +For an array option given with a key, +.Fl u +removes only that item. .Fl U unsets an option (like .Fl u ) @@ -5096,6 +5123,9 @@ The available features are: .Bl -tag -width Ds .It 256 Supports 256 colours with the SGR escape sequences. +.It appesc +Supports application escape key mode, where the Escape key sends a sequence +that cannot be mistaken for the start of another key. .It clipboard Allows setting the system clipboard. .It ccolour @@ -5364,10 +5394,16 @@ section on how to specify .Ar style . .It Ic menu\-border\-lines Ar type Set the type of characters used for drawing menu borders. -See -.Ic popup\-border\-lines -for possible values for -.Ar border\-lines . +.Ar type +may be one of +.Ic single , +.Ic rounded , +.Ic double , +.Ic heavy , +.Ic simple , +.Ic padded , +or +.Ic none . .It Ic message\-command\-style Ar style Set status line message command style. This is used for the command prompt with @@ -5541,8 +5577,18 @@ or more rows. .It Ic status\-format[] Ar format Specify the format to be used for each line of the status line. -The default builds the top status line from the various individual status -options below. +The default first status line is built from the various individual status +options below, the second shows the panes in the current window and the third +the sessions. +A default line may be extended rather than replaced by saving its default value +in a user option and expanding that in the new value, for example: +.Bd -literal -offset indent +set \-g status 2 +set \-gu status\-format # start with default status line +set \-gF @old_status_format1 "#{status\-format[1]}" +set \-g status\-format[1] "#{E:@old_status_format1}#[nolist align=right]#{pane_current_path}" +.Ed +.Pp .It Ic status\-interval Ar interval Update the status line every .Ar interval @@ -5991,6 +6037,8 @@ single lines using ACS or UTF\-8 characters double lines using UTF\-8 characters .It heavy heavy lines using UTF\-8 characters +.It rounded +single lines with rounded corners using UTF\-8 characters .It simple simple ASCII characters .It number @@ -6025,48 +6073,6 @@ see the section. Attributes are ignored. .Pp -.It Ic popup\-style Ar style -Set the popup style. -See the -.Sx STYLES -section on how to specify -.Ar style . -Attributes are ignored. -.Pp -.It Ic popup\-border\-style Ar style -Set the popup border style. -See the -.Sx STYLES -section on how to specify -.Ar style . -Attributes are ignored. -.Pp -.It Ic popup\-border\-lines Ar type -Set the type of characters used for drawing popup borders. -.Ar type -may be one of: -.Bl -tag -width Ds -.It single -single lines using ACS or UTF\-8 characters (default) -.It rounded -variation of single with rounded corners using UTF\-8 characters -.It double -double lines using UTF\-8 characters -.It heavy -heavy lines using UTF\-8 characters -.It simple -simple ASCII characters -.It padded -simple ASCII space character -.It none -no border -.El -.Pp -.Ql double -and -.Ql heavy -will fall back to standard ACS line drawing when UTF\-8 is not supported. -.Pp .It Xo Ic pane\-scrollbars .Op Ic off | modal | on | auto\-hide .Xc @@ -6346,7 +6352,7 @@ uses when the colour with that index is requested. The index may be from zero to 255. .Pp .It Xo Ic remain\-on\-exit -.Op Ic on | off | failed | key +.Op Ic on | off | failed | key | failed\-key .Xc A pane with this flag set is not destroyed when the program running in it exits. @@ -6356,6 +6362,10 @@ then only when the program exit status is not zero. If set to .Ic key , the pane stays open and closes when a key is pressed. +If set to +.Ic failed\-key , +the pane stays open and closes when a key is pressed only if the program exit +status is not zero. The pane may be reactivated with the .Ic respawn\-pane command. @@ -8209,10 +8219,16 @@ command should be omitted. .Pp .Fl b sets the type of characters used for drawing menu borders. -See -.Ic popup\-border\-lines -for possible values for -.Ar border\-lines . +.Ar border\-lines +may be one of +.Ic single , +.Ic rounded , +.Ic double , +.Ic heavy , +.Ic simple , +.Ic padded , +or +.Ic none . .Pp .Fl H sets the style for the selected menu item (see @@ -8248,15 +8264,18 @@ Both may be a row or column number, or one of the following special values: .It Li "S" Ta Fl y Ta "The line above or below the status line" .El .Pp -Or a format, which is expanded including the following additional variables: +Or a format, which is expanded with the following additional variables. +The +.Ql popup_ +prefix is retained for compatibility: .Bl -column "XXXXXXXXXXXXXXXXXXXXXXXXXX" -offset indent .It Sy "Variable name" Ta Sy "Replaced with" -.It Li "popup_centre_x" Ta "Centered in the client" -.It Li "popup_centre_y" Ta "Centered in the client" -.It Li "popup_height" Ta "Height of menu or popup" +.It Li "popup_centre_x" Ta "Centered in the window" +.It Li "popup_centre_y" Ta "Centered in the window" +.It Li "popup_height" Ta "Height of the menu" .It Li "popup_last_x" Ta "Left of the last menu" .It Li "popup_last_y" Ta "Bottom of the last menu" -.It Li "popup_mouse_bottom" Ta "Bottom of at the mouse" +.It Li "popup_mouse_bottom" Ta "Bottom at the mouse" .It Li "popup_mouse_centre_x" Ta "Horizontal centre at the mouse" .It Li "popup_mouse_centre_y" Ta "Vertical centre at the mouse" .It Li "popup_mouse_top" Ta "Top at the mouse" @@ -8267,7 +8286,7 @@ Or a format, which is expanded including the following additional variables: .It Li "popup_pane_right" Ta "Right of the pane" .It Li "popup_pane_top" Ta "Top of the pane" .It Li "popup_status_line_y" Ta "Above or below the status line" -.It Li "popup_width" Ta "Width of menu or popup" +.It Li "popup_width" Ta "Width of the menu" .It Li "popup_window_status_line_x" Ta "At the window position in status line" .It Li "popup_window_status_line_y" Ta "At the status line showing the window" .El @@ -8299,7 +8318,7 @@ The following keys are available in menus: .El .Tg display .It Xo Ic display\-message -.Op Fl aCIlNpv +.Op Fl aCIjlNpv .Op Fl c Ar target\-client .Op Fl d Ar delay .Op Fl t Ar target\-pane @@ -8339,6 +8358,12 @@ if .Fl t is given, otherwise the active pane. .Pp +If +.Fl j +is given, +.Ar message +is parsed as JSON and printed. +.Pp .Fl v prints verbose logging as the format is parsed and .Fl a @@ -8347,116 +8372,6 @@ lists the format variables and their values. .Fl I forwards any input read from stdin to the empty pane given by .Ar target\-pane . -.Tg popup -.It Xo Ic display\-popup -.Op Fl BCEkN -.Op Fl b Ar border\-lines -.Op Fl c Ar target\-client -.Op Fl d Ar start\-directory -.Op Fl e Ar environment -.Op Fl h Ar height -.Op Fl s Ar style -.Op Fl S Ar border\-style -.Op Fl t Ar target\-pane -.Op Fl T Ar title -.Op Fl w Ar width -.Op Fl x Ar position -.Op Fl y Ar position -.Op Ar shell\-command Op Ar argument ... -.Xc -.D1 Pq alias: Ic popup -Display a popup running -.Ar shell\-command -(or -.Ar default\-command -when omitted) on -.Ar target\-client . -A popup is a rectangular box drawn over the top of any panes. -If the command is run inside an existing popup, that popup is modified. -Only the -.Fl b , -.Fl B , -.Fl C , -.Fl E , -.Fl EE , -.Fl K , -.Fl N , -.Fl s , -and -.Fl S -options are accepted in this case; -all others are ignored. -.Pp -.Fl E -closes the popup automatically when -.Ar shell\-command -exits. -Two -.Fl E -closes the popup only if -.Ar shell\-command -exited with success. -.Fl k -allows any key to dismiss the popup instead of only -.Ql Escape -or -.Ql C\-c . -.Pp -.Fl x -and -.Fl y -give the position of the popup, they have the same meaning as for the -.Ic display\-menu -command. -.Fl w -and -.Fl h -give the width and height - both may be a percentage (followed by -.Ql % ) . -If omitted, half of the terminal size is used. -.Pp -.Fl B -does not surround the popup by a border. -.Pp -.Fl b -sets the type of characters used for drawing popup borders. -When -.Fl B -is specified, the -.Fl b -option is ignored. -See -.Ic popup\-border\-lines -for possible values for -.Ar border\-lines . -.Pp -.Fl s -sets the style for the popup and -.Fl S -sets the style for the popup border (see -.Sx STYLES ) . -.Pp -.Fl e -takes the form -.Ql VARIABLE=value -and sets an environment variable for the popup; it may be specified multiple -times. -.Pp -.Fl T -is a format for the popup title (see -.Sx FORMATS ) . -.Pp -The -.Fl C -flag closes any popup on the client. -.Pp -.Fl N -disables any previously specified -.Fl E , -.Fl EE , -or -.Fl k -option. .Tg showphist .It Xo Ic show\-prompt\-history .Op Fl T Ar prompt\-type @@ -8969,6 +8884,8 @@ These are set automatically if the capability is present. .It Em \&Dseks , \&Eneks Disable and enable extended keys. +.It Em \&Dsesc , \&Enesc +Disable and enable application escape key mode. .It Em \&Dsfcs , \&Enfcs Disable and enable focus reporting. These are set automatically if the @@ -9084,7 +9001,7 @@ and flags (currently not used). For example: .Bd -literal -offset indent %begin 1363006971 2 1 -0: ksh* (1 panes) [80x24] [layout b25f,80x24,0,0,2] @2 (active) +0: ksh* (1 panes) [80x24] [layout {"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"a":true,"i":0,"I":"%2"}}] @2 (active) %end 1363006971 2 1 .Ed .Pp @@ -9145,10 +9062,15 @@ The layout of a window with ID .Ar window\-id changed. The new layout is -.Ar window\-layout . -The window's visible layout is -.Ar window\-visible\-layout -and the window flags are +.Ar window\-layout +and the window's visible layout is +.Ar window\-visible\-layout . +If the +.Ar new\-layouts +flag is set, both layout fields use the new format string; see +.Ic refresh\-client +.Fl f . +The window flags are .Ar window\-flags . .It Ic %message Ar message A message sent with the @@ -9312,6 +9234,9 @@ See the option for details. .It Ev TMUX_TMPDIR The parent directory of the directory containing the server sockets. +It must be an absolute path to an existing directory and must not contain +.Ql .. ; +if it is empty it is ignored. See the .Fl L option for details. diff --git a/tmux.c b/tmux.c index ab7c5133a..1ea10b767 100644 --- a/tmux.c +++ b/tmux.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.c,v 1.223 2026/08/17 14:47:41 nicm Exp $ */ +/* $OpenBSD: tmux.c,v 1.225 2026/09/25 13:44:49 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -178,8 +178,8 @@ expand_paths(const char *s, char ***paths, u_int *n, int no_realpath) path = expanded; else { if (realpath(expanded, resolved) == NULL) { - log_debug("%s: realpath(\"%s\") failed: %s", __func__, - expanded, strerror(errno)); + log_debug("%s: realpath(\"%s\") failed: %s", + __func__, expanded, strerror(errno)); free(expanded); continue; } @@ -201,10 +201,33 @@ expand_paths(const char *s, char ***paths, u_int *n, int no_realpath) free(copy); } +static int +check_socket_path(const char *path, char **cause) +{ + char *copy, *next, *tmp; + + if (*path != '/') { + xasprintf(cause, "socket directory %s is not an absolute path", + path); + return (0); + } + copy = tmp = xstrdup(path); + while ((next = strsep(&tmp, "/")) != NULL) { + if (strcmp(next, "..") == 0) { + xasprintf(cause, "socket directory %s contains ..", + path); + free(copy); + return (0); + } + } + free(copy); + return (1); +} + static char * make_label(const char *label, char **cause) { - char **paths, *path, *base; + char **paths, *path = NULL, *base, resolved[PATH_MAX]; u_int i, n; struct stat sb; uid_t uid; @@ -214,15 +237,34 @@ make_label(const char *label, char **cause) label = "default"; uid = getuid(); - expand_paths(TMUX_SOCK, &paths, &n, 0); - if (n == 0) { - xasprintf(cause, "no suitable socket path"); - return (NULL); + /* + * An unset variable has already been dropped and an empty one is + * skipped, but anything else must be an existing absolute path with no + * ".." or it is an error. + */ + expand_paths(TMUX_SOCK, &paths, &n, 1); + for (i = 0; i < n; i++) { + if (*paths[i] == '\0') + continue; + if (!check_socket_path(paths[i], cause)) + break; + if (realpath(paths[i], resolved) == NULL) { + xasprintf(cause, + "couldn't resolve socket directory %s (%s)", + paths[i], strerror(errno)); + break; + } + path = xstrdup(resolved); + break; } - path = paths[0]; /* can only have one socket! */ - for (i = 1; i < n; i++) + for (i = 0; i < n; i++) free(paths[i]); free(paths); + if (path == NULL) { + if (*cause == NULL) + xasprintf(cause, "no suitable socket path"); + return (NULL); + } xasprintf(&base, "%s/tmux-%ld", path, (long)uid); free(path); diff --git a/tmux.h b/tmux.h index 87d32c82e..0993cbbc0 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1433 2026/09/01 12:49:49 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1451 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -63,6 +63,7 @@ struct input_ctx; struct input_request; struct input_requests; struct job; +struct json_node; struct menu_data; struct mode_tree_data; struct mouse_event; @@ -71,6 +72,7 @@ struct options_array_item; struct options_entry; struct prompt; struct window_pane_prompt; +struct redraw_damage; struct redraw_scene; struct redraw_span; struct screen_write_citem; @@ -466,6 +468,7 @@ enum tty_code_code { TTYC_DL1, TTYC_DSBP, TTYC_DSEKS, + TTYC_DSESC, TTYC_DSFCS, TTYC_DSMG, TTYC_E3, @@ -476,6 +479,7 @@ enum tty_code_code { TTYC_ENACS, TTYC_ENBP, TTYC_ENEKS, + TTYC_ENESC, TTYC_ENFCS, TTYC_ENMG, TTYC_FSL, @@ -1152,7 +1156,8 @@ enum pane_lines { PANE_LINES_SIMPLE, PANE_LINES_NUMBER, PANE_LINES_SPACES, - PANE_LINES_NONE + PANE_LINES_NONE, + PANE_LINES_ROUNDED }; /* Pane border indicator option. */ @@ -1183,6 +1188,7 @@ struct menu { struct menu_item *items; u_int count; u_int width; + u_int item_width; }; typedef void (*menu_choice_cb)(struct menu *, u_int, key_code, void *); @@ -1341,9 +1347,14 @@ struct window_pane { #define PANE_CLOSEONCLICK 0x80000 #define PANE_CAPTUREALLKEYS 0x100000 #define PANE_FLOATOVERZOOM 0x200000 +#define PANE_CLOSEONCANCEL 0x400000 bitstr_t *sync_dirty; u_int sync_dirty_size; + u_int sync_scrolled; + u_int sync_rupper; + u_int sync_rlower; + u_int sync_bg; u_int sb_slider_y; u_int sb_slider_h; @@ -1399,6 +1410,7 @@ struct window_pane { struct screen base; struct screen status_screen; + u_int status_generation; TAILQ_HEAD(, window_mode_entry) modes; @@ -1429,6 +1441,7 @@ struct window_pane { TAILQ_HEAD(window_panes, window_pane); TAILQ_HEAD(window_panes_zindex, window_pane); RB_HEAD(window_pane_tree, window_pane); +TAILQ_HEAD(redraw_damages, redraw_damage); /* Window structure. */ struct window { @@ -1472,6 +1485,9 @@ struct window { uint64_t redraw_scene_generation; + struct redraw_damages damage; + u_int damage_count; + struct menu_data *menu; u_int menu_last_px; u_int menu_last_py; @@ -1739,6 +1755,7 @@ struct tty_term { #define TERM_VT100LIKE 0x20 #define TERM_SIXEL 0x40 #define TERM_INVALIDMS 0x80 +#define TERM_NOREPLACE 0x100 int flags; LIST_ENTRY(tty_term) entry; @@ -1796,8 +1813,9 @@ struct tty { struct event timer; size_t discarded; + size_t sync_offset; + struct termios tio; - struct visible_ranges r; struct grid_cell cell; struct grid_cell last_cell; @@ -1844,7 +1862,7 @@ struct tty { }; /* Terminal command context. */ -typedef void (*tty_ctx_redraw_cb)(const struct tty_ctx *); +typedef void (*tty_ctx_redraw_cb)(const struct tty_ctx *, u_int, u_int); typedef int (*tty_ctx_set_client_cb)(struct tty_ctx *, struct client *); struct tty_ctx { struct screen *s; @@ -1859,7 +1877,6 @@ struct tty_ctx { #define TTY_CTX_INVISIBLE_PANES 0x2 #define TTY_CTX_WINDOW_BIGGER 0x4 #define TTY_CTX_SYNC 0x8 -#define TTY_CTX_OVERLAY_SYNC 0x10 #define TTY_CTX_CELL_INVALIDATE 0x20 #define TTY_CTX_PANE_OBSCURED 0x40 @@ -2167,16 +2184,6 @@ struct prompt_draw_data { u_int prompt_line; }; -/* Overlay callbacks */ -typedef struct visible_ranges *(*overlay_check_cb)(struct client *, void *, - u_int, u_int, u_int); -typedef struct screen *(*overlay_mode_cb)(struct client *, void *, u_int *, - u_int *); -typedef void (*overlay_draw_cb)(struct client *, void *); -typedef int (*overlay_key_cb)(struct client *, void *, struct key_event *); -typedef void (*overlay_free_cb)(struct client *, void *); -typedef void (*overlay_resize_cb)(struct client *, void *); - /* Client connection. */ struct client { const char *name; @@ -2262,7 +2269,7 @@ struct client { #define CLIENT_SIZECHANGED 0x400000 #define CLIENT_STATUSOFF 0x800000 #define CLIENT_REDRAWSTATUSALWAYS 0x1000000 -#define CLIENT_REDRAWOVERLAY 0x2000000 +/* 0x2000000 unused */ #define CLIENT_CONTROL_NOOUTPUT 0x4000000 #define CLIENT_DEFAULTSOCKET 0x8000000 #define CLIENT_STARTSERVER 0x10000000 @@ -2272,17 +2279,17 @@ struct client { #define CLIENT_CONTROL_PAUSEAFTER 0x100000000ULL #define CLIENT_CONTROL_WAITEXIT 0x200000000ULL #define CLIENT_WINDOWSIZECHANGED 0x400000000ULL -/* 0x800000000ULL unused */ +#define CLIENT_CONTROL_NEWLAYOUTS 0x800000000ULL #define CLIENT_BRACKETPASTING 0x1000000000ULL #define CLIENT_ASSUMEPASTING 0x2000000000ULL #define CLIENT_WRITE_ACK 0x4000000000ULL #define CLIENT_NO_DETACH_ON_DESTROY 0x8000000000ULL +#define CLIENT_CONTROL_DISCARD 0x10000000000ULL #define CLIENT_ALLREDRAWFLAGS \ (CLIENT_REDRAWWINDOW| \ CLIENT_REDRAWSTATUS| \ CLIENT_REDRAWSTATUSALWAYS| \ CLIENT_REDRAWBORDERS| \ - CLIENT_REDRAWOVERLAY| \ CLIENT_REDRAWMENU) #define CLIENT_UNATTACHEDFLAGS \ (CLIENT_DEAD| \ @@ -2328,15 +2335,6 @@ struct client { u_int pan_ox; u_int pan_oy; - overlay_check_cb overlay_check; - overlay_mode_cb overlay_mode; - overlay_draw_cb overlay_draw; - overlay_key_cb overlay_key; - overlay_free_cb overlay_free; - overlay_resize_cb overlay_resize; - void *overlay_data; - struct event overlay_timer; - struct client_files files; u_int source_file_depth; @@ -2597,6 +2595,7 @@ extern char **cfg_files; extern u_int cfg_nfiles; extern int cfg_quiet; void start_cfg(void); +void cfg_client_lost(struct client *); int load_cfg(const char *, struct cmdq_item *, struct cmd_find_state *, int, struct cmdq_item **); int load_cfg_from_buffer(const void *, size_t, const char *, @@ -2938,8 +2937,6 @@ void tty_default_attributes(struct tty *, u_int, void tty_update_mode(struct tty *, int, struct screen *); const struct grid_cell *tty_check_codeset(struct tty *, const struct grid_cell *); -struct visible_ranges *tty_check_overlay_range(struct tty *, u_int, u_int, - u_int); void tty_sync_start(struct tty *); void tty_sync_end(struct tty *); int tty_open(struct tty *, char **); @@ -2980,7 +2977,7 @@ void tty_default_colours(struct grid_cell *, struct window_pane *, u_int *); /* tty-term.c */ extern struct tty_terms tty_terms; u_int tty_term_ncodes(void); -void tty_term_apply(struct tty_term *, const char *, int); +void tty_term_apply(struct tty_term *, const char *, int, int); void tty_term_apply_overrides(struct tty_term *); struct tty_term *tty_term_create(struct tty *, char *, char **, u_int, char **); void tty_term_free(struct tty_term *); @@ -3175,6 +3172,7 @@ void cmdq_print_data(struct cmdq_item *, struct evbuffer *); void printflike(2, 3) cmdq_error(struct cmdq_item *, const char *, ...); /* cmd-wait-for.c */ +void cmd_wait_for_client_lost(struct client *); void cmd_wait_for_flush(void); /* client.c */ @@ -3265,14 +3263,8 @@ int server_create_socket(uint64_t, char **); /* server-client.c */ u_int server_client_how_many(void); -void server_client_set_overlay(struct client *, u_int, overlay_check_cb, - overlay_mode_cb, overlay_draw_cb, overlay_key_cb, - overlay_free_cb, overlay_resize_cb, void *); -void server_client_clear_overlay(struct client *); void server_client_ensure_ranges(struct visible_ranges *, u_int); int server_client_ranges_is_empty(struct visible_ranges *); -void server_client_overlay_range(u_int, u_int, u_int, u_int, u_int, u_int, - u_int, struct visible_ranges *); void server_client_set_key_table(struct client *, const char *); const char *server_client_get_key_table(struct client *); int server_client_check_nested(struct client *); @@ -3381,7 +3373,7 @@ void recalculate_sizes_now(int); /* input.c */ #define INPUT_BUF_DEFAULT_SIZE 1048576 struct input_ctx *input_init(struct window_pane *, struct bufferevent *, - struct colour_palette *, struct client *); + struct colour_palette *); void input_free(struct input_ctx *); void input_reset(struct input_ctx *, int); struct evbuffer *input_pending(struct input_ctx *); @@ -3558,7 +3550,7 @@ void screen_write_mode_clear(struct screen_write_ctx *, int); void screen_write_start_sync(struct window_pane *); void screen_write_stop_sync(struct window_pane *); void screen_write_end_sync(struct screen_write_ctx *); -void screen_write_clear_dirty(struct window_pane *); +void screen_write_sync_clear_dirty(struct window_pane *); void screen_write_cursorup(struct screen_write_ctx *, u_int); void screen_write_cursordown(struct screen_write_ctx *, u_int); void screen_write_cursorright(struct screen_write_ctx *, u_int); @@ -3606,8 +3598,12 @@ void redraw_screen(struct client *); void redraw_pane(struct client *, struct window_pane *); void redraw_pane_scrollbar(struct client *, struct window_pane *); void redraw_free_scene(struct redraw_scene *); +int redraw_client_has_window(struct client *, struct window *); void redraw_invalidate_scene(struct window *); void redraw_invalidate_all_scenes(void); +void redraw_damage_window(struct window *, u_int, u_int, u_int, u_int); +void redraw_free_damage(struct window *); +void redraw_client_damage(struct client *); int redraw_get_status_border_cell_type(struct redraw_span **, u_int); /* screen.c */ @@ -3673,6 +3669,8 @@ struct window_pane *window_find_string(struct window *, const char *); int window_has_floating_panes(struct window *); int window_has_pane(struct window *, struct window_pane *); int window_pane_contains(struct window_pane *, u_int, u_int); +int window_pane_floating_overlaps(struct window_pane *, + struct window_pane *); int window_set_active_pane(struct window *, struct window_pane *, int); void window_fire_pane_moved(struct window_pane *, struct window *, @@ -3700,6 +3698,7 @@ struct window_pane *window_pane_previous_by_number(struct window *, struct window_pane *, u_int); int window_pane_index(struct window_pane *, u_int *); int window_pane_zindex(struct window_pane *, u_int *); +int window_pane_last_index(struct window_pane *, u_int *); u_int window_count_panes(struct window *, int); void window_destroy_panes(struct window *); struct window_pane *window_pane_find_by_id_str(const char *); @@ -3780,6 +3779,9 @@ int window_pane_get_pane_status(struct window_pane *); struct style_range *window_pane_status_get_range(struct window_pane *, u_int, u_int); int window_pane_is_floating(struct window_pane *); +int window_pane_is_floating_with_hidden(struct window_pane *); +void window_redraw_floating_pane(struct window_pane *, int, int, + int, int); /* window-border.c */ void window_set_fill_cells(struct window *); @@ -3799,7 +3801,7 @@ struct visible_ranges *window_visible_ranges(struct window_pane *, int, int, u_int, struct visible_ranges *); /* layout.c */ -u_int layout_count_cells(struct layout_cell *); +u_int layout_count_cells(struct layout_cell *, int); struct layout_cell *layout_create_cell(struct layout_cell *); void layout_free_cell(struct layout_cell *, int); void layout_print_cell(struct layout_cell *, const char *, u_int); @@ -3811,8 +3813,8 @@ struct layout_cell *layout_search_by_border(struct layout_cell *, u_int, u_int); void layout_set_size(struct layout_cell *, u_int, u_int, int, int); void layout_make_leaf(struct layout_cell *, struct window_pane *); void layout_make_node(struct layout_cell *, enum layout_type); -void layout_fix_zindexes(struct window *, struct layout_cell *); int layout_cell_is_tiled(struct layout_cell *); +int layout_cell_has_tiled_child(struct layout_cell *); int layout_add_horizontal_border(struct layout_cell *, struct layout_cell *, int); void layout_fix_offsets(struct window *); @@ -3863,7 +3865,8 @@ int layout_remove_tile(struct window *, struct layout_cell *); int layout_insert_tile(struct window *, struct layout_cell *); /* layout-custom.c */ -char *layout_dump(struct window *, struct layout_cell *); +#define LAYOUT_CUSTOM_OLD_FORMAT 0x1 +char *layout_dump(struct window *, struct layout_cell *, int); int layout_parse(struct window *, const char *, char **); /* layout-set.c */ @@ -4118,6 +4121,7 @@ void menu_add_item(struct menu *, const struct menu_item *, struct cmdq_item *, struct client *, struct cmd_find_state *); void menu_free(struct menu *); +void menu_get_size(struct menu *, enum box_lines, u_int *, u_int *); int menu_display(struct menu *, int, int, struct cmdq_item *, u_int, u_int, struct client *, enum box_lines, const char *, const char *, const char *, struct cmd_find_state *, @@ -4134,20 +4138,6 @@ void menu_get_cursor(struct menu_data *, u_int *, u_int *); void menu_resize(struct menu_data *, struct window *); int menu_key(struct client *, struct menu_data *, struct key_event *); -/* popup.c */ -#define POPUP_CLOSEEXIT 0x1 -#define POPUP_CLOSEEXITZERO 0x2 -#define POPUP_CLOSEANYKEY 0x4 -typedef void (*popup_close_cb)(int, void *); -int popup_display(int, enum box_lines, struct cmdq_item *, u_int, - u_int, u_int, u_int, struct environ *, const char *, int, - char **, const char *, const char *, struct client *, - struct session *, const char *, const char *, - popup_close_cb, void *); -int popup_present(struct client *); -int popup_modify(struct client *, const char *, const char *, - const char *, enum box_lines, int); - /* style.c */ int style_parse(struct style *,const struct grid_cell *, const char *); @@ -4223,4 +4213,29 @@ struct hyperlinks *hyperlinks_copy(struct hyperlinks *); void hyperlinks_reset(struct hyperlinks *); void hyperlinks_free(struct hyperlinks *); +/* json.c */ +struct json_node *json_parse(const char *, char **); +void json_destroy_node(struct json_node *); +char *json_to_string(struct json_node *); +struct json_node *json_find(struct json_node *, const char *); +struct json_node *json_array_first(struct json_node *); +struct json_node *json_array_next(struct json_node *); +int json_get_string(struct json_node *, const char **); +int json_get_number(struct json_node *, int64_t *); +int json_get_boolean(struct json_node *, int *); +int json_get_object(struct json_node *, + struct json_node **); +int json_get_array(struct json_node *, + struct json_node **); +int json_find_string(struct json_node *, const char *, + const char **, char **); +int json_find_number(struct json_node *, const char *, + int64_t *, char **); +int json_find_boolean(struct json_node *, const char *, + int *, char **); +int json_find_object(struct json_node *, const char *, + struct json_node **, char **); +int json_find_array(struct json_node *, const char *, + struct json_node **, char **); + #endif /* TMUX_H */ diff --git a/tty-draw.c b/tty-draw.c index 06d520e11..d707d1bf3 100644 --- a/tty-draw.c +++ b/tty-draw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty-draw.c,v 1.15 2026/07/26 09:02:08 nicm Exp $ */ +/* $OpenBSD: tty-draw.c,v 1.17 2026/09/22 06:49:47 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -52,8 +52,7 @@ tty_draw_line_clear(struct tty *tty, u_int px, u_int py, u_int nx, return; /* If genuine BCE is available, can try escape sequences. */ - if (tty->client->overlay_check == NULL && - !wrapped && + if (!wrapped && nx >= 10 && !tty_fake_bce(tty, defaults, bg)) { /* Off the end of the line, use EL if available. */ @@ -219,7 +218,12 @@ tty_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, } /* Did the previous line wrap on to this one? */ - if (py != 0 && atx == 0 && tty->cx >= tty->sx && nx == tty->sx) { + if (py != 0 && + atx == 0 && + tty->cx >= tty->sx && + tty->cy != UINT_MAX && + tty->cy + 1 == aty && + nx == tty->sx) { gl = grid_get_line(gd, gd->hsize + py - 1); if (gl->flags & GRID_LINE_WRAPPED) wrapped = 1; diff --git a/tty-features.c b/tty-features.c index 0b12745e2..7d8568b57 100644 --- a/tty-features.c +++ b/tty-features.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty-features.c,v 1.43 2026/08/31 12:41:03 kirill Exp $ */ +/* $OpenBSD: tty-features.c,v 1.46 2026/09/24 08:16:13 nicm Exp $ */ /* * Copyright (c) 2020 Nicholas Marriott @@ -189,6 +189,18 @@ static const struct tty_feature tty_feature_focus = { 0 }; +/* Terminal supports application escape key mode. */ +static const char *const tty_feature_appesc_capabilities[] = { + "Enesc=\\E[?7727h", + "Dsesc=\\E[?7727l", + NULL +}; +static const struct tty_feature tty_feature_appesc = { + "appesc", + tty_feature_appesc_capabilities, + 0 +}; + /* Terminal supports cursor styles. */ static const char *const tty_feature_cstyle_capabilities[] = { "Ss=\\E[%p1%d q", @@ -198,7 +210,7 @@ static const char *const tty_feature_cstyle_capabilities[] = { static const struct tty_feature tty_feature_cstyle = { "cstyle", tty_feature_cstyle_capabilities, - 0 + TERM_NOREPLACE }; /* Terminal supports cursor colours. */ @@ -378,6 +390,7 @@ static const struct tty_feature tty_feature_utf8 = { /* Available terminal features. */ static const struct tty_feature *const tty_features[] = { &tty_feature_256, + &tty_feature_appesc, &tty_feature_bpaste, &tty_feature_ccolour, &tty_feature_clipboard, @@ -541,11 +554,11 @@ tty_apply_features(struct tty_term *term) capability = tf->capabilities; while (*capability != NULL) { log_debug("adding capability: %s", *capability); - tty_term_apply(term, *capability, 1); + tty_term_apply(term, *capability, 1, tf->flags); capability++; } } - term->flags |= tf->flags; + term->flags |= (tf->flags & ~TERM_NOREPLACE); if (tf == &tty_feature_utf8) c->flags |= CLIENT_UTF8; } @@ -568,6 +581,7 @@ tty_default_features(struct client *c, const char *name, u_int version) "256,RGB,bpaste,clipboard,mouse,strikethrough,title" { .name = "mintty", .features = TTY_FEATURES_BASE_MODERN_XTERM "," + "appesc," "ccolour," "cstyle," "extkeys," @@ -623,6 +637,7 @@ tty_default_features(struct client *c, const char *name, u_int version) "extkeys," "focus," "hyperlinks," + "margins," "usstyle" }, { .name = "ghostty", @@ -631,6 +646,7 @@ tty_default_features(struct client *c, const char *name, u_int version) "cstyle," "extkeys," "focus," + "margins," "overline," "hyperlinks," "osc7," @@ -661,7 +677,7 @@ tty_default_features(struct client *c, const char *name, u_int version) "cstyle," "extkeys," "focus" - } + }, }; u_int i; diff --git a/tty-keys.c b/tty-keys.c index 8462e0d9b..7125dcaf7 100644 --- a/tty-keys.c +++ b/tty-keys.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty-keys.c,v 1.214 2026/08/18 09:01:20 nicm Exp $ */ +/* $OpenBSD: tty-keys.c,v 1.215 2026/09/25 10:37:05 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -761,6 +761,16 @@ tty_keys_next(struct tty *tty) return (0); log_debug("%s: keys are %zu (%.*s)", c->name, len, (int)len, buf); + /* If the client has no session, there is nowhere to send input. */ + if (c->session == NULL) { + log_debug("%s: discard keys with no session", c->name); + if (event_initialized(&tty->key_timer)) + evtimer_del(&tty->key_timer); + tty->flags &= ~TTY_TIMER; + evbuffer_drain(tty->in, len); + return (1); + } + /* Is this a clipboard response? */ switch (tty_keys_clipboard(tty, buf, len, &size)) { case 0: /* yes */ diff --git a/tty-term.c b/tty-term.c index 6b149fcee..1ac078cd4 100644 --- a/tty-term.c +++ b/tty-term.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty-term.c,v 1.109 2026/08/25 08:37:08 nicm Exp $ */ +/* $OpenBSD: tty-term.c,v 1.111 2026/09/22 14:10:26 nicm Exp $ */ /* * Copyright (c) 2008 Nicholas Marriott @@ -90,6 +90,7 @@ static const struct tty_term_code_entry tty_term_codes[] = { [TTYC_DL1] = { TTYCODE_STRING, "dl1" }, [TTYC_DL] = { TTYCODE_STRING, "dl" }, [TTYC_DSEKS] = { TTYCODE_STRING, "Dseks" }, + [TTYC_DSESC] = { TTYCODE_STRING, "Dsesc" }, [TTYC_DSFCS] = { TTYCODE_STRING, "Dsfcs" }, [TTYC_DSBP] = { TTYCODE_STRING, "Dsbp" }, [TTYC_DSMG] = { TTYCODE_STRING, "Dsmg" }, @@ -101,6 +102,7 @@ static const struct tty_term_code_entry tty_term_codes[] = { [TTYC_ENACS] = { TTYCODE_STRING, "enacs" }, [TTYC_ENBP] = { TTYCODE_STRING, "Enbp" }, [TTYC_ENEKS] = { TTYCODE_STRING, "Eneks" }, + [TTYC_ENESC] = { TTYCODE_STRING, "Enesc" }, [TTYC_ENFCS] = { TTYCODE_STRING, "Enfcs" }, [TTYC_ENMG] = { TTYCODE_STRING, "Enmg" }, [TTYC_FSL] = { TTYCODE_STRING, "fsl" }, @@ -362,7 +364,8 @@ tty_term_override_next(const char *s, size_t *offset) } void -tty_term_apply(struct tty_term *term, const char *capabilities, int quiet) +tty_term_apply(struct tty_term *term, const char *capabilities, int quiet, + int flags) { const struct tty_term_code_entry *ent; struct tty_code *code; @@ -405,6 +408,9 @@ tty_term_apply(struct tty_term *term, const char *capabilities, int quiet) if (strcmp(s, ent->name) != 0) continue; code = &term->codes[i]; + if ((flags & TERM_NOREPLACE) && + code->type != TTYCODE_NONE) + continue; if (remove) { code->type = TTYCODE_NONE; @@ -457,7 +463,7 @@ tty_term_apply_overrides(struct tty_term *term) offset = 0; first = tty_term_override_next(s, &offset); if (first != NULL && fnmatch(first, term->name, 0) == 0) - tty_term_apply(term, s + offset, 0); + tty_term_apply(term, s + offset, 0, 0); a = options_array_next(a); } diff --git a/tty.c b/tty.c index 9bac687eb..40fd0303b 100644 --- a/tty.c +++ b/tty.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty.c,v 1.480 2026/08/25 08:37:08 nicm Exp $ */ +/* $OpenBSD: tty.c,v 1.484 2026/10/05 08:45:27 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -66,7 +66,6 @@ static void tty_redraw_region(struct tty *, const struct tty_ctx *); static void tty_emulate_repeat(struct tty *, enum tty_code_code, enum tty_code_code, u_int); static void tty_draw_pane(struct tty *, const struct tty_ctx *, u_int); -static int tty_check_overlay(struct tty *, u_int, u_int); #ifdef ENABLE_SIXEL static void tty_write_one(void (*)(struct tty *, const struct tty_ctx *), @@ -503,8 +502,7 @@ tty_stop_tty(struct tty *tty) if (tty_term_has(tty->term, TTYC_DSBP)) tty_raw(tty, tty_term_string(tty->term, TTYC_DSBP)); - if (tty->term->flags & TERM_VT100LIKE) - tty_raw(tty, "\033[?7727l"); + tty_raw(tty, tty_term_string(tty->term, TTYC_DSESC)); tty_raw(tty, tty_term_string(tty->term, TTYC_DSFCS)); tty_raw(tty, tty_term_string(tty->term, TTYC_DSEKS)); @@ -545,8 +543,6 @@ void tty_free(struct tty *tty) { tty_close(tty); - - free(tty->r.ranges); } void @@ -563,8 +559,7 @@ tty_update_features(struct tty *tty) tty_puts(tty, tty_term_string(tty->term, TTYC_ENEKS)); if (options_get_number(global_options, "focus-events")) tty_puts(tty, tty_term_string(tty->term, TTYC_ENFCS)); - if (tty->term->flags & TERM_VT100LIKE) - tty_puts(tty, "\033[?7727h"); + tty_puts(tty, tty_term_string(tty->term, TTYC_ENESC)); /* * Features might have changed since the first draw during attach. For @@ -1120,7 +1115,7 @@ tty_redraw_region(struct tty *tty, const struct tty_ctx *ctx) */ if (tty_large_region(tty, ctx) || ctx->flags & TTY_CTX_PANE_OBSCURED) { log_debug("%s: %s large region redraw", __func__, c->name); - ctx->redraw_cb(ctx); + ctx->redraw_cb(ctx, ctx->orupper, ctx->orlower - ctx->orupper + 1); return; } @@ -1201,9 +1196,6 @@ tty_clear_line(struct tty *tty, const struct grid_cell *defaults, u_int py, u_int px, u_int nx, u_int bg) { struct client *c = tty->client; - struct visible_ranges *r; - struct visible_range *rr; - u_int i; log_debug("%s: %s, %u at %u,%u", __func__, c->name, nx, px, py); @@ -1212,7 +1204,7 @@ tty_clear_line(struct tty *tty, const struct grid_cell *defaults, u_int py, return; /* If genuine BCE is available, can try escape sequences. */ - if (c->overlay_check == NULL && !tty_fake_bce(tty, defaults, bg)) { + if (!tty_fake_bce(tty, defaults, bg)) { /* Off the end of the line, use EL if available. */ if (px + nx >= tty->sx && tty_term_has(tty->term, TTYC_EL)) { tty_cursor(tty, px, py); @@ -1235,18 +1227,9 @@ tty_clear_line(struct tty *tty, const struct grid_cell *defaults, u_int py, } } - /* - * Couldn't use an escape sequence, use spaces. Clear only the visible - * bit if there is an overlay. - */ - r = tty_check_overlay_range(tty, px, py, nx); - for (i = 0; i < r->used; i++) { - rr = &r->ranges[i]; - if (rr->nx != 0) { - tty_cursor(tty, rr->px, py); - tty_repeat_space(tty, rr->nx); - } - } + /* Couldn't use an escape sequence, use spaces. */ + tty_cursor(tty, px, py); + tty_repeat_space(tty, nx); } /* Clear a line, adjusting to visible part of pane. */ @@ -1255,22 +1238,12 @@ tty_clear_pane_line(struct tty *tty, const struct tty_ctx *ctx, u_int py, u_int px, u_int nx, u_int bg) { struct client *c = tty->client; - struct visible_ranges *r; - struct visible_range *ri; - u_int i, l, x, rx, ry; + u_int l, x, rx, ry; log_debug("%s: %s, %u at %u,%u", __func__, c->name, nx, px, py); - if (tty_clamp_line(tty, ctx, px, py, nx, &l, &x, &rx, &ry)) { - r = tty_check_overlay_range(tty, x, ry, rx); - for (i = 0; i < r->used; i++) { - ri = &r->ranges[i]; - if (ri->nx == 0) - continue; - tty_clear_line(tty, &ctx->defaults, ry, ri->px, ri->nx, - bg); - } - } + if (tty_clamp_line(tty, ctx, px, py, nx, &l, &x, &rx, &ry)) + tty_clear_line(tty, &ctx->defaults, ry, x, rx, bg); } /* Clamp area position to visible part of pane. */ @@ -1351,11 +1324,8 @@ tty_clear_area(struct tty *tty, const struct tty_ctx *ctx, u_int py, if (nx == 0 || ny == 0) return; - /* - * If there is an overlay or BCE is not available, cannot clear as a - * region. - */ - if (c->overlay_check == NULL && !tty_fake_bce(tty, defaults, bg)) { + /* If BCE is available, can try to clear as a region. */ + if (!tty_fake_bce(tty, defaults, bg)) { /* Use ED if clearing off the bottom of the terminal. */ if (px == 0 && px + nx >= tty->sx && @@ -1427,53 +1397,28 @@ static void tty_draw_pane(struct tty *tty, const struct tty_ctx *ctx, u_int py) { struct screen *s = ctx->s; - u_int nx = ctx->sx, i, x, rx, ry, j; - struct visible_ranges *r; - struct visible_range *rr; + u_int nx = ctx->sx, i, x, rx, ry; log_debug("%s: %s %u", __func__, tty->client->name, py); if (~ctx->flags & TTY_CTX_WINDOW_BIGGER) { - r = tty_check_overlay_range(tty, ctx->xoff, ctx->yoff + py, nx); - for (j = 0; j < r->used; j++) { - rr = &r->ranges[j]; - if (rr->nx == 0) - continue; - tty_draw_line(tty, s, rr->px - ctx->xoff, py, rr->nx, - rr->px, ctx->yoff + py, &ctx->style_ctx); - } + tty_draw_line(tty, s, 0, py, nx, ctx->xoff, ctx->yoff + py, + &ctx->style_ctx); return; } - if (tty_clamp_line(tty, ctx, 0, py, nx, &i, &x, &rx, &ry)) { - r = tty_check_overlay_range(tty, x, ry, rx); - for (j = 0; j < r->used; j++) { - rr = &r->ranges[j]; - if (rr->nx == 0) - continue; - tty_draw_line(tty, s, i + rr->px - x, py, rr->nx, - rr->px, ry, &ctx->style_ctx); - } - } + if (tty_clamp_line(tty, ctx, 0, py, nx, &i, &x, &rx, &ry)) + tty_draw_line(tty, s, i, py, rx, x, ry, &ctx->style_ctx); } void tty_cmd_redrawline(struct tty *tty, const struct tty_ctx *ctx) { - u_int i, x, rx, ry, j; - struct visible_ranges *r; - struct visible_range *rr; + u_int i, x, rx, ry; if (tty_clamp_line(tty, ctx, ctx->ocx, ctx->ocy, ctx->n, - &i, &x, &rx, &ry)) { - r = tty_check_overlay_range(tty, x, ry, rx); - for (j = 0; j < r->used; j++) { - rr = &r->ranges[j]; - if (rr->nx == 0) - continue; - tty_draw_line(tty, ctx->s, ctx->ocx + i + rr->px - x, - ctx->ocy, rr->nx, rr->px, ry, &ctx->style_ctx); - } - } + &i, &x, &rx, &ry)) + tty_draw_line(tty, ctx->s, ctx->ocx + i, ctx->ocy, rx, x, ry, + &ctx->style_ctx); } /* Check if character needs to be mapped for codeset. */ @@ -1510,36 +1455,6 @@ tty_check_codeset(struct tty *tty, const struct grid_cell *gc) return (&new); } -/* Check if a single character is covered by the overlay. */ -static int -tty_check_overlay(struct tty *tty, u_int px, u_int py) -{ - struct visible_ranges *r; - - /* - * With a single character, if there is anything visible (that is, the - * range is not empty), it must be that character. - */ - r = tty_check_overlay_range(tty, px, py, 1); - return (!server_client_ranges_is_empty(r)); -} - -/* Return parts of the input range which are visible. */ -struct visible_ranges * -tty_check_overlay_range(struct tty *tty, u_int px, u_int py, u_int nx) -{ - struct client *c = tty->client; - - if (c->overlay_check == NULL) { - server_client_ensure_ranges(&tty->r, 1); - tty->r.ranges[0].px = px; - tty->r.ranges[0].nx = nx; - tty->r.used = 1; - return (&tty->r); - } - return (c->overlay_check(c, c->overlay_data, px, py, nx)); -} - #ifdef ENABLE_SIXEL /* Update context for client. */ static int @@ -1602,6 +1517,7 @@ tty_sync_start(struct tty *tty) if (tty->flags & TTY_SYNCING) return; tty->flags |= TTY_SYNCING; + tty->sync_offset = EVBUFFER_LENGTH(tty->out); if (tty_term_has(tty->term, TTYC_SYNC)) { log_debug("%s sync start", tty->client->name); @@ -1683,14 +1599,11 @@ tty_write_one(void (*cmdfn)(struct tty *, const struct tty_ctx *), void tty_cmd_insertcharacter(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || !tty_full_width(tty, ctx) || tty_fake_bce(tty, &ctx->defaults, ctx->bg) || (!tty_term_has(tty->term, TTYC_ICH) && - !tty_term_has(tty->term, TTYC_ICH1)) || - c->overlay_check != NULL) { + !tty_term_has(tty->term, TTYC_ICH1))) { tty_draw_pane(tty, ctx, ctx->ocy); return; } @@ -1705,14 +1618,11 @@ tty_cmd_insertcharacter(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_deletecharacter(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || !tty_full_width(tty, ctx) || tty_fake_bce(tty, &ctx->defaults, ctx->bg) || (!tty_term_has(tty->term, TTYC_DCH) && - !tty_term_has(tty->term, TTYC_DCH1)) || - c->overlay_check != NULL) { + !tty_term_has(tty->term, TTYC_DCH1))) { tty_draw_pane(tty, ctx, ctx->ocy); return; } @@ -1735,16 +1645,13 @@ tty_cmd_clearcharacter(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_insertline(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || !tty_full_width(tty, ctx) || tty_fake_bce(tty, &ctx->defaults, ctx->bg) || !tty_term_has(tty->term, TTYC_CSR) || !tty_term_has(tty->term, TTYC_IL1) || ctx->sx == 1 || - ctx->sy == 1 || - c->overlay_check != NULL) { + ctx->sy == 1) { tty_redraw_region(tty, ctx); return; } @@ -1762,16 +1669,13 @@ tty_cmd_insertline(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_deleteline(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || !tty_full_width(tty, ctx) || tty_fake_bce(tty, &ctx->defaults, ctx->bg) || !tty_term_has(tty->term, TTYC_CSR) || !tty_term_has(tty->term, TTYC_DL1) || ctx->sx == 1 || - ctx->sy == 1 || - c->overlay_check != NULL) { + ctx->sy == 1) { tty_redraw_region(tty, ctx); return; } @@ -1815,8 +1719,6 @@ tty_cmd_clearstartofline(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_reverseindex(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - if (ctx->ocy != ctx->orupper) return; @@ -1827,8 +1729,7 @@ tty_cmd_reverseindex(struct tty *tty, const struct tty_ctx *ctx) (!tty_term_has(tty->term, TTYC_RI) && !tty_term_has(tty->term, TTYC_RIN)) || ctx->sx == 1 || - ctx->sy == 1 || - c->overlay_check != NULL) { + ctx->sy == 1) { tty_redraw_region(tty, ctx); return; } @@ -1848,8 +1749,6 @@ tty_cmd_reverseindex(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_linefeed(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - if (ctx->ocy != ctx->orlower) return; @@ -1858,8 +1757,7 @@ tty_cmd_linefeed(struct tty *tty, const struct tty_ctx *ctx) tty_fake_bce(tty, &ctx->defaults, 8) || !tty_term_has(tty->term, TTYC_CSR) || ctx->sx == 1 || - ctx->sy == 1 || - c->overlay_check != NULL) { + ctx->sy == 1) { tty_redraw_region(tty, ctx); return; } @@ -1890,7 +1788,6 @@ tty_cmd_linefeed(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_scrollup(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; u_int i; if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || @@ -1898,8 +1795,7 @@ tty_cmd_scrollup(struct tty *tty, const struct tty_ctx *ctx) tty_fake_bce(tty, &ctx->defaults, 8) || !tty_term_has(tty->term, TTYC_CSR) || ctx->sx == 1 || - ctx->sy == 1 || - c->overlay_check != NULL) { + ctx->sy == 1) { tty_redraw_region(tty, ctx); return; } @@ -1929,7 +1825,6 @@ void tty_cmd_scrolldown(struct tty *tty, const struct tty_ctx *ctx) { u_int i; - struct client *c = tty->client; if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || (!tty_full_width(tty, ctx) && !tty_use_margin(tty)) || @@ -1938,8 +1833,7 @@ tty_cmd_scrolldown(struct tty *tty, const struct tty_ctx *ctx) (!tty_term_has(tty->term, TTYC_RI) && !tty_term_has(tty->term, TTYC_RIN)) || ctx->sx == 1 || - ctx->sy == 1 || - c->overlay_check != NULL) { + ctx->sy == 1) { tty_redraw_region(tty, ctx); return; } @@ -2027,12 +1921,10 @@ tty_cmd_clearscreen(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_alignmenttest(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; u_int i, j; - if ((ctx->flags & TTY_CTX_WINDOW_BIGGER) || - c->overlay_check != NULL) { - ctx->redraw_cb(ctx); + if (ctx->flags & TTY_CTX_WINDOW_BIGGER) { + ctx->redraw_cb(ctx, 0, ctx->sy); return; } @@ -2051,29 +1943,9 @@ tty_cmd_alignmenttest(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_cell(struct tty *tty, const struct tty_ctx *ctx) { - const struct grid_cell *gcp = ctx->cell; - struct screen *s = ctx->s; - struct visible_ranges *r; - u_int px, py, i, vis = 0; - - px = ctx->xoff + ctx->ocx - ctx->wox; - py = ctx->yoff + ctx->ocy - ctx->woy; if (!tty_is_visible(tty, ctx, ctx->ocx, ctx->ocy, 1, 1)) return; - if (gcp->data.width == 1 && !tty_check_overlay(tty, px, py)) - return; - if (gcp->data.width > 1) { /* could be partially obscured */ - r = tty_check_overlay_range(tty, px, py, gcp->data.width); - for (i = 0; i < r->used; i++) - vis += r->ranges[i].nx; - if (vis < gcp->data.width) { - tty_draw_line(tty, s, s->cx, s->cy, gcp->data.width, - px, py, &ctx->style_ctx); - return; - } - } - if (ctx->xoff + ctx->ocx - ctx->wox > tty->sx - 1 && ctx->ocy == ctx->orlower && tty_full_width(tty, ctx)) @@ -2093,9 +1965,6 @@ tty_cmd_cell(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_cells(struct tty *tty, const struct tty_ctx *ctx) { - struct visible_ranges *r; - struct visible_range *ri; - u_int i, px, py, cx; const char *cp = ctx->data.data; size_t n = ctx->data.size; @@ -2114,27 +1983,14 @@ tty_cmd_cells(struct tty *tty, const struct tty_ctx *ctx) tty->cy == tty->rlower) tty_draw_pane(tty, ctx, ctx->ocy); else - ctx->redraw_cb(ctx); + ctx->redraw_cb(ctx, ctx->ocy, 1); return; } tty_margin_off(tty); tty_cursor_pane_unless_wrap(tty, ctx, ctx->ocx, ctx->ocy); tty_attributes(tty, ctx->cell, &ctx->style_ctx); - - /* Get tty position from pane position for overlay check. */ - px = ctx->xoff + ctx->ocx - ctx->wox; - py = ctx->yoff + ctx->ocy - ctx->woy; - - r = tty_check_overlay_range(tty, px, py, n); - for (i = 0; i < r->used; i++) { - ri = &r->ranges[i]; - if (ri->nx != 0) { - cx = ri->px - ctx->xoff + ctx->wox; - tty_cursor_pane_unless_wrap(tty, ctx, cx, ctx->ocy); - tty_putn(tty, cp + ri->px - px, ri->nx, ri->nx); - } - } + tty_putn(tty, cp, n, n); } void @@ -2228,23 +2084,8 @@ tty_cmd_sixelimage(struct tty *tty, const struct tty_ctx *ctx) void tty_cmd_syncstart(struct tty *tty, const struct tty_ctx *ctx) { - struct client *c = tty->client; - - if ((ctx->flags & TTY_CTX_OVERLAY_SYNC) && - (ctx->flags & TTY_CTX_SYNC)) { - /* - * This is an overlay and a command that moves the cursor so - * start synchronized updates. - */ + if (ctx->flags & TTY_CTX_SYNC) tty_sync_start(tty); - } else if (~ctx->flags & TTY_CTX_OVERLAY_SYNC) { - /* - * This is a pane. If there is an overlay, always start; - * otherwise, only if requested. - */ - if ((ctx->flags & TTY_CTX_SYNC) || c->overlay_draw != NULL) - tty_sync_start(tty); - } } void @@ -2263,10 +2104,6 @@ tty_cell(struct tty *tty, const struct grid_cell *gc, if (gc->flags & GRID_FLAG_PADDING) return; - /* Check if character is covered by overlay or floating pane. */ - if (!tty_check_overlay(tty, tty->cx, tty->cy)) - return; - /* Check the output codeset and apply attributes. */ gcp = tty_check_codeset(tty, gc); tty_attributes(tty, gcp, style_ctx); @@ -2992,7 +2829,7 @@ tty_colours_fg(struct tty *tty, const struct grid_cell *gc) * reset because some terminals do not clear bright correctly. */ if (tty->cell.fg >= 90 && - tty->cell.bg <= 97 && + tty->cell.fg <= 97 && (gc->fg < 90 || gc->fg > 97)) tty_reset(tty); diff --git a/utf8-combined.c b/utf8-combined.c index 02abc3518..78a34074d 100644 --- a/utf8-combined.c +++ b/utf8-combined.c @@ -1,4 +1,4 @@ -/* $OpenBSD: utf8-combined.c,v 1.10 2026/06/09 11:49:36 nicm Exp $ */ +/* $OpenBSD: utf8-combined.c,v 1.11 2026/10/05 08:32:24 nicm Exp $ */ /* * Copyright (c) 2023 Nicholas Marriott @@ -121,6 +121,12 @@ utf8_should_combine(const struct utf8_data *with, const struct utf8_data *add) /* Emoji skin tone modifiers. */ switch (a) { + case 0x0261D: + case 0x026F9: + case 0x0270A: + case 0x0270B: + case 0x0270C: + case 0x0270D: case 0x1F44B: case 0x1F44C: case 0x1F44D: diff --git a/utf8.c b/utf8.c index 68c37d362..71918083b 100644 --- a/utf8.c +++ b/utf8.c @@ -1,4 +1,4 @@ -/* $OpenBSD: utf8.c,v 1.72 2026/09/01 12:49:49 nicm Exp $ */ +/* $OpenBSD: utf8.c,v 1.74 2026/10/05 08:32:24 nicm Exp $ */ /* * Copyright (c) 2008 Nicholas Marriott @@ -51,12 +51,8 @@ static struct utf8_width_cache utf8_width_cache = RB_INITIALIZER(utf8_width_cache); static struct utf8_width_item utf8_default_width_cache[] = { - { .wc = 0x0261D, .width = 2 }, - { .wc = 0x026F9, .width = 2 }, { .wc = 0x0270A, .width = 2 }, { .wc = 0x0270B, .width = 2 }, - { .wc = 0x0270C, .width = 2 }, - { .wc = 0x0270D, .width = 2 }, { .wc = 0x1F1E6, .width = 1 }, { .wc = 0x1F1E7, .width = 1 }, { .wc = 0x1F1E8, .width = 1 }, @@ -374,8 +370,13 @@ utf8_add_to_width_cache(const char *s) wc_end = wc_start; } - for (wc = wc_start; wc <= wc_end; wc++) + wc = wc_start; + for (;;) { utf8_insert_width_cache(wc, width); + if (wc == wc_end) + break; + wc++; + } } else { utf8_no_width = 1; ud = utf8_fromcstr(copy); diff --git a/window-border.c b/window-border.c index 8f2209d0e..a9bb664e2 100644 --- a/window-border.c +++ b/window-border.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-border.c,v 1.3 2026/07/23 09:38:27 nicm Exp $ */ +/* $OpenBSD: window-border.c,v 1.4 2026/09/11 10:17:16 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -120,6 +120,10 @@ window_get_border_cell(struct window_pane *wp, enum pane_lines pane_lines, gc->attr &= ~GRID_ATTR_CHARSET; utf8_copy(&gc->data, tty_acs_heavy_borders(cell_type)); break; + case PANE_LINES_ROUNDED: + gc->attr &= ~GRID_ATTR_CHARSET; + utf8_copy(&gc->data, tty_acs_rounded_borders(cell_type)); + break; case PANE_LINES_SIMPLE: gc->attr &= ~GRID_ATTR_CHARSET; utf8_set(&gc->data, SIMPLE_BORDERS[cell_type]); diff --git a/window-copy.c b/window-copy.c index c5529a3ff..bac43ef20 100644 --- a/window-copy.c +++ b/window-copy.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-copy.c,v 1.429 2026/09/01 13:04:29 nicm Exp $ */ +/* $OpenBSD: window-copy.c,v 1.435 2026/10/06 08:26:23 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -401,7 +401,7 @@ window_copy_clone_screen(struct screen *src, struct screen *hint, u_int *cx, sy = screen_hsize(src) + screen_size_y(src); if (trim) { - while (sy > screen_hsize(src)) { + while (sy > screen_hsize(src) + 1) { gl = grid_peek_line(src->grid, sy - 1); if (gl == NULL || gl->cellused != 0) break; @@ -665,7 +665,7 @@ window_copy_view_init(struct window_mode_entry *wme, data->backing = xmalloc(sizeof *data->backing); screen_init(data->backing, sx, screen_size_y(base), UINT_MAX); - data->ictx = input_init(NULL, NULL, NULL, NULL); + data->ictx = input_init(NULL, NULL, NULL); data->mx = data->cx; data->my = screen_hsize(data->backing) + data->cy - data->oy; data->showmark = 0; @@ -768,6 +768,7 @@ window_copy_scroll(struct window_pane *wp, int sl_mpos, u_int my, struct window_mode_entry *wme = TAILQ_FIRST(&wp->modes); if (wme != NULL) { + window_redraw_active_switch(wp->window, wp); window_set_active_pane(wp->window, wp, 0); window_copy_scroll1(wme, wp, sl_mpos, my, tty_oy, scroll_exit); } @@ -2216,68 +2217,80 @@ window_copy_cmd_selection_mode(struct window_copy_cmd_state *cs) else if (strcasecmp(s, "word") == 0 || strcasecmp(s, "w") == 0) { data->separators = options_get_string(so, "word-separators"); data->selflag = SEL_WORD; - } else if (strcasecmp(s, "line") == 0 || strcasecmp(s, "l") == 0) { + } else if (strcasecmp(s, "line") == 0 || strcasecmp(s, "l") == 0) data->selflag = SEL_LINE; - if (data->screen.sel == NULL) - return (WINDOW_COPY_CMD_MOVE); + else + return (WINDOW_COPY_CMD_MOVE); + if (data->selflag == SEL_CHAR || data->screen.sel == NULL) + return (WINDOW_COPY_CMD_MOVE); - /* - * Line selection normally starts with select-line, which sets - * up the reset positions used when the cursor changes - * direction. Do the same when changing an existing selection - * to line mode. - */ - if (data->cursordrag == CURSORDRAG_SEL) { - fx = data->endselx; - fy = data->endsely; - } else { - fx = data->selx; - fy = data->sely; - } - - sx = data->selx; - sy = data->sely; - ex = data->endselx; - ey = data->endsely; - if (ey < sy || (ey == sy && ex < sx)) { - x = sx; sx = ex; ex = x; - y = sy; sy = ey; ey = y; - } - grid_reader_start(&gr, data->backing->grid, sx, sy); - grid_reader_cursor_start_of_line(&gr, 1); - grid_reader_get_cursor(&gr, &sx, &sy); - grid_reader_start(&gr, data->backing->grid, ex, ey); - grid_reader_cursor_end_of_line(&gr, 1, 0); - grid_reader_get_cursor(&gr, &ex, &ey); - - data->rectflag = 0; - data->selrx = data->selx = sx; - data->selry = data->sely = sy; - data->endselrx = data->endselx = ex; - data->endselry = data->endsely = ey; - - x = data->cx; - y = screen_hsize(data->backing) + data->cy - data->oy; - data->dx = fx; - data->dy = fy; - if (data->cursordrag != CURSORDRAG_NONE && - (y < fy || (y == fy && x < fx))) { - data->lineflag = LINE_SEL_RIGHT_LEFT; - data->cursordrag = CURSORDRAG_SEL; - window_copy_scroll_to(wme, sx, sy, 1); - } else { - data->lineflag = LINE_SEL_LEFT_RIGHT; - if (data->cursordrag != CURSORDRAG_NONE) { - data->cursordrag = CURSORDRAG_ENDSEL; - x = window_copy_cursor_limit(wme, ey, 0); - window_copy_scroll_to(wme, x, ey, 1); - } - } - if (data->cursordrag == CURSORDRAG_NONE) - window_copy_set_selection(wme, 0, 0); - return (WINDOW_COPY_CMD_REDRAW); + /* + * Set up the reset positions normally initialized by select-word or + * select-line, including when changing the mode of an existing selection. + */ + if (data->cursordrag == CURSORDRAG_SEL) { + fx = data->endselx; + fy = data->endsely; + } else { + fx = data->selx; + fy = data->sely; } - return (WINDOW_COPY_CMD_MOVE); + + sx = data->selx; + sy = data->sely; + ex = data->endselx; + ey = data->endsely; + if (ey < sy || (ey == sy && ex < sx)) { + x = sx; sx = ex; ex = x; + y = sy; sy = ey; ey = y; + } + grid_reader_start(&gr, data->backing->grid, sx, sy); + if (data->selflag == SEL_WORD) + grid_reader_cursor_previous_word(&gr, data->separators, 0, 1); + else + grid_reader_cursor_start_of_line(&gr, 1); + grid_reader_get_cursor(&gr, &sx, &sy); + grid_reader_start(&gr, data->backing->grid, ex, ey); + if (data->selflag == SEL_WORD) { + grid_reader_cursor_next_word_end(&gr, data->separators); + if (options_get_number(wme->wp->window->options, "mode-keys") == + MODEKEY_VI) + grid_reader_cursor_left(&gr, 1); + } else + grid_reader_cursor_end_of_line(&gr, 1, 0); + grid_reader_get_cursor(&gr, &ex, &ey); + + data->rectflag = 0; + data->selrx = data->selx = sx; + data->selry = data->sely = sy; + data->endselrx = data->endselx = ex; + data->endselry = data->endsely = ey; + + x = data->cx; + y = screen_hsize(data->backing) + data->cy - data->oy; + data->dx = fx; + data->dy = fy; + if (data->cursordrag != CURSORDRAG_NONE && + (y < fy || (y == fy && x < fx))) { + data->lineflag = LINE_SEL_RIGHT_LEFT; + data->cursordrag = CURSORDRAG_SEL; + window_copy_scroll_to(wme, sx, sy, 1); + } else { + data->lineflag = LINE_SEL_LEFT_RIGHT; + if (data->cursordrag != CURSORDRAG_NONE) { + data->cursordrag = CURSORDRAG_ENDSEL; + if (data->selflag == SEL_LINE) + x = window_copy_cursor_limit(wme, ey, 0); + else + x = ex; + window_copy_scroll_to(wme, x, ey, 1); + } + } + if (data->selflag == SEL_WORD) + window_copy_set_selection(wme, 0, 1); + else if (data->cursordrag == CURSORDRAG_NONE) + window_copy_set_selection(wme, 0, 0); + return (WINDOW_COPY_CMD_REDRAW); } static enum window_copy_cmd_action @@ -5507,7 +5520,6 @@ static void window_copy_redraw_selection(struct window_mode_entry *wme, u_int old_y) { struct window_copy_mode_data *data = wme->data; - struct grid *gd = data->backing->grid; u_int new_y, start, end; new_y = data->cy; @@ -5519,15 +5531,6 @@ window_copy_redraw_selection(struct window_mode_entry *wme, u_int old_y) end = old_y; } - /* - * In word selection mode the first word on the line below the cursor - * might be selected, so add this line to the redraw area. - */ - if (data->selflag == SEL_WORD) { - /* Last grid line in data coordinates. */ - if (end < gd->sy + data->oy - 1) - end++; - } window_copy_redraw_lines(wme, start, end - start + 1); } @@ -5864,10 +5867,14 @@ window_copy_update_selection(struct window_mode_entry *wme, int may_redraw, { struct window_copy_mode_data *data = wme->data; struct screen *s = &data->screen; + int changed; if (s->sel == NULL && data->lineflag == LINE_SEL_NONE) return (0); - return (window_copy_set_selection(wme, may_redraw, no_reset)); + changed = window_copy_set_selection(wme, may_redraw, no_reset); + if (data->selflag == SEL_WORD && may_redraw) + window_copy_redraw_screen(wme); + return (changed); } /* @@ -6505,7 +6512,9 @@ window_copy_cursor_up(struct window_mode_entry *wme, int scroll_only) if (data->lineflag == LINE_SEL_LEFT_RIGHT && oy == data->sely) window_copy_other_end(wme); - if (scroll_only && options_get_number(oo, "mode-keys") == MODEKEY_VI) { + /* In vi mode, keep cursor on the same line unless dragging. */ + if (scroll_only && data->cursordrag == CURSORDRAG_NONE && + options_get_number(oo, "mode-keys") == MODEKEY_VI) { if (data->cy < screen_size_y(s) - 1) window_copy_update_cursor(wme, data->cx, data->cy + 1); } @@ -6585,7 +6594,9 @@ window_copy_cursor_down(struct window_mode_entry *wme, int scroll_only) if (data->lineflag == LINE_SEL_RIGHT_LEFT && oy == data->endsely) window_copy_other_end(wme); - if (scroll_only && options_get_number(oo, "mode-keys") == MODEKEY_VI) { + /* In vi mode, keep cursor on the same line unless dragging. */ + if (scroll_only && data->cursordrag == CURSORDRAG_NONE && + options_get_number(oo, "mode-keys") == MODEKEY_VI) { if (data->cy > 0) window_copy_update_cursor(wme, data->cx, data->cy - 1); } @@ -7249,13 +7260,16 @@ static void window_copy_acquire_cursor_up(struct window_mode_entry *wme, u_int hsize, u_int oy, u_int oldy, u_int px, u_int py) { + struct window_copy_mode_data *data = wme->data; u_int cy, yy, ny, nd; yy = hsize - oy; if (py < yy) { ny = yy - py; cy = 0; - nd = 1; + nd = oldy + ny + 1; + if (nd > screen_size_y(&data->screen)) + nd = screen_size_y(&data->screen); } else { ny = 0; cy = py - yy; @@ -7281,8 +7295,8 @@ window_copy_acquire_cursor_down(struct window_mode_entry *wme, u_int hsize, yy = sy - 1; if (cy > yy) { ny = cy - yy; - oldy = yy; - nd = 1; + oldy = oldy > ny ? oldy - ny : 0; + nd = sy - oldy; } else { ny = 0; nd = cy - oldy + 1; diff --git a/window-switch.c b/window-switch.c index 4b9015186..23d3ed03c 100644 --- a/window-switch.c +++ b/window-switch.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-switch.c,v 1.3 2026/08/25 07:23:30 nicm Exp $ */ +/* $OpenBSD: window-switch.c,v 1.4 2026/09/01 21:03:55 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -374,6 +374,9 @@ window_switch_init(struct window_mode_entry *wme, data->prompt = prompt_create(&pd); prompt_update(data->prompt, "(search) ", data->filter); + s = &data->screen; + screen_init(s, screen_size_x(&wp->base), screen_size_y(&wp->base), 0); + if (!args_has(args, 'Z')) data->zoomed = -1; else { @@ -382,9 +385,6 @@ window_switch_init(struct window_mode_entry *wme, server_redraw_window(wp->window); } - s = &data->screen; - screen_init(s, screen_size_x(&wp->base), screen_size_y(&wp->base), 0); - window_switch_build(data); prompt_incremental_start(data->prompt); window_switch_draw_screen(wme); diff --git a/window-visible.c b/window-visible.c index a49bfa2ef..a1ee90307 100644 --- a/window-visible.c +++ b/window-visible.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-visible.c,v 1.5 2026/07/19 17:25:38 nicm Exp $ */ +/* $OpenBSD: window-visible.c,v 1.6 2026/10/02 15:20:41 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -25,7 +25,7 @@ /* * Check if a single character is within a visible range (not obscured by a - * floating pane). + * menu or floating pane). */ int window_position_is_visible(struct visible_ranges *r, u_int px) @@ -43,9 +43,45 @@ window_position_is_visible(struct visible_ranges *r, u_int px) return (0); } +/* Remove width cells starting at px from the visible ranges. */ +static void +window_visible_ranges_remove(struct visible_ranges *r, int px, u_int width) +{ + struct visible_range *ri; + u_int i; + int start, end, right = px + width; + + if (width == 0) + return; + + for (i = 0; i < r->used; i++) { + ri = &r->ranges[i]; + start = ri->px; + end = start + ri->nx; + if (ri->nx == 0 || right <= start || px >= end) + continue; + if (px <= start) { + /* Remove the left part or the whole range. */ + ri->px = right < end ? right : end; + ri->nx = end - ri->px; + } else { + /* Keep the left part, and split off any right part. */ + ri->nx = px - start; + if (right < end) { + server_client_ensure_ranges(r, r->used + 1); + memmove(&r->ranges[i + 2], &r->ranges[i + 1], + (r->used - i - 1) * sizeof *r->ranges); + r->ranges[i + 1].px = right; + r->ranges[i + 1].nx = end - right; + r->used++; + } + } + } +} + /* * Construct ranges array for the line at starting at px,py of width cells of - * base_wp that are unobsructed. All ranges are in window coordinates. + * base_wp that are unobstructed. All ranges are in window coordinates. */ struct visible_ranges * window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, @@ -53,11 +89,10 @@ window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, { struct window_pane *wp; struct window *w; - struct visible_range *ri; + struct menu_data *md; static struct visible_ranges sr = { NULL, 0, 0 }; int found_self, sb_w, sb_pos; - int lb, rb, tb, bb, sx, ex, no_border; - u_int i, s; + int lb, rb, tb, bb, no_border; if (py < 0 || width == 0) goto empty; @@ -95,6 +130,13 @@ window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, r->used = 1; } + /* The menu is above every pane in the window. */ + md = w->menu; + if (md != NULL && + (u_int)py >= menu_y(md) && + (u_int)py - menu_y(md) < menu_height(md)) { + window_visible_ranges_remove(r, menu_x(md), menu_width(md)); + } found_self = 0; TAILQ_FOREACH_REVERSE(wp, &w->z_index, window_panes_zindex, zentry) { @@ -129,89 +171,36 @@ window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, else sb_w = sb_pos = 0; - for (i = 0; i < r->used; i++) { - ri = &r->ranges[i]; - if (ri->nx == 0) - continue; - if (no_border) { - lb = wp->xoff; - rb = wp->xoff + (int)wp->sx - 1; - } else if (sb_pos == PANE_SCROLLBARS_LEFT) { - if (wp->xoff > sb_w) - lb = wp->xoff - 1 - sb_w; - else - lb = 0; - } else { /* PANE_SCROLLBARS_RIGHT or none. */ - if (wp->xoff > 0) - lb = wp->xoff - 1; - else - lb = 0; - } - if (!no_border) { - if (sb_pos == PANE_SCROLLBARS_LEFT) - rb = wp->xoff + (int)wp->sx; - else /* PANE_SCROLLBARS_RIGHT or none. */ - rb = wp->xoff + (int)wp->sx + sb_w; - } - if (lb < 0) + if (no_border) { + lb = wp->xoff; + rb = wp->xoff + (int)wp->sx - 1; + } else if (sb_pos == PANE_SCROLLBARS_LEFT) { + if (wp->xoff > sb_w) + lb = wp->xoff - 1 - sb_w; + else + lb = 0; + } else { /* PANE_SCROLLBARS_RIGHT or none. */ + if (wp->xoff > 0) + lb = wp->xoff - 1; + else lb = 0; - if (rb < 0) - continue; - if (no_border && rb >= (int)w->sx) - rb = w->sx - 1; - else if (!no_border && rb > (int)w->sx) - rb = w->sx - 1; - if (lb > rb) - continue; - - sx = ri->px; - ex = sx + ri->nx - 1; - if (lb > sx && lb <= ex && rb > ex) { - /* - * If the left edge of floating pane falls - * inside this range and right edge covers up - * to right of range, then shrink left edge of - * range. - */ - ri->nx = lb - sx; - } else if (rb >= sx && rb <= ex && lb <= sx) { - /* - * Else if the right edge of floating pane falls - * inside of this range and left edge covers - * the left of range, then move px forward to - * right edge of pane. - */ - ri->nx = ex - rb; - ri->px = rb + 1; - } else if (lb > sx && rb <= ex) { - /* - * Else if pane fully inside range then split - * into 2 ranges. - */ - server_client_ensure_ranges(r, r->used + 1); - for (s = r->used; s > i; s--) { - memcpy(&r->ranges[s], &r->ranges[s - 1], - sizeof *r->ranges); - } - ri = &r->ranges[i]; - r->ranges[i + 1].px = rb + 1; - r->ranges[i + 1].nx = ex - rb; - /* ri->px was copied, unchanged. */ - ri->nx = lb - sx; - r->used++; - } else if (lb <= sx && rb > ex) { - /* - * If floating pane completely covers this range - * then delete it (make it 0 length). - */ - ri->nx = 0; - } else { - /* - * The range is already obscured, do - * nothing. - */ - } } + if (!no_border) { + if (sb_pos == PANE_SCROLLBARS_LEFT) + rb = wp->xoff + (int)wp->sx; + else /* PANE_SCROLLBARS_RIGHT or none. */ + rb = wp->xoff + (int)wp->sx + sb_w; + } + if (lb < 0) + lb = 0; + if (rb < 0) + continue; + if (no_border && rb >= (int)w->sx) + rb = w->sx - 1; + else if (!no_border && rb > (int)w->sx) + rb = w->sx - 1; + if (lb <= rb) + window_visible_ranges_remove(r, lb, rb - lb + 1); } return (r); diff --git a/window.c b/window.c index 618ca5ff8..ae79e2e90 100644 --- a/window.c +++ b/window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window.c,v 1.373 2026/08/24 21:17:19 nicm Exp $ */ +/* $OpenBSD: window.c,v 1.384 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -92,7 +92,11 @@ struct window_pane_prompt { int window_cmp(struct window *w1, struct window *w2) { - return (w1->id - w2->id); + if (w1->id < w2->id) + return (-1); + if (w1->id > w2->id) + return (1); + return (0); } static void @@ -199,7 +203,11 @@ winlink_cmp(struct winlink *wl1, struct winlink *wl2) int window_pane_cmp(struct window_pane *wp1, struct window_pane *wp2) { - return (wp1->id - wp2->id); + if (wp1->id < wp2->id) + return (-1); + if (wp1->id > wp2->id) + return (1); + return (0); } struct winlink * @@ -415,6 +423,7 @@ window_create(u_int sx, u_int sy, u_int xpixel, u_int ypixel) TAILQ_INIT(&w->panes); TAILQ_INIT(&w->z_index); TAILQ_INIT(&w->last_panes); + TAILQ_INIT(&w->damage); w->active = NULL; w->lastlayout = -1; @@ -449,9 +458,17 @@ window_create(u_int sx, u_int sy, u_int xpixel, u_int ypixel) static void window_destroy(struct window *w) { + struct window_pane *wp; + log_debug("window @%u destroyed (%d references)", w->id, w->references); - window_unzoom(w, 0); + if (w->flags & WINDOW_ZOOMED) { + w->flags &= ~WINDOW_ZOOMED; + TAILQ_FOREACH(wp, &w->panes, entry) { + wp->flags &= ~PANE_ZOOMED; + wp->saved_layout_cell = NULL; + } + } RB_REMOVE(windows, &windows, w); layout_free_cell(w->layout_root, 0); @@ -460,6 +477,7 @@ window_destroy(struct window *w) menu_destroy(w); window_destroy_panes(w); + redraw_free_damage(w); if (event_initialized(&w->name_event)) evtimer_del(&w->name_event); @@ -659,6 +677,34 @@ window_pane_contains(struct window_pane *wp, u_int x, u_int y) return (1); } +/* + * Does floating pane, including its borders and scrollbar, overlap any cell of + * another pane, including its scrollbar? + */ +int +window_pane_floating_overlaps(struct window_pane *fwp, struct window_pane *wp) +{ + int fxoff, fyoff, xoff, yoff, border = 0; + u_int fsx, fsy, sx, sy; + + if (!window_pane_is_floating(fwp)) + return (0); + + window_pane_full_size_offset(fwp, &fxoff, &fyoff, &fsx, &fsy); + window_pane_full_size_offset(wp, &xoff, &yoff, &sx, &sy); + + if (window_pane_get_pane_lines(fwp) != PANE_LINES_NONE) + border = 1; + + if (fxoff - border >= xoff + (int)sx || + fxoff + (int)fsx + border <= xoff) + return (0); + if (fyoff - border >= yoff + (int)sy || + fyoff + (int)fsy + border <= yoff) + return (0); + return (1); +} + void window_update_focus(struct window *w) { @@ -683,7 +729,6 @@ window_pane_update_focus(struct window_pane *wp) c->session->attached != 0 && (c->flags & CLIENT_FOCUSED) && c->session->curw->window == wp->window && - c->overlay_draw == NULL && wp->window->menu == NULL) { focused = 1; break; @@ -711,6 +756,7 @@ int window_set_active_pane(struct window *w, struct window_pane *wp, int notify) { struct window_pane *lastwp; + int unzoomed; log_debug("%s: pane %%%u", __func__, wp->id); @@ -718,7 +764,8 @@ window_set_active_pane(struct window *w, struct window_pane *wp, int notify) return (0); if (w->modal != NULL && wp != w->modal) return (0); - if ((w->flags & WINDOW_ZOOMED) && !window_pane_is_visible(wp)) + unzoomed = (w->flags & WINDOW_ZOOMED) && !window_pane_is_visible(wp); + if (unzoomed) window_unzoom(w, 1); lastwp = w->active; @@ -735,7 +782,19 @@ window_set_active_pane(struct window *w, struct window_pane *wp, int notify) } tty_update_window_offset(w); - server_redraw_window(w); + + /* + * Unzooming changes every pane's geometry and needs a full window + * redraw. Otherwise, only the previous and new active pane's border + * and status appearance changed, so avoid redrawing unaffected pane + * content. + */ + if (unzoomed) + server_redraw_window(w); + else { + server_redraw_window_borders(w); + server_status_window(w); + } if (notify) window_fire_pane_changed(w, w->active, lastwp); @@ -816,6 +875,16 @@ window_get_active_at(struct window *w, u_int x, u_int y) return (NULL); } + /* + * A floating pane is above every tiled pane, including their status + * lines, so check those first. + */ + TAILQ_FOREACH(wp, &w->z_index, zentry) { + if (window_pane_is_floating(wp) && + window_pane_contains(wp, x, y)) + return (wp); + } + if (pane_status == PANE_STATUS_TOP) { /* * Prefer a pane's top border status line over the pane above's @@ -1255,6 +1324,21 @@ window_pane_zindex(struct window_pane *wp, u_int *i) return (-1); } +int +window_pane_last_index(struct window_pane *wp, u_int *i) +{ + struct window *w = wp->window; + struct window_pane *wq; + + *i = 0; + TAILQ_FOREACH(wq, &w->last_panes, sentry) { + if (wq == wp) + return (0); + (*i)++; + } + return (-1); +} + u_int window_count_panes(struct window *w, int with_floating) { @@ -1418,7 +1502,7 @@ window_pane_wait_finish(struct window_pane *wp) { struct cmdq_item *item = wp->wait_item; struct client *c; - int retval = 0; + int retval = 128 + SIGHUP; if (item == NULL) return; @@ -1506,7 +1590,7 @@ window_pane_destroy(struct window_pane *wp) window_pane_clear_prompt(wp); window_pane_free_modes(wp); - screen_write_clear_dirty(wp); + screen_write_sync_clear_dirty(wp); if (wp->fd != -1) { #ifdef HAVE_UTEMPTER @@ -1610,7 +1694,7 @@ window_pane_set_event(struct window_pane *wp) NULL, window_pane_error_callback, wp); if (wp->event == NULL) fatalx("out of memory"); - wp->ictx = input_init(wp, wp->event, &wp->palette, NULL); + wp->ictx = input_init(wp, wp->event, &wp->palette); bufferevent_enable(wp->event, EV_READ|EV_WRITE); } @@ -2414,7 +2498,7 @@ winlink_shuffle_up(struct session *s, struct winlink *wl, int before) { int idx, last; - if (wl == NULL) + if (wl == NULL || wl->idx == INT_MAX) return (-1); if (before) idx = wl->idx; @@ -2891,3 +2975,52 @@ window_pane_is_floating(struct window_pane *wp) return (0); return (1); } + +int +window_pane_is_floating_with_hidden(struct window_pane *wp) +{ + struct layout_cell *lc = wp->layout_cell; + + if (lc == NULL) + lc = wp->saved_layout_cell; + if (lc == NULL || (lc->flags & LAYOUT_CELL_FLOATING) == 0) + return (0); + return (1); +} + +/* Report damage for a floating pane, including its border and scrollbar. */ +static void +window_damage_floating_pane(struct window_pane *wp, int xoff, int yoff, + int sx, int sy) +{ + struct window *w = wp->window; + int x0, x1, y0, y1, sb_left = 0, sb_right = 0; + struct style *sb_sy = &wp->scrollbar_style; + + if (window_pane_scrollbar_reserve(wp)) { + if (w->sb_pos == PANE_SCROLLBARS_LEFT) + sb_left = sb_sy->width + sb_sy->pad; + else + sb_right = sb_sy->width + sb_sy->pad; + } + x0 = xoff - 1 - sb_left; + x1 = xoff + sx + sb_right; + y0 = yoff - 1; + y1 = yoff + sy; + if (x0 < 0) + x0 = 0; + if (y0 < 0) + y0 = 0; + if (x1 >= x0 && y1 >= y0) + redraw_damage_window(w, x0, y0, x1 - x0 + 1U, y1 - y0 + 1U); +} + +/* Report damage for a floating pane's old and new areas. */ +void +window_redraw_floating_pane(struct window_pane *wp, int oxoff, int oyoff, + int osx, int osy) +{ + window_damage_floating_pane(wp, oxoff, oyoff, osx, osy); + window_damage_floating_pane(wp, wp->xoff, wp->yoff, wp->sx, wp->sy); + server_status_window(wp->window); +}