返回 CodeWhale
body_legacy.rs
根目录 / crates / tui / src / tui / work_surface / render / body_legacy.rs
1 //! Frozen native body renderer, acceptance only.
2 use super::*;
3
4 pub(super) fn render(frame: &mut Frame, area: Rect, app: &mut App) {
5 if area.width == 0 || area.height == 0 {
6 collapse_strip(app);
7 return;
8 }
9
10 if let Some(previous) = app.work_surface.last_area {
11 app.sidebar_hover
12 .sections
13 .retain(|section| section.content_area != previous);
14 }
15
16 let placement = app.work_surface.effective_placement;
17 // Off renders no rail; height()/split_chat() never hand us an area for it.
18 if placement == WorkSurfacePlacement::Off {
19 collapse_strip(app);
20 return;
21 }
22 let body_area = match placement {
23 // Bottom mirrors Top's body/divider split; only the divider edge
24 // differs (below-content for Top, above-content for Bottom).
25 WorkSurfacePlacement::Top => Rect {
26 y: area.y.saturating_add(1),
27 height: area.height.saturating_sub(2),
28 ..area
29 },
30 WorkSurfacePlacement::Bottom => Rect {
31 y: area.y.saturating_add(2),
32 height: area.height.saturating_sub(2),
33 ..area
34 },
35 WorkSurfacePlacement::Left => Rect {
36 width: area.width.saturating_sub(1),
37 ..area
38 },
39 WorkSurfacePlacement::Right => Rect {
40 x: area.x.saturating_add(1),
41 width: area.width.saturating_sub(1),
42 ..area
43 },
44 WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
45 };
46
47 if !placement.is_strip() {
48 app.work_surface.dock_tabs.clear();
49 app.work_surface.pressed_tab = None;
50 app.work_surface.hovered_tab = None;
51 }
52
53 super::super::model::resolve_view(app);
54 let rows = visible_rows_for_panel(app);
55 let todo_ordinals = if placement.is_strip() {
56 todo_ordinals(&rows)
57 } else {
58 HashMap::new()
59 };
60 let ordinal_width = todo_ordinals.len().max(1).to_string().len();
61 let goal_title = placement.is_strip().then(|| top_goal_title(app)).flatten();
62 let todo_progress = placement
63 .is_strip()
64 .then(|| top_todo_progress(app, &rows))
65 .flatten();
66 // Pin goal title, then progress receipt, above the scrollable rows.
67 // A compact strip keeps its last usable row for content, ahead of headers.
68 let goal_height = u16::from(goal_title.is_some() && body_area.height >= 2);
69 let fold_progress = progress_shares_goal_row(body_area.width, goal_height > 0);
70 let progress_height = u16::from(
71 todo_progress.is_some()
72 && !fold_progress
73 && body_area.height.saturating_sub(goal_height) >= 2,
74 );
75 let header_height = goal_height.saturating_add(progress_height);
76 let list_height = body_area.height.saturating_sub(header_height);
77 let body_height = usize::from(list_height);
78 let overflow = rows.len() > body_height;
79 // A capped list owes the reader the size of what it is hiding, so the
80 // last painted row becomes `↓ N more`. The scrollbar shows position; only
81 // this shows how much work is off-screen.
82 let more_row = overflow && body_height >= 2;
83 let list_rows = if more_row {
84 body_height.saturating_sub(1)
85 } else {
86 body_height
87 };
88 let inset = u16::from(body_area.width >= 16);
89 let rail_width = u16::from(overflow);
90 let content_area = Rect {
91 x: body_area.x.saturating_add(inset),
92 y: body_area.y.saturating_add(header_height),
93 width: body_area
94 .width
95 .saturating_sub(inset.saturating_mul(2))
96 .saturating_sub(rail_width),
97 height: list_height,
98 };
99
100 app.work_surface.visible_rows = list_rows;
101 app.work_surface.total_rows = rows.len();
102 // A redraw may clamp an obsolete offset, but it must not reveal the
103 // remembered keyboard selection: doing so undoes mouse-wheel scrolling
104 // whenever that selection is above the viewport (#4594).
105 app.work_surface.clamp_viewport(&rows);
106 let max_offset = rows.len().saturating_sub(list_rows.max(1));
107 app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset);
108
109 Block::default()
110 .style(Style::default().bg(app.ui_theme.panel_bg))
111 .render(area, frame.buffer_mut());
112 render_dock_tabs(frame, area, app);
113 register_dock_targets(app);
114
115 if let Some((goal_text, goal_style)) = goal_title.filter(|_| goal_height > 0) {
116 let full_width = usize::from(content_area.width);
117 // Wide strips carry the receipt right-aligned on the goal row rather
118 // than spending a second row announcing a count.
119 let receipt = todo_progress.as_deref().filter(|_| fold_progress);
120 let reserved = receipt
121 .map(|text| UnicodeWidthStr::width(text).saturating_add(2))
122 .unwrap_or(0);
123 let goal_text = truncate_line_to_width(&goal_text, full_width.saturating_sub(reserved));
124 let mut spans = vec![Span::styled(
125 goal_text.clone(),
126 goal_style.bg(app.ui_theme.panel_bg),
127 )];
128 if let Some(receipt) = receipt {
129 let gap = full_width
130 .saturating_sub(UnicodeWidthStr::width(goal_text.as_str()))
131 .saturating_sub(UnicodeWidthStr::width(receipt));
132 spans.push(Span::styled(
133 format!("{}{receipt}", " ".repeat(gap)),
134 Style::default()
135 .fg(app.ui_theme.text_muted)
136 .bg(app.ui_theme.panel_bg),
137 ));
138 }
139 Paragraph::new(Line::from(spans)).render(
140 Rect {
141 y: body_area.y,
142 height: 1,
143 ..content_area
144 },
145 frame.buffer_mut(),
146 );
147 }
148
149 if let Some(progress) = todo_progress.filter(|_| progress_height > 0) {
150 let progress = truncate_line_to_width(&progress, usize::from(content_area.width));
151 // Muted, not accent: accent_primary means "selected" everywhere else
152 // in the strip, and spending it on a static count makes the actual
153 // selection hard to find.
154 Paragraph::new(Line::from(Span::styled(
155 progress,
156 Style::default()
157 .fg(app.ui_theme.text_muted)
158 .bg(app.ui_theme.panel_bg),
159 )))
160 .render(
161 Rect {
162 y: body_area.y.saturating_add(goal_height),
163 height: 1,
164 ..content_area
165 },
166 frame.buffer_mut(),
167 );
168 }
169
170 let start = app.work_surface.scroll_offset;
171 let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
172 let identity_cap = agent_identity_cap(usize::from(content_area.width));
173 let identity_column = agent_identity_column(&visible, identity_cap);
174 let status_column = agent_status_column(&visible);
175 let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
176 let mut hover_rows = Vec::new();
177 let mut hitboxes = Vec::new();
178 for (visible_index, row) in visible.iter().enumerate() {
179 let row_y = content_area.y.saturating_add(visible_index as u16);
180 let selected =
181 app.work_surface.focused && app.work_surface.selected.as_ref() == Some(&row.id);
182 let hovered = app.work_surface.hovered.as_ref() == Some(&row.id);
183 let opened = app.work_surface.opened.as_ref() == Some(&row.id);
184 let style = row_style(app, row, selected, hovered, opened);
185 let compact_owner = if placement.is_strip() {
186 todo_ordinals
187 .get(&row.id.0)
188 .map(|ordinal| format!("{ordinal:>ordinal_width$} · "))
189 .unwrap_or_default()
190 } else {
191 String::new()
192 };
193 let mark = if opened && row.selectable {
194 "▾"
195 } else {
196 row.mark
197 };
198 // Agent focus marker: while a worker is focused every row gains a
199 // two-cell gutter and the focused worker's row shows the selection
200 // glyph in it, so the addressed fork is visible at the left edge.
201 let focus_gutter = if app.agent_focus.is_some() {
202 let focused = row
203 .id
204 .0
205 .strip_prefix("worker:")
206 .is_some_and(|id| app.agent_focus.as_ref().is_some_and(|f| f.is(id)));
207 if focused {
208 "❯ ".to_string()
209 } else {
210 " ".to_string()
211 }
212 } else {
213 String::new()
214 };
215 let prefix = if row.tone == WorkTone::Heading {
216 format!("{focus_gutter}{} ", mark)
217 } else {
218 format!("{focus_gutter}{compact_owner}{mark} ")
219 };
220
221 // Sub-agent rows own their own column layout: glyph, agent type,
222 // objective, right-aligned elapsed and tokens. They stay ordinary
223 // rows in every other respect — same hitbox, same selection, same
224 // primary action.
225 if let Some(facts) = row.agent.as_ref() {
226 let queued = row
227 .id
228 .0
229 .strip_prefix("worker:")
230 .and_then(|id| crate::tui::agent_focus::queued_suffix(app, id))
231 .map(|queued| format!(" · {queued}"));
232 let queued_width = queued.as_deref().map(UnicodeWidthStr::width).unwrap_or(0);
233 let laid_out = layout_agent_row(
234 usize::from(content_area.width).saturating_sub(queued_width),
235 UnicodeWidthStr::width(prefix.as_str()),
236 agent_identity(row, identity_cap),
237 identity_column,
238 status_column,
239 facts,
240 );
241 let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
242 let display = format!(
243 "{prefix}{}{}{}{}{}{}{}",
244 laid_out.role,
245 if laid_out.role.is_empty() {
246 String::new()
247 } else {
248 " ".repeat(AGENT_ROLE_GUTTER)
249 },
250 laid_out.status,
251 if laid_out.status.is_empty() {
252 String::new()
253 } else {
254 " ".repeat(AGENT_ROLE_GUTTER)
255 },
256 laid_out.objective,
257 " ".repeat(laid_out.gap),
258 laid_out.receipt,
259 );
260 let mut spans = vec![Span::styled(prefix.clone(), normal)];
261 if !laid_out.role.is_empty() {
262 spans.push(Span::styled(
263 format!("{}{}", laid_out.role, " ".repeat(AGENT_ROLE_GUTTER)),
264 muted,
265 ));
266 }
267 if !laid_out.status.is_empty() {
268 spans.push(Span::styled(
269 format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)),
270 muted,
271 ));
272 }
273 spans.push(Span::styled(laid_out.objective.clone(), normal));
274 spans.push(Span::styled(
275 format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
276 muted,
277 ));
278 if let Some(queued) = queued.as_deref() {
279 // Truthful `· N queued`: follow-ups the running child has not
280 // yet folded into its next round. Amber: a message is waiting
281 // on a busy agent, the one thing on this row that needs you.
282 spans.push(Span::styled(
283 queued.to_string(),
284 Style::default()
285 .fg(app.ui_theme.warning)
286 .bg(normal.bg.unwrap_or(app.ui_theme.panel_bg)),
287 ));
288 }
289 lines.push(Line::from(spans));
290
291 hitboxes.push(WorkHitbox {
292 id: row.id.clone(),
293 row_y,
294 });
295 hover_rows.push(SidebarHoverRow {
296 row_y,
297 display_text: display,
298 full_text: format!("{} · {}", row.label, row.detail),
299 detail: Some(row.detail.clone()),
300 is_truncated: laid_out.objective != facts.objective
301 || laid_out.receipt != agent_receipt(facts, AgentRowTier::Full),
302 click_action: row.primary_action.clone(),
303 stop_action: None,
304 stop_zone_start_col: None,
305 stop_zone_end_col: None,
306 });
307 continue;
308 }
309
310 let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 {
311 format!(" {}", row.detail)
312 } else {
313 String::new()
314 };
315 let prefix_width = UnicodeWidthStr::width(prefix.as_str());
316 let row_width = usize::from(content_area.width);
317 let label_budget = row_width.saturating_sub(prefix_width).max(1);
318 let label = truncate_line_to_width(&row.label, label_budget);
319 let detail_budget =
320 row_width.saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()));
321 let detail = if detail_budget >= 4 {
322 truncate_line_to_width(&detail_candidate, detail_budget)
323 } else {
324 String::new()
325 };
326 let detail_width = UnicodeWidthStr::width(detail.as_str());
327 let gap = usize::from(content_area.width)
328 .saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()) + detail_width);
329 let display = format!("{prefix}{label}{}{detail}", " ".repeat(gap));
330 lines.push(Line::from(Span::styled(display.clone(), style)));
331
332 hitboxes.push(WorkHitbox {
333 id: row.id.clone(),
334 row_y,
335 });
336
337 if row.selectable {
338 hover_rows.push(SidebarHoverRow {
339 row_y,
340 display_text: display,
341 full_text: format!("{} · {}", row.label, row.detail),
342 detail: Some(row.detail.clone()),
343 is_truncated: label != row.label || detail != detail_candidate,
344 click_action: row.primary_action.clone(),
345 stop_action: None,
346 stop_zone_start_col: None,
347 stop_zone_end_col: None,
348 });
349 }
350 }
351
352 if visible.is_empty() && app.work_surface.explicit_view && content_area.height > 0 {
353 // An explicitly opened view with nothing in it says so, once, so
354 // cycling never lands on a blank band.
355 lines.push(Line::from(Span::styled(
356 truncate_line_to_width(
357 empty_view_hint(app.work_surface.panel),
358 usize::from(content_area.width),
359 ),
360 Style::default()
361 .fg(app.ui_theme.text_muted)
362 .bg(app.ui_theme.panel_bg),
363 )));
364 }
365
366 if more_row {
367 // Right-aligned under the receipt column, muted like every other
368 // secondary figure. Scrolled to the bottom there is nothing below, so
369 // the reserved row stays blank rather than claiming a count of zero.
370 let remaining = rows
371 .len()
372 .saturating_sub(start.saturating_add(visible.len()));
373 let text = if remaining == 0 {
374 String::new()
375 } else {
376 truncate_line_to_width(
377 &format!("↓ {remaining} more"),
378 usize::from(content_area.width),
379 )
380 };
381 let pad = usize::from(content_area.width).saturating_sub(UnicodeWidthStr::width(&*text));
382 lines.push(Line::from(Span::styled(
383 format!("{}{text}", " ".repeat(pad)),
384 Style::default()
385 .fg(app.ui_theme.text_muted)
386 .bg(app.ui_theme.panel_bg),
387 )));
388 }
389
390 Paragraph::new(lines).render(content_area, frame.buffer_mut());
391 render_divider(frame, area, placement, app);
392 if overflow {
393 legacy_scrollbar(
394 frame,
395 Rect {
396 x: body_area.right().saturating_sub(1),
397 y: content_area.y,
398 width: 1,
399 height: content_area.height,
400 },
401 app.work_surface.scroll_offset,
402 list_rows,
403 rows.len(),
404 app,
405 );
406 }
407
408 app.work_surface.last_area = Some(area);
409 app.work_surface.hitboxes = hitboxes;
410 app.sidebar_hover.sections.push(SidebarHoverSection {
411 content_area,
412 lines: visible.iter().map(|row| row.label.clone()).collect(),
413 rows: hover_rows,
414 });
415 // The tab badges projected the other views on the way here; the rows
416 // a click resolves against are the ones this frame painted.
417 app.work_surface.latest_rows = rows;
418 }
419
420 fn legacy_scrollbar(
421 frame: &mut Frame,
422 area: Rect,
423 offset: usize,
424 visible: usize,
425 total: usize,
426 app: &App,
427 ) {
428 let rail_height = area.height;
429 if rail_height == 0 || total == 0 {
430 return;
431 }
432 let thumb_height = ((usize::from(rail_height) * visible) / total)
433 .max(1)
434 .min(usize::from(rail_height));
435 let max_offset = total.saturating_sub(visible).max(1);
436 let max_start = usize::from(rail_height).saturating_sub(thumb_height);
437 let thumb_start = offset.saturating_mul(max_start) / max_offset;
438 let x = area.right().saturating_sub(1);
439 for row in 0..usize::from(rail_height) {
440 let in_thumb = row >= thumb_start && row < thumb_start.saturating_add(thumb_height);
441 frame.buffer_mut()[(x, area.y.saturating_add(row as u16))]
442 // Match the transcript rail exactly: a fine border track with a
443 // brighter, narrow thumb. The old solid block looked like a
444 // separate native scrollbar bolted onto the work surface.
445 .set_symbol(if in_thumb { "┃" } else { "│" })
446 .set_fg(if in_thumb {
447 app.ui_theme.status_working
448 } else {
449 app.ui_theme.border
450 })
451 .set_bg(app.ui_theme.panel_bg);
452 }
453 }
454
454 lines RUST