Skip to main content

thndrs_lib/cli/renderer/
adapter.rs

1//! iocraft adapter for bounded surface rendering.
2//!
3//! This module is the only place the direct TUI renderer calls iocraft. It
4//! renders declarative iocraft elements into an inspectable canvas, then
5//! converts the canvas back into the existing [`Row`] contract. It does not call
6//! iocraft render loops, fullscreen mode, stdout, or stderr.
7
8use iocraft::prelude::*;
9use thndrs_agent::ToolStatus;
10
11use crate::renderer::row::Row;
12use crate::renderer::style::{self, CellStyle, Color as RendererColor, Span};
13use crate::renderer::view::{
14    ColumnAlignment, ColumnWidthPolicy, DiffDetailView, FocusedSurfaceView, HelpView, PermissionView, PickerView,
15    SetupFormView, SurfaceRenderInput, SurfaceRenderer, SurfaceThemeView, TableCellView, TableView, ThemeRole,
16    ToolDetailView,
17};
18use crate::utils;
19
20const HELP_ROWS: &[(&str, &str)] = &[
21    ("── Navigation ──", ""),
22    ("Ctrl+O", "open output, diff, warning, or error detail"),
23    ("Enter", "accept highlighted item"),
24    ("Escape", "close help, files, or commands"),
25    ("Up/Down", "move cursor or recall history"),
26    ("Ctrl+P", "open workspace file picker"),
27    ("Ctrl+T", "transpose characters"),
28    ("── Editing ──", ""),
29    ("Shift+Enter", "insert newline"),
30    ("Ctrl+A/E", "move to start/end of line"),
31    ("Ctrl+B/F", "move cursor left/right"),
32    ("Ctrl+W", "delete previous word"),
33    ("Ctrl+K", "delete to end of line"),
34    ("Ctrl+U", "delete to start of line"),
35    ("Ctrl+Y", "yank (paste) last kill"),
36    ("Alt+B/F", "move word left/right"),
37    ("Alt+D", "delete next word"),
38    ("Alt+Bksp", "delete previous word"),
39    ("── Files ──", ""),
40    ("@path", "mention a file from fuzzy search"),
41    ("── App ──", ""),
42    ("Ctrl+C", "stop a running turn"),
43    ("Tab", "accept a command or file suggestion"),
44    ("Ctrl+D", "quit after double-press"),
45];
46
47/// iocraft-backed renderer for bounded focused surfaces.
48#[derive(Default)]
49pub struct IocraftSurfaceRenderer;
50
51impl SurfaceRenderer for IocraftSurfaceRenderer {
52    fn render_surface(&mut self, input: SurfaceRenderInput<'_>) -> Vec<Row> {
53        render_surface(&input)
54    }
55}
56
57struct ViewContent {
58    title: String,
59    status: String,
60    body: Vec<SurfaceLine>,
61    focus: Option<usize>,
62    hints: String,
63    border: ThemeRole,
64}
65
66#[derive(Clone)]
67struct SurfaceLine {
68    text: String,
69    role: ThemeRole,
70}
71
72impl SurfaceLine {
73    fn new(text: impl Into<String>, role: ThemeRole) -> Self {
74        Self { text: text.into(), role }
75    }
76
77    fn text(text: impl Into<String>) -> Self {
78        Self::new(text, ThemeRole::Text)
79    }
80
81    fn muted(text: impl Into<String>) -> Self {
82        Self::new(text, ThemeRole::Muted)
83    }
84
85    fn selected(text: impl Into<String>) -> Self {
86        Self::new(text, ThemeRole::Selected)
87    }
88
89    fn title(text: impl Into<String>) -> Self {
90        Self::new(text, ThemeRole::Selected)
91    }
92}
93
94/// Render a semantic focused surface through iocraft.
95pub fn render_surface(input: &SurfaceRenderInput<'_>) -> Vec<Row> {
96    if input.width == 0 || input.height == 0 {
97        return Vec::new();
98    }
99
100    match input.surface {
101        FocusedSurfaceView::None => Vec::new(),
102        FocusedSurfaceView::Permission(permission) => {
103            permission_rows(permission, input.width, input.height, input.theme)
104        }
105        FocusedSurfaceView::CommandPicker(picker) | FocusedSurfaceView::FilePicker(picker) => {
106            quiet_picker_rows(picker, input.width, input.height)
107        }
108        FocusedSurfaceView::Help(help) => help_rows(help, input.width, input.height, input.theme),
109        FocusedSurfaceView::ToolDetail(detail) => tool_detail_rows(detail, input.width, input.height, input.theme),
110        FocusedSurfaceView::DiffDetail(detail) => diff_detail_rows(detail, input.width, input.height, input.theme),
111        FocusedSurfaceView::TranscriptLens { selected_entry, scroll } => {
112            transcript_lens_surface_rows(selected_entry, *scroll, input.width, input.height, input.theme)
113        }
114        FocusedSurfaceView::SetupForm(form) => setup_form_rows(form, input.width, input.height, input.theme),
115        FocusedSurfaceView::StructuredTable(table) => table_rows(table, input.width, input.height, input.theme),
116    }
117}
118
119/// Render a bounded transcript/detail lens with iocraft.
120pub fn transcript_lens_rows(title: &str, body: &[String], width: usize, height: usize) -> Vec<Row> {
121    if width == 0 || height == 0 {
122        return Vec::new();
123    }
124
125    let content = body.join("\n");
126    let view_width = width.min(u32::MAX as usize) as u32;
127    let view_height = height.min(u32::MAX as usize) as u32;
128    let body_height = height.saturating_sub(1).min(u32::MAX as usize) as u32;
129    let mut element = element! {
130        View(
131            flex_direction: FlexDirection::Column,
132            width: view_width,
133            height: view_height,
134        ) {
135            Text(content: title.to_string(), weight: Weight::Bold)
136            View(
137                height: body_height,
138            ) {
139                ScrollView {
140                    Text(content: content)
141                }
142            }
143        }
144    };
145
146    let canvas = element.render(Some(width));
147    canvas_to_rows(&canvas, width, CellStyle::default())
148}
149
150fn picker_content(picker: &PickerView, width: usize) -> ViewContent {
151    let mut body = vec![SurfaceLine::muted(format!("filter: {}", query_label(&picker.query)))];
152    let focus = if picker.items.is_empty() { None } else { Some(1 + picker.selected) };
153    if picker.items.is_empty() {
154        body.push(SurfaceLine::muted("no matches"));
155    } else {
156        body.extend(picker.items.iter().enumerate().map(|(index, item)| {
157            let selected = index == picker.selected;
158            let marker = if selected { "❯" } else { " " };
159            let detail = if item.detail.is_empty() {
160                String::new()
161            } else {
162                format!(
163                    "  {}",
164                    utils::truncate_ellipsis(&item.detail, width.saturating_sub(28).min(32))
165                )
166            };
167            let label = utils::truncate_ellipsis_start(
168                &item.label,
169                width.saturating_sub(4 + utils::text_width(&detail)).max(1),
170            );
171            let text = format!("{marker} {label}{detail}");
172            if selected { SurfaceLine::selected(text) } else { SurfaceLine::text(text) }
173        }));
174    }
175    ViewContent {
176        title: picker.title.clone(),
177        status: format!(
178            "focus: option {}/{}",
179            picker.selected.saturating_add(1),
180            picker.items.len().max(1)
181        ),
182        body,
183        focus,
184        hints: "Enter select · Esc close".to_string(),
185        border: ThemeRole::Selected,
186    }
187}
188
189fn quiet_picker_rows(picker: &PickerView, width: usize, height: usize) -> Vec<Row> {
190    if width == 0 || height == 0 {
191        return Vec::new();
192    }
193    let p = style::palette();
194    let content = picker_content(picker, width);
195    let rail_style = CellStyle::new().fg(p.overlay0);
196    let mut header_spans = vec![
197        Span::styled("│  ", rail_style),
198        Span::styled(content.title.to_uppercase(), CellStyle::new().fg(p.yellow).bold()),
199    ];
200    let used = super::layout::spans_width(&header_spans);
201    let status = content.status.strip_prefix("focus: ").unwrap_or(&content.status);
202    let status_width = utils::text_width(status);
203    let body_width = super::layout::content_width(width);
204    if used + status_width + 2 <= body_width {
205        header_spans.push(Span::plain(" ".repeat(body_width - used - status_width)));
206        header_spans.push(Span::styled(status, CellStyle::new().fg(p.overlay1)));
207    }
208
209    let mut rows = vec![Row::padded(header_spans, width, CellStyle::new())];
210    let body = layout_surface_body(&content, height.saturating_sub(1));
211    for line in body {
212        let text_style = match line.role {
213            ThemeRole::Selected => CellStyle::new().fg(p.text).bold(),
214            ThemeRole::Muted => CellStyle::new().fg(p.overlay0),
215            role => theme_role_style(role),
216        };
217        rows.push(Row::padded(
218            vec![Span::styled("│  ", rail_style), Span::styled(line.text, text_style)],
219            width,
220            CellStyle::new(),
221        ));
222    }
223    rows.truncate(height);
224    rows
225}
226
227fn help_rows(help: &HelpView, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
228    let body = HELP_ROWS
229        .iter()
230        .map(|(key, description)| {
231            if description.is_empty() {
232                SurfaceLine::new((*key).to_string(), ThemeRole::Selected)
233            } else {
234                let description =
235                    if *key == "Ctrl+T" && help.queue_target_toggle { "toggle queue target" } else { description };
236                SurfaceLine::text(format!("{key:<16}{description}"))
237            }
238        })
239        .collect();
240    render_bounded_view(
241        &ViewContent {
242            title: "help".to_string(),
243            status: "focus: keyboard".to_string(),
244            body,
245            focus: Some(0),
246            hints: "Esc close · keys remain active".to_string(),
247            border: ThemeRole::Selected,
248        },
249        width,
250        height,
251        theme,
252    )
253}
254
255fn permission_rows(permission: &PermissionView, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
256    let mut body = vec![SurfaceLine::new(format!("scope: {}", permission.scope), theme.warning)];
257    let focus = if permission.options.is_empty() { None } else { Some(1 + permission.selected) };
258    body.extend(permission.options.iter().enumerate().map(|(index, option)| {
259        let selected = index == permission.selected;
260        let marker = if selected { "❯" } else { " " };
261        let text = format!("{marker} {}  [{}]", option.label, option.kind);
262        if selected { SurfaceLine::selected(text) } else { SurfaceLine::text(text) }
263    }));
264    render_bounded_view(
265        &ViewContent {
266            title: format!("permission · {}", permission.title),
267            status: "approval required · focused".to_string(),
268            body,
269            focus,
270            hints: "Enter choose · Esc cancel".to_string(),
271            border: ThemeRole::Warning,
272        },
273        width,
274        height,
275        theme,
276    )
277}
278
279fn tool_detail_rows(detail: &ToolDetailView, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
280    let output_rows = detail
281        .output
282        .iter()
283        .flat_map(|line| super::layout::wrap_text(line, width.saturating_sub(2).max(1)))
284        .collect::<Vec<_>>();
285    let mut body = if output_rows.is_empty() {
286        vec![SurfaceLine::muted("no output")]
287    } else {
288        output_rows
289            .into_iter()
290            .skip(detail.scroll)
291            .map(SurfaceLine::text)
292            .collect()
293    };
294    body.insert(
295        0,
296        SurfaceLine::muted(format!("scroll: {} · entry: {}", detail.scroll, detail.entry_index)),
297    );
298    if detail.scroll > 0 {
299        body.insert(1, SurfaceLine::muted(format!("… {} rows above", detail.scroll)));
300    }
301    let border = match detail.status {
302        ToolStatus::Failed => ThemeRole::Error,
303        ToolStatus::Cancelled => ThemeRole::Warning,
304        _ => ThemeRole::Selected,
305    };
306    render_bounded_view(
307        &ViewContent {
308            title: detail.title.clone(),
309            status: format!("{} · focus: output", detail.status.label()),
310            body,
311            focus: Some(1),
312            hints: "↑/↓ scroll · Esc close".to_string(),
313            border,
314        },
315        width,
316        height,
317        theme,
318    )
319}
320
321fn diff_detail_rows(detail: &DiffDetailView, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
322    let body = detail
323        .lines
324        .iter()
325        .map(|line| {
326            if line.starts_with('+') && !line.starts_with("+++") {
327                SurfaceLine::new(line.clone(), theme.diff_added)
328            } else if line.starts_with('-') && !line.starts_with("---") {
329                SurfaceLine::new(line.clone(), theme.diff_removed)
330            } else {
331                SurfaceLine::text(line.clone())
332            }
333        })
334        .collect();
335    render_bounded_view(
336        &ViewContent {
337            title: "diff".to_string(),
338            status: format!(
339                "+{} -{} · focus: output · {}",
340                detail.summary.added,
341                detail.summary.removed,
342                if detail.summary.files.is_empty() {
343                    "working tree".to_string()
344                } else {
345                    detail.summary.files.join(", ")
346                }
347            ),
348            body,
349            focus: None,
350            hints: "↑/↓ scroll · Esc close".to_string(),
351            border: ThemeRole::Selected,
352        },
353        width,
354        height,
355        theme,
356    )
357}
358
359fn setup_form_rows(form: &SetupFormView, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
360    let mut body = form
361        .validation_errors
362        .iter()
363        .map(|error| SurfaceLine::new(format!("! {error}"), theme.error))
364        .collect::<Vec<_>>();
365    body.extend(form.details.iter().cloned().map(SurfaceLine::muted));
366
367    let field_focus = if form.actions.is_empty() { Some(form.focus_index) } else { None };
368    body.extend(form.fields.iter().enumerate().map(|(index, field)| {
369        let focused = index == form.focus_index || field.focused;
370        let marker = if focused { "❯" } else { " " };
371        let value = if field.secret && !field.value.is_empty() { "[hidden]".to_string() } else { field.value.clone() };
372        let multiline = if field.multiline { " multiline" } else { "" };
373        let error = field
374            .error
375            .as_ref()
376            .map_or(String::new(), |error| format!("  ! {error}"));
377        let text = format!("{marker} {}: {value}{multiline}{error}", field.label);
378        if focused { SurfaceLine::selected(text) } else { SurfaceLine::text(text) }
379    }));
380    let action_offset = body.len();
381    body.extend(form.actions.iter().enumerate().map(|(index, action)| {
382        let selected = index == form.selected;
383        let marker = if selected { "❯" } else { " " };
384        let text = format!("{marker} {}", action.label);
385        if selected { SurfaceLine::selected(text) } else { SurfaceLine::text(text) }
386    }));
387    render_bounded_view(
388        &ViewContent {
389            title: form.title.clone(),
390            status: format!(
391                "{} · focus: {}",
392                form.status,
393                if form.actions.is_empty() { "input" } else { "choice" }
394            ),
395            body,
396            focus: form
397                .actions
398                .is_empty()
399                .then_some(field_focus.unwrap_or_default())
400                .or_else(|| (!form.actions.is_empty()).then_some(action_offset + form.selected)),
401            hints: format!("{} · {} · Esc cancel", form.submit_label, form.cancel_label),
402            border: ThemeRole::Selected,
403        },
404        width,
405        height,
406        theme,
407    )
408}
409
410fn table_rows(table: &TableView, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
411    let body_width = width.saturating_sub(2);
412    if width < 24 && !table.narrow_fallback.is_empty() {
413        return render_bounded_view(
414            &ViewContent {
415                title: "context".to_string(),
416                status: "focus: inspect".to_string(),
417                body: table.narrow_fallback.iter().cloned().map(SurfaceLine::text).collect(),
418                focus: None,
419                hints: "Esc close".to_string(),
420                border: ThemeRole::Selected,
421            },
422            width,
423            height,
424            theme,
425        );
426    }
427
428    let widths = table_column_widths(table, body_width);
429    let mut body = vec![SurfaceLine::title(table_line(&table.header, &widths))];
430    body.push(SurfaceLine::muted(
431        widths
432            .iter()
433            .map(|width| "─".repeat(*width))
434            .collect::<Vec<_>>()
435            .join(" "),
436    ));
437    body.extend(table.rows.iter().enumerate().map(|(index, row)| {
438        let selected = table.selected_row == Some(index);
439        let marker = if selected { "❯" } else { " " };
440        let text = format!("{marker} {}", table_line(row, &widths));
441        if selected { SurfaceLine::selected(text) } else { SurfaceLine::text(text) }
442    }));
443    let status = table.selected_row.map_or_else(
444        || "focus: inspect".to_string(),
445        |row| format!("focus: row {}/{}", row + 1, table.rows.len()),
446    );
447    render_bounded_view(
448        &ViewContent {
449            title: "context".to_string(),
450            status,
451            body,
452            focus: table.selected_row.map(|row| row + 2),
453            hints: "↑/↓ inspect · Esc close".to_string(),
454            border: ThemeRole::Selected,
455        },
456        width,
457        height,
458        theme,
459    )
460}
461
462fn transcript_lens_surface_rows(
463    selected_entry: &Option<usize>, scroll: usize, width: usize, height: usize, theme: &SurfaceThemeView,
464) -> Vec<Row> {
465    render_bounded_view(
466        &ViewContent {
467            title: "transcript".to_string(),
468            status: "focus: history".to_string(),
469            body: vec![
470                SurfaceLine::text(format!(
471                    "entry: {}",
472                    selected_entry.map_or_else(|| "latest".to_string(), |entry| entry.to_string())
473                )),
474                SurfaceLine::text(format!("scroll: {scroll}")),
475            ],
476            focus: None,
477            hints: "↑/↓ scroll · Esc close".to_string(),
478            border: ThemeRole::Selected,
479        },
480        width,
481        height,
482        theme,
483    )
484}
485
486fn render_bounded_view(content: &ViewContent, width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
487    if width == 0 || height == 0 {
488        return Vec::new();
489    }
490
491    let palette = style::palette();
492    let background = palette.surface0;
493    let header = format!("{} · {}", content.title, content.status);
494    let framed = width >= 8 && height >= 3;
495    if framed {
496        let inner_width = width.saturating_sub(2);
497        let max_inner_height = height.saturating_sub(2);
498        let body_rows = layout_surface_body(content, max_inner_height);
499        let inner_height = body_rows.len().max(1).min(max_inner_height.max(1));
500        let inner_rows = render_lines(&body_rows, inner_width, inner_height, theme);
501        let border_style = theme_role_style(content.border).bg(background);
502        let mut rows = Vec::with_capacity(inner_height + 2);
503        rows.push(frame_edge(width, &header, true, border_style, background));
504        rows.extend(
505            inner_rows
506                .into_iter()
507                .map(|row| framed_content_row(row, width, border_style, background)),
508        );
509        rows.push(frame_edge(width, "", false, border_style, background));
510        rows
511    } else {
512        let max_body_height = height.saturating_sub(1);
513        let body_rows = layout_surface_body(content, max_body_height);
514        let mut lines = Vec::with_capacity(body_rows.len() + 1);
515        lines.push(SurfaceLine::new(header, content.border));
516        lines.extend(body_rows);
517        render_lines(&lines, width, height, theme)
518    }
519}
520
521fn layout_surface_body(content: &ViewContent, max_lines: usize) -> Vec<SurfaceLine> {
522    if max_lines == 0 {
523        return Vec::new();
524    }
525
526    let hint = (!content.hints.is_empty()).then(|| SurfaceLine::muted(content.hints.clone()));
527    let hint_lines = usize::from(hint.is_some());
528    let body_budget = max_lines.saturating_sub(hint_lines);
529    if content.body.len() <= body_budget {
530        let mut rows = content.body.clone();
531        if let Some(hint) = hint {
532            rows.push(hint);
533        }
534        return rows;
535    }
536
537    let marker_budget = body_budget.saturating_sub(1);
538    if marker_budget == 0 {
539        if content.body.len() == 1 {
540            return content.body.clone();
541        }
542        let (mut visible, above, below) = clip_surface_body(&content.body, content.focus, 1);
543        if let Some(line) = visible.first_mut() {
544            let hidden = match (above, below) {
545                (0, below) => format!("… {below} below · "),
546                (above, 0) => format!("… {above} above · "),
547                (above, below) => format!("… {above} above · {below} below · "),
548            };
549            line.text = format!("{hidden}{}", line.text);
550        } else {
551            visible.push(SurfaceLine::muted("… content clipped"));
552        }
553        return visible;
554    }
555    let (visible, above, below) = clip_surface_body(&content.body, content.focus, marker_budget);
556    let hidden = match (above, below) {
557        (0, below) => format!("… {below} rows below"),
558        (above, 0) => format!("… {above} rows above"),
559        (above, below) => format!("… {above} rows above · {below} below"),
560    };
561    let mut rows = visible;
562    if body_budget > 0 {
563        rows.push(SurfaceLine::muted(hidden));
564    }
565    if let Some(hint) = hint {
566        if rows.len() >= max_lines {
567            rows.pop();
568        }
569        rows.push(hint);
570    }
571    rows.truncate(max_lines);
572    rows
573}
574
575fn clip_surface_body(lines: &[SurfaceLine], focus: Option<usize>, budget: usize) -> (Vec<SurfaceLine>, usize, usize) {
576    if budget == 0 || lines.is_empty() {
577        return (Vec::new(), lines.len(), 0);
578    }
579    if lines.len() <= budget {
580        return (lines.to_vec(), 0, 0);
581    }
582
583    let focus = focus
584        .unwrap_or_else(|| lines.len().saturating_sub(1))
585        .min(lines.len() - 1);
586    let start = focus
587        .saturating_add(1)
588        .saturating_sub(budget)
589        .min(lines.len().saturating_sub(budget));
590    let end = start + budget;
591    (lines[start..end].to_vec(), start, lines.len().saturating_sub(end))
592}
593
594fn frame_edge(width: usize, label: &str, top: bool, style: CellStyle, background: RendererColor) -> Row {
595    let available = width.saturating_sub(2);
596    let label = if top {
597        format!(" {} ", utils::truncate_ellipsis(label, available.saturating_sub(2)))
598    } else {
599        String::new()
600    };
601    let label_width = utils::text_width(&label).min(available);
602    let edge = "─".repeat(available.saturating_sub(label_width));
603    let text = if top { format!("╭{label}{edge}╮") } else { format!("╰{}╯", "─".repeat(available)) };
604    Row::padded(vec![Span::styled(text, style)], width, CellStyle::new().bg(background))
605}
606
607fn framed_content_row(row: Row, width: usize, border_style: CellStyle, background: RendererColor) -> Row {
608    let mut spans = Vec::with_capacity(row.spans.len() + 2);
609    spans.push(Span::styled("│", border_style));
610    spans.extend(row.spans);
611    spans.push(Span::styled("│", border_style));
612    Row::padded(spans, width, CellStyle::new().bg(background))
613}
614
615fn render_lines(lines: &[SurfaceLine], width: usize, height: usize, theme: &SurfaceThemeView) -> Vec<Row> {
616    if width == 0 || height == 0 {
617        return Vec::new();
618    }
619
620    let p = style::palette();
621    let bg = p.surface0;
622    let view_width = width.min(u32::MAX as usize) as u32;
623    let view_height = height.min(u32::MAX as usize) as u32;
624    let content = lines
625        .iter()
626        .map(|line| utils::truncate_ellipsis(&line.text, width))
627        .collect::<Vec<_>>()
628        .join("\n");
629    let mut element = element! {
630        View(
631            background_color: Some(bg),
632            flex_direction: FlexDirection::Column,
633            width: view_width,
634            height: view_height,
635        ) {
636            Text(color: Some(p.text), content: content, wrap: TextWrap::NoWrap)
637        }
638    };
639    let canvas = element.render(Some(width));
640    let pad_style = CellStyle::default().bg(bg);
641    let mut rows = canvas_to_rows(&canvas, width, pad_style);
642    rows.truncate(height.min(lines.len()));
643    while rows.len() < height.min(lines.len()) {
644        rows.push(Row::blank(width, pad_style));
645    }
646    for (row, line) in rows.iter_mut().zip(lines.iter()) {
647        restyle_row(row, line.role, theme);
648    }
649    rows
650}
651
652fn restyle_row(row: &mut Row, role: ThemeRole, theme: &SurfaceThemeView) {
653    let p = style::palette();
654    let bg = if role == theme.selected { p.surface1 } else { p.surface0 };
655    let style = theme_role_style(role).bg(bg);
656    let text = {
657        let mut out = String::new();
658        for span in &row.spans {
659            out.push_str(&span.text);
660        }
661        out
662    };
663    *row = Row::padded(vec![Span::styled(text, style)], row.width, CellStyle::default().bg(bg));
664}
665
666fn canvas_to_rows(canvas: &Canvas, width: usize, pad_style: CellStyle) -> Vec<Row> {
667    (0..canvas.height())
668        .map(|y| {
669            let mut spans = Vec::new();
670            let mut current_text = String::new();
671            let mut current_style: Option<CellStyle> = None;
672            let mut skip_cells = 0usize;
673            for x in 0..canvas.width().min(width) {
674                if skip_cells > 0 {
675                    skip_cells -= 1;
676                    continue;
677                }
678                let cell = canvas.cell(x, y);
679                let style = cell.map_or(pad_style, cell_style);
680                let text = cell.and_then(CanvasCell::text).unwrap_or(" ");
681                skip_cells = utils::text_width(text).saturating_sub(1);
682                if current_style == Some(style) {
683                    current_text.push_str(text);
684                } else {
685                    if let Some(style) = current_style.take() {
686                        spans.push(Span::styled(std::mem::take(&mut current_text), style));
687                    }
688                    current_style = Some(style);
689                    current_text.push_str(text);
690                }
691            }
692            if let Some(style) = current_style {
693                spans.push(Span::styled(current_text, style));
694            }
695            Row::padded(spans, width, pad_style)
696        })
697        .collect()
698}
699
700fn cell_style(cell: &CanvasCell) -> CellStyle {
701    let mut style = CellStyle::new()
702        .fg(cell
703            .text_style()
704            .and_then(|style| style.color)
705            .unwrap_or(RendererColor::Reset))
706        .bg(cell.background_color.unwrap_or(RendererColor::Reset));
707    if let Some(text_style) = cell.text_style() {
708        style.bold = text_style.weight == Weight::Bold;
709        style.dim = text_style.weight == Weight::Light;
710        style.italic = text_style.italic;
711        style.underlined = text_style.underline;
712        if text_style.invert {
713            std::mem::swap(&mut style.fg, &mut style.bg);
714        }
715    }
716    style
717}
718
719fn table_column_widths(table: &TableView, width: usize) -> Vec<usize> {
720    let columns = table
721        .header
722        .len()
723        .max(table.rows.iter().map(Vec::len).max().unwrap_or(0));
724    if columns == 0 {
725        return Vec::new();
726    }
727    let separators = columns.saturating_sub(1);
728    let available = width.saturating_sub(2 + separators).max(columns);
729    let mut widths = vec![1; columns];
730    let mut flexible = Vec::new();
731    let mut used = 0usize;
732
733    for (index, column_width) in widths.iter_mut().enumerate().take(columns) {
734        let policy = table
735            .header
736            .get(index)
737            .map(|cell| cell.width)
738            .unwrap_or(ColumnWidthPolicy::Flexible);
739        *column_width = match policy {
740            ColumnWidthPolicy::Fixed(width) => width.max(1),
741            ColumnWidthPolicy::Percent(percent) => (available * percent as usize / 100).max(1),
742            ColumnWidthPolicy::Flexible => {
743                flexible.push(index);
744                1
745            }
746        };
747        used += *column_width;
748    }
749
750    if !flexible.is_empty() && used < available {
751        let extra = (available - used) / flexible.len();
752        for index in flexible {
753            widths[index] += extra;
754        }
755    }
756    widths
757}
758
759fn table_line(cells: &[TableCellView], widths: &[usize]) -> String {
760    cells
761        .iter()
762        .enumerate()
763        .map(|(index, cell)| align_cell(&cell.text, widths.get(index).copied().unwrap_or(1), cell.alignment))
764        .collect::<Vec<_>>()
765        .join(" ")
766}
767
768fn align_cell(text: &str, width: usize, alignment: ColumnAlignment) -> String {
769    let text = utils::truncate_ellipsis(text, width);
770    let text_width = utils::text_width(&text);
771    if text_width >= width {
772        return text;
773    }
774    let pad = width - text_width;
775    match alignment {
776        ColumnAlignment::Left => format!("{text}{}", " ".repeat(pad)),
777        ColumnAlignment::Right => format!("{}{text}", " ".repeat(pad)),
778        ColumnAlignment::Center => {
779            let left = pad / 2;
780            format!("{}{}{}", " ".repeat(left), text, " ".repeat(pad - left))
781        }
782    }
783}
784
785fn query_label(query: &str) -> String {
786    if query.is_empty() { "type to filter".to_string() } else { query.to_string() }
787}
788
789fn theme_role_style(role: ThemeRole) -> CellStyle {
790    let p = style::palette();
791    match role {
792        ThemeRole::Text => CellStyle::new().fg(p.text),
793        ThemeRole::Muted => CellStyle::new().fg(p.overlay0),
794        ThemeRole::Selected => CellStyle::new().fg(p.text).bold(),
795        ThemeRole::Warning => CellStyle::new().fg(p.peach),
796        ThemeRole::Error => CellStyle::new().fg(p.red),
797        ThemeRole::DiffAdded => CellStyle::new().fg(p.green),
798        ThemeRole::DiffRemoved => CellStyle::new().fg(p.red),
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use crate::renderer::row::Frame;
806    use crate::renderer::view::{
807        DiffSummaryView, PickerItemView, SetupFieldView, SurfaceThemeView, TableCellView, ThemeRole,
808    };
809
810    #[test]
811    fn transcript_lens_uses_iocraft_canvas_without_render_loop() {
812        let rows = transcript_lens_rows(
813            "details",
814            &["one".to_string(), "two".to_string(), "three".to_string()],
815            24,
816            3,
817        );
818        let text = rows.iter().map(Row::text).collect::<Vec<_>>().join("\n");
819
820        assert!(
821            text.contains("details"),
822            "title should render through iocraft canvas:\n{text}"
823        );
824        assert!(
825            text.contains("one"),
826            "body should render through iocraft canvas:\n{text}"
827        );
828        assert!(
829            rows.iter().all(|row| row.width == 24),
830            "converted rows should preserve the existing row contract"
831        );
832    }
833
834    #[test]
835    fn focused_surface_renderer_renders_picker_rows() {
836        let surface = FocusedSurfaceView::CommandPicker(PickerView {
837            title: "commands".to_string(),
838            query: "he".to_string(),
839            selected: 1,
840            items: vec![
841                PickerItemView { label: "help".to_string(), detail: "show help".to_string() },
842                PickerItemView { label: "health".to_string(), detail: "run doctor".to_string() },
843            ],
844        });
845        let mut renderer = IocraftSurfaceRenderer;
846        let rows = renderer.render_surface(SurfaceRenderInput {
847            surface: &surface,
848            theme: &test_theme(),
849            width: 32,
850            height: 4,
851        });
852        let text = rows.iter().map(Row::text).collect::<Vec<_>>().join("\n");
853
854        assert!(text.contains("COMMANDS"));
855        assert!(text.contains("❯ health"));
856        assert!(
857            !text.contains('╭'),
858            "command picker should use a quiet rail instead of a box"
859        );
860        assert!(
861            rows.iter()
862                .all(|row| row.spans.iter().all(|span| span.style.bg == RendererColor::Reset))
863        );
864        assert!(rows.iter().all(|row| row.width == 32));
865    }
866
867    #[test]
868    fn canvas_conversion_preserves_iocraft_text_style() {
869        let mut element = element! {
870            View(width: 16, height: 1) {
871                Text(
872                    color: Color::Red,
873                    content: "warn",
874                    weight: Weight::Bold,
875                    decoration: TextDecoration::Underline,
876                    italic: true,
877                )
878            }
879        };
880        let rows = canvas_to_rows(&element.render(Some(16)), 16, CellStyle::default());
881        let style = rows[0]
882            .spans
883            .iter()
884            .find(|span| span.text.contains("warn"))
885            .expect("styled text span")
886            .style;
887
888        assert_eq!(style.fg, RendererColor::Red);
889        assert!(style.bold);
890        assert!(style.underlined);
891        assert!(style.italic);
892    }
893
894    #[test]
895    fn table_surface_uses_width_policy_and_narrow_fallback() {
896        let surface = FocusedSurfaceView::StructuredTable(TableView {
897            header: vec![
898                TableCellView {
899                    text: "name".to_string(),
900                    alignment: ColumnAlignment::Left,
901                    width: ColumnWidthPolicy::Flexible,
902                },
903                TableCellView {
904                    text: "n".to_string(),
905                    alignment: ColumnAlignment::Right,
906                    width: ColumnWidthPolicy::Fixed(3),
907                },
908            ],
909            rows: vec![vec![
910                TableCellView {
911                    text: "compile".to_string(),
912                    alignment: ColumnAlignment::Left,
913                    width: ColumnWidthPolicy::Flexible,
914                },
915                TableCellView {
916                    text: "42".to_string(),
917                    alignment: ColumnAlignment::Right,
918                    width: ColumnWidthPolicy::Fixed(3),
919                },
920            ]],
921            selected_row: Some(0),
922            narrow_fallback: vec!["compile 42".to_string()],
923        });
924        let rows =
925            render_surface(&SurfaceRenderInput { surface: &surface, theme: &test_theme(), width: 16, height: 3 });
926        let text = rows.iter().map(Row::text).collect::<Vec<_>>().join("\n");
927
928        assert!(text.contains("compile 42"));
929    }
930
931    #[test]
932    fn file_picker_uses_quiet_rail_and_preserves_content_rows() {
933        let surface = FocusedSurfaceView::FilePicker(PickerView {
934            title: "files".to_string(),
935            query: "missing".to_string(),
936            selected: 0,
937            items: Vec::new(),
938        });
939
940        let rows =
941            render_surface(&SurfaceRenderInput { surface: &surface, theme: &test_theme(), width: 40, height: 8 });
942
943        assert_eq!(rows.len(), 4);
944        assert!(rows[0].text().contains("FILES"));
945        assert!(!rows.iter().any(|row| row.text().contains('╭')));
946        assert!(
947            rows.iter()
948                .all(|row| row.spans.iter().all(|span| span.style.bg == RendererColor::Reset))
949        );
950        assert!(rows.iter().any(|row| row.text().contains("no matches")));
951    }
952
953    #[test]
954    fn snapshot_iocraft_focused_surfaces() {
955        let cases = vec![
956            (
957                "command picker",
958                FocusedSurfaceView::CommandPicker(PickerView {
959                    title: "commands".to_string(),
960                    query: "c".to_string(),
961                    selected: 0,
962                    items: vec![
963                        PickerItemView { label: "clear".to_string(), detail: "clear transcript".to_string() },
964                        PickerItemView { label: "config show".to_string(), detail: "redacted config".to_string() },
965                    ],
966                }),
967            ),
968            (
969                "file picker",
970                FocusedSurfaceView::FilePicker(PickerView {
971                    title: "files".to_string(),
972                    query: "src".to_string(),
973                    selected: 1,
974                    items: vec![
975                        PickerItemView { label: "src/main.rs".to_string(), detail: "binary".to_string() },
976                        PickerItemView {
977                            label: "src/cli/renderer/adapter.rs".to_string(),
978                            detail: "focused UI".to_string(),
979                        },
980                    ],
981                }),
982            ),
983            (
984                "help",
985                FocusedSurfaceView::Help(HelpView { queue_target_toggle: false }),
986            ),
987            (
988                "tool detail",
989                FocusedSurfaceView::ToolDetail(ToolDetailView {
990                    entry_index: 2,
991                    title: "run_shell".to_string(),
992                    status: ToolStatus::Failed,
993                    scroll: 1,
994                    output: vec![
995                        "line 0".to_string(),
996                        "error: missing semicolon".to_string(),
997                        "line 2".to_string(),
998                    ],
999                }),
1000            ),
1001            (
1002                "diff detail",
1003                FocusedSurfaceView::DiffDetail(DiffDetailView {
1004                    summary: DiffSummaryView { files: vec!["src/lib.rs".to_string()], added: 1, removed: 1 },
1005                    lines: vec![
1006                        "--- a/src/lib.rs".to_string(),
1007                        "+++ b/src/lib.rs".to_string(),
1008                        "-old".to_string(),
1009                        "+new".to_string(),
1010                    ],
1011                }),
1012            ),
1013            (
1014                "setup form",
1015                FocusedSurfaceView::SetupForm(SetupFormView {
1016                    title: "setup".to_string(),
1017                    stage: "credential entry".to_string(),
1018                    status: "Umans · credential entry".to_string(),
1019                    details: vec!["Input is hidden.".to_string()],
1020                    fields: vec![SetupFieldView {
1021                        label: "credential".to_string(),
1022                        value: "sk-hidden".to_string(),
1023                        focused: true,
1024                        secret: true,
1025                        multiline: false,
1026                        error: None,
1027                    }],
1028                    focus_index: 0,
1029                    actions: Vec::new(),
1030                    selected: 0,
1031                    validation_errors: vec!["credential is required".to_string()],
1032                    submit_label: "submit".to_string(),
1033                    cancel_label: "cancel".to_string(),
1034                    complete: false,
1035                }),
1036            ),
1037            (
1038                "table",
1039                FocusedSurfaceView::StructuredTable(TableView {
1040                    header: vec![
1041                        TableCellView {
1042                            text: "command".to_string(),
1043                            alignment: ColumnAlignment::Left,
1044                            width: ColumnWidthPolicy::Percent(55),
1045                        },
1046                        TableCellView {
1047                            text: "status".to_string(),
1048                            alignment: ColumnAlignment::Center,
1049                            width: ColumnWidthPolicy::Flexible,
1050                        },
1051                    ],
1052                    rows: vec![
1053                        vec![
1054                            TableCellView {
1055                                text: "cargo test".to_string(),
1056                                alignment: ColumnAlignment::Left,
1057                                width: ColumnWidthPolicy::Percent(55),
1058                            },
1059                            TableCellView {
1060                                text: "ok".to_string(),
1061                                alignment: ColumnAlignment::Center,
1062                                width: ColumnWidthPolicy::Flexible,
1063                            },
1064                        ],
1065                        vec![
1066                            TableCellView {
1067                                text: "cargo clippy".to_string(),
1068                                alignment: ColumnAlignment::Left,
1069                                width: ColumnWidthPolicy::Percent(55),
1070                            },
1071                            TableCellView {
1072                                text: "fix".to_string(),
1073                                alignment: ColumnAlignment::Center,
1074                                width: ColumnWidthPolicy::Flexible,
1075                            },
1076                        ],
1077                    ],
1078                    selected_row: Some(1),
1079                    narrow_fallback: vec!["cargo test ok".to_string(), "cargo clippy fix".to_string()],
1080                }),
1081            ),
1082        ];
1083
1084        let mut rendered = String::new();
1085        for (label, surface) in cases {
1086            let rows =
1087                render_surface(&SurfaceRenderInput { surface: &surface, theme: &test_theme(), width: 48, height: 5 });
1088            rendered.push_str(label);
1089            rendered.push_str(":\n");
1090            rendered.push_str(&Frame { rows, width: 48, cursor: None, cursor_visible: true }.render_styled());
1091            rendered.push('\n');
1092        }
1093
1094        assert!(!rendered.contains("sk-hidden"));
1095        insta::assert_snapshot!("iocraft_focused_surfaces", rendered);
1096    }
1097
1098    fn test_theme() -> SurfaceThemeView {
1099        SurfaceThemeView {
1100            text: ThemeRole::Text,
1101            muted: ThemeRole::Muted,
1102            selected: ThemeRole::Selected,
1103            warning: ThemeRole::Warning,
1104            error: ThemeRole::Error,
1105            diff_added: ThemeRole::DiffAdded,
1106            diff_removed: ThemeRole::DiffRemoved,
1107        }
1108    }
1109}