From 0d53604b5ae2da4af0ac704c573668e3418ff7dc Mon Sep 17 00:00:00 2001 From: Jason Jean Date: Wed, 7 May 2025 16:29:36 -0400 Subject: [PATCH] fix(core): clearer tui colors on light themes (#31095) ## Current Behavior Light themes are not super clear with the new TUI. ## Expected Behavior Light themes are much clearer with the new TUI. Internal loom shared on slack for full details. ## Related Issue(s) Fixes # --------- Co-authored-by: JamesHenry --- .github/workflows/ci.yml | 2 +- .husky/pre-push | 5 +- Cargo.lock | 33 +++++++ clippy.toml | 4 + packages/nx/Cargo.toml | 1 + packages/nx/project.json | 13 +++ packages/nx/src/lib.rs | 1 + packages/nx/src/native/pseudo_terminal/mac.rs | 5 +- .../nx/src/native/pseudo_terminal/non_mac.rs | 4 +- .../native/pseudo_terminal/pseudo_terminal.rs | 6 +- packages/nx/src/native/tui/app.rs | 21 +++-- .../native/tui/components/countdown_popup.rs | 52 ++++------- .../src/native/tui/components/help_popup.rs | 61 +++++-------- .../nx/src/native/tui/components/help_text.rs | 56 ++++++------ .../native/tui/components/layout_manager.rs | 10 +- .../src/native/tui/components/pagination.rs | 13 +-- .../tui/components/task_selection_manager.rs | 4 +- .../src/native/tui/components/tasks_list.rs | 91 ++++++++++--------- .../native/tui/components/terminal_pane.rs | 77 ++++++++++------ packages/nx/src/native/tui/lifecycle.rs | 15 ++- packages/nx/src/native/tui/mod.rs | 2 + packages/nx/src/native/tui/theme.rs | 72 +++++++++++++++ packages/nx/src/native/tui/tui.rs | 3 + 23 files changed, 338 insertions(+), 213 deletions(-) create mode 100644 clippy.toml create mode 100644 packages/nx/src/native/tui/theme.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2458a1e2b0..7a4a51cd14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: pnpm nx run-many -t check-imports check-commit check-lock-files check-codeowners --parallel=1 --no-dte & pids+=($!) - pnpm nx affected --targets=lint,test,build,e2e,e2e-ci,format-native & + pnpm nx affected --targets=lint,test,build,e2e,e2e-ci,format-native,lint-native & pids+=($!) for pid in "${pids[@]}"; do diff --git a/.husky/pre-push b/.husky/pre-push index f5cdcc252a..0dbc593c59 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,6 @@ pnpm check-lock-files +pnpm pretty-quick --check +NX_TUI=false pnpm nx format-native nx +NX_TUI=false pnpm nx lint-native nx pnpm check-commit pnpm documentation -pnpm pretty-quick --check -pnpm nx format-native nx \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 171c4b981a..5a25a13694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2202,6 +2202,7 @@ dependencies = [ "sysinfo", "tar", "tempfile", + "terminal-colorsaurus", "thiserror", "tokio", "tokio-util", @@ -3550,6 +3551,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "terminal-colorsaurus" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7afe4c174a3cbfb52ebcb11b28965daf74fe9111d4e07e40689d05af06e26e8" +dependencies = [ + "cfg-if", + "libc", + "memchr", + "mio 1.0.3", + "terminal-trx", + "windows-sys 0.59.0", + "xterm-color", +] + +[[package]] +name = "terminal-trx" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "975b4233aefa1b02456d5e53b22c61653c743e308c51cf4181191d8ce41753ab" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "termios" version = "0.2.2" @@ -4636,6 +4663,12 @@ dependencies = [ "rustix 1.0.5", ] +[[package]] +name = "xterm-color" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de5f056fb9dc8b7908754867544e26145767187aaac5a98495e88ad7cb8a80f" + [[package]] name = "xxhash-rust" version = "0.8.10" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000000..c1fe7b5cf8 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +disallowed-types = [ + # We need to ensure adjustments for light and dark themes are applied appropriately + { path = "ratatui::style::Color", reason = "Use our utils from crate::native::tui::colors instead to ensure appropriate light/dark theme support" }, +] \ No newline at end of file diff --git a/packages/nx/Cargo.toml b/packages/nx/Cargo.toml index 8a1f969a25..db21dccb13 100644 --- a/packages/nx/Cargo.toml +++ b/packages/nx/Cargo.toml @@ -47,6 +47,7 @@ swc_ecma_ast = "0.107.0" sysinfo = "0.33.1" rand = "0.9.0" tar = "0.4.44" +terminal-colorsaurus = "0.4.0" thiserror = "1.0.40" tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } diff --git a/packages/nx/project.json b/packages/nx/project.json index 714b7ac15b..6555027f3b 100644 --- a/packages/nx/project.json +++ b/packages/nx/project.json @@ -152,6 +152,19 @@ "args": ["--all"] } } + }, + "lint-native": { + "command": "cargo clippy", + "cache": true, + "options": { + "cwd": "{projectRoot}/src/native", + "args": ["--frozen"] + }, + "configurations": { + "fix": { + "args": [] + } + } } } } diff --git a/packages/nx/src/lib.rs b/packages/nx/src/lib.rs index 6cebee94eb..f513d125dd 100644 --- a/packages/nx/src/lib.rs +++ b/packages/nx/src/lib.rs @@ -1,3 +1,4 @@ +#![deny(clippy::disallowed_types)] #![cfg_attr(target_os = "wasi", feature(wasi_ext))] // add all the napi macros globally #[macro_use] diff --git a/packages/nx/src/native/pseudo_terminal/mac.rs b/packages/nx/src/native/pseudo_terminal/mac.rs index de2a0121b2..a0aaff686a 100644 --- a/packages/nx/src/native/pseudo_terminal/mac.rs +++ b/packages/nx/src/native/pseudo_terminal/mac.rs @@ -1,10 +1,9 @@ use std::collections::HashMap; use tracing::trace; -use watchexec::command; use super::child_process::ChildProcess; use super::os; -use super::pseudo_terminal::PseudoTerminal; +use super::pseudo_terminal::{PseudoTerminal, PseudoTerminalOptions}; use crate::native::logger::enable_logger; #[napi] @@ -18,7 +17,7 @@ impl RustPseudoTerminal { pub fn new() -> napi::Result { enable_logger(); - let pseudo_terminal = PseudoTerminal::default()?; + let pseudo_terminal = PseudoTerminal::new(PseudoTerminalOptions::default())?; Ok(Self { pseudo_terminal }) } diff --git a/packages/nx/src/native/pseudo_terminal/non_mac.rs b/packages/nx/src/native/pseudo_terminal/non_mac.rs index 0dc2177ed2..1184bc5aab 100644 --- a/packages/nx/src/native/pseudo_terminal/non_mac.rs +++ b/packages/nx/src/native/pseudo_terminal/non_mac.rs @@ -4,7 +4,7 @@ use tracing::trace; use super::child_process::ChildProcess; use super::os; -use super::pseudo_terminal::PseudoTerminal; +use super::pseudo_terminal::{PseudoTerminal, PseudoTerminalOptions}; use crate::native::logger::enable_logger; #[napi] @@ -18,7 +18,7 @@ impl RustPseudoTerminal { pub fn new() -> napi::Result { enable_logger(); - let pseudo_terminal = PseudoTerminal::default()?; + let pseudo_terminal = PseudoTerminal::new(PseudoTerminalOptions::default())?; Ok(Self { pseudo_terminal }) } diff --git a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs index a9257912b0..68e600b9b7 100644 --- a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs +++ b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs @@ -130,7 +130,7 @@ impl PseudoTerminal { let write_buf = content.as_bytes(); debug!("Escaped Stdout: {:?}", write_buf.escape_ascii().to_string()); - while let Err(e) = stdout.write_all(&write_buf) { + while let Err(e) = stdout.write_all(write_buf) { match e.kind() { std::io::ErrorKind::Interrupted => { if !logged_interrupted_error { @@ -172,10 +172,6 @@ impl PseudoTerminal { }) } - pub fn default() -> Result { - Self::new(PseudoTerminalOptions::default()) - } - pub fn run_command( &mut self, command: String, diff --git a/packages/nx/src/native/tui/app.rs b/packages/nx/src/native/tui/app.rs index 37a0857ce3..458355ad4f 100644 --- a/packages/nx/src/native/tui/app.rs +++ b/packages/nx/src/native/tui/app.rs @@ -5,7 +5,7 @@ use napi::bindgen_prelude::External; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use ratatui::layout::{Alignment, Rect, Size}; use ratatui::style::Modifier; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use std::collections::HashMap; @@ -35,6 +35,7 @@ use super::components::Component; use super::config::TuiConfig; use super::lifecycle::RunMode; use super::pty::PtyInstance; +use super::theme::THEME; use super::tui; use super::utils::normalize_newlines; @@ -121,7 +122,7 @@ impl App { layout_manager: LayoutManager::new(task_count), frame_area: None, layout_areas: None, - terminal_pane_data: [main_terminal_pane_data, TerminalPaneData::default()], + terminal_pane_data: [main_terminal_pane_data, TerminalPaneData::new()], spacebar_mode: false, pane_tasks: [None, None], task_list_hidden: false, @@ -255,7 +256,7 @@ impl App { let pty = self .pty_instances .get_mut(&task_id) - .expect(&format!("{} has not been registered yet.", task_id)); + .unwrap_or_else(|| panic!("{} has not been registered yet.", task_id)); pty.process_output(output.as_bytes()); } @@ -752,11 +753,11 @@ impl App { tui.draw(|f| { let area = f.area(); // Cache the frame area if it's never been set before (will be updated in subsequent resize events if necessary) - if !self.frame_area.is_some() { + if self.frame_area.is_none() { self.frame_area = Some(area); } // Determine the required layout areas for the tasks list and terminal panes using the LayoutManager - if !self.layout_areas.is_some() { + if self.layout_areas.is_none() { self.recalculate_layout_areas(); } @@ -777,8 +778,8 @@ impl App { " NX ", Style::reset() .add_modifier(Modifier::BOLD) - .bg(Color::Red) - .fg(Color::Black), + .bg(THEME.error) + .fg(THEME.primary_fg), ), Span::raw(" Terminal too small "), ]); @@ -879,9 +880,9 @@ impl App { Block::default() .title(format!(" Output {} ", pane_idx + 1)) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(THEME.secondary_fg)), ) - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(THEME.secondary_fg)) .alignment(Alignment::Center); f.render_widget(placeholder, *pane_area); @@ -1348,7 +1349,7 @@ impl App { /// Actually processes the resize event by updating PTY dimensions. fn handle_pty_resize(&mut self) -> io::Result<()> { - if !self.layout_areas.is_some() { + if self.layout_areas.is_none() { return Ok(()); } diff --git a/packages/nx/src/native/tui/components/countdown_popup.rs b/packages/nx/src/native/tui/components/countdown_popup.rs index d44503f01d..f86147e821 100644 --- a/packages/nx/src/native/tui/components/countdown_popup.rs +++ b/packages/nx/src/native/tui/components/countdown_popup.rs @@ -1,7 +1,7 @@ use color_eyre::eyre::Result; use ratatui::{ layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{ Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, @@ -12,6 +12,8 @@ use ratatui::{ use std::any::Any; use std::time::{Duration, Instant}; +use crate::native::tui::theme::THEME; + use super::Component; pub struct CountdownPopup { @@ -143,27 +145,27 @@ impl CountdownPopup { let time_remaining = seconds_remaining + 1; - let content = vec![ + let content = [ Line::from(vec![ - Span::styled("• Press ", Style::default().fg(Color::DarkGray)), - Span::styled("q to exit immediately ", Style::default().fg(Color::Cyan)), - Span::styled("or ", Style::default().fg(Color::DarkGray)), - Span::styled("any other key ", Style::default().fg(Color::Cyan)), + Span::styled("• Press ", Style::default().fg(THEME.secondary_fg)), + Span::styled("q to exit immediately ", Style::default().fg(THEME.info)), + Span::styled("or ", Style::default().fg(THEME.secondary_fg)), + Span::styled("any other key ", Style::default().fg(THEME.info)), Span::styled( "to keep the TUI running and interactively explore the results.", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.secondary_fg), ), ]), Line::from(""), Line::from(vec![ Span::styled( "• Learn how to configure auto-exit and more in the docs: ", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.secondary_fg), ), Span::styled( // NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 "https://nx.dev/terminal-ui", - Style::default().fg(Color::Cyan), + Style::default().fg(THEME.info), ), ]), ]; @@ -175,20 +177,20 @@ impl CountdownPopup { " NX ", Style::default() .add_modifier(Modifier::BOLD) - .bg(Color::Cyan) - .fg(Color::Black), + .bg(THEME.info) + .fg(THEME.primary_fg), ), - Span::styled(" Exiting in ", Style::default().fg(Color::White)), + Span::styled(" Exiting in ", Style::default().fg(THEME.primary_fg)), Span::styled( format!("{}", time_remaining), - Style::default().fg(Color::Cyan), + Style::default().fg(THEME.info), ), - Span::styled("... ", Style::default().fg(Color::White)), + Span::styled("... ", Style::default().fg(THEME.primary_fg)), ])) .title_alignment(Alignment::Left) .borders(Borders::ALL) .border_type(BorderType::Plain) - .border_style(Style::default().fg(Color::Cyan)) + .border_style(Style::default().fg(THEME.info)) .padding(Padding::proportional(1)); // Get the inner area @@ -264,14 +266,14 @@ impl CountdownPopup { f.render_widget( Paragraph::new(top_text) .alignment(Alignment::Right) - .style(Style::default().fg(Color::Cyan)), + .style(Style::default().fg(THEME.info)), top_right_area, ); f.render_widget( Paragraph::new(bottom_text) .alignment(Alignment::Right) - .style(Style::default().fg(Color::Cyan)), + .style(Style::default().fg(THEME.info)), bottom_right_area, ); @@ -279,27 +281,13 @@ impl CountdownPopup { .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("↑")) .end_symbol(Some("↓")) - .style(Style::default().fg(Color::Cyan)); + .style(Style::default().fg(THEME.info)); f.render_stateful_widget(scrollbar, popup_area, &mut self.scrollbar_state); } } } -impl Clone for CountdownPopup { - fn clone(&self) -> Self { - Self { - visible: self.visible, - start_time: self.start_time, - duration: self.duration, - scroll_offset: self.scroll_offset, - scrollbar_state: self.scrollbar_state, - content_height: self.content_height, - viewport_height: self.viewport_height, - } - } -} - impl Component for CountdownPopup { fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { if self.visible { diff --git a/packages/nx/src/native/tui/components/help_popup.rs b/packages/nx/src/native/tui/components/help_popup.rs index f68ba33e04..acdecc07d8 100644 --- a/packages/nx/src/native/tui/components/help_popup.rs +++ b/packages/nx/src/native/tui/components/help_popup.rs @@ -1,7 +1,7 @@ use color_eyre::eyre::Result; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{ Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, @@ -11,9 +11,9 @@ use ratatui::{ use std::any::Any; use tokio::sync::mpsc::UnboundedSender; -use crate::native::tui::action::Action; - use super::{Component, Frame}; +use crate::native::tui::action::Action; +use crate::native::tui::theme::THEME; pub struct HelpPopup { scroll_offset: usize, @@ -154,29 +154,29 @@ impl HelpPopup { Line::from(vec![ Span::styled( "Thanks for using Nx! To get the most out of this terminal UI, please check out the docs: ", - Style::default().fg(Color::White), + Style::default().fg(THEME.primary_fg), ), Span::styled( // NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 "https://nx.dev/terminal-ui", - Style::default().fg(Color::Cyan), + Style::default().fg(THEME.info), ), ]), Line::from(vec![ Span::styled( "If you are finding Nx useful, please consider giving it a star on GitHub, it means a lot: ", - Style::default().fg(Color::White), + Style::default().fg(THEME.primary_fg), ), Span::styled( // NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 "https://github.com/nrwl/nx", - Style::default().fg(Color::Cyan), + Style::default().fg(THEME.info), ), ]), Line::from(""), // Empty line for spacing Line::from(vec![Span::styled( "Available keyboard shortcuts:", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.secondary_fg), )]), Line::from(""), // Empty line for spacing ]; @@ -201,12 +201,12 @@ impl HelpPopup { if i > 0 { spans.push(Span::styled( " or ", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.secondary_fg), )); } spans.push(Span::styled( part.to_string(), - Style::default().fg(Color::Cyan), + Style::default().fg(THEME.info), )); } @@ -215,8 +215,11 @@ impl HelpPopup { spans.push(Span::raw(padding)); // Add the separator and description - spans.push(Span::styled("= ", Style::default().fg(Color::DarkGray))); - spans.push(Span::styled(desc, Style::default().fg(Color::White))); + spans.push(Span::styled( + "= ", + Style::default().fg(THEME.secondary_fg), + )); + spans.push(Span::styled(desc, Style::default().fg(THEME.primary_fg))); Line::from(spans) } @@ -232,15 +235,15 @@ impl HelpPopup { " NX ", Style::default() .add_modifier(Modifier::BOLD) - .bg(Color::Cyan) - .fg(Color::Black), + .bg(THEME.info) + .fg(THEME.primary_fg), ), - Span::styled(" Help ", Style::default().fg(Color::White)), + Span::styled(" Help ", Style::default().fg(THEME.primary_fg)), ])) .title_alignment(Alignment::Left) .borders(Borders::ALL) .border_type(BorderType::Plain) - .border_style(Style::default().fg(Color::Cyan)) + .border_style(Style::default().fg(THEME.info)) .padding(Padding::proportional(1)); let inner_area = block.inner(popup_area); @@ -318,14 +321,14 @@ impl HelpPopup { f.render_widget( Paragraph::new(top_text) .alignment(Alignment::Right) - .style(Style::default().fg(Color::Cyan)), + .style(Style::default().fg(THEME.info)), top_right_area, ); f.render_widget( Paragraph::new(bottom_text) .alignment(Alignment::Right) - .style(Style::default().fg(Color::Cyan)), + .style(Style::default().fg(THEME.info)), bottom_right_area, ); @@ -333,26 +336,13 @@ impl HelpPopup { .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("↑")) .end_symbol(Some("↓")) - .style(Style::default().fg(Color::Cyan)); + .style(Style::default().fg(THEME.info)); f.render_stateful_widget(scrollbar, popup_area, &mut self.scrollbar_state); } } } -impl Clone for HelpPopup { - fn clone(&self) -> Self { - Self { - scroll_offset: self.scroll_offset, - scrollbar_state: self.scrollbar_state, - content_height: self.content_height, - viewport_height: self.viewport_height, - visible: self.visible, - action_tx: self.action_tx.clone(), - } - } -} - impl Component for HelpPopup { fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.action_tx = Some(tx); @@ -367,11 +357,8 @@ impl Component for HelpPopup { } fn update(&mut self, action: Action) -> Result> { - match action { - Action::Resize(w, h) => { - self.handle_resize(w, h); - } - _ => {} + if let Action::Resize(w, h) = action { + self.handle_resize(w, h); } Ok(None) } diff --git a/packages/nx/src/native/tui/components/help_text.rs b/packages/nx/src/native/tui/components/help_text.rs index 2aa3fa5e83..ad0c7520a3 100644 --- a/packages/nx/src/native/tui/components/help_text.rs +++ b/packages/nx/src/native/tui/components/help_text.rs @@ -1,11 +1,13 @@ use ratatui::{ layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::Paragraph, Frame, }; +use crate::native::tui::theme::THEME; + pub struct HelpText { collapsed_mode: bool, is_dimmed: bool, @@ -48,15 +50,17 @@ impl HelpText { } else { Style::default() }; + let key_style = base_style.fg(THEME.info); + let label_style = base_style.fg(THEME.secondary_fg); if self.collapsed_mode { // Show minimal hint let hint = vec![ - Span::styled("quit: ", base_style.fg(Color::DarkGray)), - Span::styled("q", base_style.fg(Color::Cyan)), - Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("help: ", base_style.fg(Color::DarkGray)), - Span::styled("?", base_style.fg(Color::Cyan)), + Span::styled("quit: ", label_style), + Span::styled("q", key_style), + Span::styled(" ", label_style), + Span::styled("help: ", label_style), + Span::styled("?", key_style), ]; f.render_widget( Paragraph::new(Line::from(hint)).alignment(if self.align_left { @@ -69,26 +73,26 @@ impl HelpText { } else { // Show full shortcuts let shortcuts = vec![ - Span::styled("quit: ", base_style.fg(Color::DarkGray)), - Span::styled("q", base_style.fg(Color::Cyan)), - Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("help: ", base_style.fg(Color::DarkGray)), - Span::styled("?", base_style.fg(Color::Cyan)), - Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("navigate: ", base_style.fg(Color::DarkGray)), - Span::styled("↑ ↓", base_style.fg(Color::Cyan)), - Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("filter: ", base_style.fg(Color::DarkGray)), - Span::styled("/", base_style.fg(Color::Cyan)), - Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("pin output: ", base_style.fg(Color::DarkGray)), - Span::styled("", base_style.fg(Color::DarkGray)), - Span::styled("1", base_style.fg(Color::Cyan)), - Span::styled(" or ", base_style.fg(Color::DarkGray)), - Span::styled("2", base_style.fg(Color::Cyan)), - Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("show output: ", base_style.fg(Color::DarkGray)), - Span::styled("", base_style.fg(Color::Cyan)), + Span::styled("quit: ", label_style), + Span::styled("q", key_style), + Span::styled(" ", label_style), + Span::styled("help: ", label_style), + Span::styled("?", key_style), + Span::styled(" ", label_style), + Span::styled("navigate: ", label_style), + Span::styled("↑ ↓", key_style), + Span::styled(" ", label_style), + Span::styled("filter: ", label_style), + Span::styled("/", key_style), + Span::styled(" ", label_style), + Span::styled("pin output: ", label_style), + Span::styled("", label_style), + Span::styled("1", key_style), + Span::styled(" or ", label_style), + Span::styled("2", key_style), + Span::styled(" ", label_style), + Span::styled("show output: ", label_style), + Span::styled("", key_style), ]; f.render_widget( diff --git a/packages/nx/src/native/tui/components/layout_manager.rs b/packages/nx/src/native/tui/components/layout_manager.rs index 03b8950e51..4b3559c77f 100644 --- a/packages/nx/src/native/tui/components/layout_manager.rs +++ b/packages/nx/src/native/tui/components/layout_manager.rs @@ -739,7 +739,6 @@ mod tests { #[cfg(test)] mod visual_tests { use super::*; - use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders}; use ratatui::{backend::TestBackend, Terminal}; @@ -758,10 +757,8 @@ mod tests { // Render task list if visible if let Some(task_list_area) = areas.task_list { - let task_list_block = Block::default() - .title("Task List") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)); + let task_list_block = + Block::default().title("Task List").borders(Borders::ALL); frame.render_widget(task_list_block, task_list_area); } @@ -770,8 +767,7 @@ mod tests { for (i, pane_area) in areas.terminal_panes.iter().enumerate() { let pane_block = Block::default() .title(format!("Terminal Pane {}", i + 1)) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)); + .borders(Borders::ALL); frame.render_widget(pane_block, *pane_area); } diff --git a/packages/nx/src/native/tui/components/pagination.rs b/packages/nx/src/native/tui/components/pagination.rs index abeaebc17b..7a8caf4069 100644 --- a/packages/nx/src/native/tui/components/pagination.rs +++ b/packages/nx/src/native/tui/components/pagination.rs @@ -1,6 +1,7 @@ +use crate::native::tui::theme::THEME; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::Paragraph, Frame, @@ -52,9 +53,9 @@ impl Pagination { // Left arrow - dim if we're on the first page let left_arrow = if current_page == 0 { - Span::styled("←", base_style.fg(Color::Cyan).add_modifier(Modifier::DIM)) + Span::styled("←", base_style.fg(THEME.info).add_modifier(Modifier::DIM)) } else { - Span::styled("←", base_style.fg(Color::Cyan)) + Span::styled("←", base_style.fg(THEME.info)) }; spans.push(left_arrow); @@ -62,15 +63,15 @@ impl Pagination { spans.push(Span::raw(" ")); spans.push(Span::styled( format!("{}/{}", current_page + 1, total_pages), - base_style.fg(Color::DarkGray), + base_style.fg(THEME.secondary_fg), )); spans.push(Span::raw(" ")); // Right arrow - dim if we're on the last page let right_arrow = if current_page >= total_pages.saturating_sub(1) { - Span::styled("→", base_style.fg(Color::Cyan).add_modifier(Modifier::DIM)) + Span::styled("→", base_style.fg(THEME.info).add_modifier(Modifier::DIM)) } else { - Span::styled("→", base_style.fg(Color::Cyan)) + Span::styled("→", base_style.fg(THEME.info)) }; spans.push(right_arrow); diff --git a/packages/nx/src/native/tui/components/task_selection_manager.rs b/packages/nx/src/native/tui/components/task_selection_manager.rs index 2c6ba3266b..1f7f7eb231 100644 --- a/packages/nx/src/native/tui/components/task_selection_manager.rs +++ b/packages/nx/src/native/tui/components/task_selection_manager.rs @@ -214,7 +214,7 @@ impl TaskSelectionManager { pub fn is_selected(&self, task_name: &str) -> bool { self.selected_task_name .as_ref() - .map_or(false, |selected| selected == task_name) + .is_some_and(|selected| selected == task_name) } pub fn get_selected_task_name(&self) -> Option<&String> { @@ -222,7 +222,7 @@ impl TaskSelectionManager { } pub fn total_pages(&self) -> usize { - (self.entries.len() + self.items_per_page - 1) / self.items_per_page + self.entries.len().div_ceil(self.items_per_page) } pub fn get_current_page(&self) -> usize { diff --git a/packages/nx/src/native/tui/components/tasks_list.rs b/packages/nx/src/native/tui/components/tasks_list.rs index 57cd415118..693bb9994b 100644 --- a/packages/nx/src/native/tui/components/tasks_list.rs +++ b/packages/nx/src/native/tui/components/tasks_list.rs @@ -2,7 +2,7 @@ use color_eyre::eyre::Result; use hashbrown::HashSet; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, + style::{Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Cell, Paragraph, Row, Table}, Frame, @@ -13,6 +13,10 @@ use std::{ }; use tokio::sync::mpsc::UnboundedSender; +use super::help_text::HelpText; +use super::pagination::Pagination; +use super::task_selection_manager::{SelectionMode, TaskSelectionManager}; +use crate::native::tui::theme::THEME; use crate::native::{ tasks::types::{Task, TaskResult}, tui::{ @@ -24,10 +28,6 @@ use crate::native::{ }, }; -use super::help_text::HelpText; -use super::pagination::Pagination; -use super::task_selection_manager::{SelectionMode, TaskSelectionManager}; - const CACHE_STATUS_LOCAL_KEPT_EXISTING: &str = "Kept Existing"; const CACHE_STATUS_LOCAL: &str = "Local"; const CACHE_STATUS_REMOTE: &str = "Remote"; @@ -65,7 +65,7 @@ impl Clone for TaskItem { name: self.name.clone(), duration: self.duration.clone(), cache_status: self.cache_status.clone(), - status: self.status.clone(), + status: self.status, continuous: self.continuous, terminal_output: self.terminal_output.clone(), start_time: self.start_time, @@ -313,7 +313,10 @@ impl TasksList { if in_progress_count < self.max_parallel { // When we have fewer InProgress tasks than self.max_parallel, fill the remaining slots // with empty placeholder rows to maintain the fixed height - entries.extend(std::iter::repeat(None).take(self.max_parallel - in_progress_count)); + entries.extend(std::iter::repeat_n( + None, + self.max_parallel - in_progress_count, + )); } // Always add a separator after the parallel tasks section with a bottom cap @@ -483,9 +486,9 @@ impl TasksList { /// Returns a dimmed style when focus is not on the task list. fn get_table_style(&self) -> Style { if self.is_task_list_focused() { - Style::default() + Style::default().fg(THEME.secondary_fg) } else { - Style::default().dim() + Style::default().dim().fg(THEME.secondary_fg) } } @@ -554,9 +557,9 @@ impl TasksList { /// Shows either filter input or task status based on current state. fn get_header_cells(&self, has_narrow_area_width: bool) -> Vec { let status_style = if !self.is_task_list_focused() { - Style::default().fg(Color::DarkGray).dim() + Style::default().fg(THEME.secondary_fg).dim() } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(THEME.secondary_fg) }; // Determine if all tasks are completed and the status color to use @@ -579,12 +582,12 @@ impl TasksList { .iter() .any(|t| matches!(t.status, TaskStatus::Failure)); if has_failures { - Color::Red + THEME.error } else { - Color::Green + THEME.success } } else { - Color::Cyan + THEME.info }; // Leave first cell empty for the logo @@ -685,9 +688,9 @@ impl TasksList { let should_dim = !self.is_task_list_focused(); let filter_style = if should_dim { - Style::default().fg(Color::Yellow).dim() + Style::default().fg(THEME.warning).dim() } else { - Style::default().fg(Color::Yellow) + Style::default().fg(THEME.warning) }; let instruction_text = if hidden_tasks > 0 { @@ -725,7 +728,7 @@ impl TasksList { .unwrap() .get_current_page_entries(); let selected_style = Style::default() - .fg(Color::White) + .fg(THEME.primary_fg) .add_modifier(Modifier::BOLD); let normal_style = Style::default(); @@ -747,7 +750,7 @@ impl TasksList { // Determine the color of the NX logo based on task status let logo_color = if self.tasks.is_empty() { // No tasks - Color::Cyan + THEME.info } else if all_tasks_completed { // All tasks are completed, check if any failed let has_failures = self @@ -755,13 +758,13 @@ impl TasksList { .iter() .any(|t| matches!(t.status, TaskStatus::Failure)); if has_failures { - Color::Red + THEME.error } else { - Color::Green + THEME.success } } else { // Tasks are still running - Color::Cyan + THEME.info }; // Get header cells using the existing method but add NX logo to first cell @@ -772,7 +775,7 @@ impl TasksList { // Use the logo color for the title text as well logo_color } else { - Color::White + THEME.primary_fg }; // Apply modifiers based on focus state @@ -803,7 +806,7 @@ impl TasksList { // First cell: Just the NX logo and box corner if needed let mut first_cell_spans = vec![Span::styled( " NX ", - title_style.bold().bg(logo_color).fg(Color::Black), + Style::reset().bold().bg(logo_color).fg(THEME.primary_fg), )]; // Add box corner if needed @@ -872,7 +875,7 @@ impl TasksList { Span::raw(" "), // Add vertical line for visual continuity, only on first page if is_first_page && self.max_parallel > 0 { - Span::styled("│", Style::default().fg(Color::Cyan)) + Span::styled("│", Style::default().fg(THEME.info)) } else { Span::raw(" ") }, @@ -888,7 +891,7 @@ impl TasksList { Span::raw(" "), // Add vertical line for visual continuity, only on first page if is_first_page && self.max_parallel > 0 { - Span::styled("│", Style::default().fg(Color::Cyan)) + Span::styled("│", Style::default().fg(THEME.info)) } else { Span::raw(" ") }, @@ -918,7 +921,7 @@ impl TasksList { .selection_manager .lock() .unwrap() - .is_selected(&task_name); + .is_selected(task_name); // Use the helper method to check if we should show the parallel section let show_parallel = self.should_show_parallel_section(); @@ -933,19 +936,19 @@ impl TasksList { | TaskStatus::RemoteCache => Cell::from(Line::from(vec![ Span::raw(if is_selected { ">" } else { " " }), Span::raw(" "), - Span::styled("✔", Style::default().fg(Color::Green)), + Span::styled("✔", Style::default().fg(THEME.success)), Span::raw(" "), ])), TaskStatus::Failure => Cell::from(Line::from(vec![ Span::raw(if is_selected { ">" } else { " " }), Span::raw(" "), - Span::styled("✖", Style::default().fg(Color::Red)), + Span::styled("✖", Style::default().fg(THEME.error)), Span::raw(" "), ])), TaskStatus::Skipped => Cell::from(Line::from(vec![ Span::raw(if is_selected { ">" } else { " " }), Span::raw(" "), - Span::styled("⏭", Style::default().fg(Color::Yellow)), + Span::styled("⏭", Style::default().fg(THEME.warning)), Span::raw(" "), ])), TaskStatus::InProgress | TaskStatus::Shared => { @@ -959,7 +962,7 @@ impl TasksList { if is_in_parallel_section && self.selection_manager.lock().unwrap().get_current_page() == 0 { - spans.push(Span::styled("│", Style::default().fg(Color::Cyan))); + spans.push(Span::styled("│", Style::default().fg(THEME.info))); } else { spans.push(Span::raw(" ")); } @@ -967,7 +970,7 @@ impl TasksList { // Add the spinner with consistent spacing spans.push(Span::styled( throbber_char.to_string(), - Style::default().fg(Color::LightCyan), + Style::default().fg(THEME.info_light), )); // Add trailing space to maintain consistent width @@ -978,14 +981,14 @@ impl TasksList { TaskStatus::Stopped => Cell::from(Line::from(vec![ Span::raw(if is_selected { ">" } else { " " }), Span::raw(" "), - Span::styled("◼", Style::default().fg(Color::DarkGray)), + Span::styled("◼", Style::default().fg(THEME.secondary_fg)), Span::raw(" "), ])), TaskStatus::NotStarted => Cell::from(Line::from(vec![ Span::raw(if is_selected { ">" } else { " " }), // No need for parallel section check for pending tasks Span::raw(" "), - Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::styled("·", Style::default().fg(THEME.secondary_fg)), Span::raw(" "), ])), }; @@ -1136,7 +1139,7 @@ impl TasksList { Span::raw(" "), // Add space and vertical line for parallel section (fixed position) if is_first_page && self.max_parallel > 0 { - Span::styled("│", Style::default().fg(Color::Cyan)) + Span::styled("│", Style::default().fg(THEME.info)) } else { Span::raw(" ") }, @@ -1152,7 +1155,7 @@ impl TasksList { Span::raw(" "), // Add space and vertical line for parallel section (fixed position) if is_first_page && self.max_parallel > 0 { - Span::styled("│", Style::default().fg(Color::Cyan)) + Span::styled("│", Style::default().fg(THEME.info)) } else { Span::raw(" ") }, @@ -1176,7 +1179,7 @@ impl TasksList { Span::raw(" "), // Add bottom corner for the box, or just spaces if not on first page if is_first_page { - Span::styled("└", Style::default().fg(Color::Cyan)) + Span::styled("└", Style::default().fg(THEME.info)) } else { Span::raw(" ") }, @@ -1192,7 +1195,7 @@ impl TasksList { Span::raw(" "), // Add bottom corner for the box, or just spaces if not on first page if is_first_page { - Span::styled("└", Style::default().fg(Color::Cyan)) + Span::styled("└", Style::default().fg(THEME.info)) } else { Span::raw(" ") }, @@ -1292,9 +1295,9 @@ impl TasksList { } let message_style = if is_dimmed { - Style::default().fg(Color::DarkGray).dim() + Style::default().fg(THEME.secondary_fg).dim() } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(THEME.secondary_fg) }; // No URL present in the message, render the message as is if it fits, otherwise truncate @@ -1330,9 +1333,9 @@ impl TasksList { let mut spans = vec![]; let url_style = if is_dimmed { - Style::default().fg(Color::LightCyan).underlined().dim() + Style::default().fg(THEME.info).underlined().dim() } else { - Style::default().fg(Color::LightCyan).underlined() + Style::default().fg(THEME.info).underlined() }; // Determine what fits, prioritizing the URL @@ -1558,7 +1561,7 @@ impl Component for TasksList { .split(paghelp_or_help_vertical_area); // Render components with safety checks - if row_chunks.len() > 0 + if !row_chunks.is_empty() && row_chunks[0].height > 0 && row_chunks[0].width > 0 && row_chunks[0].y < f.area().height @@ -1658,7 +1661,7 @@ impl Component for TasksList { .split(paghelp_or_help_vertical_area); // Render components with safety checks - if row_chunks.len() > 0 + if !row_chunks.is_empty() && row_chunks[0].height > 0 && row_chunks[0].width > 0 && row_chunks[0].y < f.area().height @@ -1723,7 +1726,7 @@ impl Component for TasksList { .split(paghelp_or_help_vertical_area); // Render components with safety checks - if row_chunks.len() > 0 + if !row_chunks.is_empty() && row_chunks[0].height > 0 && row_chunks[0].width > 0 && row_chunks[0].y < f.area().height diff --git a/packages/nx/src/native/tui/components/terminal_pane.rs b/packages/nx/src/native/tui/components/terminal_pane.rs index 5fd9dd8196..9ddd1b7161 100644 --- a/packages/nx/src/native/tui/components/terminal_pane.rs +++ b/packages/nx/src/native/tui/components/terminal_pane.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style, Stylize}, text::{Line, Span}, widgets::{ Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, @@ -13,9 +13,9 @@ use ratatui::{ use std::{io, sync::Arc}; use tui_term::widget::PseudoTerminal; -use crate::native::tui::pty::PtyInstance; - use super::tasks_list::TaskStatus; +use crate::native::tui::pty::PtyInstance; +use crate::native::tui::theme::THEME; pub struct TerminalPaneData { pub pty: Option>, @@ -199,35 +199,35 @@ impl<'a> TerminalPane<'a> { | TaskStatus::RemoteCache => Span::styled( " ✔ ", Style::default() - .fg(Color::Green) + .fg(THEME.success) .add_modifier(Modifier::BOLD), ), TaskStatus::Failure => Span::styled( " ✖ ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + Style::default() + .fg(THEME.error) + .add_modifier(Modifier::BOLD), ), TaskStatus::Skipped => Span::styled( " ⏭ ", Style::default() - .fg(Color::Yellow) + .fg(THEME.warning) .add_modifier(Modifier::BOLD), ), TaskStatus::InProgress | TaskStatus::Shared => Span::styled( " ● ", - Style::default() - .fg(Color::LightCyan) - .add_modifier(Modifier::BOLD), + Style::default().fg(THEME.info).add_modifier(Modifier::BOLD), ), TaskStatus::Stopped => Span::styled( " ◼ ", Style::default() - .fg(Color::DarkGray) + .fg(THEME.secondary_fg) .add_modifier(Modifier::BOLD), ), TaskStatus::NotStarted => Span::styled( " · ", Style::default() - .fg(Color::DarkGray) + .fg(THEME.secondary_fg) .add_modifier(Modifier::BOLD), ), } @@ -238,11 +238,11 @@ impl<'a> TerminalPane<'a> { TaskStatus::Success | TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCache - | TaskStatus::RemoteCache => Color::Green, - TaskStatus::Failure => Color::Red, - TaskStatus::Skipped => Color::Yellow, - TaskStatus::InProgress | TaskStatus::Shared => Color::LightCyan, - TaskStatus::NotStarted | TaskStatus::Stopped => Color::DarkGray, + | TaskStatus::RemoteCache => THEME.success, + TaskStatus::Failure => THEME.error, + TaskStatus::Skipped => THEME.warning, + TaskStatus::InProgress | TaskStatus::Shared => THEME.info, + TaskStatus::NotStarted | TaskStatus::Stopped => THEME.secondary_fg, }) } @@ -275,6 +275,8 @@ impl<'a> TerminalPane<'a> { } } +// This lifetime is needed for our terminal pane data, it breaks without it +#[allow(clippy::needless_lifetimes)] impl<'a> StatefulWidget for TerminalPane<'a> { type State = TerminalPaneState; @@ -294,7 +296,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> { // Only attempt to render if we have a valid area let text = "..."; let paragraph = Paragraph::new(text) - .style(Style::default().fg(Color::Gray)) + .style(Style::default().fg(THEME.secondary_fg)) .alignment(Alignment::Center); Widget::render(paragraph, safe_area, buf); } @@ -317,20 +319,28 @@ impl<'a> StatefulWidget for TerminalPane<'a> { }; let status_icon = self.get_status_icon(state.task_status); + let block = Block::default() .title(Line::from(if state.is_focused { vec![ status_icon.clone(), Span::raw(format!("{} ", state.task_name)) - .style(Style::default().fg(Color::White)), + .style(Style::default().fg(THEME.primary_fg)), ] } else { vec![ status_icon.clone(), Span::raw(format!("{} ", state.task_name)) - .style(Style::default().fg(Color::White)), + .style(Style::default().fg(THEME.secondary_fg)), if state.is_next_tab_target { - Span::raw("Press to focus ") + let tab_target_text = Span::raw("Press to focus output ") + .remove_modifier(Modifier::DIM); + // In light themes, use the primary fg color for the tab target text to make sure it's clearly visible + if !THEME.is_dark_mode { + tab_target_text.fg(THEME.primary_fg) + } else { + tab_target_text + } } else { Span::raw("") }, @@ -338,15 +348,26 @@ impl<'a> StatefulWidget for TerminalPane<'a> { })) .title_alignment(Alignment::Left) .borders(Borders::ALL) - .border_type(BorderType::Plain) + .border_type(if state.is_focused { + BorderType::Thick + } else { + BorderType::Plain + }) .border_style(border_style) .padding(Padding::new(2, 2, 1, 1)); // If task hasn't started yet, show pending message if matches!(state.task_status, TaskStatus::NotStarted) { + let message_style = if state.is_focused { + Style::default().fg(THEME.secondary_fg) + } else { + Style::default() + .fg(THEME.secondary_fg) + .add_modifier(Modifier::DIM) + }; let message = vec![Line::from(vec![Span::styled( "Task is pending...", - Style::default().fg(Color::DarkGray), + message_style, )])]; let paragraph = Paragraph::new(message) @@ -470,19 +491,19 @@ impl<'a> StatefulWidget for TerminalPane<'a> { let bottom_text = if self.is_currently_interactive() { Line::from(vec![ Span::raw(" "), - Span::styled("+z", Style::default().fg(Color::Cyan)), + Span::styled("+z", Style::default().fg(THEME.info)), Span::styled( " to exit interactive ", - Style::default().fg(Color::White), + Style::default().fg(THEME.primary_fg), ), ]) } else { Line::from(vec![ Span::raw(" "), - Span::styled("i", Style::default().fg(Color::Cyan)), + Span::styled("i", Style::default().fg(THEME.info)), Span::styled( " to make interactive ", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.secondary_fg), ), ]) }; @@ -512,12 +533,12 @@ impl<'a> StatefulWidget for TerminalPane<'a> { let top_text = if self.is_currently_interactive() { Line::from(vec![Span::styled( " INTERACTIVE ", - Style::default().fg(Color::White), + Style::default().fg(THEME.primary_fg), )]) } else { Line::from(vec![Span::styled( " NON-INTERACTIVE ", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.secondary_fg), )]) }; diff --git a/packages/nx/src/native/tui/lifecycle.rs b/packages/nx/src/native/tui/lifecycle.rs index 435f025994..e1d500ecfc 100644 --- a/packages/nx/src/native/tui/lifecycle.rs +++ b/packages/nx/src/native/tui/lifecycle.rs @@ -4,14 +4,13 @@ use napi::JsObject; use std::sync::{Arc, Mutex}; use tracing::debug; -use crate::native::logger::enable_logger; -use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; -use crate::native::tasks::types::{Task, TaskResult}; - use super::app::App; use super::components::tasks_list::TaskStatus; use super::config::{AutoExit, TuiCliArgs as RustTuiCliArgs, TuiConfig as RustTuiConfig}; use super::tui::Tui; +use crate::native::logger::enable_logger; +use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; +use crate::native::tasks::types::{Task, TaskResult}; #[napi(object)] #[derive(Clone)] @@ -49,7 +48,7 @@ impl From<(TuiConfig, &RustTuiCliArgs)> for RustTuiConfig { Either::B(int_value) => AutoExit::Integer(int_value), }); // Pass the converted JSON config value(s) and cli_args to instantiate the config with - RustTuiConfig::new(js_auto_exit, &rust_tui_cli_args) + RustTuiConfig::new(js_auto_exit, rust_tui_cli_args) } } @@ -88,7 +87,7 @@ impl AppLifeCycle { Self { app: Arc::new(std::sync::Mutex::new( App::new( - tasks.into_iter().map(|t| t.into()).collect(), + tasks.into_iter().collect(), initiating_tasks, run_mode, pinned_tasks, @@ -162,8 +161,8 @@ impl AppLifeCycle { &self, done_callback: ThreadsafeFunction<(), ErrorStrategy::Fatal>, ) -> napi::Result<()> { - debug!("Initializing Terminal UI"); enable_logger(); + debug!("Initializing Terminal UI"); let app_mutex = self.app.clone(); @@ -287,7 +286,7 @@ impl AppLifeCycle { #[napi(js_name = "__setCloudMessage")] pub async fn __set_cloud_message(&self, message: String) -> napi::Result<()> { if let Ok(mut app) = self.app.lock() { - let _ = app.set_cloud_message(Some(message)); + app.set_cloud_message(Some(message)); } Ok(()) } diff --git a/packages/nx/src/native/tui/mod.rs b/packages/nx/src/native/tui/mod.rs index 5bc4143c2f..876bd28441 100644 --- a/packages/nx/src/native/tui/mod.rs +++ b/packages/nx/src/native/tui/mod.rs @@ -4,5 +4,7 @@ pub mod components; pub mod config; pub mod lifecycle; pub mod pty; +pub mod theme; +#[allow(clippy::module_inception)] pub mod tui; pub mod utils; diff --git a/packages/nx/src/native/tui/theme.rs b/packages/nx/src/native/tui/theme.rs new file mode 100644 index 0000000000..06b6014e15 --- /dev/null +++ b/packages/nx/src/native/tui/theme.rs @@ -0,0 +1,72 @@ +// Sadly clippy doesn't seem to support only allowing the ratatui type, we have to disable the whole lint +#![allow(clippy::disallowed_types)] + +// This is the only file we should use the `ratatui::style::Color` type in +use ratatui::style::Color; +use std::sync::LazyLock; +use terminal_colorsaurus::{color_scheme, ColorScheme, QueryOptions}; +use tracing::debug; + +pub static THEME: LazyLock = LazyLock::new(Theme::init); + +/// Holds theme-dependent colors calculated based on dark/light mode. +#[derive(Debug)] +pub struct Theme { + pub is_dark_mode: bool, + pub primary_fg: Color, + pub secondary_fg: Color, + pub error: Color, + pub success: Color, + pub warning: Color, + pub info: Color, + pub info_light: Color, +} + +impl Theme { + fn init() -> Self { + if Self::is_dark_mode() { + debug!("Initializing dark theme"); + Self::dark() + } else { + debug!("Initializing light theme"); + Self::light() + } + } + + fn dark() -> Self { + Self { + is_dark_mode: true, + primary_fg: Color::White, + secondary_fg: Color::DarkGray, + error: Color::Red, + success: Color::Green, + warning: Color::Yellow, + info: Color::Cyan, + info_light: Color::LightCyan, + } + } + + fn light() -> Self { + Self { + is_dark_mode: false, + primary_fg: Color::Black, + secondary_fg: Color::Gray, + error: Color::Red, + success: Color::Green, + warning: Color::Yellow, + info: Color::Cyan, + info_light: Color::LightCyan, + } + } + + /// Detects if the current terminal likely uses a dark theme background. + /// NOTE: This requires raw mode access and might not work correctly once the TUI is fully running. + /// It should ideally be called once during initialization. + fn is_dark_mode() -> bool { + match color_scheme(QueryOptions::default()) { + Ok(ColorScheme::Dark) => true, + Ok(ColorScheme::Light) => false, + Err(_) => true, // Default to dark mode if detection fails + } + } +} diff --git a/packages/nx/src/native/tui/tui.rs b/packages/nx/src/native/tui/tui.rs index f024fbde12..8dcf313479 100644 --- a/packages/nx/src/native/tui/tui.rs +++ b/packages/nx/src/native/tui/tui.rs @@ -1,3 +1,4 @@ +use crate::native::tui::theme::THEME; use color_eyre::eyre::Result; use crossterm::{ cursor, @@ -165,6 +166,8 @@ impl Tui { } pub fn enter(&mut self) -> Result<()> { + // Ensure the theme is set before entering raw mode because it won't work properly once we're in raw mode + let _ = THEME.is_dark_mode; debug!("Enabling Raw Mode"); crossterm::terminal::enable_raw_mode()?; crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;