patch 8.1.1449: popup text truncated at end of screen

Problem:    Popup text truncated at end of screen.
Solution:   Move popup left if needed.  Add the "fixed" property to disable
            that. (Ben Jackson , closes #4466)
diff --git a/src/popupwin.c b/src/popupwin.c
index 4e07bdb..297faeb 100644
--- a/src/popupwin.c
+++ b/src/popupwin.c
@@ -84,6 +84,8 @@
     if (nr > 0)
 	wp->w_wantcol = nr;
 
+    wp->w_popup_fixed = dict_get_number(dict, (char_u *)"fixed") != 0;
+
     str = dict_get_string(dict, (char_u *)"pos", FALSE);
     if (str != NULL)
     {
@@ -379,6 +381,7 @@
     int		maxwidth;
     int		center_vert = FALSE;
     int		center_hor = FALSE;
+    int		allow_adjust_left = !wp->w_popup_fixed;
 
     wp->w_winrow = 0;
     wp->w_wincol = 0;
@@ -412,10 +415,14 @@
     }
 
     // When centering or right aligned, use maximum width.
-    // When left aligned use the space available.
+    // When left aligned use the space available, but shift to the left when we
+    // hit the right of the screen.
     maxwidth = Columns - wp->w_wincol;
     if (wp->w_maxwidth > 0 && maxwidth > wp->w_maxwidth)
+    {
+	allow_adjust_left = FALSE;
 	maxwidth = wp->w_maxwidth;
+    }
 
     // Compute width based on longest text line and the 'wrap' option.
     // TODO: more accurate wrapping
@@ -424,10 +431,32 @@
     {
 	int len = vim_strsize(ml_get_buf(wp->w_buffer, lnum, FALSE));
 
-	while (wp->w_p_wrap && len > maxwidth)
+	if (wp->w_p_wrap)
 	{
-	    ++wrapped;
-	    len -= maxwidth;
+	    while (len > maxwidth)
+	    {
+		++wrapped;
+		len -= maxwidth;
+		wp->w_width = maxwidth;
+	    }
+	}
+	else if (len > maxwidth
+		&& allow_adjust_left
+		&& (wp->w_popup_pos == POPPOS_TOPLEFT
+		    || wp->w_popup_pos == POPPOS_BOTLEFT))
+	{
+	    // adjust leftwise to fit text on screen
+	    int shift_by = ( len - maxwidth );
+
+	    if ( shift_by > wp->w_wincol )
+	    {
+		int truncate_shift = shift_by - wp->w_wincol;
+		len -= truncate_shift;
+		shift_by -= truncate_shift;
+	    }
+
+	    wp->w_wincol -= shift_by;
+	    maxwidth += shift_by;
 	    wp->w_width = maxwidth;
 	}
 	if (wp->w_width < len)
@@ -895,6 +924,7 @@
 	dict_add_number(dict, "maxheight", wp->w_maxheight);
 	dict_add_number(dict, "maxwidth", wp->w_maxwidth);
 	dict_add_number(dict, "zindex", wp->w_zindex);
+	dict_add_number(dict, "fixed", wp->w_popup_fixed);
 
 	for (i = 0; i < (int)(sizeof(poppos_entries) / sizeof(poppos_entry_T));
 									   ++i)
diff --git a/src/structs.h b/src/structs.h
index 2100135..591c5a2 100644
--- a/src/structs.h
+++ b/src/structs.h
@@ -2308,7 +2308,7 @@
     int		b_p_fixeol;	/* 'fixendofline' */
     int		b_p_et;		/* 'expandtab' */
     int		b_p_et_nobin;	/* b_p_et saved for binary mode */
-    int	        b_p_et_nopaste; /* b_p_et saved for paste mode */
+    int		b_p_et_nopaste; /* b_p_et saved for paste mode */
     char_u	*b_p_fenc;	/* 'fileencoding' */
     char_u	*b_p_ff;	/* 'fileformat' */
     char_u	*b_p_ft;	/* 'filetype' */
@@ -2881,6 +2881,7 @@
 #ifdef FEAT_TEXT_PROP
     int		w_popup_flags;	    // POPF_ values
     poppos_T	w_popup_pos;
+    int		w_popup_fixed;	    // do not shift popup to fit on screen
     int		w_zindex;
     int		w_minheight;	    // "minheight" for popup window
     int		w_minwidth;	    // "minwidth" for popup window
@@ -3038,8 +3039,8 @@
     int		w_p_brishift;	    /* additional shift for breakindent */
     int		w_p_brisbr;	    /* sbr in 'briopt' */
 #endif
-    long        w_p_siso;           /* 'sidescrolloff' local value */
-    long        w_p_so;             /* 'scrolloff' local value */
+    long	w_p_siso;	    /* 'sidescrolloff' local value */
+    long	w_p_so;		    /* 'scrolloff' local value */
 
     /* transform a pointer to a "onebuf" option into a "allbuf" option */
 #define GLOBAL_WO(p)	((char *)p + sizeof(winopt_T))
@@ -3471,7 +3472,7 @@
     int		js_used;	/* bytes used from js_buf */
     int		(*js_fill)(struct js_reader *);
 				/* function to fill the buffer or NULL;
-                                 * return TRUE when the buffer was filled */
+				 * return TRUE when the buffer was filled */
     void	*js_cookie;	/* can be used by js_fill */
     int		js_cookie_arg;	/* can be used by js_fill */
 };
diff --git a/src/testdir/test_popupwin.vim b/src/testdir/test_popupwin.vim
index 0eb171a..e4f7cd2 100644
--- a/src/testdir/test_popupwin.vim
+++ b/src/testdir/test_popupwin.vim
@@ -422,6 +422,7 @@
     \ 'maxheight': 21,
     \ 'zindex': 100,
     \ 'time': 5000,
+    \ 'fixed': 1
     \})
   redraw
   let res = popup_getoptions(winid)
