11#include " markdown/dom_builder.hpp"
22
3+ #include < string_view>
4+
35#include < ftxui/dom/flexbox_config.hpp>
46
57namespace markdown {
@@ -44,6 +46,22 @@ ftxui::Decorator link_style(bool is_focused, ftxui::Decorator base,
4446 return base | ftxui::underlined | theme.link ;
4547}
4648
49+ // Register a link: create a LinkTarget, wrap each element in elems[from..]
50+ // with reflect for click detection, and apply focus to the first element.
51+ void register_link (Links& links, ftxui::Elements& elems, size_t from,
52+ std::string const & url, bool is_focused) {
53+ links.emplace_back (LinkTarget{.url = url});
54+ auto & target = links.back ();
55+ size_t count = elems.size () - from;
56+ target.boxes .resize (count);
57+ for (size_t i = from; i < elems.size (); ++i) {
58+ elems[i] = elems[i] | ftxui::reflect (target.boxes [i - from]);
59+ }
60+ if (is_focused && count > 0 ) {
61+ elems[from] = elems[from] | ftxui::focus;
62+ }
63+ }
64+
4765ftxui::Element build_node (ASTNode const & node, int depth, int qd,
4866 Links& links, int focused_link,
4967 Theme const & theme);
@@ -107,7 +125,8 @@ void collect_inline_words(ASTNode const& node, int depth, int qd,
107125 break ;
108126 }
109127 case NodeType::SoftBreak:
110- break ; // flexbox gap handles spacing
128+ case NodeType::HardBreak:
129+ break ; // handled by build_wrapping_container (HardBreak splits rows)
111130 case NodeType::Strong:
112131 collect_inline_words (child, depth + 1 , qd, words,
113132 style | ftxui::bold, links, focused_link,
@@ -121,23 +140,10 @@ void collect_inline_words(ASTNode const& node, int depth, int qd,
121140 case NodeType::Link: {
122141 bool is_focused = is_next_link_focused (links, focused_link);
123142 auto ls = link_style (is_focused, style, theme);
124- // Collect link words with underline, then wrap in reflect
125143 size_t before = words.size ();
126144 collect_inline_words (child, depth + 1 , qd, words,
127145 ls, links, focused_link, theme);
128- // Wrap every word of this link with reflect for click detection
129- links.emplace_back (LinkTarget{.url = child.url });
130- auto & target = links.back ();
131- size_t word_count = words.size () - before;
132- target.boxes .resize (word_count);
133- bool first_word = true ;
134- for (size_t i = before; i < words.size (); ++i) {
135- words[i] = words[i] | ftxui::reflect (target.boxes [i - before]);
136- if (is_focused && first_word) {
137- words[i] = words[i] | ftxui::focus;
138- first_word = false ;
139- }
140- }
146+ register_link (links, words, before, child.url , is_focused);
141147 break ;
142148 }
143149 case NodeType::CodeInline:
@@ -162,8 +168,25 @@ bool is_plain_text_paragraph(ASTNode const& node) {
162168 return true ;
163169}
164170
171+ // Check if a node contains any HardBreak children.
172+ bool has_hard_break (ASTNode const & node) {
173+ for (auto const & child : node.children ) {
174+ if (child.type == NodeType::HardBreak) return true ;
175+ }
176+ return false ;
177+ }
178+
179+ // Build a flexbox row from a flat list of word elements.
180+ ftxui::Element words_to_element (ftxui::Elements& words) {
181+ static const auto wrap_config = ftxui::FlexboxConfig ().SetGap (1 , 0 );
182+ if (words.empty ()) return ftxui::text (" " );
183+ if (words.size () == 1 ) return std::move (words[0 ]);
184+ return ftxui::flexbox (std::move (words), wrap_config);
185+ }
186+
165187// Wrapping version of build_inline_container for block-level paragraphs.
166188// Splits all inline content into word-level flexbox items for line wrapping.
189+ // HardBreak nodes force a new line by splitting into separate flexbox rows.
167190ftxui::Element build_wrapping_container (ASTNode const & node, int depth, int qd,
168191 Links& links, int focused_link,
169192 Theme const & theme) {
@@ -186,13 +209,41 @@ ftxui::Element build_wrapping_container(ASTNode const& node, int depth, int qd,
186209 return ftxui::paragraph (combined);
187210 }
188211
189- static const auto wrap_config = ftxui::FlexboxConfig ().SetGap (1 , 0 );
190- ftxui::Elements words;
191- collect_inline_words (node, depth, qd, words, ftxui::nothing, links,
192- focused_link, theme);
193- if (words.empty ()) return ftxui::text (" " );
194- if (words.size () == 1 ) return std::move (words[0 ]);
195- return ftxui::flexbox (std::move (words), wrap_config);
212+ // If no hard breaks, single flexbox row (common case).
213+ if (!has_hard_break (node)) {
214+ ftxui::Elements words;
215+ collect_inline_words (node, depth, qd, words, ftxui::nothing, links,
216+ focused_link, theme);
217+ return words_to_element (words);
218+ }
219+
220+ // Split at HardBreak boundaries: each segment becomes its own row.
221+ // Build a temporary ASTNode per segment and collect words from it.
222+ ftxui::Elements rows;
223+ ASTNode segment{.type = node.type };
224+ auto flush_segment = [&] {
225+ if (segment.children .empty ()) {
226+ rows.push_back (ftxui::text (" " ));
227+ return ;
228+ }
229+ ftxui::Elements words;
230+ collect_inline_words (segment, depth, qd, words, ftxui::nothing,
231+ links, focused_link, theme);
232+ rows.push_back (words_to_element (words));
233+ segment.children .clear ();
234+ };
235+
236+ for (auto const & child : node.children ) {
237+ if (child.type == NodeType::HardBreak) {
238+ flush_segment ();
239+ } else {
240+ segment.children .push_back (child);
241+ }
242+ }
243+ flush_segment ();
244+
245+ if (rows.size () == 1 ) return std::move (rows[0 ]);
246+ return ftxui::vbox (std::move (rows));
196247}
197248
198249// Build a ListItem: first Paragraph gets bullet/number prefix,
@@ -261,10 +312,10 @@ ftxui::Element build_link(ASTNode const& node, int depth, int qd,
261312 auto el = build_inline_container (node, depth, qd, links, focused_link,
262313 theme)
263314 | link_style (is_focused, ftxui::nothing, theme);
264- if (is_focused) el = el | ftxui::focus ;
265- links. emplace_back (LinkTarget{. url = node. url } );
266- links. back (). boxes . emplace_back ( );
267- return el | ftxui::reflect (links. back (). boxes . back () );
315+ ftxui::Elements elems ;
316+ elems. push_back ( std::move (el) );
317+ register_link ( links, elems, 0 , node. url , is_focused );
318+ return std::move (elems[ 0 ] );
268319}
269320
270321ftxui::Element build_bullet_list (ASTNode const & node, int depth, int qd,
@@ -307,17 +358,18 @@ ftxui::Element build_blockquote(ASTNode const& node, int depth, int qd,
307358}
308359
309360ftxui::Element build_code_block (ASTNode const & node, Theme const & theme) {
310- std::string code = node.text ;
311- if (!code.empty () && code.back () == ' \n ' ) code.pop_back ( );
361+ std::string_view code = node.text ;
362+ if (!code.empty () && code.back () == ' \n ' ) code.remove_suffix ( 1 );
312363 ftxui::Elements lines;
313364 size_t start = 0 ;
314365 while (start <= code.size ()) {
315366 auto end = code.find (' \n ' , start);
316- if (end == std::string ::npos) {
317- lines.push_back (ftxui::text (code.substr (start)));
367+ if (end == std::string_view ::npos) {
368+ lines.push_back (ftxui::text (std::string ( code.substr (start) )));
318369 break ;
319370 }
320- lines.push_back (ftxui::text (code.substr (start, end - start)));
371+ lines.push_back (
372+ ftxui::text (std::string (code.substr (start, end - start))));
321373 start = end + 1 ;
322374 }
323375 if (lines.empty ()) lines.push_back (ftxui::text (" " ));
@@ -382,7 +434,7 @@ ftxui::Element build_node(ASTNode const& node, int depth, int qd,
382434 case NodeType::SoftBreak:
383435 return ftxui::text (" " );
384436 case NodeType::HardBreak:
385- return ftxui::text (" \n " );
437+ return ftxui::text (" " );
386438 default :
387439 return ftxui::text (node.text );
388440 }
0 commit comments