返回 CodeWhale
native_chrome.rs
根目录 / vendor / codewhale-ratatui / src / components / native_chrome.rs
1 //! Portable extraction of the mounted Codewhale TUI composer and its
2 //! borderless workflow progress rows. Source: `crates/tui/src/tui/widgets/
3 //! workbar.rs`, `widgets/mod.rs`, `composer_chrome.rs`, and `ui/frame.rs`.
4 //! Caller data replaces App and WorkflowPanel; geometry, density, columns,
5 //! failure bars, prompt and send chrome retain their native grammar.
6 //!
7 //! Adapted from Codewhale, licensed under the MIT License:
8 //! Copyright (c) 2024-2025 DeepSeek-TUI Contributors
9 //!
10 //! Permission is hereby granted, free of charge, to any person obtaining a
11 //! copy of this software and associated documentation files (the "Software"),
12 //! to deal in the Software without restriction, including without limitation
13 //! the rights to use, copy, modify, merge, publish, distribute, sublicense,
14 //! and/or sell copies of the Software, and to permit persons to whom the
15 //! Software is furnished to do so, subject to the following conditions:
16 //!
17 //! The above copyright notice and this permission notice shall be included
18 //! in all copies or substantial portions of the Software.
19 //!
20 //! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21 //! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22 //! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
23 //! THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24 //! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25 //! FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
26 //! DEALINGS IN THE SOFTWARE.
27
28 use std::{borrow::Cow, time::Duration};
29
30 use ratatui::{
31 buffer::Buffer,
32 layout::{Position, Rect},
33 style::{Modifier, Style},
34 text::{Line, Span},
35 };
36 use unicode_segmentation::UnicodeSegmentation;
37
38 use crate::{Paint, Role, Theme, TuiGround, TuiInk, glyphs, text};
39
40 const BAR_CELLS: usize = 20;
41 const MAX_RUN_ROWS: usize = 6;
42 const GAP: &str = " ";
43
44 /// The workflow lifecycle reported by the host, without inferred outcomes.
45 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
46 pub enum WorkflowRunState {
47 Pending,
48 Running,
49 Succeeded,
50 Degraded,
51 Failed,
52 Cancelled,
53 }
54 impl WorkflowRunState {
55 fn mark(self, ascii: bool) -> &'static str {
56 glyphs::pick(
57 match self {
58 Self::Pending => "○",
59 Self::Running => "•",
60 Self::Succeeded => "✓",
61 Self::Degraded => "◆",
62 Self::Failed => "✕",
63 Self::Cancelled => "⊘",
64 },
65 ascii,
66 )
67 }
68 fn ink(self, theme: &Theme) -> Style {
69 match self {
70 Self::Pending | Self::Cancelled => theme.fg(Role::Muted),
71 Self::Running => theme.tui_ink(TuiInk::Working),
72 Self::Succeeded => theme.tui_ink(TuiInk::Success),
73 Self::Degraded => theme.tui_ink(TuiInk::Warning),
74 Self::Failed => theme.fg(Role::Danger),
75 }
76 }
77 }
78
79 /// Host-reported row outcomes and facts for one run. Successful and failed
80 /// counts stay separate; cancellation never contributes to the success bar.
81 #[derive(Clone, Debug)]
82 pub struct WorkflowRun<'a> {
83 pub title: Cow<'a, str>,
84 pub state: WorkflowRunState,
85 pub succeeded: usize,
86 pub failed: usize,
87 pub cancelled: usize,
88 pub total: usize,
89 pub elapsed: Option<Duration>,
90 pub tokens: Option<u64>,
91 pub queued: usize,
92 pub reason: Option<Cow<'a, str>>,
93 }
94 impl<'a> WorkflowRun<'a> {
95 pub fn new(title: impl Into<Cow<'a, str>>, state: WorkflowRunState) -> Self {
96 Self {
97 title: title.into(),
98 state,
99 succeeded: 0,
100 failed: 0,
101 cancelled: 0,
102 total: 0,
103 elapsed: None,
104 tokens: None,
105 queued: 0,
106 reason: None,
107 }
108 }
109 pub const fn outcomes(
110 mut self,
111 succeeded: usize,
112 failed: usize,
113 cancelled: usize,
114 total: usize,
115 ) -> Self {
116 self.succeeded = succeeded;
117 self.failed = failed;
118 self.cancelled = cancelled;
119 self.total = total;
120 self
121 }
122 pub const fn elapsed(mut self, elapsed: Duration) -> Self {
123 self.elapsed = Some(elapsed);
124 self
125 }
126 pub const fn tokens(mut self, tokens: u64) -> Self {
127 self.tokens = Some(tokens);
128 self
129 }
130 pub const fn queued(mut self, queued: usize) -> Self {
131 self.queued = queued;
132 self
133 }
134 pub fn reason(mut self, reason: impl Into<Cow<'a, str>>) -> Self {
135 self.reason = Some(reason.into());
136 self
137 }
138 }
139
140 /// Localized words replace the native English defaults without changing the
141 /// row grammar. Count fields also accept templates: `done` may contain
142 /// `{done}` and `{total}`; `failed`, `cancelled`, `queued` and `more` may
143 /// contain `{count}`. Templates preserve a language's word and count order.
144 /// A host may change the shortcut shown beside folded runs.
145 #[derive(Clone, Debug)]
146 pub struct WorkflowProgressWords {
147 pub done: Cow<'static, str>,
148 pub failed: Cow<'static, str>,
149 pub cancelled: Cow<'static, str>,
150 pub queued: Cow<'static, str>,
151 pub no_tasks: Cow<'static, str>,
152 pub large: Cow<'static, str>,
153 pub gaps: Cow<'static, str>,
154 pub stopped: Cow<'static, str>,
155 pub more: Cow<'static, str>,
156 pub manage: Cow<'static, str>,
157 }
158 impl Default for WorkflowProgressWords {
159 fn default() -> Self {
160 Self {
161 done: "done".into(),
162 failed: "failed".into(),
163 cancelled: "cancelled".into(),
164 queued: "queued".into(),
165 no_tasks: "No tasks yet".into(),
166 large: "Large workflow".into(),
167 gaps: "finished with gaps".into(),
168 stopped: "stopped".into(),
169 more: "more".into(),
170 manage: "to manage".into(),
171 }
172 }
173 }
174
175 /// The native workbar: borderless progress, one row per workflow, directly
176 /// below the posture bar and above metrics in the mounted TUI compositor.
177 #[derive(Clone, Debug)]
178 pub struct WorkflowProgress<'a> {
179 pub runs: Vec<WorkflowRun<'a>>,
180 pub words: WorkflowProgressWords,
181 }
182 impl<'a> WorkflowProgress<'a> {
183 pub fn new(runs: Vec<WorkflowRun<'a>>) -> Self {
184 Self {
185 runs,
186 words: WorkflowProgressWords::default(),
187 }
188 }
189 pub fn words(mut self, words: WorkflowProgressWords) -> Self {
190 self.words = words;
191 self
192 }
193 pub fn desired_rows(&self) -> u16 {
194 Self::desired_rows_for(self.runs.len())
195 }
196 /// Reserve the native row budget before constructing the visible runs.
197 pub const fn desired_rows_for(runs: usize) -> u16 {
198 if runs > MAX_RUN_ROWS {
199 (MAX_RUN_ROWS + 1) as u16
200 } else {
201 runs as u16
202 }
203 }
204 /// Shared columns align visible runs. Narrow widths shed bar, tokens,
205 /// tail reservation and elapsed in the same order as the native workbar.
206 pub fn lines(&self, width: u16, max_rows: usize, theme: &Theme) -> Vec<Line<'static>> {
207 let width = usize::from(width);
208 let max_rows = max_rows.min(usize::from(self.desired_rows()));
209 if self.runs.is_empty() || max_rows == 0 || width < 8 {
210 return Vec::new();
211 }
212 let shown = if self.runs.len() > max_rows {
213 max_rows.saturating_sub(1)
214 } else {
215 self.runs.len()
216 };
217 let cells: Vec<_> = self.runs[..shown]
218 .iter()
219 .map(|run| RunCells::new(run, &self.words, theme.ascii()))
220 .collect();
221 let layout = ProgressLayout::fit(&cells, width.saturating_sub(1), theme.ascii());
222 let mut rows: Vec<_> = cells
223 .iter()
224 .map(|row| row.line(&layout, width, theme))
225 .collect();
226 let hidden = self.runs.len() - shown;
227 if hidden > 0 {
228 let more = if self.words.more.contains("{count}") {
229 safe(&self.words.more).replace("{count}", &hidden.to_string())
230 } else {
231 format!("+{hidden} {}", safe(&self.words.more))
232 };
233 let line = format!(
234 " {more} {} {} {}",
235 glyphs::pick("·", theme.ascii()),
236 glyphs::pick("↓", theme.ascii()),
237 safe(&self.words.manage)
238 );
239 rows.push(Line::styled(plain_clip(&line, width), theme.fg(Role::Hint)));
240 }
241 rows
242 }
243 }
244 impl Paint for WorkflowProgress<'_> {
245 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
246 let area = area.intersection(buf.area);
247 for (index, line) in self
248 .lines(area.width, usize::from(area.height), theme)
249 .into_iter()
250 .enumerate()
251 {
252 buf.set_line(area.x, area.y + index as u16, &line, area.width);
253 }
254 }
255 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
256 self.desired_rows()
257 }
258 }
259
260 fn safe(value: &str) -> String {
261 text::display_safe(value).into_owned()
262 }
263 fn counted(value: &str, count: usize) -> String {
264 let value = safe(value);
265 if value.contains("{count}") {
266 value.replace("{count}", &count.to_string())
267 } else {
268 format!("{count} {value}")
269 }
270 }
271 fn sentence(value: &str) -> String {
272 let flat = value
273 .split_whitespace()
274 .map(safe)
275 .collect::<Vec<_>>()
276 .join(" ");
277 let mut end = flat.len();
278 let mut chars = flat.char_indices().peekable();
279 while let Some((index, ch)) = chars.next() {
280 if ch == ';'
281 || (matches!(ch, '.' | '!' | '?') && chars.peek().is_some_and(|(_, c)| *c == ' '))
282 {
283 end = if matches!(ch, '.' | ';') {
284 index
285 } else {
286 index + ch.len_utf8()
287 };
288 break;
289 }
290 }
291 flat[..end].trim_end_matches(['.', ' ']).trim().to_owned()
292 }
293 fn reason(value: &str) -> String {
294 let value = value.trim_start();
295 let value = value
296 .strip_prefix('[')
297 .and_then(|v| v.split_once("] "))
298 .filter(|(tag, _)| {
299 !tag.is_empty()
300 && tag.len() <= 24
301 && tag
302 .chars()
303 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
304 })
305 .map_or(value, |(_, rest)| rest);
306 sentence(value)
307 }
308 fn elapsed(value: Duration) -> String {
309 if value.is_zero() {
310 "0s".into()
311 } else if value < Duration::from_secs(1) {
312 format!("{}ms", value.as_millis())
313 } else if value.as_secs() < 60 {
314 format!("{}s", value.as_secs())
315 } else {
316 format!("{}m {:02}s", value.as_secs() / 60, value.as_secs() % 60)
317 }
318 }
319 fn tokens(value: u64) -> String {
320 if value >= 1_000_000 {
321 format!("{:.1}M", value as f64 / 1_000_000.0)
322 } else if value >= 1_000 {
323 format!("{:.1}k", value as f64 / 1_000.0)
324 } else {
325 value.to_string()
326 }
327 }
328 fn bar_cells(succeeded: usize, failed: usize, total: usize) -> (usize, usize) {
329 if total == 0 {
330 return (0, 0);
331 }
332 let cells = |n: usize| {
333 let count = n as u128;
334 let total = total as u128;
335 let result =
336 ((count * BAR_CELLS as u128 + total / 2) / total).min(BAR_CELLS as u128) as usize;
337 if n > 0 { result.max(1) } else { 0 }
338 };
339 let failed = cells(failed);
340 (cells(succeeded).min(BAR_CELLS - failed), failed)
341 }
342
343 struct RunCells {
344 state: WorkflowRunState,
345 name: String,
346 done_cells: usize,
347 failed_cells: usize,
348 done: String,
349 problems: String,
350 elapsed: String,
351 tokens: Option<String>,
352 chips: Vec<String>,
353 reason: Option<String>,
354 }
355 impl RunCells {
356 fn new(run: &WorkflowRun<'_>, words: &WorkflowProgressWords, ascii: bool) -> Self {
357 let sep = if ascii { " . " } else { " · " };
358 let done = if run.total > 0 {
359 if words.done.contains("{done}") || words.done.contains("{total}") {
360 safe(&words.done)
361 .replace("{done}", &run.succeeded.to_string())
362 .replace("{total}", &run.total.to_string())
363 } else {
364 format!("{}/{} {}", run.succeeded, run.total, safe(&words.done))
365 }
366 } else if run.failed == 0 {
367 safe(&words.no_tasks)
368 } else {
369 String::new()
370 };
371 let mut problems = Vec::new();
372 if run.failed > 0 {
373 problems.push(counted(&words.failed, run.failed));
374 }
375 if run.cancelled > 0 {
376 problems.push(counted(&words.cancelled, run.cancelled));
377 }
378 let problems = if problems.is_empty() {
379 String::new()
380 } else {
381 format!(
382 "{}{text}",
383 if done.is_empty() { "" } else { sep },
384 text = problems.join(sep)
385 )
386 };
387 let (done_cells, failed_cells) = bar_cells(run.succeeded, run.failed, run.total);
388 let mut chips = Vec::new();
389 if run.total >= 25 {
390 chips.push(format!(
391 "{} {}",
392 if ascii { "!" } else { "⚠" },
393 safe(&words.large)
394 ));
395 }
396 if run.queued > 0 {
397 chips.push(format!(
398 "{} {}",
399 glyphs::pick("·", ascii),
400 counted(&words.queued, run.queued)
401 ));
402 }
403 let why = run.reason.as_deref().map(reason).filter(|s| !s.is_empty());
404 let reason = match run.state {
405 WorkflowRunState::Failed => Some(why.unwrap_or_else(|| {
406 if words.failed.contains("{count}") {
407 counted(&words.failed, run.failed)
408 } else {
409 safe(&words.failed)
410 }
411 })),
412 WorkflowRunState::Degraded => Some(why.map_or_else(
413 || safe(&words.gaps),
414 |why| format!("{}{sep}{why}", safe(&words.gaps)),
415 )),
416 WorkflowRunState::Cancelled => Some(safe(&words.stopped)),
417 _ => None,
418 };
419 let name = sentence(&run.title);
420 Self {
421 state: run.state,
422 name: text::truncate_words(if name.is_empty() { "workflow" } else { &name }, 40, ascii)
423 .into_owned(),
424 done_cells,
425 failed_cells,
426 done,
427 problems,
428 elapsed: run.elapsed.map(elapsed).unwrap_or_default(),
429 tokens: run
430 .tokens
431 .map(|n| format!("{}{n}", glyphs::pick("↓", ascii), n = tokens(n))),
432 chips,
433 reason,
434 }
435 }
436 fn progress_width(&self) -> usize {
437 text::width(&self.done) + text::width(&self.problems)
438 }
439 fn tail_width(&self) -> usize {
440 self.chips.iter().map(|s| text::width(s) + 1).sum::<usize>()
441 + self.reason.as_deref().map_or(0, |s| text::width(s) + 1)
442 }
443 fn line(&self, layout: &ProgressLayout, width: usize, theme: &Theme) -> Line<'static> {
444 let mut spans = vec![
445 Span::raw(" "),
446 Span::styled(
447 format!("{} ", self.state.mark(theme.ascii())),
448 self.state.ink(theme),
449 ),
450 Span::styled(
451 text::pad(
452 &text::truncate_words(&self.name, layout.name_room, theme.ascii()),
453 layout.name_cols,
454 theme.ascii(),
455 ),
456 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
457 ),
458 ];
459 if layout.bar {
460 spans.extend([
461 Span::raw(GAP),
462 Span::styled(
463 glyphs::pick("█", theme.ascii()).repeat(self.done_cells),
464 theme.tui_ink(TuiInk::Success),
465 ),
466 Span::styled(
467 glyphs::pick("×", theme.ascii()).repeat(self.failed_cells),
468 theme.fg(Role::Danger),
469 ),
470 Span::styled(
471 glyphs::pick("░", theme.ascii())
472 .repeat(BAR_CELLS - self.done_cells - self.failed_cells),
473 theme.fg(Role::Hint),
474 ),
475 ]);
476 }
477 spans.extend([
478 Span::raw(GAP),
479 Span::styled(self.done.clone(), theme.fg(Role::Muted)),
480 Span::styled(self.problems.clone(), theme.fg(Role::Danger)),
481 Span::raw(" ".repeat(layout.progress_cols.saturating_sub(self.progress_width()))),
482 ]);
483 if layout.elapsed_cols > 0 {
484 spans.extend([
485 Span::raw(GAP),
486 Span::styled(
487 text::pad(&self.elapsed, layout.elapsed_cols, theme.ascii()),
488 theme.fg(Role::Muted),
489 ),
490 ]);
491 }
492 if layout.tokens_cols > 0 {
493 spans.extend([
494 Span::raw(GAP),
495 Span::styled(
496 text::pad(
497 self.tokens.as_deref().unwrap_or(""),
498 layout.tokens_cols,
499 theme.ascii(),
500 ),
501 theme.fg(Role::Muted),
502 ),
503 ]);
504 }
505 let mut used = spans.iter().map(|s| text::width(&s.content)).sum::<usize>();
506 for (index, chip) in self.chips.iter().enumerate() {
507 let gap = if index == 0 { GAP } else { " " };
508 spans.extend([
509 Span::raw(gap),
510 Span::styled(chip.clone(), theme.tui_ink(TuiInk::Warning)),
511 ]);
512 used += gap.len() + text::width(chip);
513 }
514 if let Some(reason) = &self.reason {
515 let gap = if self.chips.is_empty() { GAP } else { " " };
516 let room = width.saturating_sub(used + gap.len());
517 if room >= 12.min(text::width(reason)) {
518 spans.extend([
519 Span::raw(gap),
520 Span::styled(
521 text::truncate_words(reason, room, theme.ascii()).into_owned(),
522 self.state.ink(theme),
523 ),
524 ]);
525 }
526 }
527 clip(spans, width)
528 }
529 }
530 struct ProgressLayout {
531 name_room: usize,
532 name_cols: usize,
533 bar: bool,
534 progress_cols: usize,
535 elapsed_cols: usize,
536 tokens_cols: usize,
537 }
538 impl ProgressLayout {
539 fn fit(rows: &[RunCells], width: usize, ascii: bool) -> Self {
540 let widest = |f: &dyn Fn(&RunCells) -> usize| rows.iter().map(f).max().unwrap_or(0);
541 let name_want = widest(&|r| text::width(&r.name));
542 let mut tail_want = widest(&RunCells::tail_width).min(24);
543 let mut layout = Self {
544 name_room: 0,
545 name_cols: 0,
546 bar: width >= 72,
547 progress_cols: widest(&RunCells::progress_width),
548 elapsed_cols: widest(&|r| text::width(&r.elapsed)),
549 tokens_cols: widest(&|r| r.tokens.as_deref().map_or(0, text::width)),
550 };
551 let room = loop {
552 let fixed = 2
553 + if layout.bar { BAR_CELLS + GAP.len() } else { 0 }
554 + GAP.len()
555 + layout.progress_cols
556 + if layout.elapsed_cols > 0 {
557 GAP.len() + layout.elapsed_cols
558 } else {
559 0
560 }
561 + if layout.tokens_cols > 0 {
562 GAP.len() + layout.tokens_cols
563 } else {
564 0
565 };
566 let room = width.saturating_sub(fixed + tail_want);
567 if room >= 12.min(name_want) {
568 break name_want.min(room.max(12.min(name_want)));
569 }
570 if layout.bar {
571 layout.bar = false;
572 } else if layout.tokens_cols > 0 {
573 layout.tokens_cols = 0;
574 } else if tail_want > 0 {
575 tail_want = 0;
576 } else if layout.elapsed_cols > 0 {
577 layout.elapsed_cols = 0;
578 } else {
579 break width.saturating_sub(fixed).max(1).min(name_want.max(1));
580 }
581 };
582 layout.name_room = room;
583 layout.name_cols = widest(&|r| text::width(&text::truncate_words(&r.name, room, ascii)));
584 layout
585 }
586 }
587 fn clip(spans: Vec<Span<'static>>, width: usize) -> Line<'static> {
588 let mut used = 0;
589 let mut output = Vec::new();
590 for span in spans {
591 let cells = text::width(&span.content);
592 if used + cells <= width {
593 used += cells;
594 output.push(span);
595 } else {
596 let room = width.saturating_sub(used);
597 if room > 0 {
598 output.push(Span::styled(plain_clip(&span.content, room), span.style));
599 }
600 break;
601 }
602 }
603 Line::from(output)
604 }
605 fn plain_clip(value: &str, width: usize) -> String {
606 let mut cells = 0;
607 value
608 .graphemes(true)
609 .take_while(|g| {
610 let next = text::width(g);
611 if cells + next > width {
612 false
613 } else {
614 cells += next;
615 true
616 }
617 })
618 .collect()
619 }
620
621 /// The mounted composer's content floor and native total-row cap.
622 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
623 pub enum NativeComposerDensity {
624 Compact,
625 #[default]
626 Comfortable,
627 Spacious,
628 }
629 impl NativeComposerDensity {
630 pub const fn max_rows(self) -> u16 {
631 match self {
632 Self::Compact => 7,
633 Self::Comfortable => 9,
634 Self::Spacious => 12,
635 }
636 }
637 }
638
639 /// Shared render and hit-test geometry. Text never claims the submit target
640 /// or its breathing cell. A compact/quiet enclosure has no submit rectangle.
641 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
642 pub struct NativeComposerGeometry {
643 pub inner: Rect,
644 pub text: Rect,
645 pub submit: Option<Rect>,
646 pub prompt_x: Option<u16>,
647 }
648
649 /// The actual mounted native composer chrome, over caller-owned text.
650 /// Editing, submit dispatch, menus and input-method state belong to the host.
651 #[derive(Clone, Debug)]
652 pub struct NativeComposer<'a> {
653 pub text: Cow<'a, str>,
654 pub placeholder: Cow<'a, str>,
655 pub focused: bool,
656 pub enclosed: bool,
657 pub density: NativeComposerDensity,
658 pub can_submit: bool,
659 pub submit_hint: Option<Cow<'a, str>>,
660 pub target: Option<Cow<'a, str>>,
661 /// Cursor index in graphemes of the displayed text, defaulting to its end.
662 pub cursor: Option<usize>,
663 pub menu_rows: usize,
664 }
665 impl<'a> NativeComposer<'a> {
666 pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
667 Self {
668 text: text.into(),
669 placeholder: "Write a task or use /.".into(),
670 focused: false,
671 enclosed: true,
672 density: NativeComposerDensity::Comfortable,
673 can_submit: false,
674 submit_hint: None,
675 target: None,
676 cursor: None,
677 menu_rows: 0,
678 }
679 }
680 pub const fn focused(mut self, focused: bool) -> Self {
681 self.focused = focused;
682 self
683 }
684 pub const fn enclosed(mut self, enclosed: bool) -> Self {
685 self.enclosed = enclosed;
686 self
687 }
688 pub const fn density(mut self, density: NativeComposerDensity) -> Self {
689 self.density = density;
690 self
691 }
692 pub const fn can_submit(mut self, can_submit: bool) -> Self {
693 self.can_submit = can_submit;
694 self
695 }
696 pub const fn cursor(mut self, grapheme: usize) -> Self {
697 self.cursor = Some(grapheme);
698 self
699 }
700 pub const fn menu_rows(mut self, rows: usize) -> Self {
701 self.menu_rows = rows;
702 self
703 }
704 pub fn placeholder(mut self, placeholder: impl Into<Cow<'a, str>>) -> Self {
705 self.placeholder = placeholder.into();
706 self
707 }
708 pub fn submit_hint(mut self, hint: impl Into<Cow<'a, str>>) -> Self {
709 self.submit_hint = Some(hint.into());
710 self
711 }
712 pub fn target(mut self, target: impl Into<Cow<'a, str>>) -> Self {
713 self.target = Some(target.into());
714 self
715 }
716 pub fn has_panel(&self, area: Rect) -> bool {
717 self.geometry(area).submit.is_some()
718 }
719 pub fn geometry(&self, area: Rect) -> NativeComposerGeometry {
720 crate::native_composer_geometry(area, self.enclosed, false)
721 }
722 pub fn desired_height(&self, width: u16, available: u16) -> u16 {
723 self.frame(None, None).desired_height(width, available)
724 }
725 pub fn cursor_position(&self, area: Rect) -> Option<Position> {
726 self.frame(None, None).plan(area).cursor
727 }
728 fn frame(
729 &self,
730 theme: Option<&Theme>,
731 area: Option<Rect>,
732 ) -> crate::NativeComposerFrame<'static> {
733 use crate::{NativeComposerFrame, NativeComposerMenu, NativeComposerStyles};
734 let width = area.map(|area| area.width);
735 let panel = area.is_some_and(|area| self.has_panel(area));
736 let value = multiline_safe(&self.text);
737 let count = self.cursor.unwrap_or_else(|| value.graphemes(true).count());
738 let cursor = value
739 .graphemes(true)
740 .take(count)
741 .map(|g| g.chars().count())
742 .sum();
743 let title = |label: String, room: Option<u16>| match room {
744 Some(room) => {
745 text::truncate_words(&label, usize::from(room), theme.is_some_and(Theme::ascii))
746 .into_owned()
747 }
748 None => label,
749 };
750 let background = theme
751 .map(|t| t.tui_ground(TuiGround::Composer))
752 .unwrap_or_default();
753 let role = |role| theme.map(|t| t.fg(role)).unwrap_or_default();
754 let border = background.patch(role(if self.focused {
755 Role::Primary
756 } else {
757 Role::Border
758 }));
759 let submit = background.patch(if self.can_submit {
760 theme.map(|t| t.tui_ink(TuiInk::Info)).unwrap_or_default()
761 } else {
762 role(Role::Dim)
763 });
764 NativeComposerFrame {
765 text: Cow::Owned(value),
766 cursor,
767 selection: None,
768 placeholder: Line::styled(
769 multiline_safe(&self.placeholder),
770 background.patch(theme.map(|t| t.tui_ink(TuiInk::Soft)).unwrap_or_default()),
771 ),
772 enclosed: self.enclosed,
773 density: self.density,
774 history_search: false,
775 focused: self.focused,
776 can_submit: self.can_submit,
777 ascii: theme.is_some_and(Theme::ascii),
778 top_title: None,
779 top_right: self.target.as_ref().map(|target| {
780 Line::styled(
781 title(
782 format!(" {} ", safe(target)),
783 width.map(|width| width.saturating_sub(2)),
784 ),
785 background
786 .patch(role(Role::Attention))
787 .add_modifier(Modifier::BOLD),
788 )
789 }),
790 hint: self
791 .submit_hint
792 .as_ref()
793 .filter(|_| !self.text.trim().is_empty())
794 .map(|hint| {
795 Line::styled(
796 title(
797 format!(" {} ", safe(hint)),
798 width.map(|width| width.saturating_sub(u16::from(panel) * 2)),
799 ),
800 background.patch(role(Role::Primary)),
801 )
802 }),
803 quiet_hint: self
804 .submit_hint
805 .as_ref()
806 .filter(|_| !self.text.trim().is_empty())
807 .map(|hint| {
808 Line::styled(
809 title(
810 format!(" {} ", safe(hint)),
811 width.map(|width| width.saturating_sub(u16::from(panel) * 2)),
812 ),
813 background.patch(role(Role::Primary)),
814 )
815 }),
816 styles: NativeComposerStyles {
817 background,
818 border,
819 quiet_border: background.patch(role(Role::Border)),
820 text: background.patch(role(Role::Foreground)),
821 selection: background.patch(role(Role::Foreground)),
822 prompt: background.patch(role(Role::Primary)),
823 submit: if self.can_submit {
824 submit.add_modifier(Modifier::BOLD)
825 } else {
826 submit
827 },
828 },
829 menu: NativeComposerMenu {
830 reserved_rows: self.menu_rows,
831 ..Default::default()
832 },
833 }
834 }
835 }
836 impl Paint for NativeComposer<'_> {
837 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
838 let area = area.intersection(buf.area);
839 for y in area.top()..area.bottom() {
840 for x in area.left()..area.right() {
841 buf[(x, y)].set_symbol(" ");
842 }
843 }
844 self.frame(Some(theme), Some(area)).render(area, buf);
845 }
846 fn height(&self, width: u16, _theme: &Theme) -> u16 {
847 self.desired_height(width, self.density.max_rows())
848 }
849 }
850 fn multiline_safe(value: &str) -> String {
851 value.split('\n').map(safe).collect::<Vec<_>>().join("\n")
852 }
853
853 lines RUST