@@ -432,6 +433,7 @@
   call assert_equal(20, res.maxwidth)
   call assert_equal(21, res.maxheight)
   call assert_equal(100, res.zindex)
+  call assert_equal(1, res.fixed)
   if has('timers')
     call assert_equal(5000, res.time)
   endif
@@ -447,6 +449,7 @@
   call assert_equal(0, res.maxwidth)
   call assert_equal(0, res.maxheight)
   call assert_equal(50, res.zindex)
+  call assert_equal(0, res.fixed)
   if has('timers')
     call assert_equal(0, res.time)
   endif
@@ -647,3 +650,183 @@
   call StopVimInTerminal(buf)
   call delete('XtestPopupBehind')
 endfunc
+
+func s:VerifyPosition( p, msg, line, col, width, height )
+  call assert_equal( a:line,   popup_getpos( a:p ).line,   a:msg . ' (l)' )
+  call assert_equal( a:col,    popup_getpos( a:p ).col,    a:msg . ' (c)' )
+  call assert_equal( a:width,  popup_getpos( a:p ).width,  a:msg . ' (w)' )
+  call assert_equal( a:height, popup_getpos( a:p ).height, a:msg . ' (h)' )
+endfunc
+
+func Test_popup_position_adjust()
+  " Anything placed past 2 cells from of the right of the screen is moved to the
+  " left.
+  "
+  " When wrapping is disabled, we also shift to the left to display on the
+  " screen, unless fixed is set.
+
+  " Entries for cases which don't vary based on wrapping.
+  " Format is per tests described below
+  let both_wrap_tests = [
+        \       [ 'a', 5, &columns,        5, &columns - 2, 1, 1 ],
+        \       [ 'b', 5, &columns + 1,    5, &columns - 2, 1, 1 ],
+        \       [ 'c', 5, &columns - 1,    5, &columns - 2, 1, 1 ],
+        \       [ 'd', 5, &columns - 2,    5, &columns - 2, 1, 1 ],
+        \       [ 'e', 5, &columns - 3,    5, &columns - 3, 1, 1 ],
+        \
+        \       [ 'aa', 5, &columns,        5, &columns - 2, 2, 1 ],
+        \       [ 'bb', 5, &columns + 1,    5, &columns - 2, 2, 1 ],
+        \       [ 'cc', 5, &columns - 1,    5, &columns - 2, 2, 1 ],
+        \       [ 'dd', 5, &columns - 2,    5, &columns - 2, 2, 1 ],
+        \       [ 'ee', 5, &columns - 3,    5, &columns - 3, 2, 1 ],
+        \
+        \       [ 'aaa', 5, &columns,        5, &columns - 2, 3, 1 ],
+        \       [ 'bbb', 5, &columns + 1,    5, &columns - 2, 3, 1 ],
+        \       [ 'ccc', 5, &columns - 1,    5, &columns - 2, 3, 1 ],
+        \       [ 'ddd', 5, &columns - 2,    5, &columns - 2, 3, 1 ],
+        \       [ 'eee', 5, &columns - 3,    5, &columns - 3, 3, 1 ],
+        \ ]
+
+  " these test groups are dicts with:
+  "  - comment: something to identify the group of tests by
+  "  - options: dict of options to merge with the row/col in tests
+  "  - tests: list of cases. Each one is a list with elements:
+  "     - text
+  "     - row
+  "     - col
+  "     - expected row
+  "     - expected col
+  "     - expected width
+  "     - expected height
+  let tests = [
+        \ {
+        \   'comment': 'left-aligned with wrapping',
+        \   'options': {
+        \     'wrap': 1,
+        \     'pos': 'botleft',
+        \   },
+        \   'tests': both_wrap_tests + [
+        \       [ 'aaaa', 5, &columns,        4, &columns - 2, 3, 2 ],
+        \       [ 'bbbb', 5, &columns + 1,    4, &columns - 2, 3, 2 ],
+        \       [ 'cccc', 5, &columns - 1,    4, &columns - 2, 3, 2 ],
+        \       [ 'dddd', 5, &columns - 2,    4, &columns - 2, 3, 2 ],
+        \       [ 'eeee', 5, &columns - 3,    5, &columns - 3, 4, 1 ],
+        \   ],
+        \ },
+        \ {
+        \   'comment': 'left aligned without wrapping',
+        \   'options': {
+        \     'wrap': 0,
+        \     'pos': 'botleft',
+        \   },
+        \   'tests': both_wrap_tests + [
+        \       [ 'aaaa', 5, &columns,        5, &columns - 3, 4, 1 ],
+        \       [ 'bbbb', 5, &columns + 1,    5, &columns - 3, 4, 1 ],
+        \       [ 'cccc', 5, &columns - 1,    5, &columns - 3, 4, 1 ],
+        \       [ 'dddd', 5, &columns - 2,    5, &columns - 3, 4, 1 ],
+        \       [ 'eeee', 5, &columns - 3,    5, &columns - 3, 4, 1 ],
+        \   ],
+        \ },
+        \ {
+        \   'comment': 'left aligned with fixed position',
+        \   'options': {
+        \     'wrap': 0,
+        \     'fixed': 1,
+        \     'pos': 'botleft',
+        \   },
+        \   'tests': both_wrap_tests + [
+        \       [ 'aaaa', 5, &columns,        5, &columns - 2, 3, 1 ],
+        \       [ 'bbbb', 5, &columns + 1,    5, &columns - 2, 3, 1 ],
+        \       [ 'cccc', 5, &columns - 1,    5, &columns - 2, 3, 1 ],
+        \       [ 'dddd', 5, &columns - 2,    5, &columns - 2, 3, 1 ],
+        \       [ 'eeee', 5, &columns - 3,    5, &columns - 3, 4, 1 ],
+        \   ],
+        \ },
+      \ ]
+
+  for test_group in tests
+    for test in test_group.tests
+      let [ text, line, col, e_line, e_col, e_width, e_height ] = test
+      let options = {
+            \ 'line': line,
+            \ 'col': col,
+            \ }
+      call extend( options, test_group.options )
+
+      let p = popup_create( text, options )
+
+      let msg = string( extend( options, { 'text': text } ) )
+      call s:VerifyPosition( p, msg, e_line, e_col, e_width, e_height )
+      call popup_close( p )
+    endfor
+  endfor
+
+  popupclear
+  %bwipe!
+endfunc
+
+function Test_adjust_left_past_screen_width()
+  " width of screen
+  let X = join(map(range(&columns), {->'X'}), '')
+
+  let p = popup_create( X, { 'line': 1, 'col': 1, 'wrap': 0 } )
+  call s:VerifyPosition( p, 'full width topleft', 1, 1, &columns, 1 )
+
+  redraw
+  let line = join(map(range(1, &columns + 1), 'screenstring(1, v:val)'), '')
+  call assert_equal(X, line)
+
+  call popup_close( p )
+  redraw
+
+  " Same if placed on the right hand side
+  let p = popup_create( X, { 'line': 1, 'col': &columns, 'wrap': 0 } )
+  call s:VerifyPosition( p, 'full width topright', 1, 1, &columns, 1 )
+
+  redraw
+  let line = join(map(range(1, &columns + 1), 'screenstring(1, v:val)'), '')
+  call assert_equal(X, line)
+
+  call popup_close( p )
+  redraw
+
+  " Extend so > window width
+  let X .= 'x'
+
+  let p = popup_create( X, { 'line': 1, 'col': 1, 'wrap': 0 } )
+  call s:VerifyPosition( p, 'full width +  1 topleft', 1, 1, &columns, 1 )
+
+  redraw
+  let line = join(map(range(1, &columns + 1), 'screenstring(1, v:val)'), '')
+  call assert_equal(X[ : -2 ], line)
+
+  call popup_close( p )
+  redraw
+
+  " Shifted then truncated (the x is not visible)
+  let p = popup_create( X, { 'line': 1, 'col': &columns - 3, 'wrap': 0 } )
+  call s:VerifyPosition( p, 'full width + 1 topright', 1, 1, &columns, 1 )
+
+  redraw
+  let line = join(map(range(1, &columns + 1), 'screenstring(1, v:val)'), '')
+  call assert_equal(X[ : -2 ], line)
+
+  call popup_close( p )
+  redraw
+
+  " Not shifted, just truncated
+  let p = popup_create( X,
+        \ { 'line': 1, 'col': 2, 'wrap': 0, 'fixed': 1 } )
+  call s:VerifyPosition( p, 'full width + 1 fixed', 1, 2, &columns - 1, 1)
+
+  redraw
+  let line = join(map(range(1, &columns + 1), 'screenstring(1, v:val)'), '')
+  let e_line = ' ' . X[ 1 : -2 ]
+  call assert_equal(e_line, line)
+
+  call popup_close( p )
+  redraw
+
+  popupclear
+  %bwipe!
+endfunction
diff --git a/src/version.c b/src/version.c
index 83181ab..90c77ea 100644
--- a/src/version.c
+++ b/src/version.c
@@ -768,6 +768,8 @@
 static int included_patches[] =
 {   /* Add new patch number below this line */
 /**/
+    1449,
+/**/
     1448,
 /**/
     1447,