返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / work_surface / render / mod.rs
1 //! Painting the work surface, and the two files it leans on.
2 //!
3 //! - [`layout`] answers *where and how tall* — placement fallback, the height
4 //! and cap arithmetic, and the side-rail split.
5 //! - [`rows`] answers *what one row says* — the sub-agent column layout, its
6 //! degradation tiers, and row styling.
7 //!
8 //! What stays here is the paint itself: the strip, the side rail, the dock
9 //! tab row, the divider and scrollbar chrome, and the strip header content
10 //! (goal title, to-do receipt) that height and paint must both agree on.
11 //! Every view — work rows and fact rows alike — goes through the one row
12 //! loop below; there is no second line-list renderer.
13
14 use std::collections::HashMap;
15
16 use ratatui::{
17 Frame,
18 layout::Rect,
19 prelude::Widget,
20 style::{Modifier, Style},
21 text::{Line, Span},
22 widgets::{Block, Paragraph},
23 };
24 use unicode_width::UnicodeWidthStr;
25
26 use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
27 use crate::tui::ui_text::truncate_line_to_width;
28 use codewhale_localization::MessageId;
29 use codewhale_palette::{ChromeInk, chrome_style};
30
31 use super::model::{
32 DockTabHitbox, DockTabTarget, RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone,
33 visible_rows_for, visible_rows_for_panel,
34 };
35
36 mod layout;
37 mod rows;
38
39 #[cfg(test)]
40 mod body_legacy;
41 #[cfg(test)]
42 mod body_tests;
43 #[cfg(test)]
44 mod dock_tabs_legacy;
45 #[cfg(test)]
46 mod dock_tabs_tests;
47
48 pub(crate) use layout::collapse_strip;
49 pub use layout::{height, split_chat};
50
51 use rows::{
52 AGENT_ROLE_GUTTER, AgentRowTier, agent_identity, agent_identity_cap, agent_identity_column,
53 agent_receipt, agent_row_styles, agent_status_column, layout_agent_row, row_style,
54 };
55
56 pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
57 if area.width == 0 || area.height == 0 {
58 collapse_strip(app);
59 return;
60 }
61
62 if let Some(previous) = app.work_surface.last_area {
63 app.sidebar_hover
64 .sections
65 .retain(|section| section.content_area != previous);
66 }
67
68 let placement = app.work_surface.effective_placement;
69 // Off renders no rail; height()/split_chat() never hand us an area for it.
70 if placement == WorkSurfacePlacement::Off {
71 collapse_strip(app);
72 return;
73 }
74 let body_area = match placement {
75 // Bottom mirrors Top's body/divider split; only the divider edge
76 // differs (below-content for Top, above-content for Bottom).
77 WorkSurfacePlacement::Top => Rect {
78 y: area.y.saturating_add(1),
79 height: area.height.saturating_sub(2),
80 ..area
81 },
82 WorkSurfacePlacement::Bottom => Rect {
83 y: area.y.saturating_add(2),
84 height: area.height.saturating_sub(2),
85 ..area
86 },
87 WorkSurfacePlacement::Left => Rect {
88 width: area.width.saturating_sub(1),
89 ..area
90 },
91 WorkSurfacePlacement::Right => Rect {
92 x: area.x.saturating_add(1),
93 width: area.width.saturating_sub(1),
94 ..area
95 },
96 WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
97 };
98
99 if !placement.is_strip() {
100 app.work_surface.dock_tabs.clear();
101 app.work_surface.pressed_tab = None;
102 app.work_surface.hovered_tab = None;
103 }
104
105 super::model::resolve_view(app);
106 let rows = visible_rows_for_panel(app);
107 let todo_ordinals = if placement.is_strip() {
108 todo_ordinals(&rows)
109 } else {
110 HashMap::new()
111 };
112 let ordinal_width = todo_ordinals.len().max(1).to_string().len();
113 let goal_title = placement.is_strip().then(|| top_goal_title(app)).flatten();
114 let todo_progress = placement
115 .is_strip()
116 .then(|| top_todo_progress(app, &rows))
117 .flatten();
118 // Pin goal title, then progress receipt, above the scrollable rows.
119 // A compact strip keeps its last usable row for content, ahead of headers.
120 // Body geometry has one shared owner; Engine still supplies viewport,
121 // caller rows, goal/progress truth and the remembered scroll offset.
122 let body_layout = codewhale_ratatui::WorkbarLayout::for_body(
123 body_area,
124 rows.len(),
125 app.work_surface.scroll_offset,
126 goal_title.is_some(),
127 todo_progress.is_some(),
128 );
129 let goal_height = body_layout.goal_height;
130 let fold_progress = progress_shares_goal_row(body_area.width, goal_height > 0);
131 let progress_height = body_layout.progress_height;
132 let content_area = body_layout.content;
133 let overflow = body_layout.overflow;
134 let more_row = body_layout.more_row;
135 let list_rows = body_layout.visible_rows;
136 app.work_surface.visible_rows = list_rows;
137 app.work_surface.total_rows = rows.len();
138 // A redraw clamps the viewport, never reveals the remembered selection.
139 app.work_surface.clamp_viewport(&rows);
140
141 Block::default()
142 .style(Style::default().bg(app.ui_theme.panel_bg))
143 .render(area, frame.buffer_mut());
144 render_dock_tabs(frame, area, app);
145 register_dock_targets(app);
146
147 if let Some((goal_text, goal_style)) = goal_title.filter(|_| goal_height > 0) {
148 let full_width = usize::from(content_area.width);
149 // Wide strips carry the receipt right-aligned on the goal row rather
150 // than spending a second row announcing a count.
151 let receipt = todo_progress.as_deref().filter(|_| fold_progress);
152 let reserved = receipt
153 .map(|text| UnicodeWidthStr::width(text).saturating_add(2))
154 .unwrap_or(0);
155 let goal_text = truncate_line_to_width(&goal_text, full_width.saturating_sub(reserved));
156 let mut spans = vec![Span::styled(
157 goal_text.clone(),
158 goal_style.bg(app.ui_theme.panel_bg),
159 )];
160 if let Some(receipt) = receipt {
161 let gap = full_width
162 .saturating_sub(UnicodeWidthStr::width(goal_text.as_str()))
163 .saturating_sub(UnicodeWidthStr::width(receipt));
164 spans.push(Span::styled(
165 format!("{}{receipt}", " ".repeat(gap)),
166 Style::default()
167 .fg(app.ui_theme.text_muted)
168 .bg(app.ui_theme.panel_bg),
169 ));
170 }
171 Paragraph::new(Line::from(spans)).render(
172 Rect {
173 y: body_area.y,
174 height: 1,
175 ..content_area
176 },
177 frame.buffer_mut(),
178 );
179 }
180
181 if let Some(progress) = todo_progress.filter(|_| progress_height > 0) {
182 let progress = truncate_line_to_width(&progress, usize::from(content_area.width));
183 // Muted, not accent: accent_primary means "selected" everywhere else
184 // in the strip, and spending it on a static count makes the actual
185 // selection hard to find.
186 Paragraph::new(Line::from(Span::styled(
187 progress,
188 Style::default()
189 .fg(app.ui_theme.text_muted)
190 .bg(app.ui_theme.panel_bg),
191 )))
192 .render(
193 Rect {
194 y: body_area.y.saturating_add(goal_height),
195 height: 1,
196 ..content_area
197 },
198 frame.buffer_mut(),
199 );
200 }
201
202 let start = app.work_surface.scroll_offset;
203 let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
204 let identity_cap = agent_identity_cap(usize::from(content_area.width));
205 let identity_column = agent_identity_column(&visible, identity_cap);
206 let status_column = agent_status_column(&visible);
207 let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
208 let mut hover_rows = Vec::new();
209 let mut hitboxes = Vec::new();
210 for (visible_index, row) in visible.iter().enumerate() {
211 let row_y = content_area.y.saturating_add(visible_index as u16);
212 let selected =
213 app.work_surface.focused && app.work_surface.selected.as_ref() == Some(&row.id);
214 let hovered = app.work_surface.hovered.as_ref() == Some(&row.id);
215 let opened = app.work_surface.opened.as_ref() == Some(&row.id);
216 let style = row_style(app, row, selected, hovered, opened);
217 let compact_owner = if placement.is_strip() {
218 todo_ordinals
219 .get(&row.id.0)
220 .map(|ordinal| format!("{ordinal:>ordinal_width$} · "))
221 .unwrap_or_default()
222 } else {
223 String::new()
224 };
225 let mark = if opened && row.selectable {
226 "▾"
227 } else {
228 row.mark
229 };
230 // Agent focus marker: while a worker is focused every row gains a
231 // two-cell gutter and the focused worker's row shows the selection
232 // glyph in it, so the addressed fork is visible at the left edge.
233 let focus_gutter = if app.agent_focus.is_some() {
234 let focused = row
235 .id
236 .0
237 .strip_prefix("worker:")
238 .is_some_and(|id| app.agent_focus.as_ref().is_some_and(|f| f.is(id)));
239 if focused {
240 "❯ ".to_string()
241 } else {
242 " ".to_string()
243 }
244 } else {
245 String::new()
246 };
247 let prefix = if row.tone == WorkTone::Heading {
248 format!("{focus_gutter}{} ", mark)
249 } else {
250 format!("{focus_gutter}{compact_owner}{mark} ")
251 };
252
253 // Sub-agent rows own their own column layout: glyph, agent type,
254 // objective, right-aligned elapsed and tokens. They stay ordinary
255 // rows in every other respect — same hitbox, same selection, same
256 // primary action.
257 if let Some(facts) = row.agent.as_ref() {
258 let queued = row
259 .id
260 .0
261 .strip_prefix("worker:")
262 .and_then(|id| crate::tui::agent_focus::queued_suffix(app, id))
263 .map(|queued| format!(" · {queued}"));
264 let queued_width = queued.as_deref().map(UnicodeWidthStr::width).unwrap_or(0);
265 let laid_out = layout_agent_row(
266 usize::from(content_area.width).saturating_sub(queued_width),
267 UnicodeWidthStr::width(prefix.as_str()),
268 agent_identity(row, identity_cap),
269 identity_column,
270 status_column,
271 facts,
272 );
273 let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
274 let display = format!(
275 "{prefix}{}{}{}{}{}{}{}",
276 laid_out.role,
277 if laid_out.role.is_empty() {
278 String::new()
279 } else {
280 " ".repeat(AGENT_ROLE_GUTTER)
281 },
282 laid_out.status,
283 if laid_out.status.is_empty() {
284 String::new()
285 } else {
286 " ".repeat(AGENT_ROLE_GUTTER)
287 },
288 laid_out.objective,
289 " ".repeat(laid_out.gap),
290 laid_out.receipt,
291 );
292 let mut spans = vec![Span::styled(prefix.clone(), normal)];
293 if !laid_out.role.is_empty() {
294 spans.push(Span::styled(
295 format!("{}{}", laid_out.role, " ".repeat(AGENT_ROLE_GUTTER)),
296 muted,
297 ));
298 }
299 if !laid_out.status.is_empty() {
300 spans.push(Span::styled(
301 format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)),
302 muted,
303 ));
304 }
305 spans.push(Span::styled(laid_out.objective.clone(), normal));
306 spans.push(Span::styled(
307 format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
308 muted,
309 ));
310 if let Some(queued) = queued.as_deref() {
311 // Truthful `· N queued`: follow-ups the running child has not
312 // yet folded into its next round. Amber: a message is waiting
313 // on a busy agent, the one thing on this row that needs you.
314 spans.push(Span::styled(
315 queued.to_string(),
316 Style::default()
317 .fg(app.ui_theme.warning)
318 .bg(normal.bg.unwrap_or(app.ui_theme.panel_bg)),
319 ));
320 }
321 lines.push(Line::from(spans));
322
323 hitboxes.push(WorkHitbox {
324 id: row.id.clone(),
325 row_y,
326 });
327 hover_rows.push(SidebarHoverRow {
328 row_y,
329 display_text: display,
330 full_text: format!("{} · {}", row.label, row.detail),
331 detail: Some(row.detail.clone()),
332 is_truncated: laid_out.objective != facts.objective
333 || laid_out.receipt != agent_receipt(facts, AgentRowTier::Full),
334 click_action: row.primary_action.clone(),
335 stop_action: None,
336 stop_zone_start_col: None,
337 stop_zone_end_col: None,
338 });
339 continue;
340 }
341
342 let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 {
343 format!(" {}", row.detail)
344 } else {
345 String::new()
346 };
347 let prefix_width = UnicodeWidthStr::width(prefix.as_str());
348 let row_width = usize::from(content_area.width);
349 let label_budget = row_width.saturating_sub(prefix_width).max(1);
350 let label = truncate_line_to_width(&row.label, label_budget);
351 let detail_budget =
352 row_width.saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()));
353 let detail = if detail_budget >= 4 {
354 truncate_line_to_width(&detail_candidate, detail_budget)
355 } else {
356 String::new()
357 };
358 let detail_width = UnicodeWidthStr::width(detail.as_str());
359 let gap = usize::from(content_area.width)
360 .saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()) + detail_width);
361 let display = format!("{prefix}{label}{}{detail}", " ".repeat(gap));
362 lines.push(Line::from(Span::styled(display.clone(), style)));
363
364 hitboxes.push(WorkHitbox {
365 id: row.id.clone(),
366 row_y,
367 });
368
369 if row.selectable {
370 hover_rows.push(SidebarHoverRow {
371 row_y,
372 display_text: display,
373 full_text: format!("{} · {}", row.label, row.detail),
374 detail: Some(row.detail.clone()),
375 is_truncated: label != row.label || detail != detail_candidate,
376 click_action: row.primary_action.clone(),
377 stop_action: None,
378 stop_zone_start_col: None,
379 stop_zone_end_col: None,
380 });
381 }
382 }
383
384 if visible.is_empty() && app.work_surface.explicit_view && content_area.height > 0 {
385 // An explicitly opened view with nothing in it says so, once, so
386 // cycling never lands on a blank band.
387 lines.push(Line::from(Span::styled(
388 truncate_line_to_width(
389 empty_view_hint(app.work_surface.panel),
390 usize::from(content_area.width),
391 ),
392 Style::default()
393 .fg(app.ui_theme.text_muted)
394 .bg(app.ui_theme.panel_bg),
395 )));
396 }
397
398 if more_row {
399 // Right-aligned under the receipt column, muted like every other
400 // secondary figure. Scrolled to the bottom there is nothing below, so
401 // the reserved row stays blank rather than claiming a count of zero.
402 let remaining = rows
403 .len()
404 .saturating_sub(start.saturating_add(visible.len()));
405 let text = if remaining == 0 {
406 String::new()
407 } else {
408 truncate_line_to_width(
409 &format!("↓ {remaining} more"),
410 usize::from(content_area.width),
411 )
412 };
413 let pad = usize::from(content_area.width).saturating_sub(UnicodeWidthStr::width(&*text));
414 lines.push(Line::from(Span::styled(
415 format!("{}{text}", " ".repeat(pad)),
416 Style::default()
417 .fg(app.ui_theme.text_muted)
418 .bg(app.ui_theme.panel_bg),
419 )));
420 }
421
422 Paragraph::new(lines).render(content_area, frame.buffer_mut());
423 render_divider(frame, area, placement, app);
424 if overflow {
425 render_scrollbar(
426 frame,
427 Rect {
428 x: body_area.right().saturating_sub(1),
429 y: content_area.y,
430 width: 1,
431 height: content_area.height,
432 },
433 app.work_surface.scroll_offset,
434 list_rows,
435 rows.len(),
436 app,
437 );
438 }
439
440 app.work_surface.last_area = Some(area);
441 app.work_surface.hitboxes = hitboxes;
442 app.sidebar_hover.sections.push(SidebarHoverSection {
443 content_area,
444 lines: visible.iter().map(|row| row.label.clone()).collect(),
445 rows: hover_rows,
446 });
447 // The tab badges projected the other views on the way here; the rows
448 // a click resolves against are the ones this frame painted.
449 app.work_surface.latest_rows = rows;
450 }
451
452 /// What an explicitly opened, empty view says on its one row.
453 fn empty_view_hint(panel: RailPanel) -> &'static str {
454 match panel {
455 RailPanel::Agents => "no agents have run this session",
456 RailPanel::Tasks => "no to-dos yet",
457 RailPanel::Background => "nothing running in the background",
458 RailPanel::Files => "no files touched this session",
459 RailPanel::Notepad => "/note add <text> to keep a note",
460 RailPanel::Context => "context budget unknown",
461 // The Git view always paints its own state row ("reading git
462 // status…", "not a git repository", "git unavailable: …").
463 RailPanel::Git => "reading git status…",
464 RailPanel::Price => "no priced turns yet",
465 }
466 }
467
468 /// Active goal as the Top strip's only title. Uses the same
469 /// paused/active/terminal resolution as the ocean header chip so a goal set
470 /// via `create_goal` is either visible everywhere or nowhere. Returns
471 /// `None` when no live goal exists — Top then paints no title row at all.
472 pub(super) fn top_goal_title(app: &App) -> Option<(String, Style)> {
473 let (objective, paused) = crate::tui::footer_ui::active_goal_chip_state(app)?;
474 let flat = objective.trim().replace(['\n', '\r'], " ");
475 if flat.is_empty() {
476 return None;
477 }
478 let text = if paused {
479 format!("Goal (paused): {flat}")
480 } else {
481 format!("Goal: {flat}")
482 };
483 let style = if paused {
484 Style::default()
485 .fg(app.ui_theme.warning)
486 .add_modifier(Modifier::BOLD)
487 } else {
488 Style::default()
489 .fg(app.ui_theme.status_working)
490 .add_modifier(Modifier::BOLD)
491 };
492 Some((text, style))
493 }
494
495 fn todo_ordinals(rows: &[WorkRow]) -> HashMap<String, usize> {
496 rows.iter()
497 .filter(|row| row.id.0.starts_with("graph:"))
498 .enumerate()
499 .map(|(index, row)| (row.id.0.clone(), index.saturating_add(1)))
500 .collect()
501 }
502
503 /// Shared goal/receipt folding rule used by both height and body painting.
504 pub(super) fn progress_shares_goal_row(width: u16, has_goal_title: bool) -> bool {
505 codewhale_ratatui::WorkbarLayout::progress_shares_goal_row(width, has_goal_title)
506 }
507
508 pub(super) fn top_todo_progress(app: &App, rows: &[WorkRow]) -> Option<String> {
509 let todos = rows
510 .iter()
511 .filter(|row| row.id.0.starts_with("graph:"))
512 .collect::<Vec<_>>();
513 let total = todos.len();
514 if total == 0 {
515 return None;
516 }
517 let completed = todos
518 .iter()
519 .filter(|row| row.tone == WorkTone::Success)
520 .count();
521 let remaining = total.saturating_sub(completed);
522 let label = format!("{} ·", app.tr(MessageId::SidebarTodoLabel));
523 Some(
524 app.tr(MessageId::WorkSurfaceTodoProgress)
525 .replace("{label}", &label)
526 .replace("{completed}", &completed.to_string())
527 .replace("{total}", &total.to_string())
528 .replace("{remaining}", &remaining.to_string()),
529 )
530 }
531
532 fn render_divider(frame: &mut Frame, area: Rect, placement: WorkSurfacePlacement, app: &App) {
533 let active = app.work_surface.resizing || app.work_surface.divider_hovered;
534 let color = if active {
535 app.ui_theme.accent_primary
536 } else {
537 app.ui_theme.border
538 };
539 match placement {
540 WorkSurfacePlacement::Off => {}
541 WorkSurfacePlacement::Top => {
542 let y = area.bottom().saturating_sub(1);
543 for x in area.left()..area.right() {
544 frame.buffer_mut()[(x, y)]
545 .set_symbol(if active { "━" } else { "─" })
546 .set_fg(color)
547 .set_bg(app.ui_theme.panel_bg);
548 }
549 }
550 WorkSurfacePlacement::Bottom => {
551 let y = area.top();
552 for x in area.left()..area.right() {
553 frame.buffer_mut()[(x, y)]
554 .set_symbol(if active { "━" } else { "─" })
555 .set_fg(color)
556 .set_bg(app.ui_theme.panel_bg);
557 }
558 }
559 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => {
560 let x = if placement == WorkSurfacePlacement::Left {
561 area.right().saturating_sub(1)
562 } else {
563 area.left()
564 };
565 for y in area.top()..area.bottom() {
566 frame.buffer_mut()[(x, y)]
567 .set_symbol(if active { "┃" } else { "│" })
568 .set_fg(color)
569 .set_bg(app.ui_theme.panel_bg);
570 }
571 }
572 }
573 }
574
575 // Engine supplies the caller's available panels, exact close semantics and
576 // live theme. The kit owns the one fitted paint/hitbox plan.
577 fn kit_panel(panel: RailPanel) -> codewhale_ratatui::WorkbarPanel {
578 use codewhale_ratatui::WorkbarPanel as P;
579 match panel {
580 RailPanel::Tasks => P::Tasks,
581 RailPanel::Agents => P::Fleet,
582 RailPanel::Background => P::Jobs,
583 RailPanel::Files => P::Files,
584 RailPanel::Notepad => P::Notes,
585 RailPanel::Context => P::Context,
586 RailPanel::Git => P::Git,
587 RailPanel::Price => P::Cost,
588 }
589 }
590
591 fn native_target(target: codewhale_ratatui::DockTabTarget) -> DockTabTarget {
592 use codewhale_ratatui::{DockTabTarget as T, WorkbarPanel as P};
593 match target {
594 T::Close => DockTabTarget::Close,
595 T::Panel(panel) => DockTabTarget::Panel(match panel {
596 P::Tasks => RailPanel::Tasks,
597 P::Fleet => RailPanel::Agents,
598 P::Jobs => RailPanel::Background,
599 P::Files => RailPanel::Files,
600 P::Notes => RailPanel::Notepad,
601 P::Context => RailPanel::Context,
602 P::Git => RailPanel::Git,
603 P::Cost => RailPanel::Price,
604 }),
605 }
606 }
607
608 fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) {
609 use codewhale_ratatui::{DockTabRow, DockTabStyles, DockTabTarget as T, WorkbarTab};
610 let entries = RailPanel::ORDER
611 .into_iter()
612 .filter_map(|panel| {
613 let count = dock_tab_count(app, panel);
614 let useful = count.is_some_and(|count| count > 0)
615 || super::views::view_always_has_content(panel);
616 (useful || panel == app.work_surface.panel).then_some(WorkbarTab {
617 panel: kit_panel(panel),
618 count,
619 })
620 })
621 .collect::<Vec<_>>();
622 let close_mark = if crate::tui::color_compat::ascii_safe_enabled() {
623 "x"
624 } else {
625 "×"
626 };
627 // Name Esc only while Engine's actual keyboard action closes this dock.
628 let esc_closes = app.work_surface.focused
629 && !super::interaction::opened_detail_on_screen(app)
630 && (app.work_surface.explicit_view || !visible_rows_for_panel(app).is_empty());
631 let close = if esc_closes && area.width >= 60 {
632 format!(" Esc {close_mark} ")
633 } else {
634 format!(" {close_mark} ")
635 };
636 let row = DockTabRow {
637 tabs: &entries,
638 active: kit_panel(app.work_surface.panel),
639 bottom: app.work_surface.effective_placement == WorkSurfacePlacement::Bottom,
640 close: close.into(),
641 hovered: app.work_surface.hovered_tab.map(|target| match target {
642 DockTabTarget::Panel(panel) => T::Panel(kit_panel(panel)),
643 DockTabTarget::Close => T::Close,
644 }),
645 pressed: app
646 .work_surface
647 .pressed_tab
648 .and_then(|target| match target {
649 DockTabTarget::Panel(panel) => Some(kit_panel(panel)),
650 DockTabTarget::Close => None,
651 }),
652 styles: DockTabStyles {
653 idle: chrome_style(&app.ui_theme, ChromeInk::Metadata),
654 active: Style::default()
655 .fg(app.ui_theme.text_body)
656 .bg(app.ui_theme.selection_bg)
657 .add_modifier(Modifier::BOLD),
658 hovered: Style::default()
659 .fg(app.ui_theme.text_body)
660 .bg(app.ui_theme.elevated_bg)
661 .add_modifier(Modifier::UNDERLINED),
662 close: chrome_style(&app.ui_theme, ChromeInk::MetadataHint),
663 close_hovered: chrome_style(&app.ui_theme, ChromeInk::Info)
664 .bg(app.ui_theme.elevated_bg)
665 .add_modifier(Modifier::UNDERLINED),
666 },
667 };
668 let plan = row.plan(area);
669 plan.paint(frame.buffer_mut());
670 app.work_surface.dock_tabs = plan
671 .hitboxes()
672 .into_iter()
673 .map(|(target, area)| DockTabHitbox {
674 target: native_target(target),
675 area,
676 })
677 .collect();
678 }
679
680 /// The badge on a view's tab: how many rows of *work* it holds. `None` for
681 /// the fact views (context, git, price), which never badge.
682 fn dock_tab_count(app: &mut App, panel: RailPanel) -> Option<usize> {
683 match panel {
684 RailPanel::Agents => Some(
685 visible_rows_for(app, panel)
686 .iter()
687 .filter(|row| row.id.0.starts_with("worker:"))
688 .count(),
689 ),
690 RailPanel::Tasks => Some(
691 visible_rows_for(app, panel)
692 .iter()
693 .filter(|row| row.id.0.starts_with("graph:"))
694 .count(),
695 ),
696 RailPanel::Background => Some(
697 visible_rows_for(app, panel)
698 .iter()
699 .filter(|row| row.selectable)
700 .count(),
701 ),
702 RailPanel::Files => Some(super::views::files_touched_count(app)),
703 RailPanel::Notepad => Some(usize::from(super::views::notepad_has_text(app))),
704 RailPanel::Context | RailPanel::Git | RailPanel::Price => None,
705 }
706 }
707
708 fn register_dock_targets(app: &mut App) {
709 let targets = app.work_surface.dock_tabs.clone();
710 for hitbox in targets {
711 let (id, action) = match hitbox.target {
712 DockTabTarget::Panel(panel) => {
713 use crate::tui::tideline::InteractionTargetId as Id;
714 let id = match panel {
715 RailPanel::Agents => Id::DOCK_TAB_AGENTS,
716 RailPanel::Tasks => Id::DOCK_TAB_TASKS,
717 RailPanel::Background => Id::DOCK_TAB_BACKGROUND,
718 RailPanel::Files => Id::DOCK_TAB_FILES,
719 RailPanel::Notepad => Id::DOCK_TAB_NOTEPAD,
720 RailPanel::Context => Id::DOCK_TAB_CONTEXT,
721 RailPanel::Git => Id::DOCK_TAB_GIT,
722 RailPanel::Price => Id::DOCK_TAB_PRICE,
723 };
724 (
725 id,
726 crate::tui::tideline::InteractionAction::ShowDockPanel(panel),
727 )
728 }
729 DockTabTarget::Close => (
730 crate::tui::tideline::InteractionTargetId::DOCK_CLOSE,
731 crate::tui::tideline::InteractionAction::DismissDock,
732 ),
733 };
734 app.viewport
735 .interaction_targets
736 .register(crate::tui::tideline::InteractionTarget {
737 id,
738 area: hitbox.area,
739 focus: crate::tui::tideline::InteractionFocus::Direct,
740 keyboard_action: Some(action),
741 mouse_action: Some(action),
742 inspect_detail: crate::tui::tideline::InspectDetail::Route,
743 });
744 }
745 }
746
747 fn render_scrollbar(
748 frame: &mut Frame,
749 area: Rect,
750 offset: usize,
751 visible: usize,
752 total: usize,
753 app: &App,
754 ) {
755 codewhale_ratatui::WorkbarScrollbar {
756 offset,
757 visible,
758 total,
759 thumb: "┃",
760 track: "│",
761 thumb_style: Style::default()
762 .fg(app.ui_theme.status_working)
763 .bg(app.ui_theme.panel_bg),
764 track_style: Style::default()
765 .fg(app.ui_theme.border)
766 .bg(app.ui_theme.panel_bg),
767 }
768 .paint(area, frame.buffer_mut());
769 }
770
770 lines RUST