1use std::io;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::Duration;
25
26use anyhow::{Context, Result};
27use crossterm::event::{Event as CtEvent, EventStream, KeyEvent, KeyEventKind};
28use futures::StreamExt;
29use ratatui::{backend::CrosstermBackend, Terminal};
30use rtcom_config::Profile;
31use rtcom_core::{
32 command::Command, Event, EventBus, ModemLineSnapshot, Parity, SerialConfig, StopBits,
33};
34use tokio::sync::broadcast;
35use tokio_util::sync::CancellationToken;
36
37use crate::{
38 app::TuiApp,
39 input::Dispatch,
40 modal::DialogAction,
41 profile_bridge::{
42 line_ending_config_to_section, line_endings_from_profile, serial_config_to_section,
43 serial_section_to_config,
44 },
45 terminal::{AltScreenGuard, MouseCaptureGuard, RawModeGuard},
46 toast::ToastLevel,
47};
48
49pub async fn run(
83 mut app: TuiApp,
84 bus: EventBus,
85 mut bus_rx: broadcast::Receiver<Event>,
86 cancel: CancellationToken,
87 profile_path: Option<PathBuf>,
88 mut profile: Profile,
89) -> Result<()> {
90 let _raw = RawModeGuard::enter()?;
98 let _alt = AltScreenGuard::enter()?;
99 let _mouse = MouseCaptureGuard::enable()?;
100
101 let backend = CrosstermBackend::new(io::stdout());
102 let mut terminal = Terminal::new(backend).context("build ratatui terminal")?;
103
104 let mut keys = EventStream::new();
105
106 let mut toast_tick = tokio::time::interval(Duration::from_millis(100));
111 toast_tick.tick().await;
114
115 terminal
117 .draw(|f| app.render(f))
118 .context("initial terminal draw")?;
119
120 loop {
121 tokio::select! {
122 biased;
123 () = cancel.cancelled() => break,
124
125 ev = keys.next() => {
126 match ev {
127 Some(Ok(CtEvent::Key(key))) => {
128 if key.kind == KeyEventKind::Press
134 && handle_key_event(
135 key,
136 &mut app,
137 &bus,
138 &cancel,
139 profile_path.as_deref(),
140 &mut profile,
141 )
142 {
143 break;
144 }
145 }
146 Some(Ok(CtEvent::Resize(cols, rows))) => {
147 app.serial_pane_mut().resize(rows, cols);
149 }
150 Some(Ok(CtEvent::Mouse(m_ev))) => {
151 let _ = app.handle_mouse(m_ev);
155 }
156 Some(Ok(_)) => {
157 }
159 Some(Err(err)) => {
160 tracing::error!(%err, "terminal event stream error");
161 break;
162 }
163 None => {
164 break;
166 }
167 }
168 }
169
170 bus_ev = bus_rx.recv() => {
171 if !handle_bus_event(bus_ev, &mut app) {
172 break;
173 }
174 }
175
176 _ = toast_tick.tick() => {
177 }
181 }
182
183 terminal.draw(|f| app.render(f)).context("terminal draw")?;
184 }
185
186 Ok(())
190}
191
192fn handle_key_event(
195 key: KeyEvent,
196 app: &mut TuiApp,
197 bus: &EventBus,
198 cancel: &CancellationToken,
199 profile_path: Option<&Path>,
200 profile: &mut Profile,
201) -> bool {
202 match app.handle_key(key) {
203 Dispatch::TxBytes(bytes) => {
204 bus.publish(Event::TxBytes(bytes::Bytes::from(bytes)));
205 false
206 }
207 Dispatch::OpenedMenu | Dispatch::ClosedMenu | Dispatch::Noop => false,
208 Dispatch::Quit => {
209 cancel.cancel();
210 true
211 }
212 Dispatch::Action(action) => {
213 apply_dialog_action(&action, app, bus, profile_path, profile);
214 false
215 }
216 }
217}
218
219fn apply_dialog_action(
236 action: &DialogAction,
237 app: &mut TuiApp,
238 bus: &EventBus,
239 profile_path: Option<&Path>,
240 profile: &mut Profile,
241) {
242 if let DialogAction::ApplyModalStyleLive(style) = action {
244 app.set_modal_style(*style);
245 return;
247 }
248
249 if let Some(cmd) = action_to_command(action) {
250 bus.publish(Event::Command(cmd));
251 return;
252 }
253
254 match action {
255 DialogAction::ApplyLineEndingsLive(_) => {
256 tracing::warn!("live line-ending change not yet supported; restart rtcom to apply");
260 }
261 DialogAction::ApplyLineEndingsAndSave(le) => {
262 tracing::info!(
267 "line endings saved to profile; restart rtcom to apply to the live session"
268 );
269 profile.line_endings = line_ending_config_to_section(le);
270 app.set_line_endings(*le);
271 persist_profile(profile, profile_path, bus);
272 }
273 DialogAction::ApplyAndSave(cfg) => {
274 bus.publish(Event::Command(Command::ApplyConfig(*cfg)));
276 profile.serial = serial_config_to_section(cfg);
277 persist_profile(profile, profile_path, bus);
278 }
279 DialogAction::ApplyModalStyleAndSave(style) => {
280 app.set_modal_style(*style);
282 profile.screen.modal_style = *style;
283 persist_profile(profile, profile_path, bus);
284 }
285 DialogAction::WriteProfile => {
286 persist_profile(profile, profile_path, bus);
287 }
288 DialogAction::ReadProfile => {
289 reload_profile(profile, app, profile_path, bus);
290 }
291 _ => {
295 tracing::warn!(?action, "unhandled DialogAction");
296 }
297 }
298}
299
300fn persist_profile(profile: &Profile, path: Option<&Path>, bus: &EventBus) {
314 let Some(path) = path else {
315 tracing::warn!("profile save requested but no profile path is available");
316 return;
317 };
318 match rtcom_config::write(path, profile) {
319 Ok(()) => {
320 bus.publish(Event::ProfileSaved {
321 path: path.to_path_buf(),
322 });
323 }
324 Err(e) => {
325 let err = rtcom_core::Error::InvalidConfig(format!("profile write: {e}"));
326 bus.publish(Event::ProfileLoadFailed {
327 path: path.to_path_buf(),
328 error: Arc::new(err),
329 });
330 }
331 }
332}
333
334fn reload_profile(profile: &mut Profile, app: &mut TuiApp, path: Option<&Path>, bus: &EventBus) {
349 let Some(path) = path else {
350 tracing::warn!("profile reload requested but no profile path is available");
351 return;
352 };
353 match rtcom_config::read(path) {
354 Ok(new_profile) => {
355 let serial_cfg = serial_section_to_config(&new_profile.serial);
356 bus.publish(Event::Command(Command::ApplyConfig(serial_cfg)));
357
358 app.set_line_endings(line_endings_from_profile(&new_profile));
359 app.set_modal_style(new_profile.screen.modal_style);
360
361 *profile = new_profile;
362
363 bus.publish(Event::ProfileSaved {
366 path: path.to_path_buf(),
367 });
368 }
369 Err(e) => {
370 let err = rtcom_core::Error::InvalidConfig(format!("profile read: {e}"));
371 bus.publish(Event::ProfileLoadFailed {
372 path: path.to_path_buf(),
373 error: Arc::new(err),
374 });
375 }
376 }
377}
378
379#[must_use]
389const fn action_to_command(action: &DialogAction) -> Option<Command> {
390 match action {
391 DialogAction::ApplyLive(cfg) => Some(Command::ApplyConfig(*cfg)),
392 DialogAction::SetDtr(state) => Some(Command::SetDtrAbs(*state)),
393 DialogAction::SetRts(state) => Some(Command::SetRtsAbs(*state)),
394 DialogAction::SendBreak => Some(Command::SendBreak),
395 DialogAction::ApplyModalStyleLive(_)
397 | DialogAction::ApplyAndSave(_)
403 | DialogAction::ApplyModalStyleAndSave(_)
404 | DialogAction::WriteProfile
405 | DialogAction::ReadProfile
406 | DialogAction::ApplyLineEndingsLive(_)
408 | DialogAction::ApplyLineEndingsAndSave(_) => None,
409 }
410}
411
412fn handle_bus_event(
420 bus_ev: std::result::Result<Event, broadcast::error::RecvError>,
421 app: &mut TuiApp,
422) -> bool {
423 match bus_ev {
424 Ok(Event::RxBytes(b)) => {
425 app.serial_pane_mut().ingest(&b);
426 }
427 Ok(Event::ConfigChanged(cfg)) => {
428 app.set_serial_config(cfg);
429 app.set_config_summary(summarise(&cfg));
430 }
431 Ok(Event::SystemMessage(msg)) => {
432 app.serial_pane_mut()
433 .ingest(format!("\r\n*** rtcom: {msg}\r\n").as_bytes());
434 }
435 Ok(Event::DeviceDisconnected { reason }) => {
436 app.serial_pane_mut()
437 .ingest(format!("\r\n*** device disconnected: {reason}\r\n").as_bytes());
438 }
439 Ok(Event::Error(e)) => {
440 tracing::error!(%e, "bus error");
441 app.push_toast(format!("error: {e}"), ToastLevel::Error);
442 }
443 Ok(Event::ModemLinesChanged { dtr, rts }) => {
444 app.set_modem_lines(ModemLineSnapshot { dtr, rts });
445 }
446 Ok(Event::ProfileSaved { path }) => {
447 app.push_toast(
448 format!("profile saved: {}", path.display()),
449 ToastLevel::Info,
450 );
451 }
452 Ok(Event::ProfileLoadFailed { path, error }) => {
453 tracing::error!(%error, path = %path.display(), "profile IO failed");
454 app.push_toast(
455 format!("profile IO failed ({}): {error}", path.display()),
456 ToastLevel::Error,
457 );
458 }
459 Ok(_) => {}
465 Err(broadcast::error::RecvError::Closed) => {
466 return false;
468 }
469 Err(broadcast::error::RecvError::Lagged(n)) => {
470 tracing::warn!("bus lagged by {n} events");
471 }
472 }
473 true
474}
475
476#[must_use]
479pub fn summarise(cfg: &SerialConfig) -> String {
480 format!(
481 "{} {}{}{} {}",
482 cfg.baud_rate,
483 cfg.data_bits.bits(),
484 parity_letter(cfg.parity),
485 stop_bits_number(cfg.stop_bits),
486 flow_word(cfg.flow_control),
487 )
488}
489
490const fn parity_letter(p: Parity) -> char {
491 match p {
492 Parity::None => 'N',
493 Parity::Even => 'E',
494 Parity::Odd => 'O',
495 Parity::Mark => 'M',
496 Parity::Space => 'S',
497 }
498}
499
500const fn stop_bits_number(s: StopBits) -> u8 {
501 match s {
502 StopBits::One => 1,
503 StopBits::Two => 2,
504 }
505}
506
507const fn flow_word(f: rtcom_core::FlowControl) -> &'static str {
508 match f {
509 rtcom_core::FlowControl::None => "none",
510 rtcom_core::FlowControl::Hardware => "hw",
511 rtcom_core::FlowControl::Software => "sw",
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use rtcom_core::{DataBits, FlowControl, SerialConfig, StopBits};
519
520 #[test]
521 fn summarise_default_is_115200_8n1_none() {
522 let cfg = SerialConfig::default();
523 assert_eq!(summarise(&cfg), "115200 8N1 none");
524 }
525
526 #[test]
527 fn summarise_custom_config() {
528 let cfg = SerialConfig {
529 baud_rate: 9600,
530 data_bits: DataBits::Seven,
531 stop_bits: StopBits::Two,
532 parity: Parity::Even,
533 flow_control: FlowControl::Hardware,
534 ..SerialConfig::default()
535 };
536 assert_eq!(summarise(&cfg), "9600 7E2 hw");
537 }
538
539 #[tokio::test]
540 async fn handle_bus_event_rx_bytes_reaches_pane() {
541 let bus = EventBus::new(8);
542 let mut app = TuiApp::new(bus);
543 assert!(handle_bus_event(
544 Ok(Event::RxBytes(bytes::Bytes::from_static(b"hi"))),
545 &mut app
546 ));
547 }
548
549 #[tokio::test]
550 async fn handle_bus_event_closed_breaks_loop() {
551 let bus = EventBus::new(8);
552 let mut app = TuiApp::new(bus);
553 assert!(!handle_bus_event(
554 Err(broadcast::error::RecvError::Closed),
555 &mut app
556 ));
557 }
558
559 #[tokio::test]
560 async fn handle_bus_event_lagged_is_logged_but_continues() {
561 let bus = EventBus::new(8);
562 let mut app = TuiApp::new(bus);
563 assert!(handle_bus_event(
564 Err(broadcast::error::RecvError::Lagged(7)),
565 &mut app
566 ));
567 }
568
569 #[tokio::test]
570 async fn handle_bus_event_config_changed_updates_summary() {
571 let bus = EventBus::new(8);
572 let mut app = TuiApp::new(bus);
573 app.set_device_summary("/dev/ttyUSB0", "old");
574 let cfg = SerialConfig {
575 baud_rate: 9600,
576 ..SerialConfig::default()
577 };
578 assert!(handle_bus_event(Ok(Event::ConfigChanged(cfg)), &mut app));
579 }
582
583 #[tokio::test]
584 async fn handle_bus_event_modem_lines_changed_reaches_app() {
585 let bus = EventBus::new(8);
586 let mut app = TuiApp::new(bus);
587 assert!(handle_bus_event(
588 Ok(Event::ModemLinesChanged {
589 dtr: false,
590 rts: true
591 }),
592 &mut app
593 ));
594 }
598
599 #[test]
600 fn apply_live_maps_to_apply_config_command() {
601 let cfg = SerialConfig::default();
602 let cmd = action_to_command(&DialogAction::ApplyLive(cfg)).expect("maps to ApplyConfig");
603 match cmd {
604 Command::ApplyConfig(out) => assert_eq!(out, cfg),
605 other => panic!("expected ApplyConfig, got {other:?}"),
606 }
607 }
608
609 #[test]
610 fn set_dtr_maps_to_set_dtr_abs_command() {
611 assert!(matches!(
612 action_to_command(&DialogAction::SetDtr(true)),
613 Some(Command::SetDtrAbs(true))
614 ));
615 assert!(matches!(
616 action_to_command(&DialogAction::SetDtr(false)),
617 Some(Command::SetDtrAbs(false))
618 ));
619 }
620
621 #[test]
622 fn set_rts_maps_to_set_rts_abs_command() {
623 assert!(matches!(
624 action_to_command(&DialogAction::SetRts(true)),
625 Some(Command::SetRtsAbs(true))
626 ));
627 assert!(matches!(
628 action_to_command(&DialogAction::SetRts(false)),
629 Some(Command::SetRtsAbs(false))
630 ));
631 }
632
633 #[test]
634 fn send_break_maps_to_send_break_command() {
635 assert!(matches!(
636 action_to_command(&DialogAction::SendBreak),
637 Some(Command::SendBreak)
638 ));
639 }
640
641 #[test]
642 fn apply_modal_style_live_returns_none_because_handled_locally() {
643 assert!(action_to_command(&DialogAction::ApplyModalStyleLive(
644 rtcom_config::ModalStyle::DimmedOverlay,
645 ))
646 .is_none());
647 }
648
649 #[test]
650 fn apply_line_endings_live_returns_none_pending_v021() {
651 let le = rtcom_core::LineEndingConfig::default();
652 assert!(action_to_command(&DialogAction::ApplyLineEndingsLive(le)).is_none());
653 }
654
655 #[test]
656 fn save_flavored_actions_return_none_pending_t18() {
657 let cfg = SerialConfig::default();
658 assert!(action_to_command(&DialogAction::ApplyAndSave(cfg)).is_none());
659 assert!(action_to_command(&DialogAction::WriteProfile).is_none());
660 assert!(action_to_command(&DialogAction::ReadProfile).is_none());
661 assert!(action_to_command(&DialogAction::ApplyModalStyleAndSave(
662 rtcom_config::ModalStyle::DimmedOverlay,
663 ))
664 .is_none());
665 }
666
667 #[tokio::test]
668 async fn apply_dialog_action_apply_live_publishes_apply_config_command() {
669 let bus = EventBus::new(8);
670 let mut rx = bus.subscribe();
671 let mut app = TuiApp::new(bus.clone());
672 let mut profile = Profile::default();
673
674 let cfg = SerialConfig {
675 baud_rate: 9600,
676 ..SerialConfig::default()
677 };
678 apply_dialog_action(
679 &DialogAction::ApplyLive(cfg),
680 &mut app,
681 &bus,
682 None,
683 &mut profile,
684 );
685
686 match rx.try_recv().expect("Command on the bus") {
687 Event::Command(Command::ApplyConfig(out)) => assert_eq!(out, cfg),
688 other => panic!("expected Event::Command(ApplyConfig), got {other:?}"),
689 }
690 }
691
692 #[tokio::test]
693 async fn apply_dialog_action_modal_style_live_does_not_publish_and_updates_cache() {
694 let bus = EventBus::new(8);
695 let mut rx = bus.subscribe();
696 let mut app = TuiApp::new(bus.clone());
697 let mut profile = Profile::default();
698
699 apply_dialog_action(
700 &DialogAction::ApplyModalStyleLive(rtcom_config::ModalStyle::DimmedOverlay),
701 &mut app,
702 &bus,
703 None,
704 &mut profile,
705 );
706
707 match rx.try_recv() {
709 Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
710 other => panic!("expected Empty, got {other:?}"),
711 }
712 }
713
714 #[tokio::test]
715 async fn persist_profile_writes_to_disk_and_publishes_profile_saved() {
716 let dir = tempfile::tempdir().unwrap();
717 let path = dir.path().join("test.toml");
718 let profile = Profile::default();
719 let bus = EventBus::new(64);
720 let mut rx = bus.subscribe();
721
722 persist_profile(&profile, Some(&path), &bus);
723
724 assert!(path.exists(), "profile file should be written");
725 match rx.try_recv().expect("ProfileSaved on the bus") {
726 Event::ProfileSaved { path: p } => assert_eq!(p, path),
727 other => panic!("expected ProfileSaved, got {other:?}"),
728 }
729 }
730
731 #[tokio::test]
732 async fn persist_profile_without_path_is_noop() {
733 let profile = Profile::default();
734 let bus = EventBus::new(64);
735 let mut rx = bus.subscribe();
736
737 persist_profile(&profile, None, &bus);
738
739 assert!(matches!(
741 rx.try_recv(),
742 Err(broadcast::error::TryRecvError::Empty)
743 ));
744 }
745
746 #[tokio::test]
747 async fn persist_profile_io_error_publishes_profile_load_failed() {
748 let dir = tempfile::tempdir().unwrap();
753 let blocker = dir.path().join("blocker");
754 std::fs::write(&blocker, b"").unwrap();
755 let path = blocker.join("nested.toml");
756
757 let profile = Profile::default();
758 let bus = EventBus::new(64);
759 let mut rx = bus.subscribe();
760
761 persist_profile(&profile, Some(&path), &bus);
762
763 match rx.try_recv().expect("ProfileLoadFailed on the bus") {
764 Event::ProfileLoadFailed { path: p, error } => {
765 assert_eq!(p, path);
766 assert!(error.to_string().contains("profile write"));
767 }
768 other => panic!("expected ProfileLoadFailed, got {other:?}"),
769 }
770 }
771
772 #[tokio::test]
773 async fn reload_profile_reads_and_dispatches_apply_config() {
774 let dir = tempfile::tempdir().unwrap();
775 let path = dir.path().join("test.toml");
776
777 let mut disk_profile = Profile::default();
779 disk_profile.serial.baud = 9600;
780 rtcom_config::write(&path, &disk_profile).unwrap();
781
782 let mut memory_profile = Profile::default();
784 let bus = EventBus::new(64);
785 let mut rx = bus.subscribe();
786 let mut app = TuiApp::new(bus.clone());
787
788 reload_profile(&mut memory_profile, &mut app, Some(&path), &bus);
789
790 assert_eq!(memory_profile.serial.baud, 9600);
791
792 match rx.try_recv().expect("ApplyConfig on the bus") {
794 Event::Command(Command::ApplyConfig(cfg)) => assert_eq!(cfg.baud_rate, 9600),
795 other => panic!("expected Command::ApplyConfig, got {other:?}"),
796 }
797 match rx.try_recv().expect("ProfileSaved on the bus") {
799 Event::ProfileSaved { path: p } => assert_eq!(p, path),
800 other => panic!("expected ProfileSaved, got {other:?}"),
801 }
802 }
803
804 #[tokio::test]
805 async fn reload_profile_malformed_toml_publishes_profile_load_failed() {
806 let dir = tempfile::tempdir().unwrap();
807 let path = dir.path().join("bad.toml");
808 std::fs::write(&path, b"not valid =~~ toml [\n").unwrap();
809
810 let mut memory_profile = Profile::default();
811 let bus = EventBus::new(64);
812 let mut rx = bus.subscribe();
813 let mut app = TuiApp::new(bus.clone());
814
815 reload_profile(&mut memory_profile, &mut app, Some(&path), &bus);
816
817 match rx.try_recv().expect("ProfileLoadFailed on the bus") {
818 Event::ProfileLoadFailed { path: p, error } => {
819 assert_eq!(p, path);
820 assert!(error.to_string().contains("profile read"));
821 }
822 other => panic!("expected ProfileLoadFailed, got {other:?}"),
823 }
824 }
825
826 #[tokio::test]
827 async fn apply_dialog_action_apply_and_save_updates_profile_and_writes() {
828 let dir = tempfile::tempdir().unwrap();
829 let path = dir.path().join("save.toml");
830 let bus = EventBus::new(64);
831 let mut rx = bus.subscribe();
832 let mut app = TuiApp::new(bus.clone());
833 let mut profile = Profile::default();
834
835 let cfg = SerialConfig {
836 baud_rate: 57_600,
837 ..SerialConfig::default()
838 };
839 apply_dialog_action(
840 &DialogAction::ApplyAndSave(cfg),
841 &mut app,
842 &bus,
843 Some(&path),
844 &mut profile,
845 );
846
847 assert_eq!(profile.serial.baud, 57_600);
849 assert!(path.exists());
851
852 match rx.try_recv().expect("ApplyConfig on the bus") {
854 Event::Command(Command::ApplyConfig(out)) => assert_eq!(out, cfg),
855 other => panic!("expected Command::ApplyConfig, got {other:?}"),
856 }
857 match rx.try_recv().expect("ProfileSaved on the bus") {
859 Event::ProfileSaved { path: p } => assert_eq!(p, path),
860 other => panic!("expected ProfileSaved, got {other:?}"),
861 }
862 }
863
864 #[tokio::test]
865 async fn handle_bus_event_profile_saved_pushes_info_toast() {
866 let bus = EventBus::new(8);
867 let mut app = TuiApp::new(bus);
868 assert_eq!(app.toasts_mut().visible_count(), 0);
869 assert!(handle_bus_event(
870 Ok(Event::ProfileSaved {
871 path: std::path::PathBuf::from("/tmp/x.toml"),
872 }),
873 &mut app,
874 ));
875 assert_eq!(app.toasts_mut().visible_count(), 1);
876 assert_eq!(
877 app.toasts_mut().visible()[0].level,
878 crate::toast::ToastLevel::Info
879 );
880 assert!(app.toasts_mut().visible()[0]
881 .message
882 .contains("/tmp/x.toml"));
883 }
884
885 #[tokio::test]
886 async fn handle_bus_event_profile_load_failed_pushes_error_toast() {
887 let bus = EventBus::new(8);
888 let mut app = TuiApp::new(bus);
889 let err = rtcom_core::Error::InvalidConfig("bad toml".to_string());
890 assert!(handle_bus_event(
891 Ok(Event::ProfileLoadFailed {
892 path: std::path::PathBuf::from("/tmp/bad.toml"),
893 error: Arc::new(err),
894 }),
895 &mut app,
896 ));
897 assert_eq!(app.toasts_mut().visible_count(), 1);
898 assert_eq!(
899 app.toasts_mut().visible()[0].level,
900 crate::toast::ToastLevel::Error
901 );
902 assert!(app.toasts_mut().visible()[0]
903 .message
904 .contains("/tmp/bad.toml"));
905 }
906
907 #[tokio::test]
908 async fn handle_bus_event_error_pushes_error_toast() {
909 let bus = EventBus::new(8);
910 let mut app = TuiApp::new(bus);
911 let err = rtcom_core::Error::InvalidConfig("boom".to_string());
912 assert!(handle_bus_event(Ok(Event::Error(Arc::new(err))), &mut app));
913 assert_eq!(app.toasts_mut().visible_count(), 1);
914 assert_eq!(
915 app.toasts_mut().visible()[0].level,
916 crate::toast::ToastLevel::Error
917 );
918 assert!(app.toasts_mut().visible()[0].message.contains("boom"));
919 }
920
921 #[tokio::test]
922 async fn apply_line_endings_and_save_persists_profile() {
923 use rtcom_core::{LineEnding, LineEndingConfig};
924 let dir = tempfile::tempdir().unwrap();
925 let path = dir.path().join("le.toml");
926 let bus = EventBus::new(64);
927 let mut rx = bus.subscribe();
928 let mut app = TuiApp::new(bus.clone());
929 let mut profile = Profile::default();
930
931 let le = LineEndingConfig {
932 omap: LineEnding::AddCrToLf,
933 imap: LineEnding::None,
934 emap: LineEnding::None,
935 };
936 apply_dialog_action(
937 &DialogAction::ApplyLineEndingsAndSave(le),
938 &mut app,
939 &bus,
940 Some(&path),
941 &mut profile,
942 );
943
944 assert_eq!(profile.line_endings.omap, "crlf");
946 let on_disk = rtcom_config::read(&path).unwrap();
948 assert_eq!(on_disk.line_endings.omap, "crlf");
949 let mut saw_saved = false;
951 while let Ok(ev) = rx.try_recv() {
952 if matches!(ev, Event::ProfileSaved { .. }) {
953 saw_saved = true;
954 }
955 }
956 assert!(saw_saved, "expected ProfileSaved event");
957 }
958
959 #[tokio::test]
960 async fn apply_line_endings_live_still_warns_and_does_not_persist() {
961 use rtcom_core::{LineEnding, LineEndingConfig};
962 let dir = tempfile::tempdir().unwrap();
963 let path = dir.path().join("le_live.toml");
964 let bus = EventBus::new(64);
965 let mut rx = bus.subscribe();
966 let mut app = TuiApp::new(bus.clone());
967 let mut profile = Profile::default();
968
969 let le = LineEndingConfig {
970 omap: LineEnding::AddCrToLf,
971 imap: LineEnding::None,
972 emap: LineEnding::None,
973 };
974 apply_dialog_action(
975 &DialogAction::ApplyLineEndingsLive(le),
976 &mut app,
977 &bus,
978 Some(&path),
979 &mut profile,
980 );
981
982 assert_eq!(profile.line_endings.omap, "none");
985 assert!(!path.exists(), "live-only path must not write profile");
986 assert!(matches!(
988 rx.try_recv(),
989 Err(broadcast::error::TryRecvError::Empty)
990 ));
991 }
992
993 #[tokio::test]
994 async fn apply_dialog_action_apply_modal_style_and_save_persists_and_updates_app() {
995 let dir = tempfile::tempdir().unwrap();
996 let path = dir.path().join("style.toml");
997 let bus = EventBus::new(64);
998 let mut rx = bus.subscribe();
999 let mut app = TuiApp::new(bus.clone());
1000 let mut profile = Profile::default();
1001
1002 apply_dialog_action(
1003 &DialogAction::ApplyModalStyleAndSave(rtcom_config::ModalStyle::Fullscreen),
1004 &mut app,
1005 &bus,
1006 Some(&path),
1007 &mut profile,
1008 );
1009
1010 assert_eq!(
1011 profile.screen.modal_style,
1012 rtcom_config::ModalStyle::Fullscreen
1013 );
1014 assert!(path.exists());
1015
1016 match rx.try_recv().expect("ProfileSaved on the bus") {
1017 Event::ProfileSaved { path: p } => assert_eq!(p, path),
1018 other => panic!("expected ProfileSaved, got {other:?}"),
1019 }
1020 }
1021}