1#![doc(html_playground_url = "https://play.rust-lang.org")]
143
144mod fmt;
146use fmt::LineFmt;
147
148#[macro_use]
151extern crate lazy_regex;
152
153#[cfg(feature = "clipboard")]
154use cli_clipboard::{ClipboardContext, ClipboardProvider};
155use tui_textarea::{CursorMove, TextArea as TextAreaWidget};
156use tuirealm::command::{Cmd, CmdResult, Direction, Position};
157use tuirealm::props::{
158 Alignment, AttrValue, Attribute, Borders, PropPayload, PropValue, Props, Style, TextModifiers,
159};
160use tuirealm::ratatui::layout::{Constraint, Direction as LayoutDirection, Layout, Rect};
161use tuirealm::ratatui::widgets::{Block, Paragraph};
162use tuirealm::{Frame, MockComponent, State, StateValue};
163
164pub const TEXTAREA_CURSOR_LINE_STYLE: &str = "cursor-line-style";
166pub const TEXTAREA_CURSOR_STYLE: &str = "cursor-style";
167pub const TEXTAREA_FOOTER_FMT: &str = "footer-fmt";
168pub const TEXTAREA_LINE_NUMBER_STYLE: &str = "line-number-style";
169pub const TEXTAREA_MAX_HISTORY: &str = "max-history";
170pub const TEXTAREA_STATUS_FMT: &str = "status-fmt";
171pub const TEXTAREA_TAB_SIZE: &str = "tab-size";
172pub const TEXTAREA_HARD_TAB: &str = "hard-tab";
173pub const TEXTAREA_SINGLE_LINE: &str = "single-line";
174#[cfg(feature = "search")]
175pub const TEXTAREA_SEARCH_PATTERN: &str = "search-pattern";
176#[cfg(feature = "search")]
177pub const TEXTAREA_SEARCH_STYLE: &str = "search-style";
178pub const TEXTAREA_LAYOUT_MARGIN: &str = "layout-margin";
179
180pub const TEXTAREA_CMD_NEWLINE: &str = "0";
182pub const TEXTAREA_CMD_DEL_LINE_BY_END: &str = "1";
183pub const TEXTAREA_CMD_DEL_LINE_BY_HEAD: &str = "2";
184pub const TEXTAREA_CMD_DEL_WORD: &str = "3";
185pub const TEXTAREA_CMD_DEL_NEXT_WORD: &str = "4";
186pub const TEXTAREA_CMD_MOVE_WORD_FORWARD: &str = "5";
187pub const TEXTAREA_CMD_MOVE_WORD_BACK: &str = "6";
188pub const TEXTAREA_CMD_MOVE_PARAGRAPH_FORWARD: &str = "7";
189pub const TEXTAREA_CMD_MOVE_PARAGRAPH_BACK: &str = "8";
190pub const TEXTAREA_CMD_MOVE_TOP: &str = "9";
191pub const TEXTAREA_CMD_MOVE_BOTTOM: &str = "a";
192pub const TEXTAREA_CMD_UNDO: &str = "b";
193pub const TEXTAREA_CMD_REDO: &str = "c";
194#[cfg(feature = "clipboard")]
195pub const TEXTAREA_CMD_PASTE: &str = "d";
196#[cfg(feature = "search")]
197pub const TEXTAREA_CMD_SEARCH_FORWARD: &str = "e";
198#[cfg(feature = "search")]
199pub const TEXTAREA_CMD_SEARCH_BACK: &str = "f";
200
201pub struct TextArea<'a> {
203 props: Props,
204 widget: TextAreaWidget<'a>,
205 status_fmt: Option<LineFmt>,
207 footer_fmt: Option<LineFmt>,
209 single_line: bool,
211}
212
213impl<I> From<I> for TextArea<'_>
214where
215 I: IntoIterator,
216 I::Item: Into<String>,
217{
218 fn from(i: I) -> Self {
219 Self::new(i.into_iter().map(|s| s.into()).collect::<Vec<String>>())
220 }
221}
222
223impl Default for TextArea<'_> {
224 fn default() -> Self {
225 Self::new(Vec::default())
226 }
227}
228
229impl<'a> TextArea<'a> {
230 pub fn new(lines: Vec<String>) -> Self {
231 Self {
232 props: Props::default(),
233 widget: TextAreaWidget::new(lines),
234 status_fmt: None,
235 footer_fmt: None,
236 single_line: false,
237 }
238 }
239
240 pub fn inactive(mut self, s: Style) -> Self {
242 self.attr(Attribute::FocusStyle, AttrValue::Style(s));
243 self
244 }
245
246 pub fn borders(mut self, b: Borders) -> Self {
248 self.attr(Attribute::Borders, AttrValue::Borders(b));
249 self
250 }
251
252 pub fn title<S: AsRef<str>>(mut self, t: S, a: Alignment) -> Self {
254 self.attr(
255 Attribute::Title,
256 AttrValue::Title((t.as_ref().to_string(), a)),
257 );
258 self
259 }
260
261 pub fn scroll_step(mut self, step: usize) -> Self {
263 self.attr(Attribute::ScrollStep, AttrValue::Length(step));
264 self
265 }
266
267 pub fn max_histories(mut self, max: usize) -> Self {
269 self.attr(
270 Attribute::Custom(TEXTAREA_MAX_HISTORY),
271 AttrValue::Payload(PropPayload::One(PropValue::Usize(max))),
272 );
273 self
274 }
275
276 pub fn cursor_style(mut self, s: Style) -> Self {
278 self.attr(
279 Attribute::Custom(TEXTAREA_CURSOR_STYLE),
280 AttrValue::Style(s),
281 );
282 self
283 }
284
285 pub fn cursor_line_style(mut self, s: Style) -> Self {
287 self.attr(
288 Attribute::Custom(TEXTAREA_CURSOR_LINE_STYLE),
289 AttrValue::Style(s),
290 );
291 self
292 }
293
294 pub fn footer_bar(mut self, fmt: &str, style: Style) -> Self {
297 self.attr(
298 Attribute::Custom(TEXTAREA_FOOTER_FMT),
299 AttrValue::Payload(PropPayload::Tup2((
300 PropValue::Str(fmt.to_string()),
301 PropValue::Style(style),
302 ))),
303 );
304 self
305 }
306
307 pub fn line_number_style(mut self, s: Style) -> Self {
309 self.attr(
310 Attribute::Custom(TEXTAREA_LINE_NUMBER_STYLE),
311 AttrValue::Style(s),
312 );
313 self
314 }
315
316 pub fn status_bar(mut self, fmt: &str, style: Style) -> Self {
319 self.attr(
320 Attribute::Custom(TEXTAREA_STATUS_FMT),
321 AttrValue::Payload(PropPayload::Tup2((
322 PropValue::Str(fmt.to_string()),
323 PropValue::Style(style),
324 ))),
325 );
326 self
327 }
328
329 pub fn style(mut self, s: Style) -> Self {
331 self.attr(Attribute::Style, AttrValue::Style(s));
332 self
333 }
334
335 pub fn tab_length(mut self, l: u8) -> Self {
337 self.attr(
338 Attribute::Custom(TEXTAREA_TAB_SIZE),
339 AttrValue::Size(l as u16),
340 );
341 self
342 }
343
344 pub fn hard_tab(mut self, enabled: bool) -> Self {
346 self.attr(
347 Attribute::Custom(TEXTAREA_HARD_TAB),
348 AttrValue::Flag(enabled),
349 );
350 self
351 }
352
353 pub fn single_line(mut self, single_line: bool) -> Self {
355 self.attr(
356 Attribute::Custom(TEXTAREA_SINGLE_LINE),
357 AttrValue::Flag(single_line),
358 );
359 self
360 }
361
362 #[cfg(feature = "search")]
363 pub fn search_style(mut self, s: Style) -> Self {
365 self.attr(
366 Attribute::Custom(TEXTAREA_SEARCH_STYLE),
367 AttrValue::Style(s),
368 );
369 self
370 }
371
372 pub fn layout_margin(mut self, margin: u16) -> Self {
374 self.attr(
375 Attribute::Custom(TEXTAREA_LAYOUT_MARGIN),
376 AttrValue::Size(margin),
377 );
378 self
379 }
380
381 fn get_block(&self) -> Option<Block<'a>> {
383 let mut block = Block::default();
384 if let Some(AttrValue::Title((title, alignment))) = self.query(Attribute::Title) {
385 block = block.title(title).title_alignment(alignment);
386 }
387 if let Some(AttrValue::Borders(borders)) = self.query(Attribute::Borders) {
388 let inactive_style = self
389 .query(Attribute::FocusStyle)
390 .unwrap_or_else(|| AttrValue::Style(Style::default()))
391 .unwrap_style();
392 let focus = self
393 .props
394 .get_or(Attribute::Focus, AttrValue::Flag(false))
395 .unwrap_flag();
396
397 return Some(
398 block
399 .border_style(match focus {
400 true => borders.style(),
401 false => inactive_style,
402 })
403 .border_type(borders.modifiers)
404 .borders(borders.sides),
405 );
406 }
407
408 None
409 }
410
411 #[cfg(feature = "clipboard")]
412 fn paste(&mut self) {
413 if let Ok(Ok(yank)) = ClipboardContext::new().map(|mut ctx| ctx.get_contents()) {
415 if self.single_line {
420 self.widget.insert_str(yank);
421 } else {
422 for line in yank.lines() {
423 self.widget.insert_str(line);
424 self.widget.insert_newline();
425 }
426 }
427 }
428 }
429}
430
431impl MockComponent for TextArea<'_> {
432 fn view(&mut self, frame: &mut Frame, area: Rect) {
433 if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
434 if let Some(block) = self.get_block() {
436 self.widget.set_block(block);
437 }
438 let margin_prop = self
439 .props
440 .get_or(
441 Attribute::Custom(TEXTAREA_LAYOUT_MARGIN),
442 AttrValue::Size(1),
443 )
444 .unwrap_size();
445 let margin = if self.get_block().is_some() {
446 margin_prop
447 } else {
448 0
449 };
450 let chunks = Layout::default()
452 .direction(LayoutDirection::Vertical)
453 .margin(margin)
454 .constraints(
455 [
456 Constraint::Min(1),
457 Constraint::Length(if self.status_fmt.is_some() { 1 } else { 0 }),
458 Constraint::Length(if self.footer_fmt.is_some() { 1 } else { 0 }),
459 ]
460 .as_ref(),
461 )
462 .split(area);
463
464 let focus = self
466 .props
467 .get_or(Attribute::Focus, AttrValue::Flag(false))
468 .unwrap_flag();
469 if !focus {
470 self.widget.set_cursor_style(Style::reset());
471 } else {
472 let style = self
473 .props
474 .get_or(
475 Attribute::Custom(TEXTAREA_CURSOR_STYLE),
476 AttrValue::Style(Style::default().add_modifier(TextModifiers::REVERSED)),
477 )
478 .unwrap_style();
479 self.widget.set_cursor_style(style);
480 }
481
482 frame.render_widget(&self.widget, chunks[0]);
484 if let Some(fmt) = self.status_fmt.as_ref() {
485 frame.render_widget(
486 Paragraph::new(fmt.fmt(&self.widget)).style(fmt.style()),
487 chunks[1],
488 );
489 }
490 if let Some(fmt) = self.footer_fmt.as_ref() {
491 frame.render_widget(
492 Paragraph::new(fmt.fmt(&self.widget)).style(fmt.style()),
493 chunks[2],
494 );
495 }
496 }
497 }
498
499 fn query(&self, attr: Attribute) -> Option<AttrValue> {
500 self.props.get(attr)
501 }
502
503 fn attr(&mut self, attr: Attribute, value: AttrValue) {
504 self.props.set(attr, value.clone());
505 match (attr, value) {
506 (Attribute::Custom(TEXTAREA_CURSOR_STYLE), AttrValue::Style(s)) => {
507 self.widget.set_cursor_style(s);
508 }
509 (Attribute::Custom(TEXTAREA_CURSOR_LINE_STYLE), AttrValue::Style(s)) => {
510 self.widget.set_cursor_line_style(s);
511 }
512 (
513 Attribute::Custom(TEXTAREA_FOOTER_FMT),
514 AttrValue::Payload(PropPayload::Tup2((
515 PropValue::Str(fmt),
516 PropValue::Style(style),
517 ))),
518 ) => {
519 self.footer_fmt = Some(LineFmt::new(&fmt, style));
520 }
521 (
522 Attribute::Custom(TEXTAREA_MAX_HISTORY),
523 AttrValue::Payload(PropPayload::One(PropValue::Usize(max))),
524 ) => {
525 self.widget.set_max_histories(max);
526 }
527 (
528 Attribute::Custom(TEXTAREA_STATUS_FMT),
529 AttrValue::Payload(PropPayload::Tup2((
530 PropValue::Str(fmt),
531 PropValue::Style(style),
532 ))),
533 ) => {
534 self.status_fmt = Some(LineFmt::new(&fmt, style));
535 }
536 (Attribute::Custom(TEXTAREA_LINE_NUMBER_STYLE), AttrValue::Style(s)) => {
537 self.widget.set_line_number_style(s);
538 }
539 (Attribute::Custom(TEXTAREA_TAB_SIZE), AttrValue::Size(size)) => {
540 self.widget.set_tab_length(size as u8);
541 }
542 (Attribute::Custom(TEXTAREA_HARD_TAB), AttrValue::Flag(enabled)) => {
543 self.widget.set_hard_tab_indent(enabled);
544 }
545 (Attribute::Custom(TEXTAREA_SINGLE_LINE), AttrValue::Flag(single_line)) => {
546 self.single_line = single_line;
547 }
548 #[cfg(feature = "search")]
549 (Attribute::Custom(TEXTAREA_SEARCH_PATTERN), AttrValue::String(pattern)) => {
550 let _ = self.widget.set_search_pattern(pattern);
551 }
552 #[cfg(feature = "search")]
553 (Attribute::Custom(TEXTAREA_SEARCH_STYLE), AttrValue::Style(s)) => {
554 self.widget.set_search_style(s);
555 }
556 (Attribute::Style, AttrValue::Style(s)) => {
557 self.widget.set_style(s);
558 }
559 (_, _) => {
560 if let Some(block) = self.get_block() {
561 self.widget.set_block(block);
562 }
563 }
564 }
565 }
566
567 fn state(&self) -> State {
568 State::Vec(
569 self.widget
570 .lines()
571 .iter()
572 .map(|x| StateValue::String(x.to_string()))
573 .collect(),
574 )
575 }
576
577 fn perform(&mut self, cmd: Cmd) -> CmdResult {
578 match cmd {
579 Cmd::Cancel => {
580 self.widget.delete_next_char();
581 CmdResult::None
582 }
583 Cmd::Custom(TEXTAREA_CMD_DEL_LINE_BY_END) => {
584 self.widget.delete_line_by_end();
585 CmdResult::None
586 }
587 Cmd::Custom(TEXTAREA_CMD_DEL_LINE_BY_HEAD) => {
588 self.widget.delete_line_by_head();
589 CmdResult::None
590 }
591 Cmd::Custom(TEXTAREA_CMD_DEL_NEXT_WORD) => {
592 self.widget.delete_next_word();
593 CmdResult::None
594 }
595 Cmd::Custom(TEXTAREA_CMD_DEL_WORD) => {
596 self.widget.delete_word();
597 CmdResult::None
598 }
599 Cmd::Custom(TEXTAREA_CMD_MOVE_PARAGRAPH_BACK) => {
600 self.widget.move_cursor(CursorMove::ParagraphBack);
601 CmdResult::None
602 }
603 Cmd::Custom(TEXTAREA_CMD_MOVE_PARAGRAPH_FORWARD) => {
604 self.widget.move_cursor(CursorMove::ParagraphForward);
605 CmdResult::None
606 }
607 Cmd::Custom(TEXTAREA_CMD_MOVE_WORD_BACK) => {
608 self.widget.move_cursor(CursorMove::WordBack);
609 CmdResult::None
610 }
611 Cmd::Custom(TEXTAREA_CMD_MOVE_WORD_FORWARD) => {
612 self.widget.move_cursor(CursorMove::WordForward);
613 CmdResult::None
614 }
615 Cmd::Custom(TEXTAREA_CMD_MOVE_BOTTOM) => {
616 if !self.single_line {
617 self.widget.move_cursor(CursorMove::Bottom);
618 }
619 CmdResult::None
620 }
621 Cmd::Custom(TEXTAREA_CMD_MOVE_TOP) => {
622 if !self.single_line {
623 self.widget.move_cursor(CursorMove::Top);
624 }
625 CmdResult::None
626 }
627 #[cfg(feature = "clipboard")]
628 Cmd::Custom(TEXTAREA_CMD_PASTE) => {
629 self.paste();
630 CmdResult::None
631 }
632 Cmd::Custom(TEXTAREA_CMD_REDO) => {
633 self.widget.redo();
634 CmdResult::None
635 }
636 #[cfg(feature = "search")]
637 Cmd::Custom(TEXTAREA_CMD_SEARCH_BACK) => {
638 self.widget.search_back(true);
639 CmdResult::None
640 }
641 #[cfg(feature = "search")]
642 Cmd::Custom(TEXTAREA_CMD_SEARCH_FORWARD) => {
643 self.widget.search_forward(true);
644 CmdResult::None
645 }
646 Cmd::Custom(TEXTAREA_CMD_UNDO) => {
647 self.widget.undo();
648 CmdResult::None
649 }
650 Cmd::Delete => {
651 self.widget.delete_char();
652 CmdResult::None
653 }
654 Cmd::GoTo(Position::Begin) => {
655 self.widget.move_cursor(CursorMove::Head);
656 CmdResult::None
657 }
658 Cmd::GoTo(Position::End) => {
659 self.widget.move_cursor(CursorMove::End);
660 CmdResult::None
661 }
662 Cmd::Move(Direction::Down) => {
663 if !self.single_line {
664 self.widget.move_cursor(CursorMove::Down);
665 }
666 CmdResult::None
667 }
668 Cmd::Move(Direction::Left) => {
669 self.widget.move_cursor(CursorMove::Back);
670 CmdResult::None
671 }
672 Cmd::Move(Direction::Right) => {
673 self.widget.move_cursor(CursorMove::Forward);
674 CmdResult::None
675 }
676 Cmd::Move(Direction::Up) => {
677 if !self.single_line {
678 self.widget.move_cursor(CursorMove::Up);
679 }
680 CmdResult::None
681 }
682 Cmd::Scroll(Direction::Down) => {
683 if !self.single_line {
684 let step = self
685 .props
686 .get_or(Attribute::ScrollStep, AttrValue::Length(8))
687 .unwrap_length();
688 (0..step).for_each(|_| self.widget.move_cursor(CursorMove::Down));
689 }
690 CmdResult::None
691 }
692 Cmd::Scroll(Direction::Up) => {
693 if !self.single_line {
694 let step = self
695 .props
696 .get_or(Attribute::ScrollStep, AttrValue::Length(8))
697 .unwrap_length();
698 (0..step).for_each(|_| self.widget.move_cursor(CursorMove::Up));
699 }
700 CmdResult::None
701 }
702 Cmd::Type('\t') => {
703 self.widget.insert_tab();
704 CmdResult::None
705 }
706 Cmd::Type('\n') | Cmd::Custom(TEXTAREA_CMD_NEWLINE) => {
707 if !self.single_line {
708 self.widget.insert_newline();
709 }
710 CmdResult::None
711 }
712 Cmd::Type(ch) => {
713 self.widget.insert_char(ch);
714 CmdResult::None
715 }
716 Cmd::Submit => CmdResult::Submit(self.state()),
717 _ => CmdResult::None,
718 }
719 }
720}