patch 9.2.0469: popup: textprop-anchored popups bleed past host window edges
Commit:
https://github.com/vim/vim/commit/e3d9929109dadef35624ea7a68a6dbf0ca013f46
Author: Yasuhiro Matsumoto <
matt...@gmail.com>
Date: Sun May 10 18:53:57 2026 +0000
patch 9.2.0469: popup: textprop-anchored popups bleed past host window edges
Problem: A popup anchored to a text property in a split window is
positioned relative to the screen and may extend into
adjacent splits or off-screen regions. There is no way to
confine the popup to the window that contains the textprop.
Solution: Add the "clipwindow" popup option to allow clipping the text
property popup to the host window (Yasuhiro Matsumoto).
Adds a "clipwindow" boolean option to popup_create()/popup_setoptions().
When set on a textprop-anchored popup, the popup's drawn extent is
confined to its host (textprop) window's content rectangle so the popup
no longer bleeds across a horizontal split's statusline (top/bottom) or
a vsplit's separator (right) into another window.
The popup keeps its full logical size and position; only the rows or
columns that fall outside the host window's content area are skipped
during drawing, so a popup that scrolls toward the host's edge looks
visually "cut off" without its borders being relocated. popup_getoptions
and popup_getpos continue to report the unclipped geometry.
Implementation:
- w_popup_topoff / w_popup_bottomoff record how many rows of the
popup fall outside the host on each side. popup_adjust_position()
computes them from the host rectangle after the logical layout is
finalised, and update_popups() and the popup-mask builder subtract
them when emitting cells/borders/scrollbar and when marking
popup-owned cells. win_update() is bracketed by transient
w_height/w_topline/w_winrow adjustments so the buffer's drawn
content matches the visible row range.
- w_popup_rightclip is the horizontal counterpart for the host's
right edge: the right border, padding and content columns past
the host are not drawn. win_update() is bracketed by a transient
w_width reduction so the buffer text is not written past the
host's right edge either.
- When the textprop scrolls just above the host window's top, the
popup is kept visible by extending the prop search above topline
(new helper find_prop_in_lines) and synthesising a negative
screen_row so the top-clip path can roll the popup off the top.
When the textprop has scrolled far enough that even the bottom
border would overlap the host edge -- or when the popup would
overflow the host's left edge at all -- the popup is hidden, and
unhidden again once it comes back within range.
- The "reduce-height" / "clamp winrow to 0" fallbacks in
popup_adjust_position are bypassed for host-clipped popups so the
popup keeps its natural anchored position instead of being
snapped to the screen edge.
Left-edge partial clipping is intentionally not supported: it
would require shrinking the buffer width during win_update, which
reflows wrapped lines and corrupts the displayed content; the
popup is hidden instead.
closes: #20166
Signed-off-by: Yasuhiro Matsumoto <
matt...@gmail.com>
Signed-off-by: Christian Brabandt <
c...@256bit.org>
diff --git a/runtime/doc/popup.txt b/runtime/doc/popup.txt
index 250127b9b..2afa93a75 100644
--- a/runtime/doc/popup.txt
+++ b/runtime/doc/popup.txt
@@ -1,4 +1,4 @@
-*popup.txt* For Vim version 9.2. Last change: 2026 May 01
+*popup.txt* For Vim version 9.2. Last change: 2026 May 10
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -712,6 +712,15 @@ The second argument of |popup_create()| is a dictionary with options:
when "textprop" is present.
textpropid Used to identify the text property when "textprop" is
present. Use zero to reset.
+ clipwindow Only used when "textprop" is set. When TRUE the popup
+ is kept within the window containing the text
+ property: if the text property scrolls past that
+ window's top, bottom, left or right edge, the popup
+ is clipped at that edge instead of being drawn
+ outside it. Once the text property has scrolled out
+ of the window the popup is hidden.
+ Default FALSE.
+ See |popup-clipwindow|.
fixed When FALSE (the default), and:
- "pos" is "botleft" or "topleft", and
- the popup would be truncated at the right edge of
@@ -949,6 +958,31 @@ If the window for which the popup was defined is closed, the popup is closed.
If the popup cannot fit in the desired position, it may show at a nearby
position.
+
+CLIP TEXTPROP POPUP TO HOST WINDOW *popup-clipwindow*
+
+When the popup is anchored to a text property in a split window, the popup is
+by default drawn relative to the whole screen and may extend past the edges of
+the window that contains the text property (the "host window"). Setting
+"clipwindow" to TRUE keeps the popup within window's content area:
+parts of the popup that fall outside the window are clipped, and the popup is
+hidden once the text property has scrolled entirely past one of the edges.
+
+Example: a tall popup anchored above the cursor that should never spill into
+the window below the split: >
+ call popup_create(body, #{
+ \ textprop: 'marker',
+ \ textpropid: id,
+ \ pos: 'topleft',
+ \ line: -1, col: 0,
+ \ posinvert: v:false,
+ \ clipwindow: v:true,
+ \ })
+<
+With "posinvert" left at its default (TRUE) the popup may be flipped to the
+opposite side of the text property when there is no room; set it to FALSE to
+keep the requested side and rely on "clipwindow" to clip the overflow.
+
Some hints:
- To avoid collision with other plugins the text property type name has to be
unique. You can also use the "bufnr" item to make it local to a buffer.
diff --git a/runtime/doc/tags b/runtime/doc/tags
index 3168235da..8eef8cf28 100644
--- a/runtime/doc/tags
+++ b/runtime/doc/tags
@@ -9791,6 +9791,7 @@ popt-option print.txt /*popt-option*
popup popup.txt /*popup*
popup-buffer popup.txt /*popup-buffer*
popup-callback popup.txt /*popup-callback*
+popup-clipwindow popup.txt /*popup-clipwindow*
popup-close popup.txt /*popup-close*
popup-examples popup.txt /*popup-examples*
popup-filter popup.txt /*popup-filter*
diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt
index a1b9afce7..f4cdc702b 100644
--- a/runtime/doc/version9.txt
+++ b/runtime/doc/version9.txt
@@ -1,4 +1,4 @@
-*version9.txt* For Vim version 9.2. Last change: 2026 May 05
+*version9.txt* For Vim version 9.2. Last change: 2026 May 10
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -52588,6 +52588,7 @@ Popups ~
- 'previewpopup' supports the same values as 'completepopup' (except for
"align").
- Support "opacity" setting for 'completepopup' option.
+- Support for clipping textproperty popups |popup-clipwindow|.
Diff mode ~
---------
diff --git a/src/popupwin.c b/src/popupwin.c
index dcbe0e854..5bd156367 100644
--- a/src/popupwin.c
+++ b/src/popupwin.c
@@ -70,6 +70,10 @@ typedef struct {
int leftcol;
int leftoff;
int has_scrollbar;
+ int topoff;
+ int bottomoff;
+ int leftclip;
+ int rightclip;
} popup_layout_T;
static poppos_entry_T poppos_entries[] = {
@@ -838,6 +842,15 @@ apply_general_options(win_T *wp, dict_T *dict)
wp->w_popup_flags &= ~POPF_POSINVERT;
}
+ nr = dict_get_bool(dict, "clipwindow", -1);
+ if (nr != -1)
+ {
+ if (nr)
+ wp->w_popup_flags |= POPF_CLIPWINDOW;
+ else
+ wp->w_popup_flags &= ~POPF_CLIPWINDOW;
+ }
+
nr = dict_get_bool(dict, "resize", -1);
if (nr != -1)
{
@@ -1320,6 +1333,320 @@ popup_extra_width(win_T *wp)
+ wp->w_has_scrollbar;
}
+/*
+ * Return the host window used to clip popup "wp" when POPF_CLIPWINDOW is set,
+ * or NULL when no clipping should be applied (option off, or the host window
+ * is no longer valid). The textprop window is used as the host; popups not
+ * anchored to a textprop are not clipped.
+ */
+ static win_T *
+popup_get_clipwin(win_T *wp)
+{
+ if (!(wp->w_popup_flags & POPF_CLIPWINDOW))
+ return NULL;
+ if (win_valid(wp->w_popup_prop_win))
+ return wp->w_popup_prop_win;
+ return NULL;
+}
+
+// Per-popup clip geometry derived from w_popup_{top,bottom}off and
+// w_popup_{left,right}clip. Filled by popup_compute_clip().
+//
+// *_extra : original border+padding at each edge.
+// clip_*_content : how many *content* rows/cols are clipped at each edge
+// (border/padding is consumed first; the rest comes off
+// w_height/w_width). >= 0.
+// eff_*_extra : 0 when that edge is clipped (border+padding gone),
+// otherwise the original *_extra.
+// eff_border[],
+// eff_padding[] : per-edge border/padding sizes (indexed [top,right,bot,left]
+// matching wp->w_popup_border / wp->w_popup_padding). At a
+// clipped edge they collapse to 0; elsewhere they keep the
+// original size. Drawing code can replace
+// `wp->w_popup_border[N] > 0 && wp->w_popup_*clip == 0`
+// with a single `cl.eff_border[N] > 0` test.
+// eff_height : drawn extent = eff_top_extra + visible content + eff_bot_extra.
+// eff_width : drawn extent = eff_left_extra + visible content + eff_right_extra
+// (does NOT include w_leftcol or scrollbar; see callers).
+typedef struct {
+ int top_extra;
+ int bot_extra;
+ int left_extra;
+ int right_extra;
+
+ int clip_top_content;
+ int clip_bot_content;
+ int clip_left_content;
+ int clip_right_content;
+
+ int eff_top_extra;
+ int eff_bot_extra;
+ int eff_left_extra;
+ int eff_right_extra;
+
+ int eff_border[4];
+ int eff_padding[4];
+
+ int eff_height;
+ int eff_width;
+} popup_clip_T;
+
+ static void
+popup_compute_clip(win_T *wp, popup_clip_T *cl)
+{
+ int h, w;
+
+ cl->top_extra = popup_top_extra(wp);
+ cl->bot_extra = wp->w_popup_padding[2] + wp->w_popup_border[2];
+ cl->left_extra = wp->w_popup_border[3] + wp->w_popup_padding[3];
+ cl->right_extra = wp->w_popup_border[1] + wp->w_popup_padding[1];
+
+ cl->clip_top_content = wp->w_popup_topoff - cl->top_extra;
+ if (cl->clip_top_content < 0)
+ cl->clip_top_content = 0;
+ cl->clip_bot_content = wp->w_popup_bottomoff - cl->bot_extra;
+ if (cl->clip_bot_content < 0)
+ cl->clip_bot_content = 0;
+ cl->clip_left_content = wp->w_popup_leftclip - cl->left_extra;
+ if (cl->clip_left_content < 0)
+ cl->clip_left_content = 0;
+ cl->clip_right_content = wp->w_popup_rightclip - cl->right_extra;
+ if (cl->clip_right_content < 0)
+ cl->clip_right_content = 0;
+
+ cl->eff_top_extra = wp->w_popup_topoff > 0 ? 0 : cl->top_extra;
+ cl->eff_bot_extra = wp->w_popup_bottomoff > 0 ? 0 : cl->bot_extra;
+ cl->eff_left_extra = wp->w_popup_leftclip > 0 ? 0 : cl->left_extra;
+ cl->eff_right_extra = wp->w_popup_rightclip > 0 ? 0 : cl->right_extra;
+
+ cl->eff_border[0] = wp->w_popup_topoff > 0 ? 0 : wp->w_popup_border[0];
+ cl->eff_border[1] = wp->w_popup_rightclip > 0 ? 0 : wp->w_popup_border[1];
+ cl->eff_border[2] = wp->w_popup_bottomoff > 0 ? 0 : wp->w_popup_border[2];
+ cl->eff_border[3] = wp->w_popup_leftclip > 0 ? 0 : wp->w_popup_border[3];
+
+ cl->eff_padding[0] = wp->w_popup_topoff > 0 ? 0 : wp->w_popup_padding[0];
+ cl->eff_padding[1] = wp->w_popup_rightclip > 0 ? 0 : wp->w_popup_padding[1];
+ cl->eff_padding[2] = wp->w_popup_bottomoff > 0 ? 0 : wp->w_popup_padding[2];
+ cl->eff_padding[3] = wp->w_popup_leftclip > 0 ? 0 : wp->w_popup_padding[3];
+
+ h = wp->w_height - cl->clip_top_content - cl->clip_bot_content;
+ if (h < 0)
+ h = 0;
+ cl->eff_height = cl->eff_top_extra + h + cl->eff_bot_extra;
+
+ w = wp->w_width - cl->clip_left_content - cl->clip_right_content;
+ if (w < 0)
+ w = 0;
+ cl->eff_width = cl->eff_left_extra + w + cl->eff_right_extra;
+}
+
+// Snapshot of the popup window geometry that update_popups() temporarily
+// mutates so that win_update() draws within the host-window clip rectangle.
+// Saved before the clip is applied, restored after win_update() returns so
+// callers continue to see the popup's logical geometry.
+// Field names omit the "w_" prefix to avoid clashing with struct-field
+// macros like w_p_wrap (= w_onebuf_opt.wo_wrap).
+typedef struct {
+ int height;
+ int width;
+ int winrow;
+ int wincol;
+ int leftcol;
+ int p_wrap;
+ linenr_T topline;
+} popup_geom_save_T;
+
+ static void
+popup_geom_save(win_T *wp, popup_geom_save_T *sv)
+{
+ sv->height = wp->w_height;
+ sv->width = wp->w_width;
+ sv->winrow = wp->w_winrow;
+ sv->wincol = wp->w_wincol;
+ sv->leftcol = wp->w_leftcol;
+ sv->p_wrap = wp->w_p_wrap;
+ sv->topline = wp->w_topline;
+}
+
+ static void
+popup_geom_restore(win_T *wp, popup_geom_save_T *sv)
+{
+ wp->w_p_wrap = sv->p_wrap;
+ wp->w_leftcol = sv->leftcol;
+ wp->w_wincol = sv->wincol;
+ wp->w_winrow = sv->winrow;
+ wp->w_topline = sv->topline;
+ wp->w_width = sv->width;
+ wp->w_height = sv->height;
+}
+
+/*
+ * Compute a screen row for a textprop that has scrolled above the host
+ * window's top. textpos2screenpos() cannot return a row above topline, so we
+ * probe at topline to fill the screen_{scol,ccol,ecol} column mapping, then
+ * extrapolate a (possibly-negative) row by counting how many buffer lines lie
+ * between the prop and topline. The popup_topoff clip path then turns the
+ * negative row into a top-clip animation as the prop rolls off the top edge.
+ */
+ static void
+popup_screenpos_above_top(
+ win_T *prop_win,
+ pos_T *pos,
+ linenr_T prop_lnum,
+ int *screen_row,
+ int *screen_scol,
+ int *screen_ccol,
+ int *screen_ecol)
+{
+ pos_T probe = *pos;
+
+ probe.lnum = prop_win->w_topline;
+ textpos2screenpos(prop_win, &probe,
+ screen_row, screen_scol, screen_ccol, screen_ecol);
+ *screen_row = prop_win->w_winrow + 1
+ - (int)(prop_win->w_topline - prop_lnum);
+}
+
+/*
+ * Hide popup "wp" because its anchoring textprop is no longer reachable.
+ * Marks the popup as POPF_HIDDEN (no-op when already hidden) and schedules a
+ * redraw of the host window so any leftover decorations are cleared.
+ */
+ static void
+popup_hide_for_textprop(win_T *wp)
+{
+ if ((wp->w_popup_flags & POPF_HIDDEN) != 0)
+ return;
+ wp->w_popup_flags |= POPF_HIDDEN;
+ if (win_valid(wp->w_popup_prop_win))
+ redraw_win_later(wp->w_popup_prop_win, UPD_SOME_VALID);
+}
+
+/*
+ * For "clipwindow" popups: search the lines above prop_win->w_topline for the
+ * popup's anchoring textprop and report whether one was found. When
+ * "max_reach" is > 0, only the last "max_reach" lines before topline are
+ * scanned; pass 0 to scan all lines from line 1. Returns false when the
+ * popup is not "clipwindow", topline is already at line 1, or no prop matches.
+ */
+ static bool
+popup_find_prop_above_top(
+ win_T *wp,
+ win_T *prop_win,
+ int max_reach,
+ textprop_T *prop,
+ linenr_T *found_lnum)
+{
+ linenr_T first;
+
+ if (!(wp->w_popup_flags & POPF_CLIPWINDOW) || prop_win->w_topline <= 1)
+ return false;
+
+ first = max_reach > 0 ? prop_win->w_topline - max_reach : 1;
+ if (first < 1)
+ first = 1;
+ return find_prop_in_lines(prop_win,
+ wp->w_popup_prop_type, wp->w_popup_prop_id,
+ prop, found_lnum, first, prop_win->w_topline - 1);
+}
+
+/*
+ * Compute and assign w_popup_topoff/bottomoff/leftclip/rightclip from the
+ * host (textprop) window's content rectangle when POPF_CLIPWINDOW is set.
+ * The popup's logical geometry (w_winrow, w_height, w_width) is preserved;
+ * only the *off/clip fields record how much of each edge falls outside.
+ * Returns true when the popup has scrolled completely past one of the host
+ * edges, in which case the caller must hide it.
+ */
+ static bool
+popup_compute_clipwindow_offsets(win_T *wp)
+{
+ win_T *cw = popup_get_clipwin(wp);
+ int extra_h, extra_w;
+ int popup_top, popup_bottom, popup_left, popup_right;
+ int total_h, total_w;
+
+ if (cw == NULL)
+ return false;
+
+ extra_h = popup_top_extra(wp)
+ + wp->w_popup_padding[2] + wp->w_popup_border[2];
+ extra_w = popup_extra_width(wp);
+
+ popup_top = wp->w_winrow;
+ popup_bottom = wp->w_winrow + wp->w_height + extra_h;
+ popup_left = wp->w_wincol;
+ popup_right = wp->w_wincol + wp->w_width + extra_w;
+ total_h = wp->w_height + extra_h;
+ total_w = wp->w_width + extra_w;
+
+ if (popup_top < cw->w_winrow)
+ wp->w_popup_topoff = cw->w_winrow - popup_top;
+ if (popup_bottom > cw->w_winrow + cw->w_height)
+ wp->w_popup_bottomoff = popup_bottom - (cw->w_winrow + cw->w_height);
+ if (popup_left < cw->w_wincol)
+ wp->w_popup_leftclip = cw->w_wincol - popup_left;
+ if (popup_right > cw->w_wincol + cw->w_width)
+ wp->w_popup_rightclip = popup_right - (cw->w_wincol + cw->w_width);
+
+ return wp->w_popup_topoff >= total_h
+ || wp->w_popup_bottomoff >= total_h
+ || wp->w_popup_leftclip >= total_w
+ || wp->w_popup_rightclip >= total_w;
+}
+
+/*
+ * Mutate "wp"'s window geometry so win_update() draws only the rows/columns
+ * that fit within the host-window clip rectangle for "clipwindow" popups.
+ * The caller must save the original geometry with popup_geom_save() before
+ * this call and restore it with popup_geom_restore() after win_update().
+ *
+ * Vertical clip: shrink w_height by the clipped content rows; advance
+ * w_topline and w_winrow when rows are cut off the top so the first visible
+ * content row lands on the host's top edge.
+ *
+ * Horizontal clip: when the right side is clipped, just shrink w_width.
+ * When the left side is clipped, advance w_leftcol so the hidden buffer
+ * columns scroll off and shift w_wincol so the first visible column lands on
+ * the host's left edge. Disable wrap so the transient w_width reduction does
+ * not reflow wrapped lines: the popup's logical width is unchanged, we just
+ * want to truncate cells that fall outside the host at draw time.
+ */
+ static void
+popup_apply_winupdate_clip(win_T *wp, popup_clip_T *cl)
+{
+ if (wp->w_popup_topoff > 0 || wp->w_popup_bottomoff > 0)
+ {
+ wp->w_height -= cl->clip_top_content + cl->clip_bot_content;
+ if (wp->w_height < 0)
+ wp->w_height = 0;
+ if (cl->clip_top_content > 0)
+ {
+ wp->w_topline += cl->clip_top_content;
+ wp->w_winrow += cl->clip_top_content;
+ }
+ }
+ if (wp->w_popup_leftclip > 0 || wp->w_popup_rightclip > 0)
+ {
+ if (cl->clip_left_content > 0 || cl->clip_right_content > 0)
+ wp->w_p_wrap = FALSE;
+ if (cl->clip_right_content > 0)
+ {
+ wp->w_width -= cl->clip_right_content;
+ if (wp->w_width < 0)
+ wp->w_width = 0;
+ }
+ if (cl->clip_left_content > 0)
+ {
+ wp->w_leftcol += cl->clip_left_content;
+ wp->w_wincol += cl->clip_left_content;
+ wp->w_width -= cl->clip_left_content;
+ if (wp->w_width < 0)
+ wp->w_width = 0;
+ }
+ }
+}
+
/*
* Adjust the position and size of the popup to fit on the screen.
*/
@@ -1361,6 +1688,10 @@ popup_adjust_position(win_T *wp)
wp->w_leftcol = 0;
wp->w_popup_leftoff = 0;
wp->w_popup_rightoff = 0;
+ wp->w_popup_topoff = 0;
+ wp->w_popup_bottomoff = 0;
+ wp->w_popup_leftclip = 0;
+ wp->w_popup_rightclip = 0;
// May need to update the "cursorline" highlighting, which may also change
// "topline"
@@ -1378,20 +1709,24 @@ popup_adjust_position(win_T *wp)
int screen_ccol;
int screen_ecol;
- // Popup window is positioned relative to a text property.
+ // Popup window is positioned relative to a text property. With
+ // "clipwindow", keep the popup visible while the textprop has just
+ // scrolled above the host's top: extrapolate a negative screen_row
+ // from a prop above topline so the top-clip path can roll the popup
+ // off the top edge. Unhiding is done in check_popup_unhidden().
+ bool prop_above_top = false;
if (!find_visible_prop(prop_win,
wp->w_popup_prop_type, wp->w_popup_prop_id,
&prop, &prop_lnum))
{
- // Text property is no longer visible, hide the popup.
- // Unhiding the popup is done in check_popup_unhidden().
- if ((wp->w_popup_flags & POPF_HIDDEN) == 0)
+ if (popup_find_prop_above_top(wp, prop_win, 0,
+ &prop, &prop_lnum))
+ prop_above_top = true;
+ else
{
- wp->w_popup_flags |= POPF_HIDDEN;
- if (win_valid(wp->w_popup_prop_win))
- redraw_win_later(wp->w_popup_prop_win, UPD_SOME_VALID);
+ popup_hide_for_textprop(wp);
+ return;
}
- return;
}
// Compute the desired position from the position of the text
@@ -1401,7 +1736,11 @@ popup_adjust_position(win_T *wp)
if (wp->w_popup_pos == POPPOS_TOPLEFT
|| wp->w_popup_pos == POPPOS_BOTLEFT)
pos.col += prop.tp_len - 1;
- textpos2screenpos(prop_win, &pos, &screen_row,
+ if (prop_above_top)
+ popup_screenpos_above_top(prop_win, &pos, prop_lnum, &screen_row,
+ &screen_scol, &screen_ccol, &screen_ecol);
+ else
+ textpos2screenpos(prop_win, &pos, &screen_row,
&screen_scol, &screen_ccol, &screen_ecol);
if (screen_scol == 0)
@@ -1771,8 +2110,11 @@ popup_adjust_position(win_T *wp)
else if (wp->w_popup_pos == POPPOS_BOTRIGHT
|| wp->w_popup_pos == POPPOS_BOTLEFT)
{
- if ((wp->w_height + extra_height) <= wantline)
- // bottom aligned: may move down
+ if ((wp->w_height + extra_height) <= wantline
+ || (wp->w_popup_flags & POPF_CLIPWINDOW))
+ // bottom aligned: may move down. With "clipwindow" the popup
+ // keeps its natural position even if it overflows the screen,
+ // because the clip logic handles the overflow.
wp->w_winrow = wantline - (wp->w_height + extra_height);
else if (wantline * 2 >= Rows || !(wp->w_popup_flags & POPF_POSINVERT))
{
@@ -1838,7 +2180,7 @@ popup_adjust_position(win_T *wp)
// make sure w_winrow is valid
if (wp->w_winrow >= Rows)
wp->w_winrow = Rows - 1;
- else if (wp->w_winrow < 0)
+ else if (wp->w_winrow < 0 && !(wp->w_popup_flags & POPF_CLIPWINDOW))
wp->w_winrow = 0;
if (wp->w_wincol + wp->w_width + extra_width
@@ -1863,25 +2205,48 @@ popup_adjust_position(win_T *wp)
// Same for the bottom edge: shift up so the border/padding/shadow stays
// on screen, and clip the height if the popup is taller than the screen.
- if (wp->w_winrow + wp->w_height + extra_height > Rows)
- wp->w_winrow = Rows - wp->w_height - extra_height;
- if (wp->w_winrow < 0)
- wp->w_winrow = 0;
- if (wp->w_winrow + wp->w_height + extra_height > Rows)
- {
- int avail = Rows - wp->w_winrow - extra_height;
- wp->w_height = avail > 0 ? avail : 0;
+ // For "clipwindow" popups the host-window clip below handles overflow, so
+ // skip these screen-edge clamps -- otherwise a synthesised negative
+ // w_winrow (popup partially above the host's top edge) would be snapped
+ // back to 0 and defeat the top-clip animation.
+ if (!(wp->w_popup_flags & POPF_CLIPWINDOW))
+ {
+ if (wp->w_winrow + wp->w_height + extra_height > Rows)
+ wp->w_winrow = Rows - wp->w_height - extra_height;
+ if (wp->w_winrow < 0)
+ wp->w_winrow = 0;
+ if (wp->w_winrow + wp->w_height + extra_height > Rows)
+ {
+ int avail = Rows - wp->w_winrow - extra_height;
+ wp->w_height = avail > 0 ? avail : 0;
+ }
}
if (wp->w_height != org_layout.height)
win_comp_scroll(wp);
+ // Confine the popup to its host window for "clipwindow". The popup's
+ // logical geometry stays untouched; only w_popup_topoff/bottomoff/
+ // leftclip/rightclip record how many rows/columns of each edge fall
+ // outside the host so the drawing code can skip them. When the popup
+ // has fully scrolled past one of the host edges, hide it instead of
+ // leaving stray decorations behind.
+ if (popup_compute_clipwindow_offsets(wp))
+ {
+ popup_hide_for_textprop(wp);
+ return;
+ }
+
wp->w_popup_last_changedtick = CHANGEDTICK(wp->w_buffer);
if (win_valid(wp->w_popup_prop_win))
{
wp->w_popup_prop_changedtick =
CHANGEDTICK(wp->w_popup_prop_win->w_buffer);
wp->w_popup_prop_topline = wp->w_popup_prop_win->w_topline;
+ wp->w_popup_prop_winrow = wp->w_popup_prop_win->w_winrow;
+ wp->w_popup_prop_wincol = wp->w_popup_prop_win->w_wincol;
+ wp->w_popup_prop_width = wp->w_popup_prop_win->w_width;
+ wp->w_popup_prop_winheight = wp->w_popup_prop_win->w_height;
}
// Need to update popup_mask if the position or size changed.
@@ -3554,6 +3919,10 @@ popup_save_layout(win_T *wp, popup_layout_T *layout)
layout->leftcol = wp->w_leftcol;
layout->leftoff = wp->w_popup_leftoff;
layout->has_scrollbar = wp->w_has_scrollbar;
+ layout->topoff = wp->w_popup_topoff;
+ layout->bottomoff = wp->w_popup_bottomoff;
+ layout->leftclip = wp->w_popup_leftclip;
+ layout->rightclip = wp->w_popup_rightclip;
}
/*
@@ -3568,7 +3937,11 @@ popup_layout_changed(win_T *wp, popup_layout_T *layout)
|| layout->leftoff != wp->w_popup_leftoff
|| layout->width != wp->w_width
|| layout->height != wp->w_height
- || layout->has_scrollbar != wp->w_has_scrollbar;
+ || layout->has_scrollbar != wp->w_has_scrollbar
+ || layout->topoff != wp->w_popup_topoff
+ || layout->bottomoff != wp->w_popup_bottomoff
+ || layout->leftclip != wp->w_popup_leftclip
+ || layout->rightclip != wp->w_popup_rightclip;
}
/*
@@ -4298,6 +4671,8 @@ f_popup_getoptions(typval_T *argvars, typval_T *rettv)
dict_add_number(dict, "resize", (wp->w_popup_flags & POPF_RESIZE) != 0);
dict_add_number(dict, "posinvert",
(wp->w_popup_flags & POPF_POSINVERT) != 0);
+ dict_add_number(dict, "clipwindow",
+ (wp->w_popup_flags & POPF_CLIPWINDOW) != 0);
// Return opacity (0-100) by converting from internal blend value
dict_add_number(dict, "opacity",
(wp->w_popup_flags & POPF_OPACITY) ? 100 - wp->w_popup_blend : 100);
@@ -4765,11 +5140,23 @@ check_popup_unhidden(win_T *wp)
{
textprop_T prop;
linenr_T lnum;
+ bool found = false;
- if ((wp->w_popup_flags & POPF_HIDDEN_FORCE) == 0
- && find_visible_prop(wp->w_popup_prop_win,
- wp->w_popup_prop_type, wp->w_popup_prop_id,
- &prop, &lnum))
+ if ((wp->w_popup_flags & POPF_HIDDEN_FORCE) != 0)
+ return FALSE;
+ if (find_visible_prop(wp->w_popup_prop_win,
+ wp->w_popup_prop_type, wp->w_popup_prop_id,
+ &prop, &lnum))
+ found = true;
+ // The textprop may have scrolled just above the host window's top.
+ // Unhide the popup so popup_adjust_position() can roll it partially
+ // onto the host's top edge via the top-clip path. Limit the search
+ // to the popup's own height so we do not resurrect a popup whose
+ // prop is already further off-screen than the popup can extend.
+ else if (popup_find_prop_above_top(wp, wp->w_popup_prop_win,
+ popup_height(wp), &prop, &lnum))
+ found = true;
+ if (found)
{
wp->w_popup_flags &= ~POPF_HIDDEN;
wp->w_popup_prop_topline = 0; // force repositioning
@@ -4793,7 +5180,11 @@ popup_need_position_adjust(win_T *wp)
if (win_valid(wp->w_popup_prop_win)
&& (wp->w_popup_prop_changedtick
!= CHANGEDTICK(wp->w_popup_prop_win->w_buffer)
- || wp->w_popup_prop_topline != wp->w_popup_prop_win->w_topline))
+ || wp->w_popup_prop_topline != wp->w_popup_prop_win->w_topline
+ || wp->w_popup_prop_winrow != wp->w_popup_prop_win->w_winrow
+ || wp->w_popup_prop_wincol != wp->w_popup_prop_win->w_wincol
+ || wp->w_popup_prop_width != wp->w_popup_prop_win->w_width
+ || wp->w_popup_prop_winheight != wp->w_popup_prop_win->w_height))
return TRUE;
// May need to adjust the width if the cursor moved.
@@ -5086,7 +5477,18 @@ may_update_popup_mask(int type)
}
width = popup_width(wp);
- height = popup_height(wp);
+ // Match the drawn extent computed by update_popups so that cells
+ // outside the clipped popup are not marked as popup-owned and the
+ // background window can draw through them.
+ if (wp->w_popup_topoff > 0 || wp->w_popup_bottomoff > 0)
+ {
+ popup_clip_T cl;
+
+ popup_compute_clip(wp, &cl);
+ height = cl.eff_height;
+ }
+ else
+ height = popup_height(wp);
popup_update_mask(wp, width, height);
// Popup with partial transparency do not block lower layers from
@@ -5095,18 +5497,25 @@ may_update_popup_mask(int type)
if ((wp->w_popup_flags & POPF_OPACITY) && wp->w_popup_blend > 0)
continue;
- for (line = wp->w_winrow;
- line < wp->w_winrow + height && line < screen_Rows; ++line)
- for (col = wp->w_wincol;
- col < wp->w_wincol + width - wp->w_popup_leftoff
- && col < screen_Columns; ++col)
- if (wp->w_zindex < POPUPMENU_ZINDEX
- && pum_visible()
- && pum_under_menu(line, col, FALSE))
- mask[line * screen_Columns + col] = POPUPMENU_ZINDEX;
- else if (wp->w_popup_mask_cells == NULL
- || !popup_masked(wp, width, height, col, line))
- mask[line * screen_Columns + col] = wp->w_zindex;
+ {
+ int mask_start = wp->w_winrow + wp->w_popup_topoff;
+ int mask_end = mask_start + height;
+ int mask_col_start = wp->w_wincol + wp->w_popup_leftclip;
+ int mask_col_end = wp->w_wincol + width - wp->w_popup_leftoff
+ - wp->w_popup_rightclip;
+
+ for (line = mask_start;
+ line < mask_end && line < screen_Rows; ++line)
+ for (col = mask_col_start;
+ col < mask_col_end && col < screen_Columns; ++col)
+ if (wp->w_zindex < POPUPMENU_ZINDEX
+ && pum_visible()
+ && pum_under_menu(line, col, FALSE))
+ mask[line * screen_Columns + col] = POPUPMENU_ZINDEX;
+ else if (wp->w_popup_mask_cells == NULL
+ || !popup_masked(wp, width, height, col, line))
+ mask[line * screen_Columns + col] = wp->w_zindex;
+ }
}
// Only check which lines are to be updated if not already
@@ -5528,6 +5937,14 @@ update_popups(void (*win_update)(win_T *wp))
{
int title_len = 0;
int title_wincol;
+ popup_clip_T cl;
+
+ // Compute the clip geometry once per iteration; w_popup_*off/clip,
+ // w_height, w_width, w_popup_border and w_popup_padding are stable
+ // for the duration of this iteration (popup_apply_winupdate_clip()
+ // mutates w_height/w_width temporarily but the result is restored
+ // before any code below reads cl again).
+ popup_compute_clip(wp, &cl);
override_success = push_highlight_overrides(wp->w_hl, wp->w_hl_len);
@@ -5613,13 +6030,25 @@ update_popups(void (*win_update)(win_T *wp))
// Draw the popup text, unless it's off screen.
if (wp->w_winrow < screen_Rows && wp->w_wincol < screen_Columns)
{
+ popup_geom_save_T saved;
+
+ popup_geom_save(wp, &saved);
+
// May need to update the "cursorline" highlighting, which may also
// change "topline"
if (wp->w_popup_last_curline != wp->w_cursor.lnum)
popup_highlight_curline(wp);
+ // Clip the buffer's drawn extent to the host window when
+ // "clipwindow" is set. The transient mutations are reverted by
+ // popup_geom_restore() so callers continue to see the popup's
+ // logical geometry via popup_getoptions/popup_getpos.
+ popup_apply_winupdate_clip(wp, &cl);
+
win_update(wp);
+ popup_geom_restore(wp, &saved);
+
// move the cursor into the visible lines, otherwise executing
// commands with win_execute() may cause the text to jump.
if (wp->w_cursor.lnum < wp->w_topline)
@@ -5631,6 +6060,12 @@ update_popups(void (*win_update)(win_T *wp))
wp->w_winrow -= top_off;
wp->w_wincol -= left_extra;
+ // "clipwindow" with top-clip shifts all popup decorations down so the
+ // first visible row of the popup lands at the host window's top edge.
+ // Apply the shift before drawing borders/padding/etc. and restore at
+ // the end of this popup's iteration.
+ wp->w_winrow += wp->w_popup_topoff;
+
// Add offset for border and padding if not done already.
if ((wp->w_flags & WFLAG_WCOL_OFF_ADDED) == 0)
{
@@ -5643,8 +6078,22 @@ update_popups(void (*win_update)(win_T *wp))
wp->w_flags |= WFLAG_WROW_OFF_ADDED;
}
- total_width = popup_width(wp) - wp->w_popup_rightoff;
- total_height = popup_height(wp);
+ // When clipped by "clipwindow", drop the border/padding slot at the
+ // clipped edge that we will not render, so the popup ends exactly on
+ // the last visible content row (no empty trailing side-border row)
+ // and starts on the first visible row when top-clipped. When
+ // unclipped, fall back to the full popup geometry (cl.eff_width
+ // excludes w_leftcol and the scrollbar, which popup_width() folds in).
+ if (wp->w_popup_leftclip > 0 || wp->w_popup_rightclip > 0)
+ total_width = cl.eff_width;
+ else
+ total_width = popup_width(wp) - wp->w_popup_rightoff;
+ if (total_width < 0)
+ total_width = 0;
+ if (wp->w_popup_topoff > 0 || wp->w_popup_bottomoff > 0)
+ total_height = cl.eff_height;
+ else
+ total_height = popup_height(wp);
popup_attr = get_win_attr(wp);
if (wp->w_winrow + total_height > cmdline_row)
@@ -5723,16 +6172,16 @@ update_popups(void (*win_update)(win_T *wp))
wp->w_popup_border[0] > 0 ? border_attr[0] : popup_attr);
}
- wincol = wp->w_wincol - wp->w_popup_leftoff;
- top_padding = wp->w_popup_padding[0];
- if (wp->w_popup_border[0] > 0)
+ wincol = wp->w_wincol - wp->w_popup_leftoff + wp->w_popup_leftclip;
+ top_padding = cl.eff_padding[0];
+ if (cl.eff_border[0] > 0)
{
// top border; do not draw over the title
if (title_len > 0)
{
screen_fill(wp->w_winrow, wp->w_winrow + 1,
wincol < 0 ? 0 : wincol, title_wincol,
- wp->w_popup_border[3] != 0 && wp->w_popup_leftoff == 0
+ cl.eff_border[3] != 0 && wp->w_popup_leftoff == 0
? border_char[4] : border_char[0],
border_char[0], border_attr[0]);
screen_fill(wp->w_winrow, wp->w_winrow + 1,
@@ -5743,18 +6192,19 @@ update_popups(void (*win_update)(win_T *wp))
{
screen_fill(wp->w_winrow, wp->w_winrow + 1,
wincol < 0 ? 0 : wincol, wincol + total_width,
- wp->w_popup_border[3] != 0 && wp->w_popup_leftoff == 0
+ cl.eff_border[3] != 0 && wp->w_popup_leftoff == 0
? border_char[4] : border_char[0],
border_char[0], border_attr[0]);
}
- if (wp->w_popup_border[1] > 0)
+ if (cl.eff_border[1] > 0)
{
buf[mb_char2bytes(border_char[5], buf)] = NUL;
screen_puts(buf, wp->w_winrow,
wincol + total_width - 1, border_attr[1]);
}
}
- else if (wp->w_popup_padding[0] == 0 && popup_top_extra(wp) > 0)
+ else if (cl.eff_padding[0] == 0 && popup_top_extra(wp) > 0
+ && wp->w_popup_topoff == 0)
top_padding = 1;
if (top_padding > 0 || wp->w_popup_padding[2] > 0)
@@ -5844,25 +6294,26 @@ update_popups(void (*win_update)(win_T *wp))
attr_thumb = highlight_attr[HLF_PST];
}
- for (i = wp->w_popup_border[0];
- i < total_height - wp->w_popup_border[2]; ++i)
+ // The side-border loop spans the popup's drawn extent. cl.eff_border
+ // and cl.eff_padding collapse the clipped edges to 0 so the loop
+ // covers the full visible area without leaving an empty trailing row.
+ for (i = cl.eff_border[0]; i < total_height - cl.eff_border[2]; ++i)
{
int pad_left;
// left and right padding only needed next to the body
int do_padding =
- i >= wp->w_popup_border[0] + wp->w_popup_padding[0]
- && i < total_height - wp->w_popup_border[2]
- - wp->w_popup_padding[2];
+ i >= cl.eff_border[0] + cl.eff_padding[0]
+ && i < total_height - cl.eff_border[2] - cl.eff_padding[2];
row = wp->w_winrow + i;
// left border
- if (wp->w_popup_border[3] > 0 && wincol >= 0)
+ if (cl.eff_border[3] > 0 && wincol >= 0)
{
buf[mb_char2bytes(border_char[3], buf)] = NUL;
screen_puts(buf, row, wincol, border_attr[3]);
}
- if (do_padding && wp->w_popup_padding[3] > 0)
+ if (do_padding && cl.eff_padding[3] > 0)
{
int col = wincol + wp->w_popup_border[3];
@@ -5899,13 +6350,13 @@ update_popups(void (*win_update)(win_T *wp))
screen_putchar(' ', row, scroll_col, popup_attr);
}
// right border
- if (wp->w_popup_border[1] > 0)
+ if (cl.eff_border[1] > 0)
{
buf[mb_char2bytes(border_char[1], buf)] = NUL;
screen_puts(buf, row, wincol + total_width - 1, border_attr[1]);
}
// right padding
- if (do_padding && wp->w_popup_padding[1] > 0)
+ if (do_padding && cl.eff_padding[1] > 0)
{
int pad_col_start = wincol + wp->w_popup_border[3]
+ wp->w_popup_padding[3] + wp->w_width + wp->w_leftcol;
@@ -5932,7 +6383,7 @@ update_popups(void (*win_update)(win_T *wp))
}
}
- if (wp->w_popup_padding[2] > 0)
+ if (cl.eff_padding[2] > 0)
{
// bottom padding
row = wp->w_winrow + wp->w_popup_border[0]
@@ -5945,24 +6396,24 @@ update_popups(void (*win_update)(win_T *wp))
padcol, padendcol, ' ', ' ', popup_attr);
}
- if (wp->w_popup_border[2] > 0)
+ if (cl.eff_border[2] > 0)
{
// bottom border
row = wp->w_winrow + total_height - 1;
screen_fill(row, row + 1,
wincol < 0 ? 0 : wincol,
wincol + total_width,
- wp->w_popup_border[3] != 0 && wp->w_popup_leftoff == 0
+ cl.eff_border[3] != 0 && wp->w_popup_leftoff == 0
? border_char[7] : border_char[2],
border_char[2], border_attr[2]);
- if (wp->w_popup_border[1] > 0)
+ if (cl.eff_border[1] > 0)
{
buf[mb_char2bytes(border_char[6], buf)] = NUL;
screen_puts(buf, row, wincol + total_width - 1, border_attr[2]);
}
}
- if (wp->w_popup_shadow)
+ if (wp->w_popup_shadow && wp->w_popup_bottomoff == 0)
{
// bottom shadow
row = wp->w_winrow + total_height;
@@ -5996,6 +6447,10 @@ update_popups(void (*win_update)(win_T *wp))
if (override_success)
pop_highlight_overrides();
+
+ // Undo the topoff shift applied before drawing the borders so the
+ // next iteration sees the popup's logical winrow.
+ wp->w_winrow -= wp->w_popup_topoff;
}
#ifdef FEAT_PROP_POPUP
diff --git a/src/proto/
textprop.pro b/src/proto/
textprop.pro
index d3ecf6d14..be9d80c6e 100644
--- a/src/proto/
textprop.pro
+++ b/src/proto/
textprop.pro
@@ -16,6 +16,7 @@ int get_text_props(buf_T *buf, linenr_T lnum, char_u **props, int will_change);
int prop_count_above_below(buf_T *buf, linenr_T lnum);
int count_props(linenr_T lnum, int only_starting, int last_line);
void sort_text_props(buf_T *buf, textprop_T *props, int *idxs, int count);
+bool find_prop_in_lines(win_T *wp, int type_id, int id, textprop_T *prop, linenr_T *found_lnum, linenr_T first_lnum, linenr_T last_lnum);
bool find_visible_prop(win_T *wp, int type_id, int id, textprop_T *prop, linenr_T *found_lnum);
char_u *props_add_count_header(char_u *line, int line_len, int textlen, int *new_len);
void add_text_props(linenr_T lnum, textprop_T *text_props, int text_prop_count);
diff --git a/src/structs.h b/src/structs.h
index 4d92ca75a..6c6ea8c0e 100644
--- a/src/structs.h
+++ b/src/structs.h
@@ -4197,6 +4197,14 @@ struct window_S
int w_popup_leftoff; // columns left of the screen
int w_popup_rightoff; // columns right of the screen
+ int w_popup_topoff; // rows above the host window's top
+ // when "clipwindow" is set
+ int w_popup_bottomoff; // rows below the host window's bottom
+ // when "clipwindow" is set
+ int w_popup_leftclip; // columns left of the host window's left
+ // when "clipwindow" is set
+ int w_popup_rightclip; // columns right of the host window's right
+ // when "clipwindow" is set
varnumber_T w_popup_last_changedtick; // b:changedtick of popup buffer
// when position was computed
varnumber_T w_popup_prop_changedtick; // b:changedtick of buffer with
@@ -4205,6 +4213,14 @@ struct window_S
int w_popup_prop_topline; // w_topline of window with
// w_popup_prop_type when position was
// computed
+ int w_popup_prop_winrow; // w_winrow of host window when
+ // position was computed
+ int w_popup_prop_wincol; // w_wincol of host window when
+ // position was computed
+ int w_popup_prop_width; // w_width of host window when
+ // position was computed
+ int w_popup_prop_winheight; // w_height of host window when
+ // position was computed
linenr_T w_popup_last_curline; // last known w_cursor.lnum of window
// with "cursorline" set
callback_T w_close_cb; // popup close callback
diff --git a/src/testdir/dumps/Test_popup_clipwindow_bottom_clip.dump b/src/testdir/dumps/Test_popup_clipwindow_bottom_clip.dump
new file mode 100644
index 000000000..b5d79480f
--- /dev/null
+++ b/src/testdir/dumps/Test_popup_clipwindow_bottom_clip.dump
@@ -0,0 +1,14 @@
+>h+0&#ffffff0|o|s|t| |l|i|n|e| |1| @28
+|h|o|s|t| |l|i|n|e| |2| @28
+|h|o|s|t| |l|i|n|e| |3| @28
+|h|o|s|t|╔+0#0000001#e0e0e08|═@8|╗| +0#0000000#ffffff0@24
+|h|o|s|t|║+0#0000001#e0e0e08| |p|o|p|u|p| |A| |║| +0#0000000#ffffff0@24
+|h|o|s|t|║+0#0000001#e0e0e08| |p|o|p|u|p| |B| |║| +0#0000000#ffffff0@24
+|[+3&&|N|o| |N|a|m|e|]| |[|+|]| @8|1|,|1| @11|T|o|p
+| +0&&@39
+|~+0#4040ff13&| @38
+|~| @38
+|~| @38
+|~| @38
+|[+1#0000000&|N|o| |N|a|m|e|]| @12|0|,|0|-|1| @9|A|l@1
+| +0&&@39
diff --git a/src/testdir/dumps/Test_popup_clipwindow_hidden.dump b/src/testdir/dumps/Test_popup_clipwindow_hidden.dump
new file mode 100644
index 000000000..c5f893069
--- /dev/null
+++ b/src/testdir/dumps/Test_popup_clipwindow_hidden.dump
@@ -0,0 +1,14 @@
+|h+0&#ffffff0|o|s|t| |l|i|n|e| |4|5| @27
+|h|o|s|t| |l|i|n|e| |4|6| @27
+|h|o|s|t| |l|i|n|e| |4|7| @27
+|h|o|s|t| |l|i|n|e| |4|8| @27
+|h|o|s|t| |l|i|n|e| |4|9| @27
+>h|o|s|t| |l|i|n|e| |5|0| @27
+|[+3&&|N|o| |N|a|m|e|]| |[|+|]| @8|5|0|,|1| @10|B|o|t
+| +0&&@39
+|~+0#4040ff13&| @38
+|~| @38
+|~| @38
+|~| @38
+|[+1#0000000&|N|o| |N|a|m|e|]| @12|0|,|0|-|1| @9|A|l@1
+| +0&&@39
diff --git a/src/testdir/dumps/Test_popup_clipwindow_left_clip.dump b/src/testdir/dumps/Test_popup_clipwindow_left_clip.dump
new file mode 100644
index 000000000..5d38f98d2
--- /dev/null
+++ b/src/testdir/dumps/Test_popup_clipwindow_left_clip.dump
@@ -0,0 +1,14 @@
+| +0&#ffffff0@26||+1&&>h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|═+0#0000001#e0e0e08@7|╗| +0#0000000#ffffff0|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&| +0&&|p+0#0000001#e0e0e08|o|p|u|p| |A|║| |n+0#0000000#ffffff0|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&| +0&&|p+0#0000001#e0e0e08|o|p|u|p| |B|║| |n+0#0000000#ffffff0|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&| +0&&|p+0#0000001#e0e0e08|o|p|u|p| |C|║| |n+0#0000000#ffffff0|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|═+0#0000001#e0e0e08@7|╝| +0#0000000#ffffff0|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+|~+0#4040ff13&| @25||+1#0000000&|h+0&&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d
+| @31|1|,|1| @10|T|o|p|
diff --git a/src/testdir/dumps/Test_popup_clipwindow_right_clip.dump b/src/testdir/dumps/Test_popup_clipwindow_right_clip.dump
new file mode 100644
index 000000000..6427f94a5
--- /dev/null
+++ b/src/testdir/dumps/Test_popup_clipwindow_right_clip.dump
@@ -0,0 +1,14 @@
+>h+0&#ffffff0|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&| +0&&@26
+|h|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e|╔+0#0000001#e0e0e08|═@3||+1#0000000#ffffff0|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e|║+0#0000001#e0e0e08| |p|o|p||+1#0000000#ffffff0|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e|║+0#0000001#e0e0e08| |p|o|p||+1#0000000#ffffff0|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e|║+0#0000001#e0e0e08| |p|o|p||+1#0000000#ffffff0|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e|╚+0#0000001#e0e0e08|═@3||+1#0000000#ffffff0|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+|h+0#0000000&|o|s|t| |c|o|n|t|e|n|t| |l|i|n|e| |a|b|c|d||+1&&|~+0#4040ff13&| @25
+| +0#0000000&@31|1|,|1| @10|T|o|p|
diff --git a/src/testdir/dumps/Test_popup_clipwindow_top_clip.dump b/src/testdir/dumps/Test_popup_clipwindow_top_clip.dump
new file mode 100644
index 000000000..9876f15cc
--- /dev/null
+++ b/src/testdir/dumps/Test_popup_clipwindow_top_clip.dump
@@ -0,0 +1,14 @@
+| +0&#ffffff0@39
+|~+0#4040ff13&| @38
+|~| @38
+|~| @38
+|~| @38
+|[+1#0000000&|N|o| |N|a|m|e|]| @12|0|,|0|-|1| @9|A|l@1
+>h+0&&|o|s|t|║+0#0000001#e0e0e08| |p|o|p|u|p| |B| |║| +0#0000000#ffffff0@24
+|h|o|s|t|║+0#0000001#e0e0e08| |p|o|p|u|p| |C| |║| +0#0000000#ffffff0@24
+|h|o|s|t|╚+0#0000001#e0e0e08|═@8|╝| +0#0000000#ffffff0@24
+|h|o|s|t| |l|i|n|e| |4| @28
+|h|o|s|t| |l|i|n|e| |5| @28
+|h|o|s|t| |l|i|n|e| |6| @28
+|[+3&&|N|o| |N|a|m|e|]| |[|+|]| @8|1|,|1| @11|T|o|p
+| +0&&@39
diff --git a/src/testdir/test_popupwin.vim b/src/testdir/test_popupwin.vim
index d6aa634c7..873840d8e 100644
--- a/src/testdir/test_popupwin.vim
+++ b/src/testdir/test_popupwin.vim
@@ -4522,6 +4522,222 @@ func Test_popup_setoptions_other_tab()
call prop_type_delete('textprop')
endfunc
+func Test_popup_clipwindow_option()
+ " Default: clipwindow is off.
+ let id = popup_create('TEST', #{})
+ call assert_equal(0, popup_getoptions(id).clipwindow)
+ call popup_close(id)
+
+ " popup_create() honours the option.
+ let id = popup_create('TEST', #{clipwindow: v:true})
+ call assert_equal(1, popup_getoptions(id).clipwindow)
+
+ " popup_setoptions() can toggle it off and on.
+ call popup_setoptions(id, #{clipwindow: v:false})
+ call assert_equal(0, popup_getoptions(id).clipwindow)
+ call popup_setoptions(id, #{clipwindow: v:true})
+ call assert_equal(1, popup_getoptions(id).clipwindow)
+
+ call popup_close(id)
+endfunc
+
+func Test_popup_clipwindow_hide_when_prop_off_screen()
+ " A "clipwindow" popup attached to a textprop should be hidden once the
+ " host window scrolls so the textprop is far enough off-screen that even
+ " the partially-clipped popup would no longer overlap, and unhidden again
+ " when the prop scrolls back into reach.
+ call prop_type_add('clipprop', {})
+ new
+ call setline(1, range(1, 200)->mapnew({_, v -> 'line ' .. v}))
+ call prop_add(5, 1, #{type: 'clipprop', length: 5})
+ let host = win_getid()
+
+ let id = popup_create('attached', #{
+ \ textprop: 'clipprop',
+ \ textpropwin: host,
+ \ line: -1,
+ \ wrap: v:false,
+ \ fixed: v:true,
+ \ clipwindow: v:true,
+ \ })
+ redraw
+ call assert_equal(1, popup_getpos(id).visible)
+
+ " Scroll the host so the prop is far below topline; popup hides.
+ call win_execute(host, 'normal! Gzb')
+ redraw
+ call assert_equal(0, popup_getpos(id).visible)
+
+ " Scroll back so the prop is on the first visible line; popup unhides.
+ call win_execute(host, 'normal! ggzt')
+ redraw
+ call assert_equal(1, popup_getpos(id).visible)
+
+ call popup_close(id)
+ bwipe!
+ call prop_type_delete('clipprop')
+endfunc
+
+func Test_popup_clipwindow_top_clip()
+ CheckScreendump
+
+ let lines =<< trim END
+ vim9script
+ set nowrap
+ :botright new
+ :resize 6
+ setline(1, range(1, 30)->mapnew((_, v) => 'host line ' .. v))
+ prop_type_add('clipprop', {})
+ prop_add(2, 1, {type: 'clipprop', length: 4})
+ popup_create(['popup A', 'popup B', 'popup C'], {
+ textprop: 'clipprop',
+ line: -4,
+ col: 0,
+ border: [],
+ padding: [0, 1, 0, 1],
+ highlight: 'PmenuSel',
+ wrap: false,
+ fixed: true,
+ posinvert: false,
+ clipwindow: true,
+ })
+ END
+ call writefile(lines, 'XtestPopupClipwindowTop', 'D')
+ let buf = RunVimInTerminal('-S XtestPopupClipwindowTop', #{rows: 14, cols: 40})
+ call VerifyScreenDump(buf, 'Test_popup_clipwindow_top_clip', {})
+
+ call StopVimInTerminal(buf)
+endfunc
+
+func Test_popup_clipwindow_bottom_clip()
+ CheckScreendump
+
+ let lines =<< trim END
+ vim9script
+ set nowrap
+ :topleft new
+ :resize 6
+ setline(1, range(1, 30)->mapnew((_, v) => 'host line ' .. v))
+ prop_type_add('clipprop', {})
+ prop_add(2, 1, {type: 'clipprop', length: 4})
+ popup_create(['popup A', 'popup B', 'popup C'], {
+ textprop: 'clipprop',
+ line: 1,
+ col: 0,
+ border: [],
+ padding: [0, 1, 0, 1],
+ highlight: 'PmenuSel',
+ wrap: false,
+ fixed: true,
+ posinvert: false,
+ clipwindow: true,
+ })
+ END
+ call writefile(lines, 'XtestPopupClipwindowBottom', 'D')
+ let buf = RunVimInTerminal('-S XtestPopupClipwindowBottom', #{rows: 14, cols: 40})
+ call VerifyScreenDump(buf, 'Test_popup_clipwindow_bottom_clip', {})
+
+ call StopVimInTerminal(buf)
+endfunc
+
+func Test_popup_clipwindow_left_clip()
+ CheckScreendump
+
+ let lines =<< trim END
+ vim9script
+ set nowrap
+ :vert botright new
+ :vert resize 22
+ set laststatus=0
+ setline(1, repeat(['host content line abcdef'], 20))
+ prop_type_add('clipprop', {})
+ prop_add(5, 6, {type: 'clipprop', length: 4})
+ popup_create(['popup A', 'popup B', 'popup C'], {
+ textprop: 'clipprop',
+ line: 0,
+ col: -10,
+ border: [],
+ padding: [0, 1, 0, 1],
+ highlight: 'PmenuSel',
+ wrap: false,
+ fixed: true,
+ posinvert: false,
+ clipwindow: true,
+ })
+ END
+ call writefile(lines, 'XtestPopupClipwindowLeft', 'D')
+ let buf = RunVimInTerminal('-S XtestPopupClipwindowLeft', #{rows: 14, cols: 50})
+ call VerifyScreenDump(buf, 'Test_popup_clipwindow_left_clip', {})
+
+ call StopVimInTerminal(buf)
+endfunc
+
+func Test_popup_clipwindow_right_clip()
+ CheckScreendump
+
+ let lines =<< trim END
+ vim9script
+ set nowrap
+ :vert topleft new
+ :vert resize 22
+ set laststatus=0
+ setline(1, repeat(['host content line abcdef'], 20))
+ prop_type_add('clipprop', {})
+ prop_add(5, 14, {type: 'clipprop', length: 4})
+ popup_create(['popup A', 'popup B', 'popup C'], {
+ textprop: 'clipprop',
+ line: 0,
+ col: 0,
+ border: [],
+ padding: [0, 1, 0, 1],
+ highlight: 'PmenuSel',
+ wrap: false,
+ fixed: true,
+ posinvert: false,
+ clipwindow: true,
+ })
+ END
+ call writefile(lines, 'XtestPopupClipwindowRight', 'D')
+ let buf = RunVimInTerminal('-S XtestPopupClipwindowRight', #{rows: 14, cols: 50})
+ call VerifyScreenDump(buf, 'Test_popup_clipwindow_right_clip', {})
+
+ call StopVimInTerminal(buf)
+endfunc
+
+func Test_popup_clipwindow_hidden()
+ CheckScreendump
+
+ let lines =<< trim END
+ vim9script
+ set nowrap
+ :topleft new
+ :resize 6
+ setline(1, range(1, 50)->mapnew((_, v) => 'host line ' .. v))
+ prop_type_add('clipprop', {})
+ prop_add(2, 1, {type: 'clipprop', length: 4})
+ popup_create(['popup A', 'popup B', 'popup C'], {
+ textprop: 'clipprop',
+ line: -4,
+ col: 0,
+ border: [],
+ padding: [0, 1, 0, 1],
+ highlight: 'PmenuSel',
+ wrap: false,
+ fixed: true,
+ posinvert: false,
+ clipwindow: true,
+ })
+ # Scroll the host so the textprop is far below topline; the popup is
+ # then hidden because the prop has scrolled out of the host window.
+ win_execute(win_getid(), 'normal! Gzb')
+ END
+ call writefile(lines, 'XtestPopupClipwindowHidden', 'D')
+ let buf = RunVimInTerminal('-S XtestPopupClipwindowHidden', #{rows: 14, cols: 40})
+ call VerifyScreenDump(buf, 'Test_popup_clipwindow_hidden', {})
+
+ call StopVimInTerminal(buf)
+endfunc
+
func Test_popup_prop_not_visible()
CheckScreendump
diff --git a/src/textprop.c b/src/textprop.c
index 48cbad4b6..a049edec9 100644
--- a/src/textprop.c
+++ b/src/textprop.c
@@ -1396,25 +1396,28 @@ sort_text_props(
}
/*
- * Find text property "type_id" in the visible lines of window "wp".
- * Match "id" when it is > 0.
- * Returns false when not found.
+ * Find text property "type_id" in lines [first_lnum, last_lnum] of window
+ * "wp"'s buffer. Match "id" when it is > 0. Returns false when not found.
*/
bool
-find_visible_prop(
+find_prop_in_lines(
win_T *wp,
int type_id,
int id,
textprop_T *prop,
- linenr_T *found_lnum)
+ linenr_T *found_lnum,
+ linenr_T first_lnum,
+ linenr_T last_lnum)
{
- // return when "type_id" no longer exists
if (text_prop_type_by_id(wp->w_buffer, type_id) == NULL)
return false;
- // w_botline may not have been updated yet.
- validate_botline_win(wp);
- for (linenr_T lnum = wp->w_topline; lnum < wp->w_botline; ++lnum)
+ if (first_lnum < 1)
+ first_lnum = 1;
+ if (last_lnum > wp->w_buffer->b_ml.ml_line_count)
+ last_lnum = wp->w_buffer->b_ml.ml_line_count;
+
+ for (linenr_T lnum = first_lnum; lnum <= last_lnum; ++lnum)
{
char_u *props;
int count = get_text_props(wp->w_buffer, lnum, &props, FALSE);
@@ -1432,6 +1435,25 @@ find_visible_prop(
return false;
}
+/*
+ * Find text property "type_id" in the visible lines of window "wp".
+ * Match "id" when it is > 0.
+ * Returns false when not found.
+ */
+ bool
+find_visible_prop(
+ win_T *wp,
+ int type_id,
+ int id,
+ textprop_T *prop,
+ linenr_T *found_lnum)
+{
+ // w_botline may not have been updated yet.
+ validate_botline_win(wp);
+ return find_prop_in_lines(wp, type_id, id, prop, found_lnum,
+ wp->w_topline, wp->w_botline - 1);
+}
+
/*
* Set the text properties for line "lnum" to "tps" array with "count" entries.
* If "count" is zero text properties are removed.
diff --git a/src/version.c b/src/version.c
index f1e40ddb2..2882be579 100644
--- a/src/version.c
+++ b/src/version.c
@@ -729,6 +729,8 @@ static char *(features[]) =
static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 469,
/**/
468,
/**/
diff --git a/src/vim.h b/src/vim.h
index ee67e4078..d9a22ef01 100644
--- a/src/vim.h
+++ b/src/vim.h
@@ -690,6 +690,7 @@ extern int (*dyn_libintl_wputenv)(const wchar_t *envstring);
#define POPF_INFO_MENU 0x400 // align info popup with popup menu
#define POPF_POSINVERT 0x800 // vertical position can be inverted
#define POPF_OPACITY 0x1000 // popup has opacity/transparency setting
+#define POPF_CLIPWINDOW 0x2000 // confine popup to its host window's rect
// flags used in w_popup_handled
#define POPUP_HANDLED_1 0x01 // used by mouse_find_win()