返回 CodeWhale
native_views.rs
根目录 / vendor / codewhale-ratatui / src / gallery / native_views.rs
1 //! Compositions of current Codewhale terminal view families.
2 //!
3 //! `InstrumentSurface` and `SessionList` are extracted renderers; the other
4 //! rooms below are examples composed from their source layout and host facts.
5 //! Input handling, configuration, catalogs, and session loading belong to the
6 //! application. Source paths are recorded beside each recipe and in VIEWS.md.
7
8 use ratatui::{
9 buffer::Buffer,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Modifier, Style},
12 symbols::border,
13 text::{Line, Span},
14 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
15 };
16
17 use super::Entry;
18 use crate::{
19 InstrumentSurface, KeyHint, List, ListRow, ListRowState, ListState, OceanRamp, Paint,
20 PickerState, Role, SessionList, SessionRow, Theme, TuiGround, TuiInk, TuiPalette, WorkbarPanel,
21 centered, text,
22 };
23
24 fn hint(key: &str, verb: &str) -> KeyHint {
25 KeyHint::new(key.to_string(), verb.to_string())
26 }
27 fn move_hint(theme: &Theme) -> KeyHint {
28 hint(if theme.ascii() { "Up/Dn" } else { "↑/↓" }, "move")
29 }
30 fn selected(theme: &Theme) -> Style {
31 theme
32 .fg(Role::Foreground)
33 .patch(theme.bg(Role::Selected))
34 .add_modifier(Modifier::BOLD)
35 }
36 fn row(area: Rect, y: u16) -> Rect {
37 Rect::new(
38 area.x,
39 area.y.saturating_add(y).min(area.bottom()),
40 area.width,
41 u16::from(y < area.height),
42 )
43 }
44 fn below(area: Rect, rows: u16) -> Rect {
45 Rect::new(
46 area.x,
47 area.y.saturating_add(rows).min(area.bottom()),
48 area.width,
49 area.height.saturating_sub(rows),
50 )
51 }
52 fn safe(value: &str, theme: &Theme) -> String {
53 let value = text::display_safe(value);
54 if !theme.ascii() {
55 return value.into_owned();
56 }
57 value
58 .chars()
59 .map(|c| match c {
60 '—' | '–' => "-".into(),
61 '·' => ".".into(),
62 '’' => "'".into(),
63 '‹' => "<".into(),
64 '›' => ">".into(),
65 _ => c.to_string(),
66 })
67 .collect()
68 }
69 fn line(value: &str, role: Role, area: Rect, buf: &mut Buffer, theme: &Theme) {
70 let value = safe(value, theme);
71 let value = text::truncate(&value, usize::from(area.width), theme.ascii());
72 if !area.is_empty() {
73 buf.set_line(
74 area.x,
75 area.y,
76 &Line::styled(value.into_owned(), theme.fg(role)),
77 area.width,
78 );
79 }
80 }
81
82 fn top_section(title: &str, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect {
83 let mut block = Block::default()
84 .title(Line::styled(
85 safe(title, theme),
86 theme.fg(Role::Primary).add_modifier(Modifier::BOLD),
87 ))
88 .borders(Borders::TOP)
89 .border_style(theme.fg(Role::Border))
90 .style(theme.tui_ground(TuiGround::Surface))
91 .padding(Padding::uniform(1));
92 if theme.ascii() {
93 block = block.border_set(border::Set {
94 horizontal_top: "-",
95 ..border::PLAIN
96 });
97 }
98 let inner = block.inner(area);
99 if !area.is_empty() {
100 block.render(area, buf);
101 }
102 inner
103 }
104
105 /// Native centered choice shape from views/{mode_picker,status_picker}.rs
106 /// and command_palette.rs. The instrument and centered shapes stay distinct.
107 fn choice_card(
108 title: &str,
109 area: Rect,
110 preferred: (u16, u16),
111 minimum: (u16, u16),
112 actions: &[KeyHint],
113 buf: &mut Buffer,
114 theme: &Theme,
115 ) -> Rect {
116 let area = area.intersection(buf.area);
117 if area.is_empty() {
118 return area;
119 }
120 let popup = centered(area, preferred.0, preferred.1, minimum.0, minimum.1);
121 let shadow = Rect::new(
122 popup.x.saturating_add(1),
123 popup.y.saturating_add(1),
124 popup
125 .width
126 .min(area.right().saturating_sub(popup.x.saturating_add(1))),
127 popup
128 .height
129 .min(area.bottom().saturating_sub(popup.y.saturating_add(1))),
130 );
131 if !shadow.is_empty() {
132 Block::default()
133 .style(theme.tui_ground(TuiGround::Elevated))
134 .render(shadow, buf);
135 }
136 Clear.render(popup, buf);
137 Block::default()
138 .style(theme.tui_ground(TuiGround::Surface))
139 .render(popup, buf);
140 let mut block = Block::default()
141 .title(Line::styled(
142 safe(title, theme),
143 theme.fg(Role::Primary).add_modifier(Modifier::BOLD),
144 ))
145 .borders(Borders::ALL)
146 .border_style(theme.fg(Role::Border))
147 .style(theme.tui_ground(TuiGround::Surface))
148 .padding(Padding::uniform(1));
149 if theme.ascii() {
150 block = block.border_set(border::Set {
151 top_left: "+",
152 top_right: "+",
153 bottom_left: "+",
154 bottom_right: "+",
155 horizontal_top: "-",
156 horizontal_bottom: "-",
157 vertical_left: "|",
158 vertical_right: "|",
159 });
160 }
161 let inner = block.inner(popup);
162 block.render(popup, buf);
163 InstrumentSurface::draw_footer(inner, buf, actions, theme, false)
164 }
165
166 fn instrument(area: Rect, buf: &mut Buffer, theme: &Theme) {
167 let body = InstrumentSurface::new("context")
168 .actions(vec![hint("Enter", "details"), hint("Esc", "close")])
169 .draw(area, buf, theme);
170 line(
171 "48k of 128k · 38% · compacts at 90%",
172 Role::Foreground,
173 row(body, 0),
174 buf,
175 theme,
176 );
177 line(
178 "system + tools · 8.4k · 24 tools",
179 Role::Muted,
180 row(body, 2),
181 buf,
182 theme,
183 );
184 line(
185 "conversation · 18 messages · 29.2k",
186 Role::Muted,
187 row(body, 3),
188 buf,
189 theme,
190 );
191 line("tool output · 10.4k", Role::Muted, row(body, 4), buf, theme);
192 }
193
194 pub(crate) fn sample_sessions() -> SessionList<'static> {
195 SessionList::new(vec![
196 SessionRow::new(
197 "4a80c752-28b1-4a96-9638-11a6ac1e394d",
198 "Review authentication",
199 )
200 .messages(18)
201 .mode("Work")
202 .updated("2026-10-01 14:12 (2m ago)")
203 .current(true),
204 SessionRow::new("b419de17-939c-4d82-af11-698ae29b449e", "Plan the release")
205 .messages(12)
206 .mode("Plan")
207 .updated("2026-10-01 13:40 (34m ago)"),
208 SessionRow::new(
209 "936efb90-55a6-4b3d-b223-b8279d319589",
210 "Review the alternate approach",
211 )
212 .messages(8)
213 .mode("Work")
214 .updated("2026-10-01 12:53 (1h ago)")
215 .fork(true),
216 SessionRow::new(
217 "ff82d346-410e-4e69-b63a-cf5208c58180",
218 "Add keyboard navigation",
219 )
220 .messages(24)
221 .mode("Operate")
222 .updated("2026-09-30 17:30 (20h ago)"),
223 SessionRow::new("3f473bc6-3aee-45a4-9510-6a599fb75452", "Session")
224 .messages(1)
225 .mode("Work")
226 .updated("2026-09-30 11:09 (1d ago)")
227 .archived(true),
228 ])
229 .state(PickerState::new(0))
230 }
231
232 // session_picker.rs::render, section_block, and build_preview_lines:
233 // history is left 56%, sessions right 44%; narrow rooms stack list above history.
234 fn sessions(area: Rect, buf: &mut Buffer, theme: &Theme) {
235 let area = area.intersection(buf.area);
236 let full = vec![
237 hint("Enter", "resume"),
238 hint("/", "search"),
239 hint("s", "sort"),
240 hint("r", "rename"),
241 hint("a", "all workspaces"),
242 hint("e", "archive"),
243 hint("x", "archived"),
244 hint("d", "delete"),
245 hint("Esc", "close"),
246 ];
247 let shell = InstrumentSurface::new("sessions").actions(full);
248 let compact = shell.areas(area, theme).body.height < 12;
249 let body = if compact {
250 InstrumentSurface::new("sessions")
251 .actions(vec![
252 hint("Enter", "resume"),
253 hint("/", "search"),
254 hint("Esc", "close"),
255 ])
256 .draw(area, buf, theme)
257 } else {
258 shell.draw(area, buf, theme)
259 };
260 if compact {
261 sample_sessions().paint(body, buf, theme);
262 return;
263 }
264 let narrow = body.width < 95;
265 let parts = Layout::default()
266 .direction(if narrow {
267 Direction::Vertical
268 } else {
269 Direction::Horizontal
270 })
271 .constraints(if narrow {
272 [Constraint::Percentage(42), Constraint::Percentage(58)]
273 } else {
274 [Constraint::Percentage(56), Constraint::Percentage(44)]
275 })
276 .split(body);
277 let (history, list) = if narrow {
278 (parts[1], parts[0])
279 } else {
280 (parts[0], parts[1])
281 };
282 let list = top_section(" sessions (1-9, PgUp/PgDn) ", list, buf, theme);
283 sample_sessions().paint(list, buf, theme);
284 let history = top_section(" history (Shift+PgUp/PgDn) ", history, buf, theme);
285 let lines = [
286 "Title: Review authentication",
287 "ID: 4a80c752-28b1-4a96-9638-11a6ac1e394d",
288 "Updated: 2026-10-01 14:12",
289 "Messages: 18 | Model: deepseek-v4",
290 "Mode: Work",
291 "",
292 "USER:",
293 " Check the sign-in flow and keyboard navigation.",
294 "",
295 "ASSISTANT:",
296 " The sign-in screen now returns focus to the account button.",
297 " I also updated the tab order through the provider choices.",
298 ];
299 Paragraph::new(
300 lines
301 .into_iter()
302 .map(|v| Line::styled(v, theme.fg(Role::Foreground)))
303 .collect::<Vec<_>>(),
304 )
305 .wrap(Wrap { trim: false })
306 .render(history, buf);
307 }
308
309 fn session_list(area: Rect, buf: &mut Buffer, theme: &Theme) {
310 sample_sessions().paint(area, buf, theme);
311 }
312 fn sessions_empty(area: Rect, buf: &mut Buffer, theme: &Theme) {
313 let body = InstrumentSurface::new("sessions")
314 .actions(vec![hint("/", "search"), hint("Esc", "close")])
315 .draw(area, buf, theme);
316 SessionList::new(Vec::new()).paint(body, buf, theme);
317 }
318
319 // views/mode_picker.rs::render: one line per mode, eight-cell name column.
320 struct ModeRow {
321 number: char,
322 name: &'static str,
323 hint: &'static str,
324 }
325 impl ListRow for ModeRow {
326 fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) {
327 let prefix = format!(
328 "{}. {}",
329 self.number,
330 text::pad(self.name, 8, theme.ascii())
331 );
332 let help = safe(self.hint, theme);
333 let hint = text::truncate_words(
334 &help,
335 usize::from(area.width).saturating_sub(text::width(&prefix)),
336 theme.ascii(),
337 );
338 let hint_style = if state.selected {
339 theme.fg(Role::Foreground).patch(theme.bg(Role::Selected))
340 } else {
341 theme.fg(Role::Muted)
342 };
343 buf.set_line(
344 area.x,
345 area.y,
346 &Line::from(vec![
347 Span::styled(prefix, state.ink(theme)),
348 Span::styled(hint.into_owned(), hint_style),
349 ]),
350 area.width,
351 );
352 }
353 }
354 fn modes(area: Rect, buf: &mut Buffer, theme: &Theme) {
355 let body = choice_card(
356 " Mode ",
357 area,
358 (68, 10),
359 (44, 8),
360 &[
361 move_hint(theme),
362 hint("Enter", "select"),
363 hint("Esc", "cancel"),
364 ],
365 buf,
366 theme,
367 );
368 let rows = [
369 ModeRow {
370 number: '1',
371 name: "Work",
372 hint: "Direct work in this session — edits and shell ask for approval",
373 },
374 ModeRow {
375 number: '2',
376 name: "Plan",
377 hint: "Read-only research first — present a plan before acting",
378 },
379 ModeRow {
380 number: '3',
381 name: "Operate",
382 hint: "Turns your prompt into a goal: parallel agents, verified work",
383 },
384 ];
385 if !body.is_empty() {
386 List::new(&rows, ListState::new(0)).paint(body, buf, theme);
387 }
388 }
389
390 // command_palette.rs::render: query/count/scope header and sectioned entries.
391 fn commands(area: Rect, buf: &mut Buffer, theme: &Theme) {
392 let body = choice_card(
393 " Commands — Find and run one action ",
394 area,
395 (90, 22),
396 (44, 8),
397 &[
398 move_hint(theme),
399 hint("Enter", "select"),
400 hint("Esc", "cancel"),
401 ],
402 buf,
403 theme,
404 );
405 line("Type to filter", Role::Muted, row(body, 0), buf, theme);
406 line("6 shown / 6 entries", Role::Dim, row(body, 1), buf, theme);
407 line(
408 if theme.ascii() {
409 "scope: c:cmd . s:skill . t:tool . m:mcp"
410 } else {
411 "scope: c:cmd · s:skill · t:tool · m:mcp"
412 },
413 Role::Dim,
414 row(body, 2),
415 buf,
416 theme,
417 );
418 let entries = [
419 ("/model", "Choose a model"),
420 ("/theme", "Preview a theme"),
421 ("/context", "Inspect the context window"),
422 ("/sessions", "Browse saved sessions"),
423 ("/config", "Open settings"),
424 ("/fleet", "Open the fleet roster"),
425 ];
426 let label_width = 24usize.min(usize::from(body.width).saturating_sub(22));
427 let heading = row(body, 4);
428 if !heading.is_empty() {
429 buf.set_line(
430 heading.x,
431 heading.y,
432 &Line::styled(
433 " Commands (6) ",
434 theme.fg(Role::Primary).add_modifier(Modifier::BOLD),
435 ),
436 heading.width,
437 );
438 }
439 for (index, (label, description)) in entries.into_iter().enumerate() {
440 let target = row(body, index as u16 + 5);
441 if target.is_empty() {
442 break;
443 }
444 let chosen = index == 0;
445 let pointer = if chosen {
446 if theme.ascii() { ">" } else { "▸" }
447 } else {
448 " "
449 };
450 let prefix = format!(
451 "{pointer} {} ",
452 text::pad(label, label_width, theme.ascii())
453 );
454 let desc = text::truncate(
455 description,
456 usize::from(target.width).saturating_sub(text::width(&prefix)),
457 theme.ascii(),
458 );
459 buf.set_line(
460 target.x,
461 target.y,
462 &Line::styled(
463 format!("{prefix}{desc}"),
464 if chosen {
465 selected(theme)
466 } else {
467 theme.fg(Role::Foreground)
468 },
469 ),
470 target.width,
471 );
472 }
473 }
474
475 // views/status_picker.rs::render; labels/hints from config.rs::StatusItem.
476 fn status_items(area: Rect, buf: &mut Buffer, theme: &Theme) {
477 let body = choice_card(
478 " Status line ",
479 area,
480 (64, 16),
481 (40, 8),
482 &[
483 hint("Space", "toggle"),
484 hint("a", "all"),
485 hint("n", "none"),
486 hint("Enter", "save"),
487 hint("Esc", "cancel"),
488 ],
489 buf,
490 theme,
491 );
492 line(
493 "Choose what the footer shows:",
494 Role::Muted,
495 row(body, 0),
496 buf,
497 theme,
498 );
499 let rows = [
500 ("Mode", "plan · act · operate", true),
501 ("Model", "the model id you'll send to", true),
502 (
503 "Context window %",
504 "tokens used / model context window",
505 true,
506 ),
507 ("Session cost", "running total for this session", true),
508 (
509 "Account balance",
510 "remaining prepaid credit from the active provider",
511 false,
512 ),
513 (
514 "Prompt cache hit rate",
515 "% of prompt served from cache",
516 true,
517 ),
518 (
519 "Output tokens",
520 "output tokens of the live or last turn",
521 true,
522 ),
523 (
524 "Time to first token",
525 "average wait for the first token",
526 true,
527 ),
528 (
529 "Output rate",
530 "average tok/s, including first-token wait",
531 true,
532 ),
533 ("Workspace", "directory this session writes to", false),
534 ("Git branch", "branch the next commit lands on", false),
535 ];
536 for (index, (label, help, checked)) in rows.into_iter().enumerate() {
537 let target = row(body, index as u16 + 2);
538 if target.is_empty() {
539 break;
540 }
541 let pointer = if index == 0 {
542 if theme.ascii() { ">" } else { "▸" }
543 } else {
544 " "
545 };
546 let mark = if checked {
547 if theme.ascii() { "[x]" } else { "[✓]" }
548 } else {
549 "[ ]"
550 };
551 let prefix = format!(" {pointer} {mark} {label} (");
552 let help_text = safe(help, theme);
553 let help = text::truncate_words(
554 &help_text,
555 usize::from(target.width).saturating_sub(text::width(&prefix) + 1),
556 theme.ascii(),
557 );
558 let display = text::pad(
559 &format!("{prefix}{help})"),
560 usize::from(target.width),
561 theme.ascii(),
562 );
563 buf.set_line(
564 target.x,
565 target.y,
566 &Line::styled(
567 display,
568 if index == 0 {
569 selected(theme)
570 } else {
571 theme.fg(if checked {
572 Role::Foreground
573 } else {
574 Role::Muted
575 })
576 },
577 ),
578 target.width,
579 );
580 }
581 }
582
583 // views/mod.rs::ListDetailLayout::split. Model picker widens the list and
584 // retains a 30-cell effort pane; provider picker retains the ordinary split.
585 fn list_detail(area: Rect, min_detail: u16) -> (Rect, Rect, bool) {
586 if area.is_empty() {
587 return (area, area, true);
588 }
589 let min_list = 30.min(area.width);
590 if area.width >= 96 && area.width.saturating_sub(1 + min_list) >= min_detail {
591 let list_width = (area.width.saturating_mul(42) / 100)
592 .clamp(min_list, area.width.saturating_sub(1 + min_detail).min(52));
593 return (
594 Rect::new(area.x, area.y, list_width, area.height),
595 Rect::new(
596 area.x + list_width + 1,
597 area.y,
598 area.width - list_width - 1,
599 area.height,
600 ),
601 false,
602 );
603 }
604 let gap = u16::from(area.height >= 8);
605 let max_list = area.height.saturating_sub(gap + 4.min(area.height));
606 let list_height = (area.height.saturating_mul(3) / 5).clamp(1, max_list.max(1));
607 (
608 Rect::new(area.x, area.y, area.width, list_height),
609 Rect::new(
610 area.x,
611 area.y + list_height + gap,
612 area.width,
613 area.height.saturating_sub(list_height + gap),
614 ),
615 true,
616 )
617 }
618
619 struct RouteRow {
620 id: &'static str,
621 route: &'static str,
622 detail: &'static str,
623 }
624 impl ListRow for RouteRow {
625 fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) {
626 let id_width = 24.min(usize::from(area.width));
627 let route_width = 15.min(usize::from(area.width).saturating_sub(id_width + 2));
628 let prefix = format!(
629 "{} {} ",
630 text::pad(self.id, id_width, theme.ascii()),
631 text::pad(self.route, route_width, theme.ascii())
632 );
633 let detail = text::truncate(
634 self.detail,
635 usize::from(area.width).saturating_sub(text::width(&prefix)),
636 theme.ascii(),
637 );
638 buf.set_line(
639 area.x,
640 area.y,
641 &Line::styled(format!("{prefix}{detail}"), state.ink(theme)),
642 area.width,
643 );
644 }
645 }
646
647 // model_picker.rs::render_route / render_pane / widen_model_pane.
648 fn models(area: Rect, buf: &mut Buffer, theme: &Theme) {
649 let area = area.intersection(buf.area);
650 let shell = InstrumentSurface::new(if theme.ascii() {
651 "route . configured"
652 } else {
653 "route · configured"
654 })
655 .actions(vec![
656 move_hint(theme),
657 hint("Tab", "switch"),
658 hint("Type", "search any model"),
659 hint("Enter", "apply"),
660 hint(
661 if theme.ascii() { "Shift+A" } else { "⇧A" },
662 "browse catalog",
663 ),
664 hint("Esc", "cancel"),
665 ]);
666 let regions = shell.areas(area, theme);
667 let body = shell.draw(area, buf, theme);
668 title_action(
669 "browse catalog (3)",
670 regions.surface,
671 regions.body.right(),
672 buf,
673 theme,
674 );
675 line("Provider: DeepSeek", Role::Muted, row(body, 0), buf, theme);
676 let (mut list, mut effort, stacked) = list_detail(below(body, 1), 24);
677 if !stacked {
678 let width = effort.width.min(30);
679 list.width = list
680 .width
681 .saturating_add(effort.width.saturating_sub(width));
682 effort.x = list.right() + 1;
683 effort.width = width;
684 }
685 line(
686 if theme.ascii() {
687 " Model . configured"
688 } else {
689 " Model · configured"
690 },
691 Role::Foreground,
692 row(list, 0),
693 buf,
694 theme,
695 );
696 line(
697 " Model Provider Details",
698 Role::Muted,
699 row(list, 1),
700 buf,
701 theme,
702 );
703 let models = [
704 RouteRow {
705 id: "deepseek-v4",
706 route: "DeepSeek",
707 detail: "active",
708 },
709 RouteRow {
710 id: "deepseek-v4-flash",
711 route: "DeepSeek",
712 detail: "saved",
713 },
714 RouteRow {
715 id: "qwen3.5:35b",
716 route: "Ollama",
717 detail: "local",
718 },
719 ];
720 if list.height > 2 {
721 List::new(&models, ListState::new(0)).paint(below(list, 2), buf, theme);
722 }
723 line(" Reasoning", Role::Muted, row(effort, 0), buf, theme);
724 let levels = ["low", "medium", "high"];
725 if effort.height > 1 {
726 List::new(&levels, ListState::new(2)).paint(below(effort, 1), buf, theme);
727 }
728 }
729
730 fn title_action(label: &str, surface: Rect, right: u16, buf: &mut Buffer, theme: &Theme) {
731 if surface.width < 28 || surface.height == 0 {
732 return;
733 }
734 let width = u16::try_from(text::width(label))
735 .unwrap_or(u16::MAX)
736 .min(surface.width.saturating_sub(18));
737 let x = right.saturating_sub(width).max(surface.x);
738 buf.set_stringn(
739 x,
740 surface.y,
741 text::truncate(label, usize::from(width), theme.ascii()),
742 usize::from(width),
743 theme.fg(Role::Primary).add_modifier(Modifier::UNDERLINED),
744 );
745 }
746
747 // provider_picker.rs::render_dashboard / render_provider_detail.
748 fn providers(area: Rect, buf: &mut Buffer, theme: &Theme) {
749 let area = area.intersection(buf.area);
750 let shell = InstrumentSurface::new("Provider").actions(vec![
751 move_hint(theme),
752 hint("/", "search"),
753 hint("Enter", "use"),
754 hint("A", "browse all"),
755 hint("R", "edit key"),
756 hint("M", "models"),
757 hint("C-t", "test connection"),
758 hint("Esc", "cancel"),
759 ]);
760 let regions = shell.areas(area, theme);
761 let body = shell.draw(area, buf, theme);
762 title_action(
763 "browse all",
764 regions.surface,
765 regions.body.right(),
766 buf,
767 theme,
768 );
769 let (list, detail, _) = list_detail(body, 34);
770 let rows = [
771 ("DeepSeek", " *", "key saved"),
772 ("Ollama", " ", "local"),
773 ("OpenAI", " ", "key saved"),
774 ];
775 for (index, (name, active, state)) in rows.into_iter().enumerate() {
776 let target = row(list, index as u16);
777 if target.is_empty() {
778 break;
779 }
780 let marker = if index == 0 {
781 if theme.ascii() { ">" } else { "▸" }
782 } else {
783 " "
784 };
785 let prefix = format!(" {marker} {name}{active} ");
786 let state = text::truncate_words(
787 state,
788 usize::from(target.width).saturating_sub(text::width(&prefix)),
789 theme.ascii(),
790 );
791 buf.set_line(
792 target.x,
793 target.y,
794 &Line::styled(
795 format!("{prefix}{state}"),
796 if index == 0 {
797 selected(theme)
798 } else {
799 theme.fg(Role::Foreground)
800 },
801 ),
802 target.width,
803 );
804 }
805 line("DeepSeek", Role::Foreground, row(detail, 0), buf, theme);
806 line("key saved", Role::Muted, row(detail, 2), buf, theme);
807 line(
808 "Model: deepseek-v4 · saved",
809 Role::Foreground,
810 row(detail, 3),
811 buf,
812 theme,
813 );
814 line(
815 "Endpoint: https://api.deepseek.com",
816 Role::Muted,
817 row(detail, 4),
818 buf,
819 theme,
820 );
821 line(
822 "Credential: environment",
823 Role::Muted,
824 row(detail, 5),
825 buf,
826 theme,
827 );
828 }
829
830 // theme_picker.rs::render; native selectable order and five two-cell swatches.
831 fn themes(area: Rect, buf: &mut Buffer, theme: &Theme) {
832 let body = InstrumentSurface::new(if theme.ascii() {
833 "theme . live preview"
834 } else {
835 "theme · live preview"
836 })
837 .actions(vec![
838 hint(if theme.ascii() { "Up/Dn" } else { "↑/↓" }, "preview"),
839 hint("Enter", "save"),
840 hint("Esc", "revert"),
841 ])
842 .draw(area, buf, theme);
843 let options = [
844 (
845 "System",
846 None,
847 "Follow terminal background (COLORFGBG / macOS appearance)",
848 ),
849 (
850 "Terminal",
851 Some(TuiPalette::Terminal),
852 "Inherit terminal colors fully (transparent surfaces, ANSI accents)",
853 ),
854 (
855 "Shoreline",
856 Some(TuiPalette::Shoreline),
857 "Warm charcoal, one restrained blue — the desktop client's palette",
858 ),
859 (
860 "Shoreline Light",
861 Some(TuiPalette::ShorelineLight),
862 "Shoreline on warm paper — the desktop client's light mode",
863 ),
864 (
865 "Underwater",
866 Some(TuiPalette::Underwater),
867 "The painted ocean field: ombre water, ambient life, the whale",
868 ),
869 (
870 "Underwater Retro",
871 Some(TuiPalette::UnderwaterRetro),
872 "Flat phosphor-teal ocean: the legacy deepsea look, no ombre",
873 ),
874 (
875 "Blue Stage",
876 Some(TuiPalette::Whale),
877 "Stage black, action blue, and one Signal Gold human beacon",
878 ),
879 (
880 "Blue Stage Light",
881 Some(TuiPalette::WhaleLight),
882 "Paper, cobalt action, and one Signal Gold human beacon",
883 ),
884 (
885 "Grayscale",
886 Some(TuiPalette::Grayscale),
887 "Color-minimal high contrast",
888 ),
889 (
890 "Catppuccin Mocha",
891 Some(TuiPalette::CatppuccinMocha),
892 "Soft pastels on warm dark",
893 ),
894 (
895 "Tokyo Night",
896 Some(TuiPalette::TokyoNight),
897 "Deep blue/violet night palette",
898 ),
899 (
900 "Dracula",
901 Some(TuiPalette::Dracula),
902 "Classic high-contrast purple",
903 ),
904 (
905 "Gruvbox Dark",
906 Some(TuiPalette::GruvboxDark),
907 "Vintage warm earth tones",
908 ),
909 ("Claude", Some(TuiPalette::Claude), "Warm navy & coral"),
910 (
911 "Matrix",
912 Some(TuiPalette::Matrix),
913 "The Matrix films inspired theme",
914 ),
915 (
916 "Solarized Light",
917 Some(TuiPalette::SolarizedLight),
918 "Solarized light — Light, calming palette on warm ivory — easy on the eyes",
919 ),
920 (
921 "Uwu",
922 Some(TuiPalette::Uwu),
923 "Soft kawaii night — sakura, mint, and peach",
924 ),
925 ];
926 for (index, (name, palette, tagline)) in options.into_iter().enumerate() {
927 let target = row(body, index as u16 + 1);
928 if target.is_empty() {
929 break;
930 }
931 let is_selected = index == 4;
932 let style = if is_selected {
933 selected(theme)
934 } else {
935 theme.fg(Role::Foreground)
936 };
937 let candidate = palette.map_or(*theme, |palette| theme.tui_palette(palette));
938 let pointer = if is_selected {
939 if theme.ascii() { ">" } else { "▸" }
940 } else {
941 " "
942 };
943 let mut spans = vec![
944 Span::styled(format!(" {pointer} "), style),
945 Span::styled(
946 format!("{}. ", index + 1),
947 if is_selected {
948 theme
949 .tui_ink(TuiInk::Working)
950 .patch(theme.bg(Role::Selected))
951 .add_modifier(Modifier::BOLD)
952 } else {
953 theme.fg(Role::Hint)
954 },
955 ),
956 Span::styled(text::pad(name, 22, theme.ascii()), style),
957 ];
958 if palette == Some(TuiPalette::Underwater) && OceanRamp::for_theme(&candidate).is_some() {
959 for color in [
960 OceanRamp::SURFACE,
961 OceanRamp::MIDDLE,
962 OceanRamp::DEEP,
963 OceanRamp::AMBIENT,
964 ] {
965 spans.push(Span::styled(" ", Style::default().bg(color)));
966 }
967 spans.push(Span::styled(
968 " ",
969 Style::default().bg(candidate.tui_ink(TuiInk::Working).fg.unwrap_or_default()),
970 ));
971 } else {
972 spans.extend(
973 [
974 Role::Background,
975 Role::Surface,
976 Role::Live,
977 Role::Attention,
978 Role::Primary,
979 ]
980 .map(|role| Span::styled(" ", candidate.bg(role))),
981 );
982 }
983 spans.push(Span::raw(" "));
984 let prefix = Line::from(spans.clone()).width();
985 let tagline_text = safe(tagline, theme);
986 let tagline = text::truncate_words(
987 &tagline_text,
988 usize::from(target.width).saturating_sub(prefix),
989 theme.ascii(),
990 );
991 spans.push(Span::styled(
992 tagline.into_owned(),
993 theme.fg(Role::Dim).patch(if is_selected {
994 theme.bg(Role::Selected)
995 } else {
996 Style::default()
997 }),
998 ));
999 buf.set_line(target.x, target.y, &Line::from(spans), target.width);
1000 }
1001 }
1002
1003 // file_picker.rs::render: explicit query, blank row, marker field, paths.
1004 fn files(area: Rect, buf: &mut Buffer, theme: &Theme) {
1005 let body = InstrumentSurface::new("@ attach · 5 matches")
1006 .actions(vec![
1007 move_hint(theme),
1008 hint("Enter", "insert @path"),
1009 hint("Esc", "cancel"),
1010 ])
1011 .draw(area, buf, theme);
1012 let query = row(body, 0);
1013 if !query.is_empty() {
1014 buf.set_line(
1015 query.x,
1016 query.y,
1017 &Line::from(vec![
1018 Span::styled("> ", theme.fg(Role::Primary).add_modifier(Modifier::BOLD)),
1019 Span::styled("tui/", theme.fg(Role::Foreground)),
1020 Span::styled(
1021 " ",
1022 theme.bg(Role::Primary).patch(theme.fg(Role::Background)),
1023 ),
1024 ]),
1025 query.width,
1026 );
1027 }
1028 for (index, path) in [
1029 "crates/tui/src/tui/ui/frame.rs",
1030 "crates/tui/src/tui/widgets/mod.rs",
1031 "crates/tui/src/tui/work_surface/render/mod.rs",
1032 "crates/tui/src/tui/session_picker.rs",
1033 "crates/tui/src/tui/theme_picker.rs",
1034 ]
1035 .into_iter()
1036 .enumerate()
1037 {
1038 let target = row(body, index as u16 + 2);
1039 if target.is_empty() {
1040 break;
1041 }
1042 let pointer = if index == 0 {
1043 if theme.ascii() { ">" } else { "▸" }
1044 } else {
1045 " "
1046 };
1047 let marker = if target.width >= 18 { " " } else { "" };
1048 let prefix = format!("{pointer} {marker}");
1049 let path = text::truncate(
1050 path,
1051 usize::from(target.width).saturating_sub(text::width(&prefix)),
1052 theme.ascii(),
1053 );
1054 buf.set_line(
1055 target.x,
1056 target.y,
1057 &Line::styled(
1058 format!("{prefix}{path}"),
1059 if index == 0 {
1060 theme.fg(Role::Foreground).patch(theme.bg(Role::Selected))
1061 } else {
1062 theme.fg(Role::Foreground)
1063 },
1064 ),
1065 target.width,
1066 );
1067 }
1068 }
1069
1070 // views/mod.rs::ConfigView::render_settings_shell / config_shell_panes.
1071 fn settings(area: Rect, buf: &mut Buffer, theme: &Theme) {
1072 let body = InstrumentSurface::new("Settings")
1073 .actions(vec![
1074 hint("type", "filter"),
1075 move_hint(theme),
1076 hint("Enter", "edit"),
1077 hint("PgUp/PgDn", "scroll"),
1078 hint("Esc", "close"),
1079 ])
1080 .draw(area, buf, theme);
1081 let nav = row(body, 0);
1082 let categories = [
1083 "Appearance",
1084 "Models & providers",
1085 "Fleet",
1086 "Work",
1087 "Tools & MCP",
1088 "Trust",
1089 "Motion",
1090 "Advanced",
1091 ];
1092 let mut x = nav.x;
1093 for (index, category) in categories
1094 .into_iter()
1095 .enumerate()
1096 .filter(|_| !nav.is_empty())
1097 {
1098 let shown = format!(" {category} ");
1099 let width = u16::try_from(text::width(&shown)).unwrap_or(u16::MAX);
1100 if x.saturating_add(width).saturating_add(2) > nav.right() {
1101 if x < nav.right() {
1102 buf.set_stringn(
1103 nav.right().saturating_sub(2),
1104 nav.y,
1105 if theme.ascii() { " >" } else { " ›" },
1106 2,
1107 theme.fg(Role::Hint),
1108 );
1109 }
1110 break;
1111 }
1112 if !nav.is_empty() {
1113 buf.set_stringn(
1114 x,
1115 nav.y,
1116 &shown,
1117 usize::from(width),
1118 if index == 0 {
1119 selected(theme)
1120 } else {
1121 theme.fg(Role::Muted)
1122 },
1123 );
1124 }
1125 x = x.saturating_add(width);
1126 }
1127 line(
1128 " Search: type to filter (5/5)",
1129 Role::Muted,
1130 row(body, 1),
1131 buf,
1132 theme,
1133 );
1134 let has_detail = body.width >= 100;
1135 let preview = u16::from(body.height >= 12);
1136 let band = if body.height >= 11 {
1137 if has_detail { 1 } else { 3 }
1138 } else {
1139 u16::from(body.height >= 5)
1140 };
1141 let list_area = Rect::new(
1142 body.x,
1143 body.y.saturating_add(2).min(body.bottom()),
1144 body.width,
1145 body.height.saturating_sub(2 + preview + band),
1146 );
1147 let groups_width = if has_detail && list_area.height >= 3 {
1148 18
1149 } else {
1150 0
1151 };
1152 let detail_width = if has_detail {
1153 (list_area.width.saturating_mul(34) / 100).clamp(28, 44)
1154 } else {
1155 0
1156 };
1157 let left_gap = u16::from(groups_width > 0);
1158 let right_gap = u16::from(detail_width > 0);
1159 let list = Rect::new(
1160 list_area.x + groups_width + left_gap,
1161 list_area.y,
1162 list_area
1163 .width
1164 .saturating_sub(groups_width + left_gap + detail_width + right_gap),
1165 list_area.height,
1166 );
1167 if groups_width > 0 {
1168 for (index, group) in ["Display"].into_iter().enumerate() {
1169 line(
1170 group,
1171 if index == 0 {
1172 Role::Primary
1173 } else {
1174 Role::Muted
1175 },
1176 row(
1177 Rect::new(list_area.x, list_area.y, groups_width, list_area.height),
1178 index as u16,
1179 ),
1180 buf,
1181 theme,
1182 );
1183 }
1184 divider(
1185 Rect::new(list_area.x + groups_width, list_area.y, 1, list_area.height),
1186 buf,
1187 theme,
1188 );
1189 }
1190 let settings = [
1191 ("Theme", "underwater", "‹ ›"),
1192 ("Thinking background highlight", "true", "[x]"),
1193 ("Contextual tips", "true", "[x]"),
1194 ("Inline file changes", "full", "‹ ›"),
1195 ("Transcript spacing", "comfortable", "‹ ›"),
1196 ];
1197 let caption = row(list, 0);
1198 if !caption.is_empty() {
1199 buf.set_line(
1200 caption.x,
1201 caption.y,
1202 &Line::styled(
1203 " Display",
1204 theme
1205 .fg(Role::Hint)
1206 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1207 ),
1208 caption.width,
1209 );
1210 }
1211 let scope_width = if list.width >= 60 { 7 } else { 0 };
1212 let available = usize::from(list.width).saturating_sub(11 + scope_width);
1213 let key_width = 19.min(available.saturating_sub(10)).max(19.min(available));
1214 let value_width = available.saturating_sub(key_width).min(44);
1215 for (index, (key, value, affordance)) in settings.into_iter().enumerate() {
1216 let target = row(list, index as u16 + 1);
1217 if target.is_empty() {
1218 break;
1219 }
1220 let chosen = index == 0;
1221 let pointer = if chosen {
1222 if theme.ascii() { ">" } else { "❯" }
1223 } else {
1224 " "
1225 };
1226 let affordance = if theme.ascii() && affordance == "‹ ›" {
1227 "< >"
1228 } else {
1229 affordance
1230 };
1231 let content = format!(
1232 "{pointer}{} {} {affordance:<3} {}",
1233 text::pad(key, key_width, theme.ascii()),
1234 text::pad(value, value_width, theme.ascii()),
1235 if scope_width > 0 { "user" } else { "" }
1236 );
1237 buf.set_line(
1238 target.x,
1239 target.y,
1240 &Line::styled(
1241 content,
1242 if chosen {
1243 selected(theme)
1244 } else {
1245 theme.fg(Role::Foreground)
1246 },
1247 ),
1248 target.width,
1249 );
1250 }
1251 if detail_width > 0 {
1252 let detail = Rect::new(list.right() + 1, list.y, detail_width, list.height);
1253 divider(Rect::new(list.right(), list.y, 1, list.height), buf, theme);
1254 line("Theme", Role::Primary, row(detail, 0), buf, theme);
1255 line("theme", Role::Dim, row(detail, 1), buf, theme);
1256 for (index, (name, value)) in [
1257 ("current", "underwater"),
1258 ("saved", "underwater"),
1259 ("startup", "underwater"),
1260 ("source", "settings.toml"),
1261 ("scope", "user"),
1262 ("apply", "applies on save"),
1263 ("kind", "choice"),
1264 ]
1265 .into_iter()
1266 .enumerate()
1267 {
1268 line(
1269 &format!("{name:<10}{value}"),
1270 Role::Foreground,
1271 row(detail, index as u16 + 3),
1272 buf,
1273 theme,
1274 );
1275 }
1276 }
1277 let band_area = Rect::new(
1278 body.x,
1279 list_area.bottom(),
1280 body.width,
1281 band.min(body.bottom().saturating_sub(list_area.bottom())),
1282 );
1283 line(
1284 "Theme: underwater — Enter to edit",
1285 Role::Muted,
1286 row(band_area, 0),
1287 buf,
1288 theme,
1289 );
1290 if band > 1 {
1291 line(
1292 "current: underwater saved: underwater startup: underwater",
1293 Role::Muted,
1294 row(band_area, 1),
1295 buf,
1296 theme,
1297 );
1298 }
1299 if band > 2 {
1300 line(
1301 "source: settings.toml scope: user apply: applies on save",
1302 Role::Muted,
1303 row(band_area, 2),
1304 buf,
1305 theme,
1306 );
1307 }
1308 if preview > 0 {
1309 super::posture::working_posture().paint(
1310 row(body, body.height.saturating_sub(1)),
1311 buf,
1312 theme,
1313 );
1314 }
1315 }
1316
1317 fn divider(area: Rect, buf: &mut Buffer, theme: &Theme) {
1318 for y in area.y..area.bottom() {
1319 if area.width > 0 {
1320 buf[(area.x, y)]
1321 .set_symbol(if theme.ascii() { "|" } else { "│" })
1322 .set_style(theme.fg(Role::Border));
1323 }
1324 }
1325 }
1326
1327 // work_surface/render/{mod,rows}.rs: reuse the extracted dock, never turn
1328 // its panels into unrelated modal cards. Full-size recipes show side placement.
1329 fn dock(panel: WorkbarPanel, area: Rect, buf: &mut Buffer, theme: &Theme) {
1330 let area = area.intersection(buf.area);
1331 let dock = super::workbar::sample(panel).placement(crate::WorkbarPlacement::Right);
1332 let regions = dock.regions(area);
1333 if let Some(dock_area) = regions.dock {
1334 dock.placement(regions.placement)
1335 .paint(dock_area, buf, theme);
1336 }
1337 if !regions.content.is_empty() {
1338 let content = regions.content;
1339 line("USER", Role::Muted, row(content, 1), buf, theme);
1340 line(
1341 "Review the changes and continue.",
1342 Role::Foreground,
1343 row(content, 2),
1344 buf,
1345 theme,
1346 );
1347 line("ASSISTANT", Role::Muted, row(content, 5), buf, theme);
1348 line(
1349 "I’m checking the selected files and the running tasks.",
1350 Role::Foreground,
1351 row(content, 6),
1352 buf,
1353 theme,
1354 );
1355 if content.height > 12 {
1356 let footer = Rect::new(content.x, content.bottom() - 5, content.width, 5);
1357 crate::NativeComposer::new("Keep the changes focused.")
1358 .focused(true)
1359 .can_submit(true)
1360 .submit_hint("Enter send")
1361 .paint(Rect::new(footer.x, footer.y, footer.width, 3), buf, theme);
1362 super::posture::working_posture().paint(row(footer, 3), buf, theme);
1363 super::posture::working_metrics().paint(row(footer, 4), buf, theme);
1364 }
1365 }
1366 }
1367 fn fleet(area: Rect, buf: &mut Buffer, theme: &Theme) {
1368 dock(WorkbarPanel::Fleet, area, buf, theme);
1369 }
1370 fn jobs(area: Rect, buf: &mut Buffer, theme: &Theme) {
1371 dock(WorkbarPanel::Jobs, area, buf, theme);
1372 }
1373 fn changed_files(area: Rect, buf: &mut Buffer, theme: &Theme) {
1374 dock(WorkbarPanel::Files, area, buf, theme);
1375 }
1376 fn context(area: Rect, buf: &mut Buffer, theme: &Theme) {
1377 dock(WorkbarPanel::Context, area, buf, theme);
1378 }
1379 fn git(area: Rect, buf: &mut Buffer, theme: &Theme) {
1380 dock(WorkbarPanel::Git, area, buf, theme);
1381 }
1382 fn cost(area: Rect, buf: &mut Buffer, theme: &Theme) {
1383 dock(WorkbarPanel::Cost, area, buf, theme);
1384 }
1385
1386 fn rich_composer_facts(theme: &Theme) -> crate::NativeComposerFrame<'static> {
1387 use crate::{
1388 NativeComposerDensity, NativeComposerFrame, NativeComposerMenu, NativeComposerMenuItem,
1389 NativeComposerStyles,
1390 };
1391 let background = theme.tui_ground(crate::TuiGround::Composer);
1392 let plain = theme.fg(crate::Role::Foreground);
1393 let muted = theme.fg(crate::Role::Muted);
1394 let primary = theme.fg(crate::Role::Primary);
1395 let mark = if theme.ascii() { ">" } else { "▸" };
1396 NativeComposerFrame {
1397 text: if theme.ascii() {
1398 "Review the source and keep the change focused"
1399 } else {
1400 "Review 鲸鱼 cafe\u{0301} and keep the change focused"
1401 }
1402 .into(),
1403 cursor: 12,
1404 selection: Some((7, 12)),
1405 placeholder: Line::styled("Write a task or use /.", muted),
1406 enclosed: true,
1407 density: NativeComposerDensity::Comfortable,
1408 history_search: false,
1409 focused: true,
1410 can_submit: true,
1411 ascii: theme.ascii(),
1412 top_title: None,
1413 top_right: Some(Line::styled(
1414 " Builder ",
1415 theme
1416 .fg(crate::Role::Attention)
1417 .add_modifier(Modifier::BOLD),
1418 )),
1419 hint: Some(Line::styled(" Enter chooses; Esc closes menu ", muted)),
1420 quiet_hint: None,
1421 styles: NativeComposerStyles {
1422 background,
1423 border: primary,
1424 quiet_border: theme.fg(crate::Role::Border),
1425 text: plain,
1426 selection: plain.patch(theme.bg(crate::Role::Selected)).add_modifier(
1427 if theme.color(crate::Role::Foreground).is_none() {
1428 Modifier::REVERSED
1429 } else {
1430 Modifier::empty()
1431 },
1432 ),
1433 prompt: primary,
1434 submit: theme
1435 .tui_ink(crate::TuiInk::Info)
1436 .add_modifier(Modifier::BOLD),
1437 },
1438 menu: NativeComposerMenu {
1439 selected: 1,
1440 reserved_rows: 3,
1441 pointer_rows: true,
1442 items: vec![
1443 NativeComposerMenuItem::Columns {
1444 name: "/review".into(),
1445 description: "Review the current changes and show findings".into(),
1446 prefix: Span::styled(" ", plain),
1447 marker: Span::styled(" ", plain),
1448 name_style: plain,
1449 description_style: muted,
1450 },
1451 NativeComposerMenuItem::Columns {
1452 name: "/test or /check".into(),
1453 description: "Run focused checks and preserve their receipt".into(),
1454 prefix: Span::styled(" ", primary),
1455 marker: Span::styled(mark, primary),
1456 name_style: primary,
1457 description_style: plain,
1458 },
1459 ],
1460 },
1461 }
1462 }
1463 fn rich_composer(area: Rect, buf: &mut Buffer, theme: &Theme) {
1464 rich_composer_facts(theme).paint(area, buf, theme);
1465 }
1466 fn rich_composer_search(area: Rect, buf: &mut Buffer, theme: &Theme) {
1467 let mut composer = rich_composer_facts(theme);
1468 composer.text = "".into();
1469 composer.cursor = 0;
1470 composer.selection = None;
1471 composer.history_search = true;
1472 composer.top_title = Some(Line::styled(
1473 " Search history ",
1474 theme.fg(crate::Role::Muted),
1475 ));
1476 composer.menu.items = vec![crate::NativeComposerMenuItem::Line(Line::styled(
1477 if theme.ascii() {
1478 "> Review this project"
1479 } else {
1480 "▸ Review this project"
1481 },
1482 theme.fg(crate::Role::Foreground),
1483 ))];
1484 composer.menu.selected = 0;
1485 composer.menu.reserved_rows = 1;
1486 composer.menu.pointer_rows = false;
1487 composer.paint(area, buf, theme);
1488 }
1489
1490 pub(crate) fn entries() -> Vec<Entry> {
1491 [
1492 (
1493 "native-composer-rich-selection",
1494 72,
1495 9,
1496 rich_composer as fn(Rect, &mut Buffer, &Theme),
1497 ),
1498 ("native-composer-rich-search", 72, 7, rich_composer_search),
1499 (
1500 "instrument-surface",
1501 100,
1502 24,
1503 instrument as fn(Rect, &mut Buffer, &Theme),
1504 ),
1505 ("session-list", 140, 8, session_list),
1506 ("view-sessions", 160, 28, sessions),
1507 ("view-sessions-narrow", 80, 24, sessions),
1508 ("view-sessions-compact", 44, 12, sessions),
1509 ("view-sessions-empty", 80, 12, sessions_empty),
1510 ("view-settings", 132, 28, settings),
1511 ("view-settings-narrow", 80, 24, settings),
1512 ("view-commands", 112, 28, commands),
1513 ("view-models", 132, 28, models),
1514 ("view-models-narrow", 80, 24, models),
1515 ("view-providers", 120, 28, providers),
1516 ("view-theme", 120, 28, themes),
1517 ("view-mode", 80, 16, modes),
1518 ("view-status", 90, 24, status_items),
1519 ("view-file-picker", 100, 24, files),
1520 ("view-fleet-dock", 120, 24, fleet),
1521 ("view-jobs-dock", 120, 24, jobs),
1522 ("view-files-dock", 120, 24, changed_files),
1523 ("view-context-dock", 120, 24, context),
1524 ("view-git-dock", 120, 24, git),
1525 ("view-cost-dock", 120, 24, cost),
1526 ]
1527 .into_iter()
1528 .map(|(name, width, height, draw)| Entry {
1529 name,
1530 width,
1531 height,
1532 draw,
1533 })
1534 .collect()
1535 }
1536
1536 lines RUST