返回 CodeWhale
instrument.rs
根目录 / vendor / codewhale-ratatui / src / components / instrument.rs
1 //! Codewhale's instrument rooms and saved-session list.
2 //!
3 //! Extracted from `crates/tui/src/tui/views/mod.rs::{render_underwater_surface,
4 //! action_footer_lines,place_footer_lines,render_panel_scroll_rail}` and
5 //! `session_picker.rs::{build_list_lines,format_session_line}` at
6 //! `a79ce5c4d5ed1a5f7032185710c27343a900351c` in Hmbown/CodeWhale.
7 //! Callers provide display facts and own filtering, persistence, and actions.
8
9 //!
10 //! MIT License
11 //!
12 //! Copyright (c) 2024-2025 DeepSeek-TUI Contributors
13 //!
14 //! Permission is hereby granted, free of charge, to any person obtaining a copy
15 //! of this software and associated documentation files (the "Software"), to deal
16 //! in the Software without restriction, including without limitation the rights
17 //! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18 //! copies of the Software, and to permit persons to whom the Software is
19 //! furnished to do so, subject to the following conditions:
20 //!
21 //! The above copyright notice and this permission notice shall be included in all
22 //! copies or substantial portions of the Software.
23 //!
24 //! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25 //! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26 //! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27 //! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28 //! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29 //! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30 //! SOFTWARE.
31
32 use std::borrow::Cow;
33
34 use ratatui::{
35 buffer::Buffer,
36 layout::{Position, Rect},
37 style::Modifier,
38 symbols::border,
39 text::{Line, Span},
40 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
41 };
42 use unicode_segmentation::UnicodeSegmentation;
43
44 use crate::{KeyHint, Paint, PickerState, Role, Theme, TuiGround, text};
45
46 /// The native room shell, including a wrapping, bottom-anchored action rail.
47 #[derive(Clone, Debug)]
48 pub struct InstrumentSurface<'a> {
49 pub title: Cow<'a, str>,
50 pub actions: Vec<KeyHint>,
51 pub quiet_gutter: bool,
52 }
53
54 /// Geometry returned by [`InstrumentSurface::areas`].
55 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
56 pub struct InstrumentAreas {
57 pub surface: Rect,
58 pub body: Rect,
59 pub footer: Rect,
60 }
61
62 impl<'a> InstrumentSurface<'a> {
63 #[must_use]
64 pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
65 Self {
66 title: title.into(),
67 actions: Vec::new(),
68 quiet_gutter: false,
69 }
70 }
71
72 #[must_use]
73 pub fn actions(mut self, actions: Vec<KeyHint>) -> Self {
74 self.actions = actions;
75 self
76 }
77
78 /// Keep one quiet row before the action rail when four body rows remain.
79 #[must_use]
80 pub const fn quiet_gutter(mut self, enabled: bool) -> Self {
81 self.quiet_gutter = enabled;
82 self
83 }
84
85 fn block(&self, area: Rect, theme: &Theme) -> Block<'static> {
86 let surface = surface_area(area);
87 let title = native_safe(&self.title, theme);
88 let title = text::truncate_words(
89 &title,
90 usize::from(surface.width.saturating_sub(4)),
91 theme.ascii(),
92 );
93 let mut block = Block::default()
94 .title(Line::from(Span::styled(
95 format!(" {title} "),
96 theme.fg(Role::Primary).add_modifier(Modifier::BOLD),
97 )))
98 .borders(Borders::TOP | Borders::BOTTOM)
99 .border_style(theme.fg(Role::Border))
100 .style(theme.tui_ground(TuiGround::Surface))
101 .padding(Padding::new(1, 1, u16::from(area.height >= 24), 0));
102 if theme.ascii() {
103 block = block.border_set(border::Set {
104 horizontal_top: "-",
105 horizontal_bottom: "-",
106 ..border::PLAIN
107 });
108 }
109 block
110 }
111
112 /// Layout against the same rectangle used for painting. For a clipped
113 /// buffer, intersect that rectangle with `buf.area` first.
114 #[must_use]
115 pub fn areas(&self, area: Rect, theme: &Theme) -> InstrumentAreas {
116 let surface = surface_area(area);
117 let inner = self.block(area, theme).inner(surface);
118 let (body, footer) = footer_areas(
119 inner,
120 action_lines(&self.actions, inner.width, theme).len(),
121 self.quiet_gutter,
122 );
123 InstrumentAreas {
124 surface,
125 body,
126 footer,
127 }
128 }
129
130 /// Paint the native shell and action rail, returning its content rectangle.
131 pub fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect {
132 let area = area.intersection(buf.area);
133 if area.is_empty() {
134 return area;
135 }
136 Clear.render(area, buf);
137 Block::default()
138 .style(theme.tui_ground(TuiGround::Surface))
139 .render(area, buf);
140 let block = self.block(area, theme);
141 let inner = block.inner(surface_area(area));
142 block.render(surface_area(area), buf);
143 Self::draw_footer(inner, buf, &self.actions, theme, self.quiet_gutter)
144 }
145
146 /// Reuse the native action rail inside a centered choice or another host
147 /// surface. Hints wrap as whole actions, with a bold padded key and one
148 /// space between actions; no action is discarded to make the row fit.
149 pub fn draw_footer(
150 area: Rect,
151 buf: &mut Buffer,
152 actions: &[KeyHint],
153 theme: &Theme,
154 quiet_gutter: bool,
155 ) -> Rect {
156 let area = area.intersection(buf.area);
157 let lines = action_lines(actions, area.width, theme);
158 let (body, footer) = footer_areas(area, lines.len(), quiet_gutter);
159 if !footer.is_empty() {
160 Paragraph::new(lines).render(footer, buf);
161 }
162 body
163 }
164 }
165
166 impl Paint for InstrumentSurface<'_> {
167 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
168 1
169 }
170 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
171 self.draw(area, buf, theme);
172 }
173 }
174
175 fn surface_area(area: Rect) -> Rect {
176 let x = u16::from(area.width >= 44);
177 let y = u16::from(area.height >= 24);
178 Rect::new(
179 area.x.saturating_add(x),
180 area.y.saturating_add(y),
181 area.width.saturating_sub(2 * x),
182 area.height.saturating_sub(2 * y),
183 )
184 }
185
186 fn footer_areas(inner: Rect, line_count: usize, quiet: bool) -> (Rect, Rect) {
187 let height = u16::try_from(line_count)
188 .unwrap_or(u16::MAX)
189 .min(inner.height);
190 let gutter = u16::from(height > 0 && quiet && inner.height >= height.saturating_add(4));
191 let footer = Rect::new(
192 inner.x,
193 inner.bottom().saturating_sub(height),
194 inner.width,
195 height,
196 );
197 let body = Rect::new(
198 inner.x,
199 inner.y,
200 inner.width,
201 inner.height.saturating_sub(height.saturating_add(gutter)),
202 );
203 (body, footer)
204 }
205
206 fn action_lines(actions: &[KeyHint], width: u16, theme: &Theme) -> Vec<Line<'static>> {
207 if width == 0 {
208 return Vec::new();
209 }
210 let mut result = Vec::new();
211 let mut current = Vec::new();
212 let mut used = 0usize;
213 for action in actions {
214 let key = native_safe(&action.keys, theme);
215 let label = native_safe(&action.verb, theme);
216 let needed = text::width(&key) + 2 + text::width(&label);
217 if !current.is_empty() && used + 1 + needed > usize::from(width) {
218 result.push(Line::from(std::mem::take(&mut current)));
219 used = 0;
220 }
221 if !current.is_empty() {
222 current.push(Span::raw(" "));
223 used += 1;
224 }
225 let key_role = if action.enabled {
226 Role::Primary
227 } else {
228 Role::Dim
229 };
230 let label_role = if action.enabled {
231 Role::Muted
232 } else {
233 Role::Dim
234 };
235 current.push(Span::styled(
236 format!(" {key} "),
237 theme.fg(key_role).add_modifier(Modifier::BOLD),
238 ));
239 current.push(Span::styled(label, theme.fg(label_role)));
240 used += needed;
241 }
242 if !current.is_empty() {
243 result.push(Line::from(current));
244 }
245 result
246 }
247
248 /// A saved-session display row. `updated` is formatted by the host, so the
249 /// library needs neither a clock nor a session store.
250 #[derive(Clone, Debug, PartialEq, Eq)]
251 pub struct SessionRow<'a> {
252 pub id: Cow<'a, str>,
253 pub title: Cow<'a, str>,
254 pub message_count: usize,
255 pub mode: Option<Cow<'a, str>>,
256 pub updated: Cow<'a, str>,
257 pub current: bool,
258 pub fork: bool,
259 pub archived: bool,
260 }
261
262 impl<'a> SessionRow<'a> {
263 #[must_use]
264 pub fn new(id: impl Into<Cow<'a, str>>, title: impl Into<Cow<'a, str>>) -> Self {
265 Self {
266 id: id.into(),
267 title: title.into(),
268 message_count: 0,
269 mode: None,
270 updated: Cow::Borrowed(""),
271 current: false,
272 fork: false,
273 archived: false,
274 }
275 }
276 #[must_use]
277 pub const fn messages(mut self, count: usize) -> Self {
278 self.message_count = count;
279 self
280 }
281 #[must_use]
282 pub fn mode(mut self, mode: impl Into<Cow<'a, str>>) -> Self {
283 self.mode = Some(mode.into());
284 self
285 }
286 #[must_use]
287 pub fn updated(mut self, updated: impl Into<Cow<'a, str>>) -> Self {
288 self.updated = updated.into();
289 self
290 }
291 #[must_use]
292 pub const fn current(mut self, current: bool) -> Self {
293 self.current = current;
294 self
295 }
296 #[must_use]
297 pub const fn fork(mut self, fork: bool) -> Self {
298 self.fork = fork;
299 self
300 }
301 #[must_use]
302 pub const fn archived(mut self, archived: bool) -> Self {
303 self.archived = archived;
304 self
305 }
306
307 fn line(&self, words: &SessionListWords<'_>, theme: &Theme) -> String {
308 let id = native_safe(&self.id, theme);
309 let id = id.get(..8).unwrap_or(&id);
310 let raw_title = if self.title == "Session" {
311 id.to_string()
312 } else {
313 native_safe(&self.title, theme)
314 };
315 let title = session_truncate(&raw_title, 32);
316 let unit = if self.message_count == 1 {
317 &words.message
318 } else {
319 &words.messages
320 };
321 let mut flags = String::new();
322 for (enabled, label) in [
323 (self.current, &words.current),
324 (self.fork, &words.fork),
325 (self.archived, &words.archived),
326 ] {
327 if enabled {
328 flags.push_str(" | ");
329 flags.push_str(&native_safe(label, theme));
330 }
331 }
332 let mode = self.mode.as_ref().map_or_else(
333 || native_safe(&words.unknown_mode, theme),
334 |mode| native_safe(mode, theme).to_ascii_lowercase(),
335 );
336 format!(
337 "{id} | {title} | {} {}{flags} | {mode} | {}",
338 self.message_count,
339 native_safe(unit, theme),
340 native_safe(&self.updated, theme)
341 )
342 }
343 }
344
345 /// Localizable copy used by the native session list. Query, status, sort
346 /// labels, titles, and timestamps remain caller-provided text.
347 #[derive(Clone, Debug, PartialEq, Eq)]
348 pub struct SessionListWords<'a> {
349 pub new_title: Cow<'a, str>,
350 pub confirm_delete: Cow<'a, str>,
351 pub empty_title: Cow<'a, str>,
352 pub empty_hint: Cow<'a, str>,
353 /// Contains `{start}`, `{end}`, and `{total}`.
354 pub showing_range: Cow<'a, str>,
355 pub message: Cow<'a, str>,
356 pub messages: Cow<'a, str>,
357 pub current: Cow<'a, str>,
358 pub fork: Cow<'a, str>,
359 pub archived: Cow<'a, str>,
360 pub unknown_mode: Cow<'a, str>,
361 }
362
363 impl Default for SessionListWords<'_> {
364 fn default() -> Self {
365 Self {
366 new_title: "New title: ".into(),
367 confirm_delete: "Confirm delete (y/n)".into(),
368 empty_title: "No saved sessions yet.".into(),
369 empty_hint: "Send a message to start one — it saves automatically.".into(),
370 showing_range: "Showing {start}-{end} / {total}".into(),
371 message: "msg".into(),
372 messages: "msgs".into(),
373 current: "current".into(),
374 fork: "fork".into(),
375 archived: "archived".into(),
376 unknown_mode: "unknown".into(),
377 }
378 }
379 }
380
381 /// The portable saved-session list used by the current `/sessions` room.
382 /// Supply already-filtered/sorted rows and update [`PickerState`] in the host.
383 #[derive(Clone, Debug)]
384 pub struct SessionList<'a> {
385 pub rows: Vec<SessionRow<'a>>,
386 pub state: PickerState,
387 pub scope_sort: Cow<'a, str>,
388 pub query: Option<Cow<'a, str>>,
389 pub rename: Option<Cow<'a, str>>,
390 pub status: Option<Cow<'a, str>>,
391 pub confirm_delete: bool,
392 pub words: SessionListWords<'a>,
393 }
394
395 /// A visible source-row index and its one-line mouse target.
396 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
397 pub struct SessionHitbox {
398 pub index: usize,
399 pub area: Rect,
400 }
401
402 impl<'a> SessionList<'a> {
403 #[must_use]
404 pub fn new(rows: Vec<SessionRow<'a>>) -> Self {
405 Self {
406 rows,
407 state: PickerState::default(),
408 scope_sort: "scope and sort · recent".into(),
409 query: None,
410 rename: None,
411 status: None,
412 confirm_delete: false,
413 words: SessionListWords::default(),
414 }
415 }
416 #[must_use]
417 pub const fn state(mut self, state: PickerState) -> Self {
418 self.state = state;
419 self
420 }
421 #[must_use]
422 pub fn scope_sort(mut self, label: impl Into<Cow<'a, str>>) -> Self {
423 self.scope_sort = label.into();
424 self
425 }
426 #[must_use]
427 pub fn query(mut self, query: impl Into<Cow<'a, str>>) -> Self {
428 self.query = Some(query.into());
429 self
430 }
431 #[must_use]
432 pub fn rename(mut self, title: impl Into<Cow<'a, str>>) -> Self {
433 self.rename = Some(title.into());
434 self
435 }
436 #[must_use]
437 pub fn status(mut self, status: impl Into<Cow<'a, str>>) -> Self {
438 self.status = Some(status.into());
439 self
440 }
441 #[must_use]
442 pub const fn confirm_delete(mut self, confirm: bool) -> Self {
443 self.confirm_delete = confirm;
444 self
445 }
446 #[must_use]
447 pub fn words(mut self, words: SessionListWords<'a>) -> Self {
448 self.words = words;
449 self
450 }
451
452 fn layout(&self, area: Rect) -> SessionLayout {
453 let header = 1 + usize::from(self.confirm_delete || self.status.is_some());
454 let visible = usize::from(area.height)
455 .saturating_sub(header + usize::from(!self.rows.is_empty()))
456 .max(1);
457 let offset = self.state.visible_offset(self.rows.len(), visible);
458 let scrolls =
459 area.width >= 2 && area.height > 0 && self.rows.len().saturating_add(header) > visible;
460 SessionLayout {
461 header,
462 visible,
463 offset,
464 content: Rect::new(
465 area.x,
466 area.y,
467 area.width.saturating_sub(u16::from(scrolls)),
468 area.height,
469 ),
470 scrolls,
471 }
472 }
473
474 /// Visible item targets, excluding the header, range label, and rail.
475 /// Pass the clipped paint area when the buffer only covers part of a view.
476 #[must_use]
477 pub fn hitboxes(&self, area: Rect, _theme: &Theme) -> Vec<SessionHitbox> {
478 if area.is_empty() || self.rows.is_empty() {
479 return Vec::new();
480 }
481 let layout = self.layout(area);
482 self.rows
483 .iter()
484 .enumerate()
485 .skip(layout.offset)
486 .take(layout.visible)
487 .enumerate()
488 .filter_map(|(slot, (index, _))| {
489 let y = area
490 .y
491 .saturating_add(u16::try_from(layout.header + slot).unwrap_or(u16::MAX));
492 (y < area.bottom() && layout.content.width > 0).then_some(SessionHitbox {
493 index,
494 area: Rect::new(area.x, y, layout.content.width, 1),
495 })
496 })
497 .collect()
498 }
499
500 #[must_use]
501 pub fn item_at(&self, area: Rect, position: Position, theme: &Theme) -> Option<usize> {
502 self.hitboxes(area, theme)
503 .into_iter()
504 .find_map(|hit| hit.area.contains(position).then_some(hit.index))
505 }
506 }
507
508 struct SessionLayout {
509 header: usize,
510 visible: usize,
511 offset: usize,
512 content: Rect,
513 scrolls: bool,
514 }
515
516 impl Paint for SessionList<'_> {
517 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
518 u16::try_from(
519 1 + usize::from(self.confirm_delete || self.status.is_some())
520 + if self.rows.is_empty() {
521 2
522 } else {
523 self.rows.len() + 1
524 },
525 )
526 .unwrap_or(u16::MAX)
527 }
528 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
529 let area = area.intersection(buf.area);
530 if area.is_empty() {
531 return;
532 }
533 let layout = self.layout(area);
534 if layout.scrolls {
535 scroll_rail(
536 area,
537 buf,
538 self.rows.len().saturating_add(layout.header),
539 layout.offset,
540 layout.visible,
541 theme,
542 );
543 }
544 let width = usize::from(layout.content.width);
545 let header = if let Some(query) = &self.query {
546 format!("/{}", native_safe(query, theme))
547 } else if let Some(rename) = &self.rename {
548 format!(
549 "{}{}_",
550 native_safe(&self.words.new_title, theme),
551 native_safe(rename, theme)
552 )
553 } else {
554 native_safe(&self.scope_sort, theme)
555 };
556 let mut lines = vec![Line::styled(
557 session_truncate(&header, width),
558 theme.fg(Role::Muted),
559 )];
560 if self.confirm_delete {
561 lines.push(Line::styled(
562 session_truncate(&native_safe(&self.words.confirm_delete, theme), width),
563 theme.fg(Role::Attention).add_modifier(Modifier::BOLD),
564 ));
565 } else if let Some(status) = &self.status {
566 lines.push(Line::styled(
567 session_truncate(&native_safe(status, theme), width),
568 theme.fg(Role::Primary),
569 ));
570 }
571 if self.rows.is_empty() {
572 lines.push(Line::styled(
573 session_truncate(&native_safe(&self.words.empty_title, theme), width),
574 theme.fg(Role::Muted),
575 ));
576 lines.push(Line::styled(
577 session_truncate(&native_safe(&self.words.empty_hint, theme), width),
578 theme.fg(Role::Hint),
579 ));
580 } else {
581 for (slot, (index, row)) in self
582 .rows
583 .iter()
584 .enumerate()
585 .skip(layout.offset)
586 .take(layout.visible)
587 .enumerate()
588 {
589 let prefix = if slot < 9 {
590 format!("{}. ", slot + 1)
591 } else {
592 " ".into()
593 };
594 let style = if index == self.state.selected.min(self.rows.len().saturating_sub(1)) {
595 theme
596 .fg(Role::Foreground)
597 .patch(theme.bg(Role::Selected))
598 .add_modifier(Modifier::BOLD)
599 } else {
600 theme.fg(if row.current {
601 Role::Primary
602 } else {
603 Role::Foreground
604 })
605 };
606 lines.push(Line::styled(
607 session_truncate(&format!("{prefix}{}", row.line(&self.words, theme)), width),
608 style,
609 ));
610 }
611 if self.rows.len() > layout.visible {
612 let label = native_safe(&self.words.showing_range, theme)
613 .replace("{start}", &layout.offset.saturating_add(1).to_string())
614 .replace(
615 "{end}",
616 &layout
617 .offset
618 .saturating_add(layout.visible)
619 .min(self.rows.len())
620 .to_string(),
621 )
622 .replace("{total}", &self.rows.len().to_string());
623 lines.push(Line::styled(
624 session_truncate(&label, width),
625 theme.fg(Role::Dim),
626 ));
627 }
628 }
629 Paragraph::new(lines).render(layout.content, buf);
630 }
631 }
632
633 fn scroll_rail(
634 area: Rect,
635 buf: &mut Buffer,
636 total: usize,
637 offset: usize,
638 visible: usize,
639 theme: &Theme,
640 ) {
641 let height = usize::from(area.height);
642 if height == 0 || area.width < 2 || total <= visible.max(1) {
643 return;
644 }
645 let thumb = height
646 .saturating_mul(visible)
647 .div_ceil(total)
648 .clamp(1, height);
649 let max_offset = total.saturating_sub(visible);
650 let top = (height - thumb)
651 .saturating_mul(offset.min(max_offset))
652 .checked_div(max_offset)
653 .unwrap_or(0);
654 for i in 0..height {
655 let on = i >= top && i < top + thumb;
656 let glyph = match (on, theme.ascii()) {
657 (true, false) => "█",
658 (false, false) => "│",
659 (true, true) => "#",
660 (false, true) => "|",
661 };
662 buf[(area.right() - 1, area.y + i as u16)]
663 .set_symbol(glyph)
664 .set_style(theme.fg(if on { Role::Muted } else { Role::Border }));
665 }
666 }
667
668 /// The session list's native three-dot truncation and conservative title
669 /// budget. Grapheme boundaries and degenerate widths are made safe here.
670 fn session_truncate(value: &str, max: usize) -> String {
671 if text::width(value) <= max {
672 return value.into();
673 }
674 let mut out = String::new();
675 let mut used = 0;
676 for g in value.graphemes(true) {
677 let width = text::width(g);
678 if if max > 3 {
679 used + width >= max - 3
680 } else {
681 used + width > max
682 } {
683 break;
684 }
685 out.push_str(g);
686 used += width;
687 }
688 if max > 3 {
689 out.push_str("...");
690 }
691 out
692 }
693
694 fn native_safe(value: &str, theme: &Theme) -> String {
695 let safe = text::display_safe(value);
696 if !theme.ascii() {
697 return safe.into_owned();
698 }
699 safe.chars()
700 .map(|c| match c {
701 '↑' => "Up".into(),
702 '↓' => "Dn".into(),
703 '←' => "Left".into(),
704 '→' => "Right".into(),
705 '·' => ".".into(),
706 '—' | '–' => "-".into(),
707 '↵' => ">".into(),
708 '⇆' => "<>".into(),
709 '×' => "x".into(),
710 '✓' => "+".into(),
711 '▸' => ">".into(),
712 '…' => "...".into(),
713 _ => c.to_string(),
714 })
715 .collect()
716 }
717
717 lines RUST