1use 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#[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
94pub 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
119pub 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}