Skip to main content

slt/
layout.rs

1//! Flexbox layout engine: builds a tree from commands, computes positions,
2//! and renders to a [`Buffer`].
3
4use crate::buffer::Buffer;
5use crate::rect::Rect;
6use crate::style::{
7    Align, Border, BorderSides, Color, Constraints, Justify, Margin, Padding, Style,
8};
9use unicode_width::UnicodeWidthChar;
10use unicode_width::UnicodeWidthStr;
11
12/// Main axis direction for a container's children.
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Direction {
16    /// Lay out children horizontally (left to right).
17    Row,
18    /// Lay out children vertically (top to bottom).
19    Column,
20}
21
22#[derive(Debug, Clone)]
23pub(crate) enum Command {
24    Text {
25        content: String,
26        style: Style,
27        grow: u16,
28        align: Align,
29        wrap: bool,
30        truncate: bool,
31        margin: Margin,
32        constraints: Constraints,
33    },
34    BeginContainer {
35        direction: Direction,
36        gap: u32,
37        align: Align,
38        align_self: Option<Align>,
39        justify: Justify,
40        border: Option<Border>,
41        border_sides: BorderSides,
42        border_style: Style,
43        bg_color: Option<Color>,
44        padding: Padding,
45        margin: Margin,
46        constraints: Constraints,
47        title: Option<(String, Style)>,
48        grow: u16,
49        group_name: Option<String>,
50    },
51    BeginScrollable {
52        grow: u16,
53        border: Option<Border>,
54        border_sides: BorderSides,
55        border_style: Style,
56        padding: Padding,
57        margin: Margin,
58        constraints: Constraints,
59        title: Option<(String, Style)>,
60        scroll_offset: u32,
61    },
62    Link {
63        text: String,
64        url: String,
65        style: Style,
66        margin: Margin,
67        constraints: Constraints,
68    },
69    RichText {
70        segments: Vec<(String, Style)>,
71        wrap: bool,
72        align: Align,
73        margin: Margin,
74        constraints: Constraints,
75    },
76    EndContainer,
77    BeginOverlay {
78        modal: bool,
79    },
80    EndOverlay,
81    Spacer {
82        grow: u16,
83    },
84    FocusMarker(usize),
85    InteractionMarker(usize),
86    RawDraw {
87        draw_id: usize,
88        constraints: Constraints,
89        grow: u16,
90        margin: Margin,
91    },
92}
93
94#[derive(Debug, Clone)]
95struct OverlayLayer {
96    node: LayoutNode,
97    modal: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum NodeKind {
102    Text,
103    Container(Direction),
104    Spacer,
105    RawDraw(usize),
106}
107
108#[derive(Debug, Clone)]
109pub(crate) struct LayoutNode {
110    kind: NodeKind,
111    content: Option<String>,
112    style: Style,
113    pub grow: u16,
114    align: Align,
115    pub(crate) align_self: Option<Align>,
116    justify: Justify,
117    wrap: bool,
118    truncate: bool,
119    gap: u32,
120    border: Option<Border>,
121    border_sides: BorderSides,
122    border_style: Style,
123    bg_color: Option<Color>,
124    padding: Padding,
125    margin: Margin,
126    constraints: Constraints,
127    title: Option<(String, Style)>,
128    children: Vec<LayoutNode>,
129    pos: (u32, u32),
130    size: (u32, u32),
131    is_scrollable: bool,
132    scroll_offset: u32,
133    content_height: u32,
134    cached_wrapped: Option<Vec<String>>,
135    segments: Option<Vec<(String, Style)>>,
136    cached_wrapped_segments: Option<Vec<Vec<(String, Style)>>>,
137    pub(crate) focus_id: Option<usize>,
138    pub(crate) interaction_id: Option<usize>,
139    link_url: Option<String>,
140    group_name: Option<String>,
141    overlays: Vec<OverlayLayer>,
142}
143
144#[derive(Debug, Clone)]
145struct ContainerConfig {
146    gap: u32,
147    align: Align,
148    align_self: Option<Align>,
149    justify: Justify,
150    border: Option<Border>,
151    border_sides: BorderSides,
152    border_style: Style,
153    bg_color: Option<Color>,
154    padding: Padding,
155    margin: Margin,
156    constraints: Constraints,
157    title: Option<(String, Style)>,
158    grow: u16,
159}
160
161impl LayoutNode {
162    fn text(
163        content: String,
164        style: Style,
165        grow: u16,
166        align: Align,
167        text_flags: (bool, bool),
168        margin: Margin,
169        constraints: Constraints,
170    ) -> Self {
171        let (wrap, truncate) = text_flags;
172        let width = UnicodeWidthStr::width(content.as_str()) as u32;
173        Self {
174            kind: NodeKind::Text,
175            content: Some(content),
176            style,
177            grow,
178            align,
179            align_self: None,
180            justify: Justify::Start,
181            wrap,
182            truncate,
183            gap: 0,
184            border: None,
185            border_sides: BorderSides::all(),
186            border_style: Style::new(),
187            bg_color: None,
188            padding: Padding::default(),
189            margin,
190            constraints,
191            title: None,
192            children: Vec::new(),
193            pos: (0, 0),
194            size: (width, 1),
195            is_scrollable: false,
196            scroll_offset: 0,
197            content_height: 0,
198            cached_wrapped: None,
199            segments: None,
200            cached_wrapped_segments: None,
201            focus_id: None,
202            interaction_id: None,
203            link_url: None,
204            group_name: None,
205            overlays: Vec::new(),
206        }
207    }
208
209    fn rich_text(
210        segments: Vec<(String, Style)>,
211        wrap: bool,
212        align: Align,
213        margin: Margin,
214        constraints: Constraints,
215    ) -> Self {
216        let width: u32 = segments
217            .iter()
218            .map(|(s, _)| UnicodeWidthStr::width(s.as_str()) as u32)
219            .sum();
220        Self {
221            kind: NodeKind::Text,
222            content: None,
223            style: Style::new(),
224            grow: 0,
225            align,
226            align_self: None,
227            justify: Justify::Start,
228            wrap,
229            truncate: false,
230            gap: 0,
231            border: None,
232            border_sides: BorderSides::all(),
233            border_style: Style::new(),
234            bg_color: None,
235            padding: Padding::default(),
236            margin,
237            constraints,
238            title: None,
239            children: Vec::new(),
240            pos: (0, 0),
241            size: (width, 1),
242            is_scrollable: false,
243            scroll_offset: 0,
244            content_height: 0,
245            cached_wrapped: None,
246            segments: Some(segments),
247            cached_wrapped_segments: None,
248            focus_id: None,
249            interaction_id: None,
250            link_url: None,
251            group_name: None,
252            overlays: Vec::new(),
253        }
254    }
255
256    fn container(direction: Direction, config: ContainerConfig) -> Self {
257        Self {
258            kind: NodeKind::Container(direction),
259            content: None,
260            style: Style::new(),
261            grow: config.grow,
262            align: config.align,
263            align_self: config.align_self,
264            justify: config.justify,
265            wrap: false,
266            truncate: false,
267            gap: config.gap,
268            border: config.border,
269            border_sides: config.border_sides,
270            border_style: config.border_style,
271            bg_color: config.bg_color,
272            padding: config.padding,
273            margin: config.margin,
274            constraints: config.constraints,
275            title: config.title,
276            children: Vec::new(),
277            pos: (0, 0),
278            size: (0, 0),
279            is_scrollable: false,
280            scroll_offset: 0,
281            content_height: 0,
282            cached_wrapped: None,
283            segments: None,
284            cached_wrapped_segments: None,
285            focus_id: None,
286            interaction_id: None,
287            link_url: None,
288            group_name: None,
289            overlays: Vec::new(),
290        }
291    }
292
293    fn spacer(grow: u16) -> Self {
294        Self {
295            kind: NodeKind::Spacer,
296            content: None,
297            style: Style::new(),
298            grow,
299            align: Align::Start,
300            align_self: None,
301            justify: Justify::Start,
302            wrap: false,
303            truncate: false,
304            gap: 0,
305            border: None,
306            border_sides: BorderSides::all(),
307            border_style: Style::new(),
308            bg_color: None,
309            padding: Padding::default(),
310            margin: Margin::default(),
311            constraints: Constraints::default(),
312            title: None,
313            children: Vec::new(),
314            pos: (0, 0),
315            size: (0, 0),
316            is_scrollable: false,
317            scroll_offset: 0,
318            content_height: 0,
319            cached_wrapped: None,
320            segments: None,
321            cached_wrapped_segments: None,
322            focus_id: None,
323            interaction_id: None,
324            link_url: None,
325            group_name: None,
326            overlays: Vec::new(),
327        }
328    }
329
330    fn border_inset(&self) -> u32 {
331        if self.border.is_some() {
332            1
333        } else {
334            0
335        }
336    }
337
338    fn border_left_inset(&self) -> u32 {
339        if self.border.is_some() && self.border_sides.left {
340            1
341        } else {
342            0
343        }
344    }
345
346    fn border_right_inset(&self) -> u32 {
347        if self.border.is_some() && self.border_sides.right {
348            1
349        } else {
350            0
351        }
352    }
353
354    fn border_top_inset(&self) -> u32 {
355        if self.border.is_some() && self.border_sides.top {
356            1
357        } else {
358            0
359        }
360    }
361
362    fn border_bottom_inset(&self) -> u32 {
363        if self.border.is_some() && self.border_sides.bottom {
364            1
365        } else {
366            0
367        }
368    }
369
370    fn frame_horizontal(&self) -> u32 {
371        self.padding.horizontal() + self.border_left_inset() + self.border_right_inset()
372    }
373
374    fn frame_vertical(&self) -> u32 {
375        self.padding.vertical() + self.border_top_inset() + self.border_bottom_inset()
376    }
377
378    fn min_width(&self) -> u32 {
379        let width = match self.kind {
380            NodeKind::Text => self.size.0,
381            NodeKind::Spacer | NodeKind::RawDraw(_) => 0,
382            NodeKind::Container(Direction::Row) => {
383                let gaps = if self.children.is_empty() {
384                    0
385                } else {
386                    (self.children.len() as u32 - 1) * self.gap
387                };
388                let children_width: u32 = self.children.iter().map(|c| c.min_width()).sum();
389                children_width + gaps + self.frame_horizontal()
390            }
391            NodeKind::Container(Direction::Column) => {
392                self.children
393                    .iter()
394                    .map(|c| c.min_width())
395                    .max()
396                    .unwrap_or(0)
397                    + self.frame_horizontal()
398            }
399        };
400
401        let width = width.max(self.constraints.min_width.unwrap_or(0));
402        let width = match self.constraints.max_width {
403            Some(max_w) => width.min(max_w),
404            None => width,
405        };
406        width.saturating_add(self.margin.horizontal())
407    }
408
409    fn min_height(&self) -> u32 {
410        let height = match self.kind {
411            NodeKind::Text => 1,
412            NodeKind::Spacer | NodeKind::RawDraw(_) => 0,
413            NodeKind::Container(Direction::Row) => {
414                self.children
415                    .iter()
416                    .map(|c| c.min_height())
417                    .max()
418                    .unwrap_or(0)
419                    + self.frame_vertical()
420            }
421            NodeKind::Container(Direction::Column) => {
422                let gaps = if self.children.is_empty() {
423                    0
424                } else {
425                    (self.children.len() as u32 - 1) * self.gap
426                };
427                let children_height: u32 = self.children.iter().map(|c| c.min_height()).sum();
428                children_height + gaps + self.frame_vertical()
429            }
430        };
431
432        let height = height.max(self.constraints.min_height.unwrap_or(0));
433        height.saturating_add(self.margin.vertical())
434    }
435
436    fn min_height_for_width(&self, available_width: u32) -> u32 {
437        match self.kind {
438            NodeKind::Text if self.wrap => {
439                let inner_width = available_width.saturating_sub(self.margin.horizontal());
440                let lines = if let Some(ref segs) = self.segments {
441                    wrap_segments(segs, inner_width).len().max(1) as u32
442                } else {
443                    let text = self.content.as_deref().unwrap_or("");
444                    wrap_lines(text, inner_width).len().max(1) as u32
445                };
446                lines.saturating_add(self.margin.vertical())
447            }
448            _ => self.min_height(),
449        }
450    }
451}
452
453fn wrap_lines(text: &str, max_width: u32) -> Vec<String> {
454    if text.is_empty() {
455        return vec![String::new()];
456    }
457    if max_width == 0 {
458        return vec![text.to_string()];
459    }
460
461    fn split_long_word(word: &str, max_width: u32) -> Vec<(String, u32)> {
462        let mut chunks: Vec<(String, u32)> = Vec::new();
463        let mut chunk = String::new();
464        let mut chunk_width = 0_u32;
465
466        for ch in word.chars() {
467            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
468            if chunk.is_empty() {
469                if ch_width > max_width {
470                    chunks.push((ch.to_string(), ch_width));
471                } else {
472                    chunk.push(ch);
473                    chunk_width = ch_width;
474                }
475                continue;
476            }
477
478            if chunk_width + ch_width > max_width {
479                chunks.push((std::mem::take(&mut chunk), chunk_width));
480                if ch_width > max_width {
481                    chunks.push((ch.to_string(), ch_width));
482                    chunk_width = 0;
483                } else {
484                    chunk.push(ch);
485                    chunk_width = ch_width;
486                }
487            } else {
488                chunk.push(ch);
489                chunk_width += ch_width;
490            }
491        }
492
493        if !chunk.is_empty() {
494            chunks.push((chunk, chunk_width));
495        }
496
497        chunks
498    }
499
500    fn push_word_into_line(
501        lines: &mut Vec<String>,
502        current_line: &mut String,
503        current_width: &mut u32,
504        word: &str,
505        word_width: u32,
506        max_width: u32,
507    ) {
508        if word.is_empty() {
509            return;
510        }
511
512        if word_width > max_width {
513            let chunks = split_long_word(word, max_width);
514            for (chunk, chunk_width) in chunks {
515                if current_line.is_empty() {
516                    *current_line = chunk;
517                    *current_width = chunk_width;
518                } else if *current_width + 1 + chunk_width <= max_width {
519                    current_line.push(' ');
520                    current_line.push_str(&chunk);
521                    *current_width += 1 + chunk_width;
522                } else {
523                    lines.push(std::mem::take(current_line));
524                    *current_line = chunk;
525                    *current_width = chunk_width;
526                }
527            }
528            return;
529        }
530
531        if current_line.is_empty() {
532            *current_line = word.to_string();
533            *current_width = word_width;
534        } else if *current_width + 1 + word_width <= max_width {
535            current_line.push(' ');
536            current_line.push_str(word);
537            *current_width += 1 + word_width;
538        } else {
539            lines.push(std::mem::take(current_line));
540            *current_line = word.to_string();
541            *current_width = word_width;
542        }
543    }
544
545    let mut lines: Vec<String> = Vec::new();
546    let mut current_line = String::new();
547    let mut current_width: u32 = 0;
548    let mut current_word = String::new();
549    let mut word_width: u32 = 0;
550
551    for ch in text.chars() {
552        if ch == ' ' {
553            push_word_into_line(
554                &mut lines,
555                &mut current_line,
556                &mut current_width,
557                &current_word,
558                word_width,
559                max_width,
560            );
561            current_word.clear();
562            word_width = 0;
563            continue;
564        }
565
566        current_word.push(ch);
567        word_width += UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
568    }
569
570    push_word_into_line(
571        &mut lines,
572        &mut current_line,
573        &mut current_width,
574        &current_word,
575        word_width,
576        max_width,
577    );
578
579    if !current_line.is_empty() {
580        lines.push(current_line);
581    }
582
583    if lines.is_empty() {
584        vec![String::new()]
585    } else {
586        lines
587    }
588}
589
590fn wrap_segments(segments: &[(String, Style)], max_width: u32) -> Vec<Vec<(String, Style)>> {
591    if max_width == 0 || segments.is_empty() {
592        return vec![vec![]];
593    }
594    let mut chars: Vec<(char, Style)> = Vec::new();
595    for (text, style) in segments {
596        for ch in text.chars() {
597            chars.push((ch, *style));
598        }
599    }
600    if chars.is_empty() {
601        return vec![vec![]];
602    }
603
604    let mut lines: Vec<Vec<(String, Style)>> = Vec::new();
605    let mut i = 0;
606    while i < chars.len() {
607        let mut line_chars: Vec<(char, Style)> = Vec::new();
608        let mut line_width: u32 = 0;
609
610        if !lines.is_empty() {
611            while i < chars.len() && chars[i].0 == ' ' {
612                i += 1;
613            }
614        }
615
616        while i < chars.len() {
617            let (ch, st) = chars[i];
618            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
619            if line_width + ch_width > max_width && line_width > 0 {
620                if let Some(bp) = line_chars.iter().rposition(|(c, _)| *c == ' ') {
621                    let rewind = line_chars.len() - bp - 1;
622                    i -= rewind;
623                    line_chars.truncate(bp);
624                }
625                break;
626            }
627            line_chars.push((ch, st));
628            line_width += ch_width;
629            i += 1;
630        }
631
632        let mut line_segs: Vec<(String, Style)> = Vec::new();
633        let mut cur = String::new();
634        let mut cur_style: Option<Style> = None;
635        for (ch, st) in &line_chars {
636            if cur_style == Some(*st) {
637                cur.push(*ch);
638            } else {
639                if let Some(s) = cur_style {
640                    if !cur.is_empty() {
641                        line_segs.push((std::mem::take(&mut cur), s));
642                    }
643                }
644                cur_style = Some(*st);
645                cur.push(*ch);
646            }
647        }
648        if let Some(s) = cur_style {
649            if !cur.is_empty() {
650                let trimmed = cur.trim_end().to_string();
651                if !trimmed.is_empty() {
652                    line_segs.push((trimmed, s));
653                } else if !line_segs.is_empty() {
654                    if let Some(last) = line_segs.last_mut() {
655                        let t = last.0.trim_end().to_string();
656                        if t.is_empty() {
657                            line_segs.pop();
658                        } else {
659                            last.0 = t;
660                        }
661                    }
662                }
663            }
664        }
665        lines.push(line_segs);
666    }
667    if lines.is_empty() {
668        vec![vec![]]
669    } else {
670        lines
671    }
672}
673
674pub(crate) fn build_tree(commands: &[Command]) -> LayoutNode {
675    let mut root = LayoutNode::container(Direction::Column, default_container_config());
676    let mut overlays: Vec<OverlayLayer> = Vec::new();
677    build_children(&mut root, commands, &mut 0, &mut overlays, false);
678    root.overlays = overlays;
679    root
680}
681
682fn default_container_config() -> ContainerConfig {
683    ContainerConfig {
684        gap: 0,
685        align: Align::Start,
686        align_self: None,
687        justify: Justify::Start,
688        border: None,
689        border_sides: BorderSides::all(),
690        border_style: Style::new(),
691        bg_color: None,
692        padding: Padding::default(),
693        margin: Margin::default(),
694        constraints: Constraints::default(),
695        title: None,
696        grow: 0,
697    }
698}
699
700fn build_children(
701    parent: &mut LayoutNode,
702    commands: &[Command],
703    pos: &mut usize,
704    overlays: &mut Vec<OverlayLayer>,
705    stop_on_end_overlay: bool,
706) {
707    let mut pending_focus_id: Option<usize> = None;
708    let mut pending_interaction_id: Option<usize> = None;
709    while *pos < commands.len() {
710        match &commands[*pos] {
711            Command::FocusMarker(id) => {
712                pending_focus_id = Some(*id);
713                *pos += 1;
714            }
715            Command::InteractionMarker(id) => {
716                pending_interaction_id = Some(*id);
717                *pos += 1;
718            }
719            Command::Text {
720                content,
721                style,
722                grow,
723                align,
724                wrap,
725                truncate,
726                margin,
727                constraints,
728            } => {
729                let mut node = LayoutNode::text(
730                    content.clone(),
731                    *style,
732                    *grow,
733                    *align,
734                    (*wrap, *truncate),
735                    *margin,
736                    *constraints,
737                );
738                node.focus_id = pending_focus_id.take();
739                parent.children.push(node);
740                *pos += 1;
741            }
742            Command::RichText {
743                segments,
744                wrap,
745                align,
746                margin,
747                constraints,
748            } => {
749                let mut node =
750                    LayoutNode::rich_text(segments.clone(), *wrap, *align, *margin, *constraints);
751                node.focus_id = pending_focus_id.take();
752                parent.children.push(node);
753                *pos += 1;
754            }
755            Command::Link {
756                text,
757                url,
758                style,
759                margin,
760                constraints,
761            } => {
762                let mut node = LayoutNode::text(
763                    text.clone(),
764                    *style,
765                    0,
766                    Align::Start,
767                    (false, false),
768                    *margin,
769                    *constraints,
770                );
771                node.link_url = Some(url.clone());
772                node.focus_id = pending_focus_id.take();
773                node.interaction_id = pending_interaction_id.take();
774                parent.children.push(node);
775                *pos += 1;
776            }
777            Command::BeginContainer {
778                direction,
779                gap,
780                align,
781                align_self,
782                justify,
783                border,
784                border_sides,
785                border_style,
786                bg_color,
787                padding,
788                margin,
789                constraints,
790                title,
791                grow,
792                group_name,
793            } => {
794                let mut node = LayoutNode::container(
795                    *direction,
796                    ContainerConfig {
797                        gap: *gap,
798                        align: *align,
799                        align_self: *align_self,
800                        justify: *justify,
801                        border: *border,
802                        border_sides: *border_sides,
803                        border_style: *border_style,
804                        bg_color: *bg_color,
805                        padding: *padding,
806                        margin: *margin,
807                        constraints: *constraints,
808                        title: title.clone(),
809                        grow: *grow,
810                    },
811                );
812                node.focus_id = pending_focus_id.take();
813                node.interaction_id = pending_interaction_id.take();
814                node.group_name = group_name.clone();
815                *pos += 1;
816                build_children(&mut node, commands, pos, overlays, false);
817                parent.children.push(node);
818            }
819            Command::BeginScrollable {
820                grow,
821                border,
822                border_sides,
823                border_style,
824                padding,
825                margin,
826                constraints,
827                title,
828                scroll_offset,
829            } => {
830                let mut node = LayoutNode::container(
831                    Direction::Column,
832                    ContainerConfig {
833                        gap: 0,
834                        align: Align::Start,
835                        align_self: None,
836                        justify: Justify::Start,
837                        border: *border,
838                        border_sides: *border_sides,
839                        border_style: *border_style,
840                        bg_color: None,
841                        padding: *padding,
842                        margin: *margin,
843                        constraints: *constraints,
844                        title: title.clone(),
845                        grow: *grow,
846                    },
847                );
848                node.is_scrollable = true;
849                node.scroll_offset = *scroll_offset;
850                node.focus_id = pending_focus_id.take();
851                node.interaction_id = pending_interaction_id.take();
852                *pos += 1;
853                build_children(&mut node, commands, pos, overlays, false);
854                parent.children.push(node);
855            }
856            Command::BeginOverlay { modal } => {
857                *pos += 1;
858                let mut overlay_node =
859                    LayoutNode::container(Direction::Column, default_container_config());
860                overlay_node.interaction_id = pending_interaction_id.take();
861                build_children(&mut overlay_node, commands, pos, overlays, true);
862                overlays.push(OverlayLayer {
863                    node: overlay_node,
864                    modal: *modal,
865                });
866            }
867            Command::Spacer { grow } => {
868                parent.children.push(LayoutNode::spacer(*grow));
869                *pos += 1;
870            }
871            Command::RawDraw {
872                draw_id,
873                constraints,
874                grow,
875                margin,
876            } => {
877                let node = LayoutNode {
878                    kind: NodeKind::RawDraw(*draw_id),
879                    content: None,
880                    style: Style::new(),
881                    grow: *grow,
882                    align: Align::Start,
883                    align_self: None,
884                    justify: Justify::Start,
885                    wrap: false,
886                    truncate: false,
887                    gap: 0,
888                    border: None,
889                    border_sides: BorderSides::all(),
890                    border_style: Style::new(),
891                    bg_color: None,
892                    padding: Padding::default(),
893                    margin: *margin,
894                    constraints: *constraints,
895                    title: None,
896                    children: Vec::new(),
897                    pos: (0, 0),
898                    size: (
899                        constraints.min_width.unwrap_or(0),
900                        constraints.min_height.unwrap_or(0),
901                    ),
902                    is_scrollable: false,
903                    scroll_offset: 0,
904                    content_height: 0,
905                    cached_wrapped: None,
906                    segments: None,
907                    cached_wrapped_segments: None,
908                    focus_id: pending_focus_id.take(),
909                    interaction_id: None,
910                    link_url: None,
911                    group_name: None,
912                    overlays: Vec::new(),
913                };
914                parent.children.push(node);
915                *pos += 1;
916            }
917            Command::EndContainer => {
918                *pos += 1;
919                return;
920            }
921            Command::EndOverlay => {
922                *pos += 1;
923                if stop_on_end_overlay {
924                    return;
925                }
926            }
927        }
928    }
929}
930
931mod flexbox;
932mod render;
933
934pub(crate) use flexbox::compute;
935pub(crate) use render::{render, render_debug_overlay};
936
937#[derive(Default)]
938pub(crate) struct FrameData {
939    pub scroll_infos: Vec<(u32, u32)>,
940    pub scroll_rects: Vec<Rect>,
941    pub hit_areas: Vec<Rect>,
942    pub group_rects: Vec<(String, Rect)>,
943    pub content_areas: Vec<(Rect, Rect)>,
944    pub focus_rects: Vec<(usize, Rect)>,
945    pub focus_groups: Vec<Option<String>>,
946}
947
948/// Collect all per-frame data from a laid-out tree in a single DFS pass.
949///
950/// Replaces the 7 individual `collect_*` functions that each traversed the
951/// tree independently, reducing per-frame traversals from 7× to 1×.
952pub(crate) fn collect_all(node: &LayoutNode) -> FrameData {
953    let mut data = FrameData::default();
954
955    // scroll_infos, scroll_rects, focus_rects process the root node itself.
956    // group_rects, content_areas, focus_groups skip the root.
957    if node.is_scrollable {
958        let viewport_h = node.size.1.saturating_sub(node.frame_vertical());
959        data.scroll_infos.push((node.content_height, viewport_h));
960        data.scroll_rects
961            .push(Rect::new(node.pos.0, node.pos.1, node.size.0, node.size.1));
962    }
963    if let Some(id) = node.focus_id {
964        if node.pos.1 + node.size.1 > 0 {
965            data.focus_rects.push((
966                id,
967                Rect::new(node.pos.0, node.pos.1, node.size.0, node.size.1),
968            ));
969        }
970    }
971    if let Some(id) = node.interaction_id {
972        let rect = if node.pos.1 + node.size.1 > 0 {
973            Rect::new(node.pos.0, node.pos.1, node.size.0, node.size.1)
974        } else {
975            Rect::new(0, 0, 0, 0)
976        };
977        if id >= data.hit_areas.len() {
978            data.hit_areas.resize(id + 1, Rect::new(0, 0, 0, 0));
979        }
980        data.hit_areas[id] = rect;
981    }
982
983    let child_offset = if node.is_scrollable {
984        node.scroll_offset
985    } else {
986        0
987    };
988    for child in &node.children {
989        collect_all_inner(child, &mut data, child_offset, None);
990    }
991
992    for overlay in &node.overlays {
993        collect_all_inner(&overlay.node, &mut data, 0, None);
994    }
995
996    data
997}
998
999fn collect_all_inner(
1000    node: &LayoutNode,
1001    data: &mut FrameData,
1002    y_offset: u32,
1003    active_group: Option<&str>,
1004) {
1005    // --- scroll_infos (no y_offset dependency) ---
1006    if node.is_scrollable {
1007        let viewport_h = node.size.1.saturating_sub(node.frame_vertical());
1008        data.scroll_infos.push((node.content_height, viewport_h));
1009    }
1010
1011    // --- scroll_rects (uses y_offset) ---
1012    if node.is_scrollable {
1013        let adj_y = node.pos.1.saturating_sub(y_offset);
1014        data.scroll_rects
1015            .push(Rect::new(node.pos.0, adj_y, node.size.0, node.size.1));
1016    }
1017
1018    // --- hit_areas (indexed by interaction_id) ---
1019    if let Some(id) = node.interaction_id {
1020        let rect = if node.pos.1 + node.size.1 > y_offset {
1021            Rect::new(
1022                node.pos.0,
1023                node.pos.1.saturating_sub(y_offset),
1024                node.size.0,
1025                node.size.1,
1026            )
1027        } else {
1028            Rect::new(0, 0, 0, 0)
1029        };
1030        if id >= data.hit_areas.len() {
1031            data.hit_areas.resize(id + 1, Rect::new(0, 0, 0, 0));
1032        }
1033        data.hit_areas[id] = rect;
1034    }
1035
1036    // --- group_rects ---
1037    if let Some(name) = &node.group_name {
1038        if node.pos.1 + node.size.1 > y_offset {
1039            data.group_rects.push((
1040                name.clone(),
1041                Rect::new(
1042                    node.pos.0,
1043                    node.pos.1.saturating_sub(y_offset),
1044                    node.size.0,
1045                    node.size.1,
1046                ),
1047            ));
1048        }
1049    }
1050
1051    // --- content_areas ---
1052    if matches!(node.kind, NodeKind::Container(_)) {
1053        let adj_y = node.pos.1.saturating_sub(y_offset);
1054        let full = Rect::new(node.pos.0, adj_y, node.size.0, node.size.1);
1055        let inset_x = node.padding.left + node.border_left_inset();
1056        let inset_y = node.padding.top + node.border_top_inset();
1057        let inner_w = node.size.0.saturating_sub(node.frame_horizontal());
1058        let inner_h = node.size.1.saturating_sub(node.frame_vertical());
1059        let content = Rect::new(node.pos.0 + inset_x, adj_y + inset_y, inner_w, inner_h);
1060        data.content_areas.push((full, content));
1061    }
1062
1063    // --- focus_rects ---
1064    if let Some(id) = node.focus_id {
1065        if node.pos.1 + node.size.1 > y_offset {
1066            data.focus_rects.push((
1067                id,
1068                Rect::new(
1069                    node.pos.0,
1070                    node.pos.1.saturating_sub(y_offset),
1071                    node.size.0,
1072                    node.size.1,
1073                ),
1074            ));
1075        }
1076    }
1077
1078    // --- focus_groups ---
1079    let current_group = node.group_name.as_deref().or(active_group);
1080    if let Some(id) = node.focus_id {
1081        if id >= data.focus_groups.len() {
1082            data.focus_groups.resize(id + 1, None);
1083        }
1084        data.focus_groups[id] = current_group.map(ToString::to_string);
1085    }
1086
1087    // --- Recurse into children ---
1088    let child_offset = if node.is_scrollable {
1089        y_offset.saturating_add(node.scroll_offset)
1090    } else {
1091        y_offset
1092    };
1093    for child in &node.children {
1094        collect_all_inner(child, data, child_offset, current_group);
1095    }
1096}
1097
1098pub(crate) fn collect_raw_draw_rects(node: &LayoutNode) -> Vec<(usize, Rect)> {
1099    let mut rects = Vec::new();
1100    collect_raw_draw_rects_inner(node, &mut rects, 0);
1101    for overlay in &node.overlays {
1102        collect_raw_draw_rects_inner(&overlay.node, &mut rects, 0);
1103    }
1104    rects
1105}
1106
1107fn collect_raw_draw_rects_inner(node: &LayoutNode, rects: &mut Vec<(usize, Rect)>, y_offset: u32) {
1108    if let NodeKind::RawDraw(draw_id) = node.kind {
1109        let adj_y = node.pos.1.saturating_sub(y_offset);
1110        rects.push((
1111            draw_id,
1112            Rect::new(node.pos.0, adj_y, node.size.0, node.size.1),
1113        ));
1114    }
1115    let child_offset = if node.is_scrollable {
1116        y_offset.saturating_add(node.scroll_offset)
1117    } else {
1118        y_offset
1119    };
1120    for child in &node.children {
1121        collect_raw_draw_rects_inner(child, rects, child_offset);
1122    }
1123}
1124
1125#[cfg(test)]
1126#[allow(clippy::print_stderr)]
1127mod tests {
1128    use super::*;
1129
1130    #[test]
1131    fn wrap_empty() {
1132        assert_eq!(wrap_lines("", 10), vec![""]);
1133    }
1134
1135    #[test]
1136    fn wrap_fits() {
1137        assert_eq!(wrap_lines("hello", 10), vec!["hello"]);
1138    }
1139
1140    #[test]
1141    fn wrap_word_boundary() {
1142        assert_eq!(wrap_lines("hello world", 7), vec!["hello", "world"]);
1143    }
1144
1145    #[test]
1146    fn wrap_multiple_words() {
1147        assert_eq!(
1148            wrap_lines("one two three four", 9),
1149            vec!["one two", "three", "four"]
1150        );
1151    }
1152
1153    #[test]
1154    fn wrap_long_word() {
1155        assert_eq!(wrap_lines("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
1156    }
1157
1158    #[test]
1159    fn wrap_zero_width() {
1160        assert_eq!(wrap_lines("hello", 0), vec!["hello"]);
1161    }
1162
1163    #[test]
1164    fn diagnostic_demo_layout() {
1165        use super::{compute, ContainerConfig, Direction, LayoutNode};
1166        use crate::rect::Rect;
1167        use crate::style::{Align, Border, Constraints, Justify, Margin, Padding, Style};
1168
1169        // Build the tree structure matching demo.rs:
1170        // Root (Column, grow:0)
1171        //   └─ Container (Column, grow:1, border:Rounded, padding:all(1))
1172        //        ├─ Text "header" (grow:0)
1173        //        ├─ Text "separator" (grow:0)
1174        //        ├─ Container (Column, grow:1)  ← simulates scrollable
1175        //        │    ├─ Text "content1" (grow:0)
1176        //        │    ├─ Text "content2" (grow:0)
1177        //        │    └─ Text "content3" (grow:0)
1178        //        ├─ Text "separator2" (grow:0)
1179        //        └─ Text "footer" (grow:0)
1180
1181        let mut root = LayoutNode::container(
1182            Direction::Column,
1183            ContainerConfig {
1184                gap: 0,
1185                align: Align::Start,
1186                align_self: None,
1187                justify: Justify::Start,
1188                border: None,
1189                border_sides: BorderSides::all(),
1190                border_style: Style::new(),
1191                bg_color: None,
1192                padding: Padding::default(),
1193                margin: Margin::default(),
1194                constraints: Constraints::default(),
1195                title: None,
1196                grow: 0,
1197            },
1198        );
1199
1200        // Outer bordered container with grow:1
1201        let mut outer_container = LayoutNode::container(
1202            Direction::Column,
1203            ContainerConfig {
1204                gap: 0,
1205                align: Align::Start,
1206                align_self: None,
1207                justify: Justify::Start,
1208                border: Some(Border::Rounded),
1209                border_sides: BorderSides::all(),
1210                border_style: Style::new(),
1211                bg_color: None,
1212                padding: Padding::all(1),
1213                margin: Margin::default(),
1214                constraints: Constraints::default(),
1215                title: None,
1216                grow: 1,
1217            },
1218        );
1219
1220        // Header text
1221        outer_container.children.push(LayoutNode::text(
1222            "header".to_string(),
1223            Style::new(),
1224            0,
1225            Align::Start,
1226            (false, false),
1227            Margin::default(),
1228            Constraints::default(),
1229        ));
1230
1231        // Separator 1
1232        outer_container.children.push(LayoutNode::text(
1233            "separator".to_string(),
1234            Style::new(),
1235            0,
1236            Align::Start,
1237            (false, false),
1238            Margin::default(),
1239            Constraints::default(),
1240        ));
1241
1242        // Inner scrollable-like container with grow:1
1243        let mut inner_container = LayoutNode::container(
1244            Direction::Column,
1245            ContainerConfig {
1246                gap: 0,
1247                align: Align::Start,
1248                align_self: None,
1249                justify: Justify::Start,
1250                border: None,
1251                border_sides: BorderSides::all(),
1252                border_style: Style::new(),
1253                bg_color: None,
1254                padding: Padding::default(),
1255                margin: Margin::default(),
1256                constraints: Constraints::default(),
1257                title: None,
1258                grow: 1,
1259            },
1260        );
1261
1262        // Content items
1263        inner_container.children.push(LayoutNode::text(
1264            "content1".to_string(),
1265            Style::new(),
1266            0,
1267            Align::Start,
1268            (false, false),
1269            Margin::default(),
1270            Constraints::default(),
1271        ));
1272        inner_container.children.push(LayoutNode::text(
1273            "content2".to_string(),
1274            Style::new(),
1275            0,
1276            Align::Start,
1277            (false, false),
1278            Margin::default(),
1279            Constraints::default(),
1280        ));
1281        inner_container.children.push(LayoutNode::text(
1282            "content3".to_string(),
1283            Style::new(),
1284            0,
1285            Align::Start,
1286            (false, false),
1287            Margin::default(),
1288            Constraints::default(),
1289        ));
1290
1291        outer_container.children.push(inner_container);
1292
1293        // Separator 2
1294        outer_container.children.push(LayoutNode::text(
1295            "separator2".to_string(),
1296            Style::new(),
1297            0,
1298            Align::Start,
1299            (false, false),
1300            Margin::default(),
1301            Constraints::default(),
1302        ));
1303
1304        // Footer
1305        outer_container.children.push(LayoutNode::text(
1306            "footer".to_string(),
1307            Style::new(),
1308            0,
1309            Align::Start,
1310            (false, false),
1311            Margin::default(),
1312            Constraints::default(),
1313        ));
1314
1315        root.children.push(outer_container);
1316
1317        // Compute layout with 80x50 terminal
1318        compute(&mut root, Rect::new(0, 0, 80, 50));
1319
1320        // Debug output
1321        eprintln!("\n=== DIAGNOSTIC LAYOUT TEST ===");
1322        eprintln!("Root node:");
1323        eprintln!("  pos: {:?}, size: {:?}", root.pos, root.size);
1324
1325        let outer = &root.children[0];
1326        eprintln!("\nOuter bordered container (grow:1):");
1327        eprintln!("  pos: {:?}, size: {:?}", outer.pos, outer.size);
1328
1329        let inner = &outer.children[2];
1330        eprintln!("\nInner container (grow:1, simulates scrollable):");
1331        eprintln!("  pos: {:?}, size: {:?}", inner.pos, inner.size);
1332
1333        eprintln!("\nAll children of outer container:");
1334        for (i, child) in outer.children.iter().enumerate() {
1335            eprintln!("  [{}] pos: {:?}, size: {:?}", i, child.pos, child.size);
1336        }
1337
1338        // Assertions
1339        // Root should fill the entire 80x50 area
1340        assert_eq!(
1341            root.size,
1342            (80, 50),
1343            "Root node should fill entire terminal (80x50)"
1344        );
1345
1346        // Outer container should also be 80x50 (full height due to grow:1)
1347        assert_eq!(
1348            outer.size,
1349            (80, 50),
1350            "Outer bordered container should fill entire terminal (80x50)"
1351        );
1352
1353        // Calculate expected inner container height:
1354        // Available height = 50 (total)
1355        // Border inset = 1 (top) + 1 (bottom) = 2
1356        // Padding = 1 (top) + 1 (bottom) = 2
1357        // Fixed children heights: header(1) + sep(1) + sep2(1) + footer(1) = 4
1358        // Expected inner height = 50 - 2 - 2 - 4 = 42
1359        let expected_inner_height = 50 - 2 - 2 - 4;
1360        assert_eq!(
1361            inner.size.1, expected_inner_height as u32,
1362            "Inner container height should be {} (50 - border(2) - padding(2) - fixed(4))",
1363            expected_inner_height
1364        );
1365
1366        // Inner container should start at y = border(1) + padding(1) + header(1) + sep(1) = 4
1367        let expected_inner_y = 1 + 1 + 1 + 1;
1368        assert_eq!(
1369            inner.pos.1, expected_inner_y as u32,
1370            "Inner container should start at y={} (border+padding+header+sep)",
1371            expected_inner_y
1372        );
1373
1374        eprintln!("\n✓ All assertions passed!");
1375        eprintln!("  Root size: {:?}", root.size);
1376        eprintln!("  Outer container size: {:?}", outer.size);
1377        eprintln!("  Inner container size: {:?}", inner.size);
1378        eprintln!("  Inner container pos: {:?}", inner.pos);
1379    }
1380
1381    #[test]
1382    fn collect_focus_rects_from_markers() {
1383        use super::*;
1384        use crate::style::Style;
1385
1386        let commands = vec![
1387            Command::FocusMarker(0),
1388            Command::Text {
1389                content: "input1".into(),
1390                style: Style::new(),
1391                grow: 0,
1392                align: Align::Start,
1393                wrap: false,
1394                truncate: false,
1395                margin: Default::default(),
1396                constraints: Default::default(),
1397            },
1398            Command::FocusMarker(1),
1399            Command::Text {
1400                content: "input2".into(),
1401                style: Style::new(),
1402                grow: 0,
1403                align: Align::Start,
1404                wrap: false,
1405                truncate: false,
1406                margin: Default::default(),
1407                constraints: Default::default(),
1408            },
1409        ];
1410
1411        let mut tree = build_tree(&commands);
1412        let area = crate::rect::Rect::new(0, 0, 40, 10);
1413        compute(&mut tree, area);
1414
1415        let fd = collect_all(&tree);
1416        assert_eq!(fd.focus_rects.len(), 2);
1417        assert_eq!(fd.focus_rects[0].0, 0);
1418        assert_eq!(fd.focus_rects[1].0, 1);
1419        assert!(fd.focus_rects[0].1.width > 0);
1420        assert!(fd.focus_rects[1].1.width > 0);
1421        assert_ne!(fd.focus_rects[0].1.y, fd.focus_rects[1].1.y);
1422    }
1423
1424    #[test]
1425    fn focus_marker_tags_container() {
1426        use super::*;
1427        use crate::style::{Border, Style};
1428
1429        let commands = vec![
1430            Command::FocusMarker(0),
1431            Command::BeginContainer {
1432                direction: Direction::Column,
1433                gap: 0,
1434                align: Align::Start,
1435                align_self: None,
1436                justify: Justify::Start,
1437                border: Some(Border::Single),
1438                border_sides: BorderSides::all(),
1439                border_style: Style::new(),
1440                bg_color: None,
1441                padding: Padding::default(),
1442                margin: Default::default(),
1443                constraints: Default::default(),
1444                title: None,
1445                grow: 0,
1446                group_name: None,
1447            },
1448            Command::Text {
1449                content: "inside".into(),
1450                style: Style::new(),
1451                grow: 0,
1452                align: Align::Start,
1453                wrap: false,
1454                truncate: false,
1455                margin: Default::default(),
1456                constraints: Default::default(),
1457            },
1458            Command::EndContainer,
1459        ];
1460
1461        let mut tree = build_tree(&commands);
1462        let area = crate::rect::Rect::new(0, 0, 40, 10);
1463        compute(&mut tree, area);
1464
1465        let fd = collect_all(&tree);
1466        assert_eq!(fd.focus_rects.len(), 1);
1467        assert_eq!(fd.focus_rects[0].0, 0);
1468        assert!(fd.focus_rects[0].1.width >= 8);
1469        assert!(fd.focus_rects[0].1.height >= 3);
1470    }
1471}