rm_lisa/display/renderers/color/
mod.rs

1//! A color "renderer" that renders using fancy ASCII codes.
2
3use crate::{
4	display::{
5		renderers::{
6			ConsoleOutputFeatures, ConsoleRenderer,
7			color::{
8				fields::{create_combined_message, create_field_tailers},
9				helpers::{
10					EMPTY_HEADER, calculate_message_width, calculate_tailer_width,
11					chunk_string_into_width, create_header, erase_line, move_cursor, pad_to_width,
12				},
13				terminal::TerminalState,
14			},
15		},
16		tracing::{FlattenedTracingField, SuperConsoleLogMessage},
17	},
18	errors::LisaError,
19	input::{InputProvider, TerminalInputEvent},
20	tasks::{GloballyUniqueTaskId, LisaTaskStatus, TaskEvent},
21};
22use chrono::{DateTime, Utc};
23use fnv::FnvHashMap;
24use owo_colors::OwoColorize;
25use parking_lot::Mutex;
26use std::{
27	env::var as env_var,
28	fmt::Write,
29	hash::BuildHasherDefault,
30	sync::atomic::{AtomicBool, Ordering},
31};
32use tracing::Level;
33
34mod fields;
35mod helpers;
36mod terminal;
37
38/// A structure that renders using ANSI color codes, and is optimized for
39/// humans who are not visually impaired.
40#[derive(Debug)]
41pub struct ColorConsoleRenderer {
42	/// If a user has manually requested that log output be paused.
43	force_pause: AtomicBool,
44	/// A new PS1 that needs to be loaded (isn't immediate).
45	new_ps1: Mutex<Option<String>>,
46	/// The current terminal state.
47	state: Mutex<TerminalState>,
48	/// The amount of task lines that were last rendered.
49	task_lines_rendered: Mutex<u16>,
50}
51
52impl ColorConsoleRenderer {
53	/// Create a new color'd console renderer.
54	#[must_use]
55	pub fn new() -> Self {
56		Self {
57			force_pause: AtomicBool::new(false),
58			new_ps1: Mutex::new(None),
59			state: Mutex::new(TerminalState::new(Self::default_ps1_impl())),
60			task_lines_rendered: Mutex::new(0),
61		}
62	}
63
64	/// The actual default PS1 implementation.
65	///
66	/// Used by various pieces within the color renderer.
67	#[must_use]
68	fn default_ps1_impl() -> String {
69		"$ ".to_owned()
70	}
71}
72
73impl Default for ColorConsoleRenderer {
74	fn default() -> Self {
75		Self::new()
76	}
77}
78
79impl ConsoleRenderer for ColorConsoleRenderer {
80	fn should_use_renderer(
81		&self,
82		stream_features: &dyn ConsoleOutputFeatures,
83		environment_prefix: &str,
84	) -> bool {
85		// If someone has explicitly specificed a log format, ignore all else.
86		if let Ok(explicit_renderer) = env_var(format!("{environment_prefix}_LOG_FORMAT")) {
87			return explicit_renderer.trim().eq_ignore_ascii_case("color");
88		}
89
90		// Now check for `NO_COLOR`/`NOCOLOR`/`CLICOLOR`
91		for no_color_var in ["NO_COLOR", "NOCOLOR"] {
92			if env_var(no_color_var).as_deref() == Ok("1") {
93				return false;
94			}
95		}
96		for color_var in ["CLICOLOR", "CLI_COLOR", "CLICOLOR_FORCE"] {
97			let env = env_var(color_var);
98			if env.as_deref() == Ok("0") {
99				return false;
100			}
101			if env.as_deref() == Ok("1") {
102				return true;
103			}
104		}
105
106		if !stream_features.is_atty() {
107			return false;
108		}
109		if !stream_features.enable_ansi() {
110			return false;
111		}
112
113		true
114	}
115
116	fn render_message(
117		&self,
118		app_name: &'static str,
119		log: SuperConsoleLogMessage,
120		term_width: u16,
121	) -> Result<String, LisaError> {
122		let mut data = String::new();
123
124		let header = create_header(app_name, &log)?;
125		let tailer_width = calculate_tailer_width(term_width);
126		let msg_width = calculate_message_width(term_width);
127
128		let (real_msg, skip_cause) =
129			if *log.level() == Level::ERROR && log.metadata().contains_key("cause") {
130				let mut new_message = log
131					.message()
132					.map_or("<no message>".to_owned(), ToOwned::to_owned);
133				new_message.push('\n');
134				write!(
135					&mut new_message,
136					"{}",
137					log.metadata()
138						.get("cause")
139						.unwrap_or_else(|| unreachable!())
140				)?;
141				(Some(new_message), true)
142			} else {
143				(log.message().map(ToOwned::to_owned).clone(), false)
144			};
145
146		// If we have too many fields it will be very awkward to render them in the
147		// gutter... instead render them as part of the main message body. Similar
148		// to how tracing works.
149		let excessive_field_count = if skip_cause { 4 } else { 3 };
150		let actual_field_count: usize = log
151			.metadata()
152			.values()
153			.map(FlattenedTracingField::field_count)
154			.sum();
155		if actual_field_count > excessive_field_count || log.force_combine() {
156			let messages = create_combined_message(
157				msg_width + tailer_width,
158				log.metadata(),
159				real_msg.unwrap_or("<no message>".to_owned()),
160				log.should_hide_fields_for_humans(),
161				skip_cause,
162			);
163
164			for (idx, msg) in messages.into_iter().enumerate() {
165				write!(
166					&mut data,
167					"{}{}",
168					if idx == 0 {
169						header.as_str()
170					} else {
171						EMPTY_HEADER
172					},
173					msg,
174				)?;
175				writeln!(&mut data)?;
176			}
177		} else {
178			let empty_map = FnvHashMap::with_capacity_and_hasher(0, BuildHasherDefault::default());
179			let mut fields = create_field_tailers(
180				tailer_width,
181				if log.should_hide_fields_for_humans() {
182					&empty_map
183				} else {
184					log.metadata()
185				},
186				skip_cause,
187				false,
188			);
189			let mut messages =
190				chunk_string_into_width(msg_width, &real_msg.unwrap_or("<no message>".to_owned()));
191
192			while fields.len() < messages.len() {
193				fields.push(pad_to_width("|".to_owned(), tailer_width));
194			}
195			while messages.len() < fields.len() {
196				messages.push(pad_to_width(String::new(), msg_width));
197			}
198
199			for idx in 0..fields.len() {
200				write!(
201					&mut data,
202					"{}{}{}",
203					if idx == 0 {
204						header.as_str()
205					} else {
206						EMPTY_HEADER
207					},
208					messages[idx],
209					fields[idx],
210				)?;
211				writeln!(&mut data)?;
212			}
213		}
214
215		Ok(data)
216	}
217
218	fn default_ps1(&self) -> String {
219		Self::default_ps1_impl()
220	}
221
222	fn supports_ansi(&self) -> bool {
223		true
224	}
225
226	fn should_pause_log_events(&self, _provider: &dyn InputProvider) -> bool {
227		self.force_pause.load(Ordering::Acquire)
228	}
229
230	fn render_input(
231		&self,
232		_app_name: &'static str,
233		provider: &dyn InputProvider,
234		term_width: u16,
235	) -> Result<String, LisaError> {
236		let msg_width = calculate_message_width(term_width);
237		let tailer_width = calculate_tailer_width(term_width);
238
239		let mut state_lock = self.state.lock();
240		let new_ps1_lock = self.new_ps1.lock();
241		Ok(state_lock.render_current_standalone(
242			new_ps1_lock.as_deref(),
243			msg_width,
244			tailer_width,
245			&provider.current_input(),
246		))
247	}
248
249	fn clear_input(&self, _term_width: u16) -> String {
250		let state_lock = self.state.lock();
251		state_lock.clear_current_render()
252	}
253
254	fn clear_task_list(&self, _task_list_size: usize) -> String {
255		let mut data = String::new();
256
257		let task_lines_lock = self.task_lines_rendered.lock();
258		for _ in 0..*task_lines_lock {
259			if data.is_empty() {
260				data = move_cursor(helpers::CursorDirection::Left, 9999);
261			} else {
262				data.push_str(&move_cursor(helpers::CursorDirection::Up, 1));
263			}
264			data.push_str(&erase_line(helpers::ClearLine::EntireLine));
265		}
266		std::mem::drop(task_lines_lock);
267
268		data
269	}
270
271	fn rerender_tasks(
272		&self,
273		_new_task_events: &[TaskEvent],
274		current_task_states: &FnvHashMap<
275			GloballyUniqueTaskId,
276			(DateTime<Utc>, String, LisaTaskStatus),
277		>,
278		tasks_running_since: Option<DateTime<Utc>>,
279		term_height: u16,
280	) -> Result<String, LisaError> {
281		let Some(running_since) = tasks_running_since else {
282			return Ok(String::with_capacity(0));
283		};
284		if current_task_states.is_empty() {
285			return Ok(String::with_capacity(0));
286		}
287
288		let mut task_lines_lock = self.task_lines_rendered.lock();
289		let max_lines_rendered = term_height / 10;
290		// 1 for running line, 1 for tailing newline.
291		*task_lines_lock = 2;
292		let my_time = Utc::now();
293		let mut data = String::new();
294		data.push('[');
295		write!(&mut data, "{}", '+'.bright_green())?;
296		data.push_str("] Running...");
297
298		let duration_since_start_time_delta = my_time.signed_duration_since(running_since);
299		writeln!(
300			&mut data,
301			"{}.{}s",
302			std::cmp::min(0, duration_since_start_time_delta.num_seconds()),
303			std::cmp::min(0, duration_since_start_time_delta.subsec_micros()),
304		)?;
305
306		let mut keys_to_sort = current_task_states.keys().collect::<Vec<_>>();
307		keys_to_sort.sort_by(|one, two| {
308			let first_comp = one.0.cmp(&two.0);
309			let second_comp = one.1.cmp(&two.1);
310
311			if second_comp == std::cmp::Ordering::Equal {
312				first_comp
313			} else {
314				second_comp
315			}
316		});
317
318		let mut did_break_early = false;
319		for (tasks_rendered, key) in keys_to_sort.into_iter().enumerate() {
320			// We've already rendered our maximum amount of tasks.
321			if u16::try_from(tasks_rendered).unwrap_or(u16::MAX) > max_lines_rendered {
322				did_break_early = true;
323				break;
324			}
325			*task_lines_lock += 1;
326			let (task_start_time, task_name, status) =
327				current_task_states.get(key).expect("Guaranteed to exist!");
328			let time_delta_since_task_start = my_time.signed_duration_since(task_start_time);
329
330			match status {
331				LisaTaskStatus::Inactive => {
332					writeln!(
333						&mut data,
334						"{}",
335						format!(
336							"  | => {}/{}|{task_name}: {status} [{}.{:04}s...]",
337							key.0,
338							key.1,
339							std::cmp::min(0, time_delta_since_task_start.num_seconds()),
340							std::cmp::min(0, time_delta_since_task_start.subsec_micros()),
341						)
342						.white()
343						.bold()
344					)?;
345				}
346				LisaTaskStatus::Running(_) => {
347					writeln!(
348						&mut data,
349						"{}",
350						format!(
351							"  | => {}/{}|{task_name}: {status} [{}.{:04}s...]",
352							key.0,
353							key.1,
354							std::cmp::min(0, time_delta_since_task_start.num_seconds()),
355							std::cmp::min(0, time_delta_since_task_start.subsec_micros()),
356						)
357						.cyan()
358						.italic()
359					)?;
360				}
361				LisaTaskStatus::Waiting(_) => {
362					writeln!(
363						&mut data,
364						"{}",
365						format!(
366							"  | => {}/{}|{task_name}: {status} [{}.{:04}s...]",
367							key.0,
368							key.1,
369							std::cmp::min(0, duration_since_start_time_delta.num_seconds()),
370							std::cmp::min(0, duration_since_start_time_delta.subsec_micros()),
371						)
372						.yellow()
373						.italic()
374					)?;
375				}
376			}
377		}
378		if did_break_early {
379			writeln!(
380				&mut data,
381				"  | => {} tasks also running...",
382				current_task_states.len() - usize::from(*task_lines_lock),
383			)?;
384			*task_lines_lock += 1;
385		}
386		std::mem::drop(task_lines_lock);
387
388		Ok(data)
389	}
390
391	fn on_input(
392		&self,
393		event: TerminalInputEvent,
394		provider: &dyn InputProvider,
395	) -> Result<String, LisaError> {
396		let mut state_lock = self.state.lock();
397		Ok(state_lock.on_input_event(provider, event, &self.force_pause))
398	}
399
400	fn update_ps1(&self, new_ps1: String) {
401		let mut new_ps1_lock = self.new_ps1.lock();
402		_ = new_ps1_lock.insert(new_ps1);
403	}
404}