Skip to main content

gui/panels/
terminal.rs

1//! # Terminal Panel Module
2//!
3//! This module implements the interactive Terminal UI panel for the R.E.K.T.A.L. GUI application.
4//! It displays a scrollable log history of colored text fragments ([`TextFragment`]), supports command 
5//! history navigation using the arrow keys, and dispatches user commands to the central controller.
6
7use crate::controller::UiEvent;
8use crate::UI_EVENT_SENDER;
9use common::logging::LogLevel::*;
10use common::r_log;
11use eframe::egui;
12use eframe::egui::Color32;
13
14/// Represents a single piece of text with an associated display color.
15#[derive(Clone)]
16pub struct TextFragment {
17    /// The string content of the fragment.
18    pub text: String,
19    /// The color used to render this fragment in the terminal log output.
20    pub color: Color32,
21}
22
23/// UI Panel managing the interactive terminal tab state and rendering.
24#[derive(Clone)]
25pub struct TerminalPanel {
26    /// Unique tab identifier hosting this terminal panel instance.
27    pub tab_id: u32,
28    /// Current input text entered in the terminal command line.
29    input_text: String,
30    /// History of output lines, where each line consists of multiple [`TextFragment`]s.
31    history: Vec<Vec<TextFragment>>,
32    /// Record of previously executed commands for arrow key navigation.
33    command_history: Vec<String>,
34    /// Maximum number of output lines retained in history.
35    history_length: usize,
36    /// Pointer index for navigating through `command_history`.
37    position_in_history: usize,
38    /// Visibility state of the terminal panel settings window.
39    settings_open: bool,
40    /// Temporary text edit buffer for settings inputs.
41    settings_text: String,
42    /// Indicates whether the terminal is active and accepting user input (e.g. when authenticated).
43    pub(crate) is_active: bool,
44}
45
46impl TerminalPanel {
47    /// Creates a new [`TerminalPanel`] instance for the specified tab ID.
48    ///
49    /// # Arguments
50    /// * `tab_id` - Unique identifier of the host tab.
51    pub fn new(tab_id: u32, is_active: bool) -> Self {
52        let initial_line = vec![];
53        Self {
54            tab_id,
55            input_text: String::new(),
56            history: vec![initial_line],
57            command_history: Vec::new(),
58            history_length: 100,
59            position_in_history: 0,
60            settings_open: false,
61            settings_text: String::new(),
62            is_active,
63        }
64    }
65
66    /// Appends a multi-colored line composed of [`TextFragment`]s to the terminal output history.
67    ///
68    /// # Arguments
69    /// * `fragments` - A vector of colored text fragments representing one line of log output.
70    pub fn add_fragments(&mut self, fragments: Vec<TextFragment>) {
71        self.history.push(fragments);
72        self.enforce_history_length();
73    }
74
75    /// Truncates the terminal output history if it exceeds `history_length`.
76    fn enforce_history_length(&mut self) {
77        if self.history.len() > self.history_length {
78            let excess = self.history.len() - self.history_length;
79            self.history.drain(0..excess);
80        }
81    }
82
83    /// Renders the top bar of the terminal panel, including the settings gear button.
84    ///
85    /// # Arguments
86    /// * `ui` - Mutable reference to the `egui::Ui` context.
87    fn draw_top_bar(&mut self, ui: &mut egui::Ui) {
88        ui.horizontal(|ui| {
89            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
90                if ui.button("⚙").on_hover_text("Settings").clicked() {
91                    self.settings_open = true;
92                    self.settings_text = self.history_length.to_string();
93                }
94            });
95        });
96    }
97
98    /// Renders the modal settings window for configuring maximum history length.
99    ///
100    /// # Arguments
101    /// * `ui` - Mutable reference to the `egui::Ui` context.
102    fn draw_settings_window(&mut self, ui: &mut egui::Ui) {
103        if self.settings_open {
104            let mut is_open = self.settings_open;
105
106            egui::Window::new(format!("Settings - Panel {}", self.tab_id))
107                .collapsible(false)
108                .resizable([false, false])
109                .pivot(egui::Align2::CENTER_CENTER)
110                .show(ui.ctx(), |ui| {
111                    egui::Grid::new(format!("seetings_grid_{}", self.tab_id))
112                        .num_columns(2)
113                        .show(ui, |ui| {
114                            ui.label("Command history lenght: ");
115                            if ui.text_edit_singleline(&mut self.settings_text).changed() {
116                                self.settings_text.retain(|c| c.is_ascii_digit());
117                            }
118                        });
119                    ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
120                        if ui.button("save").on_hover_text("Save").clicked() {
121                            if let Ok(val) = self.settings_text.parse::<usize>() {
122                                self.history_length = val;
123                                self.enforce_history_length();
124                            }
125                            is_open = false;
126                        }
127                        if ui.button("discard").on_hover_text("Discard").clicked() {
128                            is_open = false;
129                        }
130                    });
131                    false
132                });
133            self.settings_open = is_open;
134        }
135    }
136
137    /// Renders the central scrollable area containing formatted log history lines.
138    ///
139    /// Automatically scrolls and sticks to the bottom when new output lines arrive.
140    ///
141    /// # Arguments
142    /// * `ui` - Mutable reference to the `egui::Ui` context.
143    fn draw_central_panel(&mut self, ui: &mut egui::Ui) {
144        egui::ScrollArea::vertical()
145            .id_source(format!("terminal_scroll_{}", self.tab_id))
146            .stick_to_bottom(true)
147            .auto_shrink([false, false])
148            .show(ui, |ui| {
149                for line_fragments in &self.history {
150                    ui.horizontal_wrapped(|ui| {
151                        for fragment in line_fragments {
152                            ui.label(egui::RichText::new(&fragment.text).color(fragment.color));
153                        }
154                    });
155                }
156            });
157    }
158
159    /// Renders the bottom command prompt input field.
160    ///
161    /// Dispatches [`UiEvent::SendTerminalCommand`] on Enter press and handles Up/Down arrow key history navigation.
162    ///
163    /// # Arguments
164    /// * `ui` - Mutable reference to the `egui::Ui` context.
165    fn draw_input_panel(&mut self, ui: &mut egui::Ui) {
166        egui::TopBottomPanel::bottom(format!("terminal_input_panel_{}", self.tab_id))
167            .resizable(false)
168            .show_inside(ui, |ui| {
169                ui.separator();
170                ui.horizontal(|ui| {
171                    ui.label("> ");
172                    let response = ui.add(
173                        egui::TextEdit::singleline(&mut self.input_text)
174                            .desired_width(f32::INFINITY)
175                            .interactive(self.is_active),
176                    );
177
178                    if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
179                        if !self.input_text.is_empty() {
180                            self.add_fragments(vec![
181                                TextFragment {
182                                    text: "> ".to_string(),
183                                    color: Color32::GRAY,
184                                 },
185                                TextFragment {
186                                    text: self.input_text.clone(),
187                                    color: Color32::WHITE,
188                                },
189                            ]);
190
191                            self.command_history.push(self.input_text.clone());
192                            self.position_in_history = 0; // Reset history position
193
194                            if let Some(sender) = UI_EVENT_SENDER.read().unwrap().as_ref() {
195                                if let Err(e) = sender.send(UiEvent::SendTerminalCommand {
196                                    id: self.tab_id,
197                                    command: self.input_text.clone(),
198                                }) {
199                                    r_log!(Error, "Failed to send UiEvent: {}", e);
200                                }
201                            }
202                            self.input_text.clear();
203                            response.request_focus();
204                        }
205                    }
206                    if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) {
207                        if !self.command_history.is_empty() {
208                            if self.position_in_history < self.command_history.len() {
209                                self.position_in_history += 1;
210                            }
211                            self.input_text = self.command_history
212                                [self.command_history.len() - self.position_in_history]
213                                .clone();
214                        }
215                    }
216
217                    if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) {
218                        if self.position_in_history > 1 {
219                            self.position_in_history -= 1;
220                            self.input_text = self.command_history
221                                [self.command_history.len() - self.position_in_history]
222                                .clone();
223                        } else if self.position_in_history == 1 {
224                            self.position_in_history = 0;
225                            self.input_text.clear();
226                        }
227                    }
228                });
229            });
230    }
231
232    /// Main entry point to render the entire terminal panel layout.
233    ///
234    /// # Arguments
235    /// * `ui` - Mutable reference to the `egui::Ui` context.
236    pub fn ui(&mut self, ui: &mut egui::Ui) {
237        let panel_id = ui
238            .id()
239            .with(format!("terminal_content_area_{}", self.tab_id));
240
241        ui.push_id(panel_id, |ui| {
242            self.draw_settings_window(ui);
243            self.draw_top_bar(ui);
244            self.draw_input_panel(ui);
245            self.draw_central_panel(ui);
246        });
247    }
248}