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}