@@ -1507,15 +1507,12 @@ impl<U: EventListener> Crosswords<U> {
15071507 ..
15081508 } ) => {
15091509 for line in ( start. row . 0 ..end. row . 0 ) . map ( Line :: from) {
1510- res += self
1511- . line_to_string ( line, start. col ..end. col , start. col . 0 != 0 )
1512- . trim_end ( ) ;
1510+ res +=
1511+ & self . line_to_string ( line, start. col ..end. col , start. col . 0 != 0 ) ;
15131512 res += "\n " ;
15141513 }
15151514
1516- res += self
1517- . line_to_string ( end. row , start. col ..end. col , true )
1518- . trim_end ( ) ;
1515+ res += & self . line_to_string ( end. row , start. col ..end. col , true ) ;
15191516 }
15201517 Some ( Selection {
15211518 ty : SelectionType :: Lines ,
@@ -1532,40 +1529,104 @@ impl<U: EventListener> Crosswords<U> {
15321529 }
15331530
15341531 pub fn bounds_to_string ( & self , start : Pos , end : Pos ) -> String {
1535- let mut res = String :: new ( ) ;
1532+ let mut text = String :: new ( ) ;
1533+ let mut blank_rows: usize = 0 ;
1534+ let mut blank_cells: usize = 0 ;
1535+ let last_col = self . grid . last_column ( ) ;
15361536
15371537 for line in ( start. row . 0 ..=end. row . 0 ) . map ( Line :: from) {
15381538 let start_col = if line == start. row {
15391539 start. col
15401540 } else {
15411541 Column ( 0 )
15421542 } ;
1543- let end_col = if line == end. row {
1544- end. col
1545- } else {
1546- self . grid . last_column ( )
1547- } ;
1543+ let end_col = if line == end. row { end. col } else { last_col } ;
1544+
1545+ // Carry buffered blank cells across wrap continuations only.
1546+ // Without this, `aaa \n aaa"` (where row N wraps into N+1) would
1547+ // collapse the cross-row gap from two spaces to one.
1548+ let is_wrap_continuation =
1549+ line. 0 > start. row . 0 && self . grid [ line - 1i32 ] [ last_col] . wrapline ( ) ;
1550+ if !is_wrap_continuation {
1551+ blank_cells = 0 ;
1552+ }
1553+
1554+ let mut row_text = String :: new ( ) ;
1555+ let had_content = self . append_cells (
1556+ & mut row_text,
1557+ line,
1558+ start_col..end_col,
1559+ line == end. row ,
1560+ & mut blank_cells,
1561+ ) ;
1562+
1563+ if !had_content {
1564+ // Defer entirely-blank rows; trailing blank rows get dropped.
1565+ blank_rows += 1 ;
1566+ continue ;
1567+ }
1568+
1569+ for _ in 0 ..blank_rows {
1570+ text. push ( '\n' ) ;
1571+ }
1572+ blank_rows = 0 ;
1573+
1574+ text. push_str ( & row_text) ;
15481575
1549- res += & self . line_to_string ( line, start_col..end_col, line == end. row ) ;
1576+ let cur_wraps = self . grid [ line] [ last_col] . wrapline ( ) ;
1577+ if end_col >= last_col && !cur_wraps {
1578+ text. push ( '\n' ) ;
1579+ blank_cells = 0 ;
1580+ }
15501581 }
15511582
1552- res . strip_suffix ( '\n' ) . map ( str:: to_owned) . unwrap_or ( res )
1583+ text . strip_suffix ( '\n' ) . map ( str:: to_owned) . unwrap_or ( text )
15531584 }
15541585
1555- /// Convert a single line in the grid to a String.
1586+ /// Convert a single line in the grid to a String. Used by Block selection;
1587+ /// trailing blank cells are dropped. No trailing newline is appended —
1588+ /// the caller controls row separation.
15561589 fn line_to_string (
15571590 & self ,
15581591 line : Line ,
1559- mut cols : Range < Column > ,
1592+ cols : Range < Column > ,
15601593 include_wrapped_wide : bool ,
15611594 ) -> String {
15621595 let mut text = String :: new ( ) ;
1596+ let mut blank_cells = 0 ;
1597+ self . append_cells (
1598+ & mut text,
1599+ line,
1600+ cols,
1601+ include_wrapped_wide,
1602+ & mut blank_cells,
1603+ ) ;
1604+ text
1605+ }
15631606
1607+ /// Append cells from a single line to `text`, buffering blank cells
1608+ /// (`\0` and trailing spaces) so that:
1609+ /// - `\0` cells inside a run of content become real spaces
1610+ /// - trailing blanks at end of the run are dropped (caller decides
1611+ /// whether to flush them via the `blank_cells` accumulator)
1612+ ///
1613+ /// Returns true if the line emitted any non-blank content.
1614+ fn append_cells (
1615+ & self ,
1616+ text : & mut String ,
1617+ line : Line ,
1618+ mut cols : Range < Column > ,
1619+ include_wrapped_wide : bool ,
1620+ blank_cells : & mut usize ,
1621+ ) -> bool {
1622+ let mut had_content = false ;
15641623 let grid_line = & self . grid [ line] ;
15651624 let line_length = std:: cmp:: min ( grid_line. line_length ( ) , cols. end + 1 ) ;
15661625
15671626 // Include wide char when trailing spacer is selected.
1568- if matches ! ( grid_line[ cols. start] . wide( ) , Wide :: Spacer ) {
1627+ if cols. start < self . grid . columns ( )
1628+ && matches ! ( grid_line[ cols. start] . wide( ) , Wide :: Spacer )
1629+ {
15691630 cols. start -= 1 ;
15701631 }
15711632
@@ -1586,25 +1647,34 @@ impl<U: EventListener> Crosswords<U> {
15861647 tab_mode = true ;
15871648 }
15881649
1589- if !matches ! ( cell. wide( ) , Wide :: Spacer | Wide :: LeadingSpacer ) {
1590- // Push cells primary character.
1591- text. push ( cell. c ( ) ) ;
1650+ if matches ! ( cell. wide( ) , Wide :: Spacer | Wide :: LeadingSpacer ) {
1651+ continue ;
1652+ }
1653+
1654+ let c = cell. c ( ) ;
1655+ let has_extras = cell. extras_id ( ) . is_some ( ) ;
15921656
1593- // Push zero-width characters.
1594- if let Some ( extras_id) = cell. extras_id ( ) {
1595- if let Some ( extras) = self . grid . extras_table . get ( extras_id) {
1596- for c in & extras. zerowidth {
1597- text. push ( * c) ;
1598- }
1657+ // Buffer blank cells. They only get emitted as real spaces if a
1658+ // non-blank cell follows (on this row or a wrap continuation).
1659+ if !has_extras && ( c == '\0' || c == ' ' ) {
1660+ * blank_cells += 1 ;
1661+ continue ;
1662+ }
1663+
1664+ for _ in 0 ..* blank_cells {
1665+ text. push ( ' ' ) ;
1666+ }
1667+ * blank_cells = 0 ;
1668+
1669+ text. push ( c) ;
1670+ if let Some ( extras_id) = cell. extras_id ( ) {
1671+ if let Some ( extras) = self . grid . extras_table . get ( extras_id) {
1672+ for c in & extras. zerowidth {
1673+ text. push ( * c) ;
15991674 }
16001675 }
16011676 }
1602- }
1603-
1604- if cols. end >= self . grid . columns ( ) - 1
1605- && ( line_length. 0 == 0 || !self . grid [ line] [ line_length - 1 ] . wrapline ( ) )
1606- {
1607- text. push ( '\n' ) ;
1677+ had_content = true ;
16081678 }
16091679
16101680 // If wide char is not part of the selection, but leading spacer is, include it.
@@ -1613,10 +1683,15 @@ impl<U: EventListener> Crosswords<U> {
16131683 && matches ! ( grid_line[ line_length - 1 ] . wide( ) , Wide :: LeadingSpacer )
16141684 && include_wrapped_wide
16151685 {
1686+ for _ in 0 ..* blank_cells {
1687+ text. push ( ' ' ) ;
1688+ }
1689+ * blank_cells = 0 ;
16161690 text. push ( self . grid [ line - 1i32 ] [ Column ( 0 ) ] . c ( ) ) ;
1691+ had_content = true ;
16171692 }
16181693
1619- text
1694+ had_content
16201695 }
16211696
16221697 #[ inline]
@@ -4695,9 +4770,12 @@ mod tests {
46954770 Side :: Right ,
46964771 ) ;
46974772 }
4773+ // Trailing space on the wrapped row is preserved as a buffered blank
4774+ // and only flushed if a non-blank cell follows on the continuation
4775+ // row. Here the selection ends mid-wrap so the trailing space is dropped.
46984776 assert_eq ! (
46994777 term. selection_to_string( ) ,
4700- Some ( String :: from( "\" aaa\" \n \n aaa " ) )
4778+ Some ( String :: from( "\" aaa\" \n \n aaa" ) )
47014779 ) ;
47024780
47034781 // A wrapline.
@@ -4836,6 +4914,133 @@ mod tests {
48364914 ) ;
48374915 }
48384916
4917+ fn make_term_for_selection ( rows : usize , cols : usize ) -> Crosswords < VoidListener > {
4918+ let size = CrosswordsSize :: new ( cols, rows) ;
4919+ let window_id = crate :: event:: WindowId :: from ( 0 ) ;
4920+ Crosswords :: new (
4921+ size,
4922+ CursorShape :: Block ,
4923+ VoidListener { } ,
4924+ window_id,
4925+ 0 ,
4926+ 10_000 ,
4927+ )
4928+ }
4929+
4930+ fn select_simple (
4931+ term : & mut Crosswords < VoidListener > ,
4932+ start : ( i32 , usize ) ,
4933+ end : ( i32 , usize ) ,
4934+ ) {
4935+ term. selection = Some ( Selection :: new (
4936+ SelectionType :: Simple ,
4937+ Pos {
4938+ row : Line ( start. 0 ) ,
4939+ col : Column ( start. 1 ) ,
4940+ } ,
4941+ Side :: Left ,
4942+ ) ) ;
4943+ if let Some ( s) = term. selection . as_mut ( ) {
4944+ s. update (
4945+ Pos {
4946+ row : Line ( end. 0 ) ,
4947+ col : Column ( end. 1 ) ,
4948+ } ,
4949+ Side :: Right ,
4950+ ) ;
4951+ }
4952+ }
4953+
4954+ /// `\0` cells in the middle of a run of content must be emitted as ASCII
4955+ /// spaces, not raw NULs. This is the "TUI redrew its UI and left holes"
4956+ /// case (e.g. fullscreen apps that paint with cursor positioning).
4957+ #[ test]
4958+ fn null_cells_inside_run_become_spaces ( ) {
4959+ let mut term = make_term_for_selection ( 1 , 7 ) ;
4960+ let grid = & mut term. grid ;
4961+ // Row layout: a, a, \0, \0, \0, b, b
4962+ grid[ Line ( 0 ) ] [ Column ( 0 ) ] . set_c ( 'a' ) ;
4963+ grid[ Line ( 0 ) ] [ Column ( 1 ) ] . set_c ( 'a' ) ;
4964+ grid[ Line ( 0 ) ] [ Column ( 5 ) ] . set_c ( 'b' ) ;
4965+ grid[ Line ( 0 ) ] [ Column ( 6 ) ] . set_c ( 'b' ) ;
4966+
4967+ select_simple ( & mut term, ( 0 , 0 ) , ( 0 , 6 ) ) ;
4968+ let s = term. selection_to_string ( ) . unwrap ( ) ;
4969+ assert_eq ! ( s, "aa bb" ) ;
4970+ assert ! ( !s. contains( '\0' ) , "selection must not contain raw NULs" ) ;
4971+ }
4972+
4973+ /// Trailing `\0` and trailing spaces on a non-wrapped row must be dropped
4974+ /// rather than padding the copy out to column width.
4975+ #[ test]
4976+ fn trailing_blanks_on_non_wrapped_row_are_dropped ( ) {
4977+ let mut term = make_term_for_selection ( 1 , 8 ) ;
4978+ let grid = & mut term. grid ;
4979+ grid[ Line ( 0 ) ] [ Column ( 0 ) ] . set_c ( 'h' ) ;
4980+ grid[ Line ( 0 ) ] [ Column ( 1 ) ] . set_c ( 'i' ) ;
4981+ grid[ Line ( 0 ) ] [ Column ( 2 ) ] . set_c ( ' ' ) ;
4982+ grid[ Line ( 0 ) ] [ Column ( 3 ) ] . set_c ( ' ' ) ;
4983+ // cols 4..=7 stay as \0
4984+
4985+ select_simple ( & mut term, ( 0 , 0 ) , ( 0 , 7 ) ) ;
4986+ assert_eq ! ( term. selection_to_string( ) , Some ( String :: from( "hi" ) ) ) ;
4987+ }
4988+
4989+ /// Trailing blank rows in a multi-row selection must be dropped, not
4990+ /// emitted as a run of `\n`s.
4991+ #[ test]
4992+ fn trailing_blank_rows_are_dropped ( ) {
4993+ let mut term = make_term_for_selection ( 5 , 5 ) ;
4994+ let grid = & mut term. grid ;
4995+ grid[ Line ( 0 ) ] [ Column ( 0 ) ] . set_c ( 'x' ) ;
4996+ grid[ Line ( 0 ) ] [ Column ( 1 ) ] . set_c ( 'y' ) ;
4997+ // Rows 1..=4 are entirely \0.
4998+
4999+ select_simple ( & mut term, ( 0 , 0 ) , ( 4 , 4 ) ) ;
5000+ assert_eq ! ( term. selection_to_string( ) , Some ( String :: from( "xy" ) ) ) ;
5001+ }
5002+
5003+ /// Blank rows between non-blank rows must still be emitted as `\n`s, so
5004+ /// real visual gaps in the selection are preserved.
5005+ #[ test]
5006+ fn blank_rows_between_content_are_preserved ( ) {
5007+ let mut term = make_term_for_selection ( 5 , 5 ) ;
5008+ let grid = & mut term. grid ;
5009+ grid[ Line ( 0 ) ] [ Column ( 0 ) ] . set_c ( 'a' ) ;
5010+ // Rows 1, 2 entirely \0.
5011+ grid[ Line ( 3 ) ] [ Column ( 0 ) ] . set_c ( 'b' ) ;
5012+ // Row 4 entirely \0.
5013+
5014+ select_simple ( & mut term, ( 0 , 0 ) , ( 4 , 4 ) ) ;
5015+ assert_eq ! ( term. selection_to_string( ) , Some ( String :: from( "a\n \n \n b" ) ) ) ;
5016+ }
5017+
5018+ /// When a row wraps into the next, the trailing-space buffer must carry
5019+ /// across so the visual gap survives the round-trip through the clipboard.
5020+ #[ test]
5021+ fn trailing_space_carries_across_wrap_continuation ( ) {
5022+ let mut term = make_term_for_selection ( 2 , 5 ) ;
5023+ let grid = & mut term. grid ;
5024+ // Row 0: "ab " with a wrap into row 1.
5025+ grid[ Line ( 0 ) ] [ Column ( 0 ) ] . set_c ( 'a' ) ;
5026+ grid[ Line ( 0 ) ] [ Column ( 1 ) ] . set_c ( 'b' ) ;
5027+ grid[ Line ( 0 ) ] [ Column ( 2 ) ] . set_c ( ' ' ) ;
5028+ grid[ Line ( 0 ) ] [ Column ( 3 ) ] . set_c ( ' ' ) ;
5029+ grid[ Line ( 0 ) ] [ Column ( 4 ) ] . set_c ( ' ' ) ;
5030+ grid[ Line ( 0 ) ] [ Column ( 4 ) ] . set_wrapline ( true ) ;
5031+ // Row 1: " cd "
5032+ grid[ Line ( 1 ) ] [ Column ( 0 ) ] . set_c ( ' ' ) ;
5033+ grid[ Line ( 1 ) ] [ Column ( 1 ) ] . set_c ( 'c' ) ;
5034+ grid[ Line ( 1 ) ] [ Column ( 2 ) ] . set_c ( 'd' ) ;
5035+ grid[ Line ( 1 ) ] [ Column ( 3 ) ] . set_c ( ' ' ) ;
5036+ grid[ Line ( 1 ) ] [ Column ( 4 ) ] . set_c ( ' ' ) ;
5037+
5038+ // Trailing spaces on row 1 dropped (no further continuation), but the
5039+ // 4 spaces between `b` and `c` must survive across the wrap.
5040+ select_simple ( & mut term, ( 0 , 0 ) , ( 1 , 4 ) ) ;
5041+ assert_eq ! ( term. selection_to_string( ) , Some ( String :: from( "ab cd" ) ) ) ;
5042+ }
5043+
48395044 #[ test]
48405045 fn parse_cargo_version ( ) {
48415046 assert_eq ! ( version_number( "0.0.1-nightly" ) , 1 ) ;
0 commit comments