diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index bdc68c5..b32e7ad 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -140,6 +140,7 @@ save-output = Save output as text... monitor-activity = Monitor for activity monitor-silence = Monitor for silence monitor-process-finished = Monitor for process finishing +read-only = Read-only ## Notifications notification-show = Show tab diff --git a/i18n/fr/cosmic_term.ftl b/i18n/fr/cosmic_term.ftl index f699685..bb3a00a 100644 --- a/i18n/fr/cosmic_term.ftl +++ b/i18n/fr/cosmic_term.ftl @@ -108,6 +108,7 @@ save-output = Enregistrer la sortie comme texte... monitor-activity = Surveiller l'activité monitor-silence = Surveiller le silence monitor-process-finished = Surveiller la fin du processus +read-only = Lecture seule ## Notifications notification-show = Afficher l'onglet diff --git a/src/main.rs b/src/main.rs index e749142..f5c6eb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,6 +115,7 @@ fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> { Some(MonitorKind::Activity) => Some("dialog-information-symbolic"), Some(MonitorKind::Silence) => Some("alarm-symbolic"), Some(MonitorKind::ProcessFinished) => Some("emblem-ok-symbolic"), + None if terminal.read_only => Some("changes-prevent-symbolic"), None if terminal.monitoring_enabled() => Some("view-reveal-symbolic"), None => None, } @@ -390,6 +391,7 @@ pub enum Action { LayoutLoad, LayoutSave, Monitor(MonitorKind), + ToggleReadOnly, PaneFocusDown, PaneFocusLeft, PaneFocusRight, @@ -449,6 +451,7 @@ impl Action { Self::LayoutLoad => Message::LayoutLoad, Self::LayoutSave => Message::LayoutSave, Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt), + Self::ToggleReadOnly => Message::ReadOnlyToggle(entity_opt), Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right), @@ -556,6 +559,7 @@ pub enum Message { ShortcutSearch(String), MonitorTick, MonitorToggle(MonitorKind, Option), + ReadOnlyToggle(Option), MouseEnter(pane_grid::Pane), Opacity(u8), PaneClicked(pane_grid::Pane), @@ -1809,6 +1813,22 @@ impl App { } } + fn terminal_read_only(&self, pane: pane_grid::Pane, entity: segmented_button::Entity) -> bool { + self.pane_model + .panes + .get(pane) + .and_then(|tab_model| tab_model.data::>(entity)) + .is_some_and(|terminal| terminal.lock().unwrap().read_only) + } + + fn active_terminal_read_only(&self) -> bool { + let pane = self.pane_model.focused(); + self.pane_model + .panes + .get(pane) + .is_some_and(|tab_model| self.terminal_read_only(pane, tab_model.active())) + } + fn active_terminal_monitors(&self) -> (bool, bool, bool) { let pane = self.pane_model.focused(); if let Some(tab_model) = self.pane_model.panes.get(pane) { @@ -3034,6 +3054,20 @@ impl Application for App { } } } + Message::ReadOnlyToggle(entity_opt) => { + if let Some(tab_model) = self.pane_model.active_mut() { + let entity = entity_opt.unwrap_or_else(|| tab_model.active()); + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + terminal.read_only = !terminal.read_only; + icon_update = Some(monitor_icon_name(&terminal)); + } + if let Some(icon_name) = icon_update { + monitor_update_tab_icon(tab_model, entity, icon_name); + } + } + } Message::MonitorToggle(monitor_kind, entity_opt) => { if let Some(tab_model) = self.pane_model.active_mut() { let entity = entity_opt.unwrap_or_else(|| tab_model.active()); @@ -3718,7 +3752,7 @@ impl Application for App { let terminal = terminal.lock().unwrap(); let rgb = terminal.effective_color(index); let text = f(rgb); - terminal.input_no_scroll(text.into_bytes()); + terminal.respond(text.into_bytes()); } } TermEvent::CursorBlinkingChange => { @@ -3732,7 +3766,7 @@ impl Application for App { && let Some(terminal) = tab_model.data::>(entity) { let terminal = terminal.lock().unwrap(); - terminal.input_no_scroll(text.into_bytes()); + terminal.respond(text.into_bytes()); } } TermEvent::ResetTitle => { @@ -3757,7 +3791,7 @@ impl Application for App { { let terminal = terminal.lock().unwrap(); let text = f(terminal.size().into()); - terminal.input_no_scroll(text.into_bytes()); + terminal.respond(text.into_bytes()); } } TermEvent::Title(title) => { @@ -4160,6 +4194,7 @@ impl Application for App { &self.config, &self.key_binds, self.active_terminal_monitors(), + self.active_terminal_read_only(), &self.ssh_hosts, )] } @@ -4292,6 +4327,7 @@ impl Application for App { entity, link, self.terminal_monitors(pane, entity), + self.terminal_read_only(pane, entity), ) }), ) diff --git a/src/menu.rs b/src/menu.rs index 4ffb397..7fc4fed 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -45,6 +45,7 @@ pub fn context_menu( entity: segmented_button::Entity, link: Option, monitors: (bool, bool, bool), + read_only: bool, ) -> Vec> { let item = |label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action)); @@ -78,6 +79,7 @@ pub fn context_menu( monitors.2, Action::Monitor(MonitorKind::ProcessFinished), ), + check(fl!("read-only"), read_only, Action::ToggleReadOnly), MenuItem::Divider, item(fl!("split-horizontal"), Action::PaneSplitHorizontal), item(fl!("split-vertical"), Action::PaneSplitVertical), @@ -163,6 +165,7 @@ pub fn menu_bar<'a>( config: &Config, key_binds: &HashMap, monitors: (bool, bool, bool), + read_only: bool, ssh_hosts: &[String], ) -> Element<'a, Message> { let mut profile_items = Vec::with_capacity(config.profiles.len()); @@ -264,6 +267,12 @@ pub fn menu_bar<'a>( monitors.2, Action::Monitor(MonitorKind::ProcessFinished), ), + MenuItem::CheckBox( + fl!("read-only"), + None, + read_only, + Action::ToggleReadOnly, + ), MenuItem::Divider, MenuItem::Button( fl!("menu-color-schemes"), diff --git a/src/shortcuts.rs b/src/shortcuts.rs index a04a103..768f15e 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -90,6 +90,7 @@ pub enum KeyBindAction { TabPrev, TabRename, ToggleFullscreen, + ToggleReadOnly, WindowClose, WindowNew, ZoomIn, @@ -132,6 +133,7 @@ impl KeyBindAction { Self::TabPrev => Some(Action::TabPrev), Self::TabRename => Some(Action::TabRename), Self::ToggleFullscreen => Some(Action::ToggleFullscreen), + Self::ToggleReadOnly => Some(Action::ToggleReadOnly), Self::WindowClose => Some(Action::WindowClose), Self::WindowNew => Some(Action::WindowNew), Self::ZoomIn => Some(Action::ZoomIn), @@ -295,6 +297,7 @@ pub fn action_label(action: KeyBindAction) -> String { KeyBindAction::TabPrev => fl!("previous-tab"), KeyBindAction::TabRename => fl!("rename-tab"), KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"), + KeyBindAction::ToggleReadOnly => fl!("read-only"), KeyBindAction::WindowClose => fl!("close-window"), KeyBindAction::WindowNew => fl!("new-window"), KeyBindAction::ZoomIn => fl!("zoom-in"), @@ -369,7 +372,11 @@ pub fn shortcut_groups() -> Vec { KeyBindAction::ZoomReset, ], }); - let mut other_actions = vec![KeyBindAction::ClearScrollback, KeyBindAction::SaveOutput]; + let mut other_actions = vec![ + KeyBindAction::ClearScrollback, + KeyBindAction::SaveOutput, + KeyBindAction::ToggleReadOnly, + ]; #[cfg(feature = "password_manager")] other_actions.push(KeyBindAction::PasswordManager); groups.push(ShortcutGroup { diff --git a/src/terminal.rs b/src/terminal.rs index 356e009..213e5fd 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -382,6 +382,9 @@ pub struct Terminal { pub monitor_job_running: bool, pub attention: Option, notify_last: Option<(Instant, u64)>, + /// [yoda] Lecture seule : aucune saisie de l'utilisateur n'atteint le PTY + /// (touches, collage, souris) ; les réponses du terminal passent toujours. + pub read_only: bool, pub needs_update: bool, pub profile_id_opt: Option, pub tab_title_override: Option, @@ -514,6 +517,7 @@ impl Terminal { monitor_job_running: false, attention: None, notify_last: None, + read_only: false, mouse_reporter: Default::default(), needs_update: true, notifier, @@ -607,7 +611,7 @@ impl Terminal { const FOCUS_OUT: &[u8] = b"\x1b[O"; let input = if is_focused { FOCUS_IN } else { FOCUS_OUT }; - self.input_no_scroll(input); + self.respond(input); } } } @@ -620,7 +624,18 @@ impl Terminal { self.with_buffer_mut(|buffer| buffer.set_redraw(redraw)); } + /// Saisie de l'utilisateur vers le PTY ; ignorée en lecture seule. pub fn input_no_scroll>>(&self, input: I) { + if self.read_only { + return; + } + self.notifier.notify(input); + } + + /// [yoda] Réponse du terminal lui-même (rapports demandés par + /// l'application, focus) : jamais bloquée par la lecture seule, sinon + /// l'application attendrait une réponse qui ne vient pas. + pub fn respond>>(&self, input: I) { self.notifier.notify(input); } @@ -638,6 +653,10 @@ impl Terminal { } pub fn input_scroll>>(&self, input: I) { + // En lecture seule, une frappe ne ramène pas non plus la vue en bas + if self.read_only { + return; + } self.input_no_scroll(input); self.scroll(TerminalScroll::Bottom); } @@ -1299,8 +1318,11 @@ impl Terminal { y, ); - for code in codes { - self.notifier.notify(code); + // Saisie de l'utilisateur : rien en lecture seule + if !self.read_only { + for code in codes { + self.notifier.notify(code); + } } } else { self.scroll_as_arrows(delta); @@ -1513,6 +1535,61 @@ impl Drop for Terminal { mod tests { use super::*; + /// Lecture seule, de bout en bout : un vrai `cat` dans un vrai PTY. La + /// saisie bloquée n'arrive pas ; une réponse du terminal, si ; la saisie + /// repasse quand le verrou est levé. + #[cfg(unix)] + #[test] + fn read_only_blocks_input_but_not_replies() { + use std::time::Duration; + + let (_panes, pane) = pane_grid::State::new(()); + let mut model = segmented_button::Model::::default(); + let entity = model.insert().id(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let options = Options { + shell: Some(tty::Shell::new("cat".into(), Vec::new())), + ..Options::default() + }; + let mut terminal = Terminal::new( + pane, + entity, + event_tx, + Config::default(), + options, + &AppConfig::default(), + crate::terminal_theme::cosmic_dark(), + None, + None, + ) + .unwrap(); + + let wait_for = |terminal: &Terminal, needle: &str| { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if terminal.scrollback_text().contains(needle) { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + false + }; + + terminal.read_only = true; + terminal.input_scroll(&b"bloque\n"[..]); + terminal.paste("colle\n".to_string()); + terminal.respond(&b"repond\n"[..]); + assert!(wait_for(&terminal, "repond")); + // Écrites avant la réponse, la saisie et le collage seraient déjà là + let text = terminal.scrollback_text(); + assert!(!text.contains("bloque"), "{text}"); + assert!(!text.contains("colle"), "{text}"); + + terminal.read_only = false; + terminal.input_scroll(&b"libre\n"[..]); + assert!(wait_for(&terminal, "libre")); + } + #[test] fn block_fill_couvre_toute_la_plage_unicode() { // U+2580–U+259F : chaque élément de bloc a sa géométrie native. diff --git a/src/terminal_box.rs b/src/terminal_box.rs index 9b41a22..6efd65b 100644 --- a/src/terminal_box.rs +++ b/src/terminal_box.rs @@ -974,7 +974,10 @@ where let buffer_size = terminal.with_buffer(|buffer| buffer.size()); let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR); - let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE); + // [yoda] En lecture seule, la souris revient au terminal : sélection et + // défilement locaux, même dans une application qui la capture. + let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE) + && !terminal.read_only; let term_mode = *terminal.term.lock().mode(); match event { Event::Window(event) => match event